Chapter 44: Building Diagnostics
A modular application can become hard to understand if the connections are invisible.
The architecture from Chapters 39–42 routes events through a runtime, dispatches commands through handlers, installs modules, and propagates context through boundaries. If you can’t see the flow, modularity becomes magic. The promise the architecture makes — that the application’s behavior is explainable, traceable, debuggable — depends on the diagnostic layer being usable.
This chapter builds the diagnostic surface. The runtime already records every event, command, module install, and error to a diagnostic stream (Chapter 39). What’s left is making that stream consumable: a debug module that prints to the console for development, a debug overlay that renders the trace as a panel inside the application, and the operational discipline of when to enable diagnostics in production.
After this chapter, Part IV is complete. The architecture is implemented end-to-end in plain TypeScript: runtime, shell, boundaries, metadata observation, data patterns, and diagnostics. Part V wraps the same architecture in web components via Lit. Part VI ships it as Kitsune.
What the Diagnostic Stream Records
Section titled “What the Diagnostic Stream Records”Chapter 39 defined the diagnostic entry types. The full vocabulary:
type DiagnosticEntry = | { kind: 'event'; type: string; event: AppEvent } | { kind: 'event-handler-error'; type: string; error: SerializedError } | { kind: 'command'; type: string; command: AppCommand } | { kind: 'command-result'; type: string; ok: boolean; error?: SerializedError } | { kind: 'command-no-handler'; type: string; error: SerializedError } | { kind: 'module-install'; name: string } | { kind: 'module-install-error'; name: string; error: SerializedError } | { kind: 'module-uninstall'; name: string } | { kind: 'module-uninstall-error'; name: string; error: SerializedError } | { kind: 'shell-event'; type: string } // shell.starting, shell.started, etc.Each entry gets a timestamp added by the diagnostic stream. The result is an ordered, timestamped record of the runtime’s behavior over time.
The trace covers the architecture’s substantial moving parts. Module installations show how the application bootstrapped. Events show what the application observed. Commands show what the application requested. Errors at any level show where the runtime stopped working as expected. The shell’s lifecycle events show the application’s overall state.
This is enough information to debug most application behaviors. The chapter’s job is making it consumable.
The Console-Based Debug Module
Section titled “The Console-Based Debug Module”The simplest diagnostic consumer is a module that logs everything to the console.
const debugModule = defineKitModule({ name: 'debug', onInstall: ({ runtime }) => { runtime.onDiagnostic((entry) => { const time = new Date(entry.at).toISOString().split('T')[1]?.slice(0, -1) const prefix = `[${time}]`
switch (entry.kind) { case 'event': console.log(`${prefix} EVENT ${entry.type}`, entry.event.payload ?? '') break case 'event-handler-error': console.error(`${prefix} EVENT-ERROR ${entry.type}`, entry.error) break case 'command': console.log(`${prefix} CMD-START ${entry.type}`, entry.command.payload ?? '') break case 'command-result': console.log(`${prefix} CMD-DONE ${entry.type}`, entry.ok ? 'ok' : entry.error) break case 'command-no-handler': console.error(`${prefix} CMD-NO-HANDLER ${entry.type}`) break case 'module-install': console.log(`${prefix} MODULE-INSTALL ${entry.name}`) break case 'module-uninstall': console.log(`${prefix} MODULE-UNINSTALL ${entry.name}`) break default: console.log(`${prefix} ${entry.kind}`, entry) } }) }})Installing this module produces a readable console trace of every event, command, module installation, and error. For development, this is often enough. The browser’s console has filtering, search, and persistence; the trace is plain text that can be copied and shared.
A team can tune the verbosity — log only errors in CI, log everything during local development, log a structured-JSON stream when the application is being instrumented for automated testing. The pattern is small enough that customizing it per environment is straightforward.
The On-Page Debug Overlay
Section titled “The On-Page Debug Overlay”Console logs are useful, but an on-page overlay makes the architecture’s behavior visible during interaction. The user can click a button, watch the events appear in real time, expand each one to see the context and payload, and trace exactly what happened.
The overlay is a custom element that subscribes to the diagnostic stream:
class KitDebugOverlay extends LitElement { @state() private entries: TimestampedEntry[] = [] @state() private filter = '' @state() private isOpen = true
private unsubscribe?: () => void
static styles = css` :host { position: fixed; bottom: 1rem; right: 1rem; width: 24rem; max-height: 60vh; background: rgba(20, 20, 20, 0.95); color: #fff; font-family: ui-monospace, monospace; font-size: 0.75rem; border-radius: 6px; box-shadow: 0 4px 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; z-index: 999999; }
.header { padding: 0.5rem 0.75rem; display: flex; gap: 0.5rem; align-items: center; border-bottom: 1px solid #333; }
input { flex: 1; background: #222; color: #fff; border: 1px solid #333; padding: 0.25rem 0.5rem; border-radius: 3px; font: inherit; }
.entries { overflow: auto; padding: 0.25rem 0; }
.entry { padding: 0.25rem 0.75rem; border-bottom: 1px solid #2a2a2a; cursor: pointer; }
.entry:hover { background: #2a2a2a; }
.entry.event { border-left: 2px solid #4a9; } .entry.command { border-left: 2px solid #49a; } .entry.error { border-left: 2px solid #a44; color: #fbb; }
details summary { list-style: none; } `
connectedCallback() { super.connectedCallback() const runtime = (window as any).__kitRuntime__ as Runtime | undefined if (!runtime) { console.warn('Kit debug overlay: no runtime found at window.__kitRuntime__') return } this.unsubscribe = runtime.onDiagnostic((entry) => { this.entries = [...this.entries.slice(-499), entry as TimestampedEntry] }) }
disconnectedCallback() { super.disconnectedCallback() this.unsubscribe?.() }
render() { if (!this.isOpen) { return html`<button @click=${() => this.isOpen = true}>Debug</button>` }
const filtered = this.filter ? this.entries.filter((e) => this.entryMatches(e, this.filter)) : this.entries
return html` <div class="header"> <strong>Kit Debug</strong> <input type="text" placeholder="filter…" .value=${this.filter} @input=${(e: Event) => this.filter = (e.target as HTMLInputElement).value} /> <button @click=${() => this.entries = []}>Clear</button> <button @click=${() => this.isOpen = false}>×</button> </div> <div class="entries"> ${filtered.map((entry) => this.renderEntry(entry))} </div> ` }
private renderEntry(entry: TimestampedEntry) { const isError = entry.kind.endsWith('-error') || entry.kind === 'command-no-handler' const isEvent = entry.kind === 'event' const isCommand = entry.kind === 'command' || entry.kind === 'command-result'
const classes = [ 'entry', isEvent && 'event', isCommand && 'command', isError && 'error' ].filter(Boolean).join(' ')
return html` <details class=${classes}> <summary> <code>${formatTime(entry.at)}</code> <strong>${entry.kind}</strong> ${'type' in entry ? html`<code>${entry.type}</code>` : ''} ${'name' in entry ? html`<code>${entry.name}</code>` : ''} </summary> <pre>${JSON.stringify(entry, null, 2)}</pre> </details> ` }
private entryMatches(entry: TimestampedEntry, filter: string): boolean { const lower = filter.toLowerCase() if (entry.kind.toLowerCase().includes(lower)) return true if ('type' in entry && entry.type.toLowerCase().includes(lower)) return true if ('name' in entry && entry.name.toLowerCase().includes(lower)) return true return false }}
customElements.define('kit-debug-overlay', KitDebugOverlay)
function formatTime(ms: number): string { return new Date(ms).toISOString().split('T')[1].slice(0, -1)}
interface TimestampedEntry { at: number kind: string type?: string name?: string [key: string]: unknown}The overlay is one custom element, around 150 lines of Lit-flavored TypeScript. Drop it onto any page that hosts a Kitsune-style runtime, expose the runtime at window.__kitRuntime__ (or pass it in another way), and the overlay shows the trace.
The overlay’s design choices are deliberate. The trace is bounded (500 entries; older ones drop off). Each entry is expandable to show its full JSON. The filter is text-based and matches against kind, type, or name. The color coding distinguishes events, commands, and errors. The overlay can be collapsed when not needed.
Adding the overlay to an application is a one-liner:
<kit-debug-overlay></kit-debug-overlay>The overlay only appears if the runtime is exposed, so the same element in production (where the runtime isn’t exposed) is invisible.
Filter and Search
Section titled “Filter and Search”A working debug overlay needs a way to find specific entries in a long trace. The implementation above includes a text filter that matches event types, command names, and module names. For richer debugging, the filter can be extended:
- Filter by context (
feature:billingshows only events with feature billing). - Filter by entity (
entity:user_123shows only events that touched a specific user). - Filter by error state (
status:errorshows only errors). - Filter by time range (entries from the last N seconds).
These are small additions to the matching function. The architectural point is that the diagnostic trace is queryable — the team can pull a specific subset of events that show what they’re investigating, rather than scrolling through everything.
The Kitsune dev tools (Part VI) extend this further with a graphical view of the event/command flow, a module-by-module breakdown of subscribers, and a performance view showing how long each handler took. For Part IV, the simple overlay is the educational scaffold.
Production Considerations
Section titled “Production Considerations”The diagnostic stream has a real production cost. Recording every event, command, and error consumes memory; the overlay rendering consumes CPU. For high-frequency applications (a heavy real-time tool, a complex SPA), running the diagnostic stream in production can produce measurable performance regressions.
A few patterns mitigate the cost.
Default off. The debug module isn’t installed in production by default. Only specific environments (local development, staging, debug builds) install it.
Gate by URL flag. The application can check for a query parameter (?debug=1) at startup and install the debug module only when it’s present. This lets developers turn on diagnostics for a single session in production without exposing the trace to all users.
Sample. Even when diagnostics are enabled, the stream can sample — record one in every N events. The sample is enough to spot patterns; the cost is a fraction of the full stream’s.
Bound the entry count. The overlay above caps at 500 entries. The diagnostic stream itself can be bounded too — keep the last N entries and drop the rest. This prevents the trace from growing unbounded over long sessions.
Skip noisy events. High-frequency events (mouse.move if the application tracks it, animation tick events, anything that fires hundreds of times per second) can be excluded from the debug stream via a filter. The exclusion is a configuration concern, not a runtime concern.
For most applications, the architectural cost of diagnostics is low and the architectural benefit is high. The debug module ships in development, becomes optional in production, and gives the team a real-time view of what the application is doing.
What Part IV Built
Section titled “What Part IV Built”Looking back over Chapters 39–44:
Chapter 39 built the runtime. Event bus, command bus, modules, providers, diagnostics. Under 300 lines of TypeScript.
Chapter 40 built the shell. The application’s home. Module installation, lifecycle, root boundary. Under 200 lines.
Chapter 41 built the boundary system. Declarative <kit-boundary> and programmatic runtime.attachBoundary. Context collection through DOM walking. Under 150 lines.
Chapter 42 built the metadata boundary. The delegated listener that turns DOM events into runtime events with full context. Under 200 lines.
Chapter 43 built the data layer. Repositories, adapters, validation, async-loading widgets. Around 200 lines of pattern code (the application’s actual data layer is on top of these patterns).
Chapter 44 built the diagnostic surface. Console debug module, on-page overlay. Around 200 lines.
The total is around 1,200 lines of TypeScript. That’s the entire architecture — a working coordination layer, a complete event/command bus, a lifecycle-aware shell, a context-tree boundary system, a metadata bridge from DOM to runtime, a clean data layer, and a debug overlay. Less code than most React applications import in their first 100KB bundle.
The leverage of the architecture isn’t in the lines of code. It’s in the separation of concerns the lines establish. Components describe meaning. Boundaries supply context. Modules handle consequences. Events are facts. Commands are requests. The runtime routes. The diagnostic stream records.
Part V picks up from here. Web-native components, built on Lit, participate in the architecture. The component library uses the metadata protocol, attaches boundaries when appropriate, and emits events the modules consume. The architecture from Part IV is the substrate; Part V builds the ergonomic component layer on top.
What Comes Next
Section titled “What Comes Next”Part V begins by introducing web components and Lit (Chapter 45) — why custom elements are the right component primitive for the platform-first architecture, and why Lit is the canonical decorating layer (Chapter 33’s term). The chapters that follow build the Kit component library: kit-button (Ch 46), kit-dialog (Ch 47), disclosure / select / native controls (Ch 48), forms and form-associated components (Ch 49), styling with tokens / cascade / containers (Ch 50), and the design-systems chapter (Ch 51).
After Part V, the architecture has a complete component library on top of the runtime. Part VI then ships the whole thing as Kitsune — the maintained framework with the polish, the testing infrastructure, the documentation, and the integration adapters real applications need.
Exercise: Build the Debug Overlay
Section titled “Exercise: Build the Debug Overlay”Implement the debug overlay from this chapter and wire it into the application you built in Chapter 42’s exercise.
Test the architecture end-to-end:
- Start the application. Open the overlay. The trace should show the shell starting and the modules installing.
- Submit the form. The trace should show:
event: profile.save_requestedfrom the form’s metadata.command: profile.savefrom the save-orchestrator.command-result: profile.save okafter the command completes.event: profile.savedfrom the orchestrator.- The other modules’ responses.
- Click the notify button. The trace should show the
command: notification.showand its result. - Use the filter input to find just events (filter:
event), just commands (filter:command), or specific types (filter:profile.save). - Trigger an intentional error — make one module throw inside its handler. The trace should show the error clearly, and the rest of the runtime should keep working.
Then extend the overlay:
- Add a context filter — show only entries where the event’s context.feature matches a specific value.
- Add a clear-on-each-navigation feature — clear the entries when the route changes.
- Add a replay button — pick an event from the trace and re-emit it (useful for testing what would have happened if a specific event fired in different circumstances).
Reflect on:
- What’s now visible that wasn’t before?
- Which architectural decisions does the trace make legible?
- How would this help debug a real production incident?
- What additional information would the Kitsune dev tools want to show? (Module-by-module subscriber listings, per-event-type performance breakdowns, the full closed-loop diagram with live highlighting of active paths.)
The overlay is small. Its leverage is enormous. Making the architecture inspectable is the practical payoff of the events-and-commands-and-modules design. The architecture is debuggable because the trace is observable. The trace is observable because the runtime exposes it. The runtime exposes it because the architecture treats diagnostics as a first-class concern.
Part IV ends here. The architecture is implemented end-to-end. The next part wraps it in web components.