Skip to content

Diagnostics

When you build modular software, the price is causalitywhy did this happen? gets harder. The runtime emits a diagnostic event for every interesting thing it does, so you can answer it.

event.emitted — an event was emitted with a given type
command.dispatched — a command was sent
command.handler_registered — a command handler was registered
command.handled — a command handler ran successfully
command.failed — a command handler threw
command.unhandled — no handler for a dispatched command
module.installed — a module finished setup
runtime.started — every module's start hook has run
runtime.stopped — every module's stop hook has run
provider.overwritten — a provider token was assigned twice
boundary.created — a runtime-level boundary handle was created
boundary.destroyed — a runtime-level boundary handle was destroyed

This is not the production observability stream. It’s a development tool — a way to ask the runtime “what just happened?” while you build.

The runtime exposes a single subscription:

runtime.onDiagnostic((diagnostic) => {
console.debug('[kit]', diagnostic.type, diagnostic.detail)
})

A diagnostic is:

type KitDiagnostic = {
type: string
timestamp: number
detail?: Record<string, unknown>
}

Subscribe many times; each handler runs.

The @atheory-ai/kitsune-dev package wraps this in a one-line module:

import { debugModule } from '@atheory-ai/kitsune-dev'
shell.modules = [...yourModules, debugModule()]

It logs every event and every diagnostic to the console while you build. Remove it for production.

You can pass a custom logger:

debugModule({ log: (msg, detail) => myLogger.debug(msg, detail) })

A typical click in a Quill app produces, in order:

[kit:event] note.saved
[kit:diagnostic] event.emitted { eventType: 'note.saved', event: {...} }
[kit:diagnostic] command.dispatched { commandType: 'notification.show', command: {...} }
[kit:diagnostic] command.handled { commandType: 'notification.show' }

If a handler is missing:

[kit:diagnostic] command.unhandled { commandType: 'oops.typo' }

If a handler throws:

[kit:diagnostic] command.failed { commandType: 'draft.save', error: TypeError... }

Use these to triage why a UI didn’t update. Most “the button didn’t do anything” bugs are visible in the diagnostic stream.

Diagnostics work with any sink. To record into IndexedDB, ship to a logging service, or feed a future devtools panel:

runtime.onDiagnostic((d) => {
diagnosticBuffer.push(d)
if (diagnosticBuffer.length > 1000) diagnosticBuffer.shift()
})

The buffer is a ring of recent diagnostics. A future devtools UI can show this as a timeline.

Diagnostics describe the runtime. They are about events, commands, and modules. Application logging — “user X did Y” — should go through events, not diagnostics. The same is true for analytics: emit a session.started event and let an analytics module observe it.

Strip diagnostics in production builds:

  • Don’t install debugModule in production.
  • Subscribe to onDiagnostic only behind a debug flag.
  • Use a build-time constant if you want to drop the subscription entirely.

The runtime always emits diagnostics, but with no subscribers they’re cheap (a for over an empty set).