9.1 KiB
Slice 9: telemetry (spec)
Status: draft for the build (owner, 2026-11-03: "build i18n, then slice 9 (we said we want cross host spans)";
2026-11-04: "go ahead"). Telemetry was decided on 2026-10-18 (decisions under "## Telemetry" in decisions.md, from
"Context" to "Telemetry store") and is stated in design.md §29 and §30 (cost guards). Chord v2 so far records only
metrics (cost counters such as chord.data.validations, chord.link.encodes) and stores a persisted job run's start
carrier (slice 6b). This slice adds the spans, events and metrics of §29, carriers across links, and Chord's telemetry
schema, so one trace follows a call from a page through the server's relay into a worker's method, Durable's phases
and pi-ai's model request.
Read in full before writing code: this spec; decisions.md (the telemetry decisions of 2026-10-18, R5 in "R4 and R5,
protocol header schema", and every later entry that touches telemetry, links, relays, workers or jobs); design.md
(all of it; §22 wire, §29 telemetry and §30 cost model in particular); slices.md (the feature map rows for §8
ctx.trace, §12 the container-width benchmark, §22 trace carriers, §29; the carry-over "Slice 9"); pi-telemetry
(packages/telemetry/src: TelemetrySpan, TelemetryContext, propagation, links, metrics, typed schemas, the in-memory
adapter, render.ts, the conformance tests); Chord's src/telemetry-sqlite/; Durable's harness/telemetry.ts and how
Durable starts its spans (the model for a typed schema); pi-ai's pi.ai.request wrapper; Chord's src/context,
src/host (ctx, object, attempt, root, host, routines, jobs), src/link, src/remote, src/node/http,
src/node/workers. Root AGENTS.md "Chord v2 Code Style" (binding).
1. Chord's telemetry schema
CHORD_TELEMETRY_SCHEMA (src/telemetry.ts, exported from the root), typed with pi-telemetry's
defineTelemetrySchema like DURABLE_TELEMETRY_SCHEMA: every span, event and metric name Chord emits with its
attributes. The existing cost counters (chord.data.*, chord.link.*, chord.store.*, chord.query.runs,
chord.cycle.visits) are declared in it and recorded through it. Ids (object identity, hold, call, address key) are
span attributes only, never metric attributes.
2. Spans
All spans are short and in one process (decision "Durable and AI spans"). Names and their parent:
| Span | Where | Parent | Attributes (besides ids) |
|---|---|---|---|
chord.call |
a consumer's call of a remote member (ref.method(args, ctx)) |
the caller's ctx.telemetry |
type, member, address kind |
chord.method |
a provider running a method | the call's carrier (remote) or the caller's span (local) | type, member, principal kind, outcome |
chord.relay |
a relay forwarding a hold or a call | the arriving carrier | type, member or hold, upstream address kind |
chord.hold |
a consumer's hold until bound (the first bound or failure) |
the holder's span | type, outcome |
chord.activate |
one activation attempt of an object (factory, natives opened, live or failed) | root, linked to its first trigger | type, attempt, outcome (live, failed, parked) |
chord.open / chord.close |
one native's open / close | the attempt's span / root | type, native index |
chord.load, chord.reload |
a host's load / reload | the caller's span | plugins, fates counts |
chord.resolve |
a route resolving an address (a worker start included) | root, linked to its first trigger | kind, outcome |
chord.revoke |
a revocation | the caller's span | count |
chord.routine |
one routine run (self.on, onLive handler) |
root, linked to the commit that triggered it when known | type, routine |
chord.job |
one job run phase (intent, run, finish) | the stored carrier (persisted) or the starter's span | job, phase, outcome |
- Shared work (one activation for several getters, a worker start for several holds) is its own root span linked to its first trigger; each waiting span links to it (decision "Across links" / "Shared work").
- Failed spans carry
status: "error"with a fixed category (activation_failed,call_failed, ...) and the error message truncated to 500 characters; cancelling is not an error (decision "Errors in telemetry"). ctx.trace(name, attributes, fn)(round-3 cut C11, decided here as kept: design §29 already states it): a child span of the handler'sctx.telemetry,fngets a childctx(principal andgetkept), so calls inside are parented.
3. Across hosts
- Carriers on the wire:
callandholdget an optionaltracefield (R5: aTelemetryCarrier, bounded: at most 8 keys, keys and values at most 256 characters; over the bounds the message fails theMESSAGEschema as any malformed header). The consumer injectsctx.telemetry's carrier when its adapter has propagation; the provider extracts it (malformed or absent: the adapter's root, never a throw) and startschord.methodunder it. The binary codec gets a flag bit for the field; the text codec an optional element.PROTOCOL_VERSIONstays 1 (nothing is deployed outside our own apps; the carry-over rule for bumps applies once p3 ships). - Relays read the carrier from the envelope, start
chord.relayunder it, and forward the relay span's carrier upstream (the envelope rewrite already copies the header once). - Workers (
chord/workers): a worker start is achord.resolveroot span; a held worker's methods arechord.methodunder the forwarded carrier, so a trace reads tab → server relay → worker method. - HTTP (
chord/http): a request is achord.httpspan (method, route pattern, status; the path never, it may carry ids or secrets), its handler'sctxunder it; an incoming W3Ctraceparentheader is extracted only from a contributed relay thatverifys (never from browsers). - Durable and pi-ai already accept a span (
HarnessOptions.telemetry, pi-ai'stelemetryoption): a worker's Harness gets its host's adapter, and a method that submits input passes itsctx.telemetry, sopi.durable.submitis a child ofchord.method(Durable starts its own trace per input submission and links to the trigger, as decided).
4. Events and metrics
- Events on the enclosing span:
chord.commit(documents, ops, duration) for commits inside a method or routine,chord.retry(backoff),chord.park(cause),chord.reload.fate(one per object fate: swapped, restarted, parked). - Metrics (low cardinality, never ids):
chord.link.state_messages,chord.link.bytes(encoded WebSocket frames, sizes the code already has),chord.commit.duration,chord.calls.in_flight(up/down),chord.holds.active,chord.links.open,chord.park.duration,chord.backpressure.wait. Nothing is serialized or walked to measure.
5. Storage and reading
chord/telemetry-sqlite exists (decision "Telemetry store"); check it against §29 and fix gaps (links inline, metrics
per 10 s series, retention, bounded queue). The app kit: startServer and startWorker take an optional telemetry
adapter (default NOOP) and pass it to their hosts, HTTP and workers; a worker's adapter comes from its startWorker
options (the kit does not ship spans across processes itself: each process exports to the store it is given; a shared
SQLite file per data directory is the app's choice, documented with a short example).
6. Cost
With the NOOP adapter every span site costs one call and an empty object; no carrier is injected (no propagation),
no attribute objects are built when the adapter cannot record (check pi-telemetry's API for the cheapest form; no
isRecording exists: decided). Cost guards: the slice's tests assert through the in-memory adapter that a call over a
relay produces exactly the spans of §2 and no extra encodes; a benchmark (test/bench/telemetry.ts, by hand) measures
a call over a link with the NOOP adapter versus without the span code (target: within noise) and with the in-memory
adapter; results in docs/benchmarks.md. The §12 container-width benchmark (R24) is carried over into this slice's
benchmark file if not done yet (check benchmarks.md).
7. Tests and examples
- One test file per source file, one
describeper mechanism; the in-memory adapter andrenderTracefor assertions. - A cross-host test: a client calls a method over a relay into a worker (memory links or the workers component), the
method commits and calls a local object; the store-rendered trace shows client
chord.call→ serverchord.relay→ workerchord.method→chord.commitevent, one trace id; a malformed carrier from a client gives a new root. - Jobs: a persisted run resumed in a new attempt stays under its stored carrier (slice 6b's field, now used).
- Example
62-telemetry: a kit app (server and a worker kind) with the SQLite telemetry store, a page or a client calling a worker method that submits to a fake Durable harness or does a model-free piece of work; it prints the rendered trace across the three hosts and a metric query. - Docs: design.md §29 marked built with anything refined; the guide gets a short chapter "Seeing a request across hosts"; CHANGELOG under Unreleased; delete the carry-over section "Slice 9".