node:* API and runs in any runtime; the file-backed sink lives behind the
/file subpath and the Control Plane sink behind /cp.
Install
The three contracts
Event
What a capture adapter emits — one normalized observation. Persisted append-only as NDJSON, one event per line, so field names are snake_case and values are plain JSON.event_type and role are open strings: the vocabulary belongs to the adapter,
not to core.
Template
The expected shape of an operation, as data. A stage’smatch is a match-set —
any one of its events closes the stage — and a witness may name the role that
has to have observed it.
OperationTemplate) and the validator; reading YAML off
disk is the caller’s job. The CLI ships its own templates and its own loader —
see haia-trace template.
Receipt
The verdict(events, template) produces. See
Operation Receipt for the model, and
haia-trace receipt for reading one back.
Assemble a receipt
unassigned holds the events with no context_id — session attestations, or
out-of-band signals such as a chain confirmation added later. They belong to no
single operation, so they are handed back rather than attributed or dropped.
Determinism
The assembler is pure: no wall clock, no random id, no I/O, no model. Given the same events and template it returns a byte-identical receipt, which is what lets one be reproduced or hashed. Receipt identity and timestamps are the caller’s concern; nothing is stamped here. Events are put into a total order before folding — byoccurred_at, then seq,
then event_id — so shuffled input, or events merged from two capture sessions,
yield the same receipt.
Streaming
assembleProgressively and assembleReceiptsProgressively are generators over
the same inputs, yielding a snapshot after each event — a baseline at 0, then
processed counting to total. The last snapshot equals the eager result. This
is what backs the progress indicator in haia-trace build.
Record events
createRecorder is the one place an adapter mints events. It owns the session’s
seq counter and stamps event_id, occurred_at, seq, and adapter:
context_id is never generated for you: it groups the events of one operation,
so it has to come from your runtime context or a protocol request id.
An adapter that records straight to one sink binds it at construction and calls
record instead — stamp and write in one call, with no way to mint an event and
forget to persist it. It returns the sink’s verdict:
record on a recorder built without a writer throws a TypeError naming the
fix — a wiring bug that fails on the first call, before any event is at stake.
Pass
occurred_at when your source learns about a fact after it happened — a
webhook delivery, a chain query. Deliveries retry and arrive out of order, so
capture time is not fact time, and stamping the clock would order the operation
by delivery accidents. In-process adapters omit it and get the recorder’s
clock. now and newId are injectable too, which is how tests stay
deterministic.Sinks
A sink is where a recorder puts events. The contract lives in the root export and is runtime-agnostic —EventWriter, EventReader, SinkErrorHandler and the
NDJSON codec (encodeEventLine, decodeEventLine, decodeEventLines) — so a
browser or edge adapter can implement it without pulling in node:fs.
write(event) never throws, and returns whether the sink accepted the event.
Ignore the verdict and you get plain fail-open capture; read it and a caller with
real delivery semantics — a webhook receiver that must answer 500 for the source
to redeliver — can act on a refusal without any sink ever throwing.
Three ship with the package:
File
The file-backed sink is behind the/file subpath — the only module in the
package that imports node:*:
createRunEventWriter(dir, opts) owns the naming — one file per session. When
you need a fixed path instead, createFileEventWriter(path, onError) appends to
exactly that file. Both store inside write, so a true from either means the
line is on disk — which is what lets @usehaia/trace-circle hand
one straight to a webhook route.
Runs are a list rather than a reader over all of them at once on purpose. Events
carry no run id, and context_id is only unique within a run — an adapter may
number operations per session — so concatenating two runs and grouping by
context_id would fold unrelated operations into one receipt.
Every path is an argument; this package owns no directory. The producer and
whoever reads the runs back must be pointed at the same one. haia-trace uses
.trace/events unless --dir says otherwise, which is what the
example matches. A relative path resolves against the working directory; pass an
absolute one when the cwd is not fixed.
Control Plane
createCpEventWriter, behind the /cp subpath, uploads the same events to a
Haia Control Plane project, so a run can also be looked
at in the dashboard. It is a mirror, not a replacement: the local run file
stays what the assembler reads, and nothing here reads back. It reaches the
network through fetch alone — no node:*, no dependency — so it runs wherever
your recorder does.
Note what
session_id is not: the run. One run is one start of your service
and can hold many unrelated payments, so grouping by it would merge them.
agentId is required rather than defaulted, for the same reason — a run id names
a capture, not a payer, so events from one agent across restarts would land as
different subjects, and nothing on the outside would show that the identity was
invented.
write() queues and returns; a batch goes out when it fills (100 events by
default) or the flush interval elapses (2s). flush() settles what has been
queued and never rejects — await it before a process exits, or the last batch
leaves with it. Configuration is validated eagerly and throws: a bad URL or a
missing key is a wiring bug, not a per-event fault.
Multicast
createMulticastEventWriter(...writers) fans one stream out to several sinks and
is itself an ordinary EventWriter, so the producer never learns there is more
than one. Recording to disk and to the Control Plane is the usual reason:
flush() settles all of them and resolves
however each one went.
write returns the AND of the writers’ verdicts: true only when every sink
accepted. That is the reading a caller acting on the result needs, since “all
sinks have it” is the only answer that lets a webhook receiver acknowledge a
delivery. A sink you treat as best-effort belongs outside the multicast, called
separately — the fan-out will not guess which failures matter.
Fail-open
A producer sits in a payment path, so a sink failure must degrade to “capture stopped”, never “payment broke”.write does not throw on any of the three —
faults go to onError, and a refusal comes back as false.
For the file sink: if the run file cannot be created for a reason no later write
can clear (usually a directory the process may not write to), you get one report
and a writer that accepts events and drops them, rather than one identical error
per event. A transient failure such as the open-file limit is reported and then
recorded through once it clears. Calling createRunEventWriter with no directory
is a caller mistake and still throws.
The Control Plane sink is fail-open but not quiet: dropped batches and events the
ingest API refused are both reported, so “the Control Plane has nothing” is never
forged from a failure.
Wire
onError on any producer whose capture matters. Recording nothing is the
right outcome when the disk or the network refuses, but without a handler that
refusal has nowhere to go and the run is simply absent.