@usehaia/trace-core.
Install
Attach it
One call, anywhere after you construct the instance:trace() is idempotent per instance, and with no
writer it prints NDJSON to stdout — the same encoding the file sink writes, so
it can be piped into a file.
The same call works on the other side of a payment:
.trace/events is where haia-trace build reads from unless
--dir says otherwise — naming the same directory on both
sides is what makes recording and assembly meet. The path is resolved against
the working directory; pass an absolute one when your cwd is not fixed. A run
can also be mirrored to a Haia Control Plane project while it is written
locally — see the Control Plane sink.What it attaches to
trace() resolves the instance’s kind by duck-typing its method set — no
@x402 value is imported at runtime, so API-compatible forks work too — and
stamps every event with the observing role.
x402HTTPClient and x402HTTPResourceServer are wrappers that expose one hook
each; the rest live on the x402Client / x402ResourceServer they hold.
trace() follows into that instance, so one call still covers the whole kind.
What it records
Every event carriesadapter: "trace-x402" and the observing role; the ones
belonging to a payment also carry a context_id grouping it.
Client — role: "client"
Resource server — role: "server"
Facilitator — role: "facilitator"
The same six verify/settle hooks as a resource server, with the same event types.
It has no request gate and no cancellation hook. Only role separates the two
sides, which is why templates constrain on it.
The adapter also records its own state: trace.attached /
trace.attach_partial / trace.attach_failed once per traced instance, and
trace.capture_failed when a firing could not be mapped. None belongs to a
payment, so none carries a context_id and the assembler reports them as
unassigned.
Outcomes are read, not assumed
TheonAfter* hooks fire on success and rejection alike. onVerifyFailure /
onSettleFailure are reserved for a thrown fault, while a clean “not valid” or
“settlement rejected” arrives as a result — so the outcome is read off the
context.
The client’s response hooks (onPaymentResponse, and MCP’s onAfterPayment)
fire for every ending a paid request can have:
The 402 is on
onPaymentResponse only. MCP’s onAfterPayment is handed no
offer, so a tool call that came back unsettled always reads as
x402.payment.failed there.
Only a settlement that actually succeeded reports x402.payment.responded —
that is the client-side witness a template reads as “the payment settled”.
A resource server and a facilitator both report a decline as
x402.verify.failed, but not in the same field: the SDK hands the facilitator
its “not valid” through the failure hook, so the reason lands in error.message,
while a resource server gets it as a result and the reason lands in
verify.invalid_reason.
Redaction
Payloads are built from an allowlist, never copied from the hook context — a field the x402 SDK adds later is dropped by default rather than silently recorded. Two things are never written:PaymentPayload.payload— the scheme-specific signed authorization. This is the credential that moves the money.extra/extensions— open-ended bags defined by schemes and third-party extensions.
pay_to, timeout), the verify and
settle results including the transaction hash and payer, and, for a fault, the
error’s name and message. Stacks are dropped.
Grouping concurrent payments
x402 hands hooks no request id, and the adapter has to run whereAsyncLocalStorage does not exist, so the context_id is derived from the
contexts themselves, through two keys that both identify a payment:
- the nonce in the signed payload (falling back to its signature), which travels with the payment;
- for the hooks that fire before a payload exists, the object identity of the
PaymentRequiredthe SDK threads from the 402 response through payment creation.
context_id rather than
guessed into an operation.
The id itself is a crypto.randomUUID(), unique beyond the run it was minted in,
so events mirrored to a Control Plane
project — which holds many runs
side by side — still group correctly. Because the key comes from the payment, a
client and a server traced in the same process resolve one payment to the same
context_id.
Options
TraceOptions, TraceAttestation,
TraceKind, TraceInstanceKind, TraceRole — and resetTraceSession(), which
drops the process-wide recorder and correlator for test isolation.
Passivity
SetHAIA_TRACE_DISABLE=1 to make trace() a no-op.
Attestation
trace() returns what it connected to, so “no payment events happened” is
distinguishable from “the recorder never wired up”:
attached labels a hook by where it registered, so a hook found on a wrapped
instance reads client.onPaymentResponse or server.onAfterSettle. missing
lists the hooks of the kind that registered nowhere — the firings this run will
not have. The same fact is recorded as an event on every run.
From a run to receipts
The events are the input tohaia-trace build, which groups them
by context_id and assembles one
Operation Receipt per payment:
where: { role: ... }, the role that has to have observed it. Apply the one
matching the side you traced — one build per side, even when a single run
captured two (build covers why).
Every buyer stage has a witness in each client kind’s flow — HTTP, MCP, and the
bare x402Client — so a clean payment assembles as full from any of them.