core
Transaction lifecycle tracing for the on-chain actions of AI agents, built on OpenTelemetry.
@hashspan/core turns a transaction into two spans inside your existing trace:
send {chainId}: from “about to send” until the transaction hash is known (or sending failed).confirm {chainId}: waiting for the receipt: status, block, gas, fees (including the OP-stack L1 fee) and revert reason. It carries a span link to itssendspan.
Payments that another party settles on chain, such as x402 payments, become a payment {chainId} span instead
of a send span.
The core is library-agnostic and read-only: it never signs, sends or fetches anything. Adapters such as
@hashspan/viem call it for you. Use the core directly to instrument any other send path.
Install
Section titled “Install”npm install @hashspan/core @opentelemetry/api@opentelemetry/api is the only peer dependency. Bring your own OpenTelemetry SDK and exporter. Requires Node.js 22.3
or later; in other runtimes, hashed address mode needs a custom hash function and otherwise records no addresses,
with a diag warning.
import { createTxTracker } from '@hashspan/core';import { context } from '@opentelemetry/api';
const tracker = createTxTracker({ agent: { name: 'treasury-bot' } });
// Inside your tool, where the agent framework's span is active:const send = tracker.startSend({ chainId: 8453, from, to, value, functionName: 'transfer' });let hash: string;try { // Run in the send span's context, so that wallet or RPC spans of the call nest under it. hash = await context.with(send.context, () => sendSomehow()); send.end({ hash });} catch (error) { send.fail(error); throw error;}
// Later, wherever you wait for the receipt (or in a background watcher):const confirm = tracker.startConfirm({ chainId: 8453, hash });const receipt = await waitSomehow(hash);confirm.end({ status: receipt.status, // 'success' | 'reverted' blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice, l1Fee: receipt.l1Fee, // OP-stack chains // Hash of the mined transaction: if a replacement was mined, the receipt is attributed to it (ADR 0008). transactionHash: receipt.transactionHash,});startConfirm can be called by every part of your code that waits for the receipt: calls for the same transaction
share one confirm span. A receipt from any of them ends it; a timeout or failure ends it once every caller gave up.
End every handle you start, since an open handle keeps the shared span open.
send.fail(error, { errorType }) records a library’s machine-readable error code as error.type instead
of the error’s class name, if it is a short identifier ([A-Za-z0-9_.-], at most 64 characters); exception.type
stays the class name.
tracker.startPayment({ chainId, protocol, payer, recipient, asset, amount }) records a payment that another
party settles on chain, such as an x402 facilitator, as a payment {chainId} span; end it with
end({ status, hash }) or fail(error). A settlement with a hash links the transaction’s confirm span to the payment
span (ADR 0013).
All three calls accept an explicit parent Context as a second argument. An integration that learns about a call only
after it started can record it after the fact: pass startTime in the input and endTime in the options of the
handle method, e.g. send.end({ hash }, { endTime }) (ADR 0009). Every method is safe to call: failures inside
the instrumentation are reported through diag and never thrown into your code. The positional forms of earlier
releases, send.end(hash, endTime) and send.fail(error, endTime, { errorType }), still work and are deprecated
until 1.0 (ADR 0014).
Options
Section titled “Options”| Option | Default | Description |
|---|---|---|
tracerProvider |
global provider | Tracer provider to use |
address |
'raw' |
'raw', 'hashed', 'off', or { mode: 'hashed', hash: (address) => string } |
errorMessages |
'off' |
What failed spans record about the error: 'off' (type only), 'sanitized' (first line, addresses per address mode, calldata removed; in hashed and off mode any hex longer than an address) or 'raw' (full message and stack trace). 'raw' can record RPC URLs that include API keys, as some libraries put the request URL in the message; 'sanitized' keeps only the first line (viem puts the URL on a later line), which is best effort. See ADR 0006 |
paymentResource |
'origin' |
How much of a paid resource’s URL x402.resource records: 'origin' (scheme, host and port; nothing for a resource that is not a URL), 'path' (also the path, never the query string, fragment or user info) or 'off'. Paths often carry user or account identifiers. At most 512 characters are recorded |
recordFunctionArguments |
false |
Record functionArguments as a JSON array in blockchain.contract.function.arguments: bigints as decimal strings, addresses per address mode (longer hex values become <hex> in hashed and off mode), at most 4096 characters. Reads only own enumerable data properties: toJSON() and getters are never called, so a Date records as {}; a Proxy’s traps still run |
agent |
none | Agent { id, name }; a field set here always wins, unset fields come from the Baggage entries gen_ai.agent.id / gen_ai.agent.name |
agentFromBaggage |
true |
Read agent identity fields that agent leaves unset from Baggage; set to false in services that accept requests from outside their trust boundary |
redact |
none | (attributes) => attributes, runs last on every attribute set, including exception event attributes; if it throws, only non-sensitive identifiers are kept |
linkTtlMs |
600000 |
How long a sent transaction can be linked from its confirmation |
maxTrackedTransactions |
10000 |
Upper bound on transactions kept for linking |
What is recorded
Section titled “What is recorded”Chain id, transaction hash, sender/recipient (per address mode), value, nonce, function name and selector, and,
on confirmation, status, block number, gas used, effective gas price, L1 fee, total fee and revert reason. Decoded
call arguments are recorded only with recordFunctionArguments, and error messages only with errorMessages.
Attribute definitions:
docs/semconv.md.
Privacy notes
Section titled “Privacy notes”- Address modes are not anonymity.
address: 'off'and'hashed'keep addresses out of your telemetry backend. They do not hide who transacted: every span carries the transaction hash, and anyone can look up its sender, recipient, value and calldata in a block explorer. Use them to limit what your backend stores and who can query it, not to make transactions untraceable. - Agent identity in Baggage travels. Baggage is propagated to every downstream service your instrumented clients
call when a Baggage propagator is configured (it is part of the default OpenTelemetry SDK setup), including third
party APIs. Put only identifiers there that may leave your system, such as an opaque agent id. For identifiers
that must stay internal, use the tracker’s static
agentoption instead, which is recorded on spans but never propagated, or strip the entries before outbound calls. - Inbound Baggage can claim an identity. A caller can send Baggage entries with any agent id. A field set in the
agentoption cannot be overridden that way; to ignore identity from Baggage entirely, setagentFromBaggage: false(ADR 0011). - The redaction hook (
redact) runs last on every attribute set and on exception attributes; use it for anything else your policy forbids. - Your callbacks’ errors go to the diagnostic logger. If a custom
hashfunction or theredacthook throws, its error object is logged through the OpenTelemetrydiaglogger, outside the address mode and the redaction hook. Errors of the instrumented call never are. Do not put sensitive values, such as the address being hashed, into errors your callbacks throw, or routediagto a sink your policy allows.
License
Section titled “License”Apache-2.0