10. Analytics, Without Touching the UI
This is the chapter the whole tutorial has been building toward. We’re going to add product analytics to Quill — track every meaningful action with surface, feature, and entity context — and we are not going to touch the form, the cards, the editor, the dialog, the toasts, or the persistence layer.
What we want
Section titled “What we want”A module that observes domain events and forwards them to an analytics service. We’ll start with a console.log stub so you can see it work, then sketch how to swap in PostHog or Amplitude.
src/analytics-module.ts
Section titled “src/analytics-module.ts”import { defineKitModule, type KitEvent } from '@atheory-ai/kitsune-core'
export type AnalyticsClient = { track(event: string, properties: Record<string, unknown>): void identify?(userId: string, traits?: Record<string, unknown>): void}
const consoleClient: AnalyticsClient = { track(event, properties) { console.log('[analytics]', event, properties) },}
const TRACKED: Record<string, string> = { 'note.created': 'Note Created', 'note.updated': 'Note Updated', 'note.deleted': 'Note Deleted', 'note.opened': 'Note Opened', 'note.delete_requested': 'Note Delete Requested',}
export function analyticsModule(client: AnalyticsClient = consoleClient) { const tracked = (event: KitEvent) => { const productName = TRACKED[event.type] if (!productName) return
client.track(productName, { surface: event.context?.surface, surfaces: event.context?.surfaces, feature: event.context?.feature, entity_type: event.context?.entity?.type, entity_id: event.context?.entity?.id, ...event.payload, timestamp_ms: event.timestamp, }) }
return defineKitModule({ name: 'analytics', events: Object.fromEntries(Object.keys(TRACKED).map((type) => [type, tracked])), })}The module:
- Maps internal event types to product-friendly names (the kind product managers want in dashboards).
- Pulls surface, feature, and entity from boundary context — context the UI never had to pass.
- Adds the event’s payload as additional properties.
- Calls a pluggable
AnalyticsClient.
Install it
Section titled “Install it”In main.ts:
import { analyticsModule } from './analytics-module.js'
shell.modules = [ notesModule(seedNotes), auditModule(), noteToastsModule(), analyticsModule(), notificationModule(), dialogModule(), debugModule(),]That’s it. No other change.
Watch it work
Section titled “Watch it work”Open the app. Click a note card. The console shows:
[analytics] Note Opened { surface: 'note-card', surfaces: ['app', 'note-list', 'note-card'], feature: 'notes', entity_type: 'note', entity_id: 'n_welcome', id: 'n_welcome', timestamp_ms: 1735075200000}Create a note: Note Created with the new id. Delete a note: Note Delete Requested (when the user clicks delete) followed by Note Deleted (when the persistence module emits the fact).
The product event has more context than most analytics calls written by hand have. The boundary tree gave us surface stacks for free. The persistence module gave us the entity. The user’s payload joined in. None of the UI components knew about any of this.
Swap the client
Section titled “Swap the client”Replace consoleClient with a real one:
import posthog from 'posthog-js'
posthog.init('YOUR_KEY', { api_host: 'https://app.posthog.com' })
const posthogClient: AnalyticsClient = { track: (event, props) => posthog.capture(event, props), identify: (userId, traits) => posthog.identify(userId, traits),}
shell.modules = [ notesModule(seedNotes), auditModule(), noteToastsModule(), analyticsModule(posthogClient), // ...]The form, the dialog, the cards, the editor, and the persistence layer don’t change.
Add a new tracked event
Section titled “Add a new tracked event”Want to track when the user opens the command palette? Add it to the runtime — the palette dispatches a command, but we can also have it emit an event. Two changes:
- In
palette.ts, afterpalette.showPopover():
runtime.emit({ type: 'palette.opened' })- In
analytics-module.ts, add toTRACKED:
'palette.opened': 'Command Palette Opened',Reload. The next palette open shows up in analytics. Two lines.
Why this is structurally different
Section titled “Why this is structurally different”Compare to the same task in a typical React codebase:
- Open
NoteCard.tsx. AdduseAnalytics(). Wrap the click handler. - Open
NewNoteForm.tsx. AdduseAnalytics(). Track on submit. - Open
DeleteConfirmDialog.tsx. AdduseAnalytics(). Track on confirm. - Open
CommandPalette.tsx. AdduseAnalytics(). Track on open. - Hope you didn’t miss any. Hope the surface/feature/entity is consistent across them. Hope the next person adding a button remembers.
In Kitsune: one new file. Three additions to a constants object. Done. Forever.
What we have now
Section titled “What we have now”Eleven chapters in. Full notes app. Persistence, audit, toasts, dialog confirmation, command palette, analytics. The UI components total roughly 100 lines of TS and 60 lines of CSS. The application logic is in five modules totaling roughly 250 lines. Everything composes through the runtime.
You can now read the rest of Quill’s source and understand it without studying it. That’s the architecture working.
What’s next
Section titled “What’s next”A short closing chapter on where to take Quill from here, and how to think about Kitsune in your own apps.