Skip to content

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.

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.

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.

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.

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.

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.

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:

  1. In palette.ts, after palette.showPopover():
runtime.emit({ type: 'palette.opened' })
  1. In analytics-module.ts, add to TRACKED:
'palette.opened': 'Command Palette Opened',

Reload. The next palette open shows up in analytics. Two lines.

Compare to the same task in a typical React codebase:

  • Open NoteCard.tsx. Add useAnalytics(). Wrap the click handler.
  • Open NewNoteForm.tsx. Add useAnalytics(). Track on submit.
  • Open DeleteConfirmDialog.tsx. Add useAnalytics(). Track on confirm.
  • Open CommandPalette.tsx. Add useAnalytics(). 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.

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.

A short closing chapter on where to take Quill from here, and how to think about Kitsune in your own apps.

Next: Chapter 11 — Where to Take Quill Next →