ADR 0012 — A built-in observability module
- Status: Accepted
- Date: 2026-05-20
Context
stitchkit shipped only the low-level hooks — LifecycleHooks and
ToolCallHooks (ADR 0004, ADR 0007). Every consuming project then re-built the
same audit layer on top of them: a request-context, a trace id, a payload
sanitiser, a transport-source tag, an HTTP audit wrapper, a sink. Across three
sibling projects this layer was duplicated, divergent, or missing entirely —
the framework had ended the duplicated transport layer but not the duplicated
observability layer above it.
The hooks are the right primitive. The question was whether the audit layer above them belongs in the framework too — and if so, how much of it.
Decision
Ship an observability module — stitchkit/observability, a new entrypoint.
It owns the reusable audit machinery; a consuming project supplies only an audit
table and a write(event) function.
The module owns four things:
- W3C Trace Context —
traceparentparsing, formatting and span chaining. A trace spans the front-end call, the HTTP handler and every tool call beneath it. - A request context over
AsyncLocalStorage— trace ids, transport source, identity and timing, reachable without threading a parameter. RequestEvent— one normalised audit shape produced by both surfaces, so a single audit table stays queryable across HTTP, MCP and agent calls.createAuditHook— wires both surfaces into one sink, plus the sanitisation that makes a payload safe to store.
The HTTP audit is a fetch-handler wrapper, not a lifecycle hook.
LifecycleHooks has a single onError (ADR 0004) — an audit built on it would
compete with the application’s own error handler for that one slot. The wrapper
sees the final Response instead — success and error alike, one uniform place,
no contention. The tool-call audit is a hook (afterToolCall), which has no
such conflict.
The module owns the machinery, not the policy. It does not own the audit
table (a project’s database model), the logger backend (pino vs console — the
StitchLogger interface stays the only contract), project-domain transport
sources, or any live-stream / admin-UI concern. Those stay with the project.
The split is the same as ADR 0002: a generic core, no domain model.
A separate entrypoint — not folded into stitchkit/server — keeps the audit
machinery tree-shakeable and the concern distinct: a project that does not audit
never imports it.
Consequences
- A consuming project’s request logging collapses to a table plus a
writefunction; trace ids, the context, sanitisation and wiring come from the framework. - One normalised
RequestEventmeans one queryable audit store across every surface, instead of three disconnected logs. - The raw hooks remain fully available for anything that is not a full audit row — a one-off metric, a custom log line.
- The module depends on
node:async_hooks. Bun implements it; consistent with ADR 0011.