Installation
trace() or withTrace() root finishes in a build where no module was compiled by the transform, the SDK prints one [bitfab] warning naming the root and linking back to that section.
Quick Start
Choose an instrumentation style
Two decisions, in order. First opt-out or opt-in:trace() records a root and every first-party call beneath it, while withSpan() records exactly what you wrap. Opt-out is the recommended starting point and is still experimental; see Instrumentation for the comparison and Experimental subtree tracing below for setup.
Then, within opt-in, wrapper or decorator:
- withSpan (widest support)
- Decorator (TypeScript 5+)
withSpan works with every supported TypeScript version and with class methods, standalone functions, class fields, accessors, and functions from other libraries.span() decorator form requires TypeScript 5.0 or newer and a build pipeline that supports the standard ECMAScript decorator transform. Leave experimentalDecorators and emitDecoratorMetadata disabled or omitted. Use withSpan() in legacy-decorator projects. The experimental subtree transform accepts both standard @bitfab.trace / @bitfab.node output and the legacy output used by frameworks such as NestJS.
Coding Agent Prompt (Cursor, Claude Code)
Coding Agent Prompt (Cursor, Claude Code)
Copy this prompt into your coding agent (tested with Cursor and Claude Code using Sonnet 4.5):
Basic Configuration
BITFAB_CAPTURE_ENABLED decides when your code does not. Set it to 0 to turn capture off or 1 to turn it on, and the switch applies to every client that was built without one. A value passed in code always wins over the variable.Capture off is not tracing removed. With
captureEnabled: false the wrappers stay in place and keep their trace function keys, so replay and seedTrace still work against a capture-off client. Replay records inside each item, and seedTrace records the one call it runs. Everything else runs untraced and sends nothing, including framework handlers, processors, middleware, and local BAML calls. enabled is a deprecated alias that warns once.Missing API key doesn’t crash. If the API key is missing, empty, or whitespace-only, the SDK automatically disables tracing and logs a one-time warning at first use. All wrapped functions still execute normally — no spans are sent, no errors are thrown. You don’t need any conditional logic around the API key.
API key resolution
The key is resolved lazily, the first time a span runs, not when the client is constructed. This matters in scripts: with ES modules, animport that constructs the client is evaluated before the importing file’s body runs dotenv.config(), so a key read at construction would be empty even though it is set moments later. Resolving at first use reads the key after env loading has happened.
BITFAB_API_KEY from the environment, again at first use. For standalone scripts where a run that emits no traces should be treated as a failure rather than silently skipped, set strict:
node --env-file=.env script.ts, so every module-level read sees the key.
Tracing
Experimental subtree tracing
For server-side TypeScript, Bitfab can record one root plus the first-party function calls beneath it, at any depth, without wrapping every function. The SDK includes every transform adapter, so there is no second package to install. Add one adapter at the point where your server code is first compiled. If you previously installedbitfab-transform or @bitfab/transform, remove that dependency and update its imports to @bitfab/sdk/transform/. The adapters run on Node 22.18 or later.
Next.js needs only a config wrapper. It instruments Node server modules in both Turbopack and webpack builds, while leaving client and Edge bundles untouched:
next.config.ts
vite.config.ts
experimentalDecorators, jsx, and verbatimModuleSyntax settings from the nearest tsconfig.json. It does not emit decorator metadata, so classes that rely on emitDecoratorMetadata should run through the Nest or webpack adapter instead. With tsx, register tsx first:
BITFAB_SDK_RESOLVE_FROM to its package.json:
nest-cli.json:
nest-cli.json
webpack.config.cjs
tsc or SWC builder still records the rich root but does not gain automatic descendants from this adapter. Plain tsconfig.json cannot load a TypeScript transformer. Complete runnable transform configs, direct-Node and Next.js fixtures, and a real Nest server fixture with OTLP delivery assertions live in bitfab-typescript-example/auto-trace/.
The root APIs are ordinary runtime wrappers; the transform does not rewrite their arguments or change their behavior. Without a transform, they still record one normal rich root span and run the code unchanged. They cannot discover calls beneath the root unless a compatible transform instruments those functions.
For a class method, use the normal method decorator:
withTrace:
getFunction() binds the key once, so the trace root, its spans, and any framework integration read it from one place. The client-level forms take the key inline and behave identically:
withTrace wrapper create the same rich root span and call-scoped subtree context. Span inclusion is opt-out. Every eligible first-party call beneath the root is recorded by default. Most calls require no Bitfab wrappers. Use node() for a transformed method or withNode() for a transformed standalone function only when one discovered call needs explicit naming, typing, capture, finalization, or replay-mocking policy. A configured call outside the enclosing subtree trace runs normally and never creates a span or trace. The build adapter rewrites eligible first-party functions because imports, callbacks, dependency injection, and virtual dispatch make the runtime call tree impossible to predict statically. Each rewritten function checks the call-scoped context first. Outside the traced invocation, it runs its original body directly without emitting a span or constructing span metadata, captured inputs, or an invocation closure. A capture-off client or a client without a resolvable API key also runs the root directly. It does not request the sim plan or activate automatic trace context.
The options follow the same subtree model as Python:
nameandtypecontrol the rich root span. Unconfigured descendants arefunctionspans.mockOnReplayDefault: truemakes replay mocking the default for nodes under the trace’s defaultmock: "marked"replay strategy. A configured node withmockOnReplay: falseoverrides it. The option is off by default.maxDepth(default 30) andmaxCapturedSubtreeSpans(default 500) bound each invocation, and nothing caps the total number of spans one trace root sends.maxSpanswas removed, so passing it is a type error.maxCapturedSubtreeSpanscaps discovered calls sent with their inputs and outputs. Calls declared withnode()orwithNode(), nested trace roots, framework integration spans, and calls the loaded sim plan turned capture off for do not count toward it, so they keep recording past it. A call sent without inputs and outputs only because the sim plan could not be read counts toward it. PastmaxCapturedSubtreeSpans, further discovered calls with capture on record nothing. Both limits apply to calls in the order they start, so a parent is never dropped after its children were sent. Hitting any limit warns once, and the trace carries the flat metadata keysbitfab.truncated_by(the limits that fired, in the ordermax_depth,max_captured_subtree_spans) andbitfab.dropped_spans(how many calls were not recorded) because the emitted tree is incomplete.excludeaccepts simple or qualified function names. Skipping is transparent: calls below an excluded function still parent to the nearest recorded caller.- Anonymous callbacks are omitted. Rest-argument wrapper functions are omitted unless
includeWrappers: trueis set.
node() annotation or Bitfab setup. Inputs are the original call arguments supplied at the function boundary, including the complete objects and arrays used by destructured parameters, not reconstructed bindings or placeholders. Each span also carries timing, parentage, its qualified name, and a stable source-derived function ID.
node() and withNode() apply trace-owned configuration to one discovered call. capture: true (the default) makes that call rich and applies name, type (default custom), experimentId, mockOnReplay, and finalize. capture: false omits the call and transparently attaches its captured descendants to the nearest captured parent. Combining capture: false with mockOnReplay: true throws because an omitted call has no recorded output.
The runtime protocol is transform-agnostic: every adapter emits the same calls to @bitfab/sdk/auto, so application code and versioned, lexical function IDs do not change when the build tool changes. Re-running a transform is safe: generated modules carry an idempotence marker and are not instrumented twice.
Bitfab’s installed SDK implementation is a dependency and is never transformed. Methods decorated with trace() are left alone, because every call to them goes through the root. Functions passed to withSpan(), withTrace(), and withNode() are transformed like any other repository function, so one function can be a root, an explicit span, and a configured node at once. A withTrace() root records its own function once: at run time it skips only the call it is already recording, whether the function is defined in the same file or imported. Every other call to that function is still recorded, so a direct call inside another root appears as a discovered call and its withNode() span appears beneath any root that calls it. One root call records one span: a withTrace() root wrapped around a withNode() function, in either order, records only the root, with the root’s name and type, and the node settings apply when the function runs as a node beneath a different root. Named repository functions called inside an explicit span remain part of the automatic tree.
Nested withTrace() roots. A withTrace() or trace() root entered beneath another one starts a separate trace while the outer root keeps recording. The nested root’s function and every call beneath it appear in both traces with separate span IDs, and each trace has the same shape it would record alone, so you can test either boundary of an agent on its own. Every copy of a span also carries the contexts and prompt the call added, so each trace’s model and token totals include the calls that ran inside it. Two overlapping roots therefore double span volume in the region they share. Each root applies its own limits and explicit exclusions to its copy. A node() or withNode() configuration applies in every root’s copy, including experimentId and the finalized output, and finalize runs once per call; framework spans (the OpenAI Agents processor, the Vercel AI SDK middleware, the LangGraph integration) attach inside the innermost trace only. The outer trace’s span for the nested root carries nested_trace_id, nested_trace_function_key, and nested_root_span_id, and the nested root span carries enclosing_trace_id, enclosing_span_id, and enclosing_trace_function_key, so the two traces point at each other. In the trace viewer the enclosing trace’s span for the nested root shows a lip naming the nested trace function. It opens that trace at its root span. The nested trace’s root shows a lip back to the enclosing trace function that opens the span that started it.
replay() or seedTrace() a nested root starts no trace of its own. The item’s trace records it as an ordinary descendant, so an experiment never gains traces under a second trace function key.
Mixing opt-in and opt-out tracing
withSpan() is the opt-in surface: it records exactly the functions you wrap. withTrace(), trace(), node(), and withNode() are the opt-out surface: they record a root plus the first-party calls beneath it. Pick one per workflow. Entering one beneath the other throws MixedTracingError naming both surfaces:
node() or withNode() when a discovered call needs its own name, type, capture, finalization, or replay-mocking policy: they configure the call the trace already owns rather than opening a second surface. Going the other way, wrap the caller with withTrace() too, or drop back to withSpan() throughout.
Wanting a withSpan() root with a subtree lower down is really just a subtree with a smaller root. Move withTrace() to the function you wanted the subtree under.
node() and withNode() follow the same rule. Beneath a withSpan() with no enclosing trace there is nothing for them to configure, so they throw rather than run as a silent no-op. With no tracing active at all they still run the function untouched.
The root span that replay() and seedTrace() wrap around an undecorated callable is the one exception. It belongs to neither surface, so a withTrace() root called from that callable nests beneath it instead of throwing, matching Python.
The Python SDK raises
MixedTracingError in the same places. Framework adapters are unaffected. The LangGraph, OpenAI Agents, and Vercel AI SDK wrappers open their spans on the surrounding surface rather than declaring one, and the callback-based handlers emit spans directly, so an adapter instrumenting part of your stack never trips the check.withTrace() subtree, matching the Agents SDK’s own nesting. A node() there nests the same way without raising, and a withSpan() there still raises MixedTracingError.
Configure subtree capture in code with trace bounds, exclusions, and node capture options. Full capture is the default. Source-level exclude and capture: false options determine which calls are recorded.
Without a compatible transform, both root APIs still emit their normal rich root and run unchanged; automatic descendants are absent, and the first root to finish prints one [bitfab] warning telling you to add the bundled adapter. Compatibility requires the adapter to see your original server-side TypeScript or JavaScript before decorators are lowered. Generator function definitions, JavaScript #private methods, getters/setters, dependency modules, declarations, "use client" modules, and Edge bundles are currently skipped. Async-generator roots preserve automatic context while their results are consumed, so eligible regular functions called from the generator body still appear.
Opt-in tracing with explicit spans
Give a step its own span when any of these is true:- It calls a model. Always. This is the span you iterate on, compare across experiments, and attach graders to.
- It reads external mutable state (DB query, HTTP
GET, object storage, vector search, cache). These are the spans you will want to mock on replay. - It writes external state (DB write, queue publish, email, charge, file write). Mark these to mock on replay so a replayed trace does not repeat the side effect.
- It transforms the model output (parsing, validation, ranking, formatting), so a quality regression points at the model or at your post-processing.
- It retries or loops, one span per attempt or iteration, so a trace shows how many attempts it really took.
Declaring the trace function key
Using getFunction() to Link Spans
Within opt-in tracing, declare the trace function key once and wrap multiple functions. This is the recommended opt-in form on every supported TypeScript version:
tracedProcessOrder(id) records one trace with four spans:
processOrder would record the same work as a single node, with the model call, the database read, and the validation collapsed into the root’s input and output.
Multi-File Projects
For projects with instrumented functions spread across multiple files, create a dedicated file that initializes Bitfab and exports the function. Import it wherever you need to instrument.Wrapping Existing Functions Inline
When wrapping a function you didn’t define (e.g. an SDK or library call), pass it directly towithSpan and call the result immediately. This ensures the arguments are captured as span input.
Using withSpan() Directly
For a single span without linking to a function group:
Automatic Nesting
Spans nest automatically based on call stack:captureWhen: "nested":
Method Decorators (TypeScript 5+)
Decorators are an optional shorthand for class methods. Within opt-in tracing, preferwithSpan() because it works with every supported TypeScript version and every kind of callable. For new workflows on a supported build path, start with opt-out withTrace() and configure discovered calls with withNode().
Requirements
Use decorators only when all of these requirements are met:
- The project compiles with TypeScript 5.0 or newer
- Its compiler or transpiler supports the standard ECMAScript decorator transform
experimentalDecoratorsis disabled or omittedemitDecoratorMetadatais disabled or omitted
span() decorator does not support TypeScript’s older legacy decorator transform. Projects that use legacy decorators or emitted decorator metadata should continue using withSpan(). The experimental subtree trace() and node() decorators are separate APIs and support legacy method decorator output when @bitfab/sdk/transform runs first.
Bind methods to one trace function
Use getFunction() when several methods belong to the same traced workflow. Calls nest automatically when one decorated method calls another:
new DocumentService().process(text) records one trace with process as the root and #normalize as a nested call.
Decorate a method directly
Use @bitfab.span(traceFunctionKey, options) when a class has only one method to trace or you do not need a key-bound getFunction() handle:
this are handled exactly as they are with withSpan().
Continue using withSpan() for:
- Standalone functions
- Class fields and accessors
- Functions from other libraries
- TypeScript 4.x projects
- Projects using the legacy decorator transform
withSpan(), getFunction(), framework handlers, and replay. The published SDK declarations do not reference TypeScript 5-only global types. Only the @...span() syntax requires TypeScript 5.0 or newer.
Span Options
Parameters:traceFunctionKey(required): String identifier for grouping spansname(optional): Display name. Defaults to the function’s qualified name (Order.processfor a method,processfor a plain function), then the trace function keytype(optional): Span type. Defaults to"custom". A label only, used to organize and filter spans in the dashboard; it does not change how the span is traced, replayed, or evaluatedcaptureWhen(optional):"always"(default) or"nested". Nested-only spans are captured under an active parent and run untraced when called standalone. Unknown values warn once and default to"always"experimentId(optional): Link the span and, when it is the root, its trace to an experiment.testRunIdis a deprecated alias; passing both with different values throwsmockOnReplay(optional): Return this call’s recorded output under the defaultmock: "marked"replay strategyfinalize(optional):(result) => serializableView. Record a serializable view of a non-serializable result (a live stream). See Tracing streaming functions
Tracing Streaming Functions
A streaming function returns a live stream object that the caller consumes directly (an SSE response, a UI message stream). That object isn’t serializable as a trace output, and awaiting it to completion before returning would break streaming and first-byte latency. BecausewithTrace does not have a finalize option, treat this root as an explicit opt-in fallback. Keep its whole call stack opt-in and never put the span beneath withTrace.
The withSpan finalize option hands the live stream back to the caller unchanged, but records await finalize(result) as the span output, a drained, serializable, replayable value such as { text, usage, toolCalls }.
For the Vercel AI SDK, use the prebuilt finalizers.aiSdk helper. Reading the result’s text / totalUsage promises does not consume the live stream (the AI SDK tees internally), so your own streaming is unaffected. (Use both together: the middleware records the model call as a child span, finalizers.aiSdk records the streamed output on the root. The middleware alone, with no root, records one llm span per call and no workflow around it.)
chat-turn key, so the model span nests beneath the root:
wrapLanguageModel, runChatTurn records a single node: the model call happens inside the span but is not one, so there is no recorded prompt, no per-call token usage, and nothing to mock on replay.
Provide your own finalize to record a specific shape:
ReadableStream, use finalizers.readableStream, which tee()s the stream and collects its chunks; the caller must use the live branch it hands back:
finalize runs in the background and never affects the caller’s value; a finalize that throws records an error on the span instead of crashing the host. It is ignored for async-generator results, which are captured automatically. Inputs to the wrapped function must still be serializable for the trace to replay.
An async generator has two async chains: the service produces values and the controller consumes them. To include spans from both chains in one trace, make the controller the outer root and trace the generator as its child:
Span Context
UsegetCurrentSpan() to get a handle to the active span, then call .addContext() to attach contextual key-value pairs from inside a traced function — useful for runtime values like request IDs, computed scores, or dynamic context:
addContext call pushes the entire object as one entry. Multiple calls accumulate entries:
Span IDs
Access the canonical Bitfab span and trace IDs fromgetCurrentSpan().id and getCurrentSpan().traceId. These are useful for persisted lookups, replay, or logging:
Span Prompt
UsegetCurrentSpan() to set the prompt string on the current span. This is stored in span_data.prompt and is useful for capturing the exact prompt text sent to an LLM:
setPrompt call wins — it overwrites any previously set prompt on the span. Calling setPrompt outside a span context is a no-op (it never crashes).
Framework Integrations
Bitfab provides automatic tracing for popular AI frameworks. See the dedicated guides for full API references:LangGraph / LangChain
Callback tracing plus Experimental (alpha)
ToolNode output mocking for replayOpenAI Agents SDK
Trace processor for agent runs
BAML
Auto-capture prompts and LLM metadata
Claude Agent SDK
Capture LLM turns, tool calls, and subagents
Vercel AI SDK
Language model middleware for every generateText / streamText call
Trace Context
UsegetCurrentTrace() to set context that applies to the entire trace (all spans within a single execution). This is useful for grouping traces by session or attaching trace-level metadata:
setSessionId(id): Groups traces by user session. Stored as a database column for efficient filtering.setName(name): The trace’s title in Bitfab, and a field you can search and filter on. Use it for the case, ticket, or record the run is about. Unset, the trace is titled by its trace function key.setMetadata(obj): Searchable exact-match metadata on the trace. Merges with existing metadata.
_, ., :, or -; values are strings, numbers, or booleans up to 500 characters after conversion to text. Metadata filters match exact key/value pairs case-insensitively.
Search it from the SDK with client.traces.search. Up to 10 supplied pairs must all match; pass nextCursor back as cursor for another page.
setName recorded. name matches the name’s words in order, so name set to "Order ABC" also finds "Order ABC 123". nameContains matches any part of the name, including inside a word, and needs at least 3 characters. Both are case-insensitive, and every filter you supply has to match.
experimentId to list only the replay traces one experiment produced. Every result also carries experimentId (null for a captured trace), durationMs (the trace’s wall-clock time, null when it has no recorded end time), and tokens ({ input, output, cached, total } summed across the trace’s spans, null when none were recorded). That is enough to compare two experiments on time and token spend without opening each trace:
callerMetadata, name, nameContains, or experimentId is required, so a search can never return the organization’s traces unfiltered. Reach for caller metadata when you are looking up a run by an identifier your own code owns, for the name when you are hunting for a run by the title a person reads, and for the experiment when you want what one replay run produced.
addContext(obj): Key-value context entries. Accumulates across multiple calls.
Dropping a Trace
Call.drop() on the current-trace handle to discard the in-flight trace. Once flagged, spans that complete afterward are not uploaded at all, and the flag rides out on the completion payload, so when the trace completes the server scrubs any payloads that already raced out (the trace, its external trace, and sibling spans), deletes the archived S3 objects, and marks it dropped instead of completed, keeping only a skeleton audit row. Use it to discard runs you never want stored (health checks, test traffic) or a run you know carries sensitive data.
- Safe to call outside a trace (a no-op), and never throws into your application.
Detached Trace
Useclient.getTrace(traceId) to get a handle to a trace that has already closed. This lets you add context, merge metadata, or set the session ID from any process, thread, or agent that knows the trace ID, with no shared in-memory state.
traceId is Bitfab’s canonical trace ID, the same UUID exposed by getCurrentSpan().traceId for native SDK traces and used in Bitfab trace URLs. All methods block: each resolves once the server has applied the change and rejects if the server refused it, the same way getTraceSpan behaves. They write whether or not capture is on, because the trace they name already exists and captureEnabled governs recording new runs. If you were ignoring the returned promise, start awaiting it - a rejected update (for example one naming a trace ID that does not exist) now surfaces to you instead of being logged and dropped.
addContext(context): Appends a context entry. Existing entries are preserved.setMetadata(metadata): Shallow-merges searchable metadata using the limits above.setSessionId(sessionId): Replaces any existing session ID.
Read One Persisted Span
UsegetTraceSpan to fetch one span without loading the full trace. Both the trace ID and exact span ID are canonical Bitfab IDs; ingestion source IDs are not accepted. Look up by id alone, or by name, type, or both. Repeated matches default to the last span.
type is one of "llm", "agent", "function", "guardrail", "handoff", or "custom". With both name and type, a span must match both. occurrence also accepts a zero-based integer. An id cannot be combined with name or type. A missing trace or span returns null.
List a Trace’s Spans
Useclient.spans.list to read a trace’s spans in the order they ran, one page at a time, without loading the full trace. No filter is required. spanType and spanName narrow the list, fields picks which payloads each span carries (input, output, reasoning, content, errors, contexts), and limit is 1 to 200 (default 50). Each span also carries parentSpanId, startedAt, and endedAt. Pass nextCursor back as cursor for the next page. A trace outside your organization throws a 404 BitfabError.
getSpan. It takes Bitfab’s span ID or the span ID the SDK sent, and the span it returns carries its traceId. A span outside your API key’s organization returns null.
Error Handling
Errors are captured in the span and re-raised:error_source: "code". SDK-internal errors (e.g. serialization failures) are recorded with source: "sdk". Both appear in the span’s errors array in the Bitfab dashboard.
Span Delivery
Spans are batched and delivered in the background, so tracing never sits on your request path. To make sure everything reached Bitfab before a script or test exits:false when an export fails or the deadline expires.
Traces also flush automatically on process exit via a beforeExit hook.
OpenTelemetry Transport
The SDK lazily creates one private OpenTelemetry provider and bounded
BatchSpanProcessor per client. It does not replace your application’s global
OTel provider, and an unused client starts no OTel worker. withSpan and every
framework handler submit the same replay-safe Bitfab payloads through one
transport interface. Batches are sent to Bitfab as OTLP/JSON. Each carrier is encoded once and the
request body is assembled from those encodings, so a batch is never re-encoded
to measure its size.
Live and replay traces share the same OTel pipeline. Before completing a replay
experiment, the SDK flushes OTel and uses delivery acknowledgments to confirm
every carrier that reached Bitfab. If any delivery is uncertain, it polls
Bitfab until every submitted replay trace completion and expected span count is
persisted.
For the full ownership model, carrier format, live and replay flows, batching
limits, lifecycle, and failure semantics, see
OpenTelemetry Transport Architecture.
The SDK partitions count-based OTel exports into requests of at most about
3 MB. Each request contains at most 256 carriers and is packed using their
exact encoded size, and up to 32 are sent concurrently
(BITFAB_OTEL_EXPORT_CONCURRENCY, 1-64). Set
BITFAB_OTEL_MAX_REQUEST_BYTES to a positive integer no greater than 3000000
to use a smaller target for a stricter proxy; unsafe values warn
and fall back to 3000000. An oversized carrier gets its own request and uses
the compression and trimming fallback described below. If it still cannot fit
or ingress rejects it with HTTP 413, the SDK reports an export failure.
If Bitfab rejects malformed carriers from an
otherwise valid direct batch, the standard OTLP partialSuccess response is
logged with the rejected-span count and reason.
A single span may use up to 7,800,000 carrier bytes when its dedicated request
gzips below the 3,000,000-byte wire target and remains below the 8,000,000-byte
decompressed ingress limit. The carrier is the payload re-escaped into the OTLP
attribute. If it does not compress enough, compression is unavailable, or it
exceeds the raw ceiling, the SDK replaces its largest fields with
<unserializable: too_large_N_bytes> placeholders until it fits the
2,800,000-byte fallback budget. The trim is recorded on the span’s errors so
the trace is flagged as incomplete.
For transient clients in long-running processes, call client.close(30_000)
when finished. Closing is idempotent: it flushes and shuts down only that
client’s OTel worker, which is also reused by framework handlers created from
that client. A shared application client can remain open and will still shut
down automatically at process exit. Handlers you construct directly, without a
Bitfab client, own their worker and expose their own close().
Content capture from the sim plan
The client reads your organization’s sim plan in the background and applies it to every span it sends. Once the plan has loaded, a span whose capture is turned off in the plan is not sent at all, whether a trace root discovered it or it came fromwithSpan. Its children are still sent, each carrying the
nearest captured ancestor the SDK resolved while the trace ran, so Bitfab nests
them under the closest call above them whose capture is on and the trace still
completes. A trace’s root
span, a span recorded by a framework integration, and a span that recorded an
error are still sent with capture off. They record their name, type, timing,
errors, contexts, and links to other traces, but not their inputs, outputs, or
prompt, and carry content_off_by_simulation_plan: true so Bitfab knows why
the content is missing. Their inputs and outputs are never serialized in the
first place. A call declared with node() or withNode() and a nested trace
root ignore the plan and are always sent with their content, even while the
plan cannot be read. A discovered call that shares a name with a declared node
is still dropped. The plan is matched by the trace’s root trace function key and the
span’s name. The read starts when the client is created (when the API key
comes from a function, as soon as you wrap a function or call getFunction)
and is given a five second timeout. A top-level async root from trace() or
withTrace() that starts while that first read is still running waits for
it, for at most five seconds, so its spans record with the plan applied. Once
the first read has finished, or that wait has run out, no root waits again.
A synchronous root, a plain function that returns a promise, a nested root,
and every other traced call never wait. A span sent while that first read is
still running is held back until the read finishes. If the read succeeds, held
spans go out with the plan applied, so no span leaves the process with content
the plan turned off, not even the first one. If it fails (a network error, a
timeout, an HTTP error other than 404, or a response that is not understood),
held spans go out without their inputs and outputs, and so does every later
span until a read succeeds, with a one-time warning. Each trace’s root span,
spans recorded by a framework integration, calls declared with node() or
withNode(), and nested trace roots keep their content in that case. A
span sent without content only because the plan could not be read still counts
toward maxCapturedSubtreeSpans. flushTraces(), close(), and the
exit hook wait up to the five second read timeout for a read that is still
running, then send what is held without inputs and outputs instead of dropping
it. At most 1,000 records are held per client, and past that the oldest are
sent without inputs and outputs, with a one-time warning. A trace’s completion
waits behind whichever of that trace’s spans are held, and goes out right away
when none are, so a trace made only of framework spans is never held back. A
failed read is retried on the next span after ten seconds. Once any read
succeeds the plan applies from then on, and a failed refresh after that keeps
the last plan in effect. The plan is refreshed about once a minute while spans flow,
so a change takes effect within one refresh. Spans recorded by a framework
integration (the OpenAI Agents processor, the LangGraph handler and
integration, the Claude Agent SDK handler, the Vercel AI SDK middleware)
always keep their content, and the plan cannot turn those off; every span
carries a span_origin record (the SDK name and version, and
instrumentation.name saying what recorded it). The client reads the
plan only when an API key resolves and the client is recording, meaning
capture is on or the call runs inside a replay or seed. Otherwise it sends no
read and prints no warning. A server that has no sim plan
feature answers the read with 404, which counts as an empty plan loaded:
nothing is held and nothing is stripped. Pass simulationPlan: false to the
client to turn the plan off entirely (no read, nothing held back, nothing
stripped), the same as setting BITFAB_DISABLE_SIM_PLAN to any value that is
not empty or whitespace. A browser deployment must proxy
GET /api/sdk/sim-plan alongside the OTLP route, or pass
simulationPlan: false.
Advanced Configuration
timeout: Request timeout in milliseconds for API calls. Defaults to 120000 (2 minutes).envVars: Pass LLM provider API keys for native function execution viacall().enabled: Whenfalse, all tracing is disabled. Wrapped functions still execute normally but no spans are sent.simulationPlan: Whenfalse, the sim plan is never read and content capture is never narrowed. See Content capture from the sim plan.bamlClient: The generated BAML client instance (e.g.,bfrom@baml). See BAML framework guide for full usage.
Replay
A trace is replayable when its root span has serializable inputs, or when the workflow is instrumented through a framework handler (whose recorded root input is itself serializable). One of these must hold for replay to work. Replay historical traces through an updated function version to compare outputs:item.traceId is a real server trace ID for completed items. Plain callables are wrapped in withSpan internally, so every replayed invocation records a trace. If NO completed item’s trace persisted (uploads wholesale failed), replay() throws a BitfabError instead of silently returning null trace IDs. If only SOME items’ traces are missing (a transient per-item upload failure), those items get null trace IDs with a loud console.error and the rest of the run is returned intact. item.traceId is also null for errored (unreplayable) items, and for all items when the server predates the trace-ID mapping (a console warning explains which).
Anything describing the trace being replayed carries the original prefix: originalDurationMs, originalModel, and originalTokens. Unprefixed fields are the replay’s own: durationMs is how long this run’s call took, and tokens is the replayed run’s usage (the same numbers Bitfab’s experiments view shows). Comparing tokens.total against originalTokens.total tells you how your change moved cost. Each field is null when it wasn’t captured.
model remains as a deprecated alias for originalModel. Note that durationMs changed meaning: it used to report the original trace’s duration and now reports the replay’s.
Options:
limit: Maximum number of recent traces to replay (default: 5; maximum: 5,000). Ignored whentraceIds,datasetIdordatasetIdsis passed, since an explicit ID list or a dataset already determines how many traces replay. To replay part of a dataset selection, name the members to run intraceIds.traceIds: Specific trace IDs to replay (max 100). The ID count determines how many traces replay, andlimitis ignored when both are passed. Passed alongside a dataset selector it pins which members of that selection replay, and the server rejects any ID none of those datasets contains.name: What this run is testing, such asbaseline. Bitfab already records the commit, branch, and datasets.notes: Run conditions Bitfab cannot see, such as a forced feature flag. Kept on the experiment next to its name.maxConcurrency: Number of traces to replay in parallel (default: 10)attempts: How many times to replay each trace (1 to 100). Every attempt is its own replay trace under the same experiment, so the experiment reports per-attempt pass rates and flags traces whose attempts disagree. Default:1codeChangeDescription: Optional rationale for the code change being tested in this replay (stored on the experiment); when supplied alone, it is preserved while files are captured automaticallycodeChangeFiles: Optional list of edited files, each as{ path, before, after }(use""for newly created or deleted files); omit to capture automatically or passnullto suppress capturemock: Mock strategy for child spans during replay:"marked"(default, only return historical output for child spans declared withmockOnReplay: true),"none"(run real code for every child), or"all"(return historical output for every matched recorded child; a missing occurrence fails the item closed). See Mocking child spans during replay below.mockOverride: One{ match, value }pair, or an array of them, that injects a custom value into matched spans (first matcher wins). Takes precedence overregisterMockOverrideand the basemockstrategy. See Injecting custom values with overrides below.experimentGroupId: Optional UUID string that groups multiple replay runs into a single experiment group. Pass the same ID across successivereplay()calls to link them together in the dashboard; a new ID creates the group.experimentGroupName: Optional name for the experiment group inexperimentGroupId. Applied when this replay creates the group, or when the group exists without a name; a group that already has a name keeps it. RequiresexperimentGroupId.datasetId: Optional dataset UUID. Replays that dataset’s traces and durably attributes the resulting experiment to it, andlimitis ignored because the dataset determines the item count. PasstraceIdsalongside it to replay only those members.datasetIds: Optional dataset UUIDs, for benchmarking one function against several corpora in a single run. Replays the union of their traces, graded by the union of their graders, and attributes the experiment to every one of them. Pass one dataset throughdatasetIdand several through this.graderIds: Optional array of grader UUIDs (max 100) attached directly to this replay run, independent of the dataset’s own graders. The resulting experiment is graded by the union of these and the dataset’s runnable graders. Use it to grade a single run with a check you don’t want to add to the dataset permanently. Each id must be an active grader in the same organization and trace function, or the replay is rejected with a 400. A replay with no dataset can still carry graders this way.adaptInputs: Optional hook to reshape recorded inputs onto the function’s current signature when its shape changed after the traces were captured. See Adapting inputs after a signature change below.onItemStart: Optional callback fired when a worker begins processing an item, before replay setup and customer code run. Pair it withonItemFinishto distinguish queued items from in-flight items whose callback has not returned. A throwing callback never crashes the run. The installedbitfab-replaycommand usesreportReplayProgressfor both callbacks so liveness heartbeats identify the historical traces currently in flight.onItemFinish: Optional callback fired exactly once per item as it finishes, always with that item plus running totals (original/source trace id, the server replaytraceIdread back per trace off the OTLP ingest response and surfaced as each item finishes (its trace is flushed on finish, so the id is in hand at the callback, not only after the whole run), input, result, original output, error, duration, tokens/model metadata). It never emits a whole-run completion event. Use it to render live progress or start evaluating completed items while replay runs. A throwing callback never crashes the run. The deprecatedonProgresscallback receives the same per-item events plus its legacy item-less terminalcompleteevent, and is ignored when both are supplied. The installedbitfab-replaycommand uses the SDK’s ready-madereportReplayProgresscallback for both lifecycle hooks; it writes events to stderr, which the plugin uses to identify in-flight traces, report finished items, and write per-item result files (stdout remains available for direct-runReplayResultJSON).dbBranch: Optionalboolean | DbBranchOptions.dbBranch: truerequests a DB branch per replay item with the mirror’s own sizing; pass an object to tune it, andfalseor omission leaves branching off. Each replay worker resolves its branch from the source trace’s captured snapshot reference, somaxConcurrencyalso bounds live branches.getCurrentReplayBranch()hands the branch to you inside the replayed function, and the SDK releases it after the item. The accessor returnsnullwhen no branch was resolved (e.g. the trace predates snapshot capture, or DB branching isn’t configured), sobranch?.databaseUrl ?? process.env.DATABASE_URLfalls back to your live database. The fields tune the branch:minCuandmaxCuare the compute’s autoscaling floor and ceiling (0.25 to 56). Equal values pin a fixed size, allowed up to 56; an autoscaling range may not span more than 8 CU or exceed 16 CU; setting them equal pins the size, so one item can’t post a better number purely because it ran against an already-scaled endpoint.warmupSqlis appended to the branch’s readiness check, so the cache is warm before your function sees the branch and the warm-up is never charged to the replayed call. Omit them and the branch keeps the mirror’s own defaults.resume: Optional experiment ID of a replay that stopped partway. Runs only the trace and attempt pairs that never finished, in that same experiment. See Resuming a stopped replay below.force: Optional, withresumeonly. Resumes even when the experiment may still be running or the code changed since it started.onInterrupt: Optional callback fired when Ctrl-C (SIGINT) or SIGTERM stops the run after its experiment was created, once the experiment is marked interrupted. See When a replay stops early below.
Replaying handler-instrumented functions
Workflows instrumented through a framework handler (getLangGraphCallbackHandler, getLangChainCallbackHandler, getClaudeAgentHandler, getOpenAiAgentHandler) have no withTrace- or withSpan-wrapped root in the application code: the handler (or run wrapper) records the framework invocation itself as the root span, with the framework’s own input (a LangGraph initial state, an agent prompt, the run input) as the recorded root input. LangGraph/LangChain roots are registered as pending traces when the root callback starts and completed when it ends, so long-running runs can appear before final output is available. These traces are fully replayable. Pass the handler’s trace function key plus any plain callable that re-invokes the framework entrypoint:
The OpenAI Agents SDK uses
getOpenAiAgentHandler(key).wrapRun(agent, input) (a drop-in for run) for the replayable root; the bare getOpenAiTracingProcessor captures internals only and records an empty-input root. The Claude Agent SDK handler needs a hint: the prompt is not present in the message stream, so pass it explicitly, wrapQuery(stream, { input: prompt }) (or wrapResponse(stream, { input })), for the handler to record a replayable root.replay()fetches the handler-recorded production traces by the key string, and wraps a plain callable inwithSpanunder that key internally so each replayed invocation records a trace tied to the experiment. The key is the only link; it does not matter that production traces were written by the handler and the callable was written today.- Passing an already-
withTrace- orwithSpan-wrapped function under the same key also works (older SDKs require a wrapped form); a wrapped function whose key contradicts the replay key throws. - The recorded root input is whatever the handler captured at the framework boundary (a LangGraph state object arrives as a single argument).
- Attaching the handler inside the callable makes the replayed graph’s node/LLM/tool spans nest under the replay span, so replay traces have the same tree as production ones.
- The callable rebuilds runtime wiring the trace never captured: framework
configurable, dependency objects, API keys. Put every unsafe call made by that wiring behind a replay-mockable marked span. Use a no-op value only for a replay-only callback slot with no recorded call to mock.
Mocking child spans during replay
For the workflow-level guide, see Replay Mocking. When iterating on a root function, child spans sometimes fail in your local environment for reasons unrelated to the code under test: a paid API key is missing, an external service is flaky, or a production-only DB row isn’t seeded locally. Themock option lets the child return its recorded output so the root function can still run.
Three strategies on replay():
"marked"(default): only descendants declared withmockOnReplay: trueare short-circuited; everything else runs real. This is the iteration-friendly mode."none": every child span runs real code. Use only when you intentionally want every dependency real and have verified that is safe."all": every matched recorded descendantwithSpanreturns its historical output. The root function still runs real; a missing or exhausted child occurrence fails the item closed. Useful for a quick sanity-check against recorded data; not the recommended iteration strategy because changes to matched descendants won’t execute.
SpanOptions.mockOnReplay:
mockOnReplay is a per-span tag at definition time — it has no effect outside replay, and it’s read by the default mock: "marked" strategy. The root function always runs real code; only descendants can be mocked.
When a strategy selects a child for mocking but no historical occurrence is available (for example, the recorded trace did not reach that branch or the replay called it more times), the item errors and the real child does not execute.
Injecting custom values with overrides
Marking a span replays its recorded output. A mock override substitutes a value you supply for a matched span, so downstream real code runs against it — for “what if this step returned X” experiments without editing the traced code. An override is a{ match, value } pair: match selects spans by structural metadata (spanName, type, traceFunctionKey, originalSpanId); value is the substitution (full replacement) — a flat value, or a function of the context.
The trace function key passed to replay() selects the workflow’s historical root traces; it does not by itself identify the descendant to override. Because the example above binds every call to process-article, match the descendant by its span name. If your calls use separate trace function keys, matching node.traceFunctionKey is also valid.
value can also be a function receiving the span’s live replay inputs and a getOriginalOutput() that lazily fetches the recorded output (memoized per trace) when you want to tweak it rather than replace it:
value (one that never calls getOriginalOutput) fetches no recorded outputs at all. Register overrides on the client to apply them to every replay:
{ match, value } pair; in the latter case both the key and match must match:
NO_MOCK_OVERRIDE to decline a span without using undefined (which remains a valid mocked output):
mockOverride, then registered overrides, then the base mock strategy. NO_MOCK_OVERRIDE continues at the next override, then falls back to that base strategy. A synchronous wrapped function cannot wait for any Promise-returning resolver, including one that eventually resolves to NO_MOCK_OVERRIDE. For mixed sync/async trees, use a non-async routing function that returns NO_MOCK_OVERRIDE synchronously for sync keys and returns a Promise only for async keys. Likewise, because getOriginalOutput() is async, a synchronous span cannot use it; make the span async, or use mock: "all". A flat value or synchronous function works on synchronous spans.
Adapting inputs after a signature change
Replay deserializes each trace’s inputs exactly as they were captured against the function’s signature at trace time, then spreads them into the current function. If the signature drifted since capture (a param renamed, reordered, collapsed into an options object, or a new required arg added), that spread no longer lines up and the call throws. TheadaptInputs hook reshapes the recorded inputs onto the current signature so replay can still run:
error is set and the run continues, so a single unmappable trace never crashes the batch. The array it returns is what gets spread into the function and what item.input reports.
ctx carries { originalTraceId, originalSpanId } (with deprecated sourceTraceId/sourceSpanId aliases) so a table-driven adapter can look up a per-trace transform (ctx.originalTraceId is the original Bitfab trace ID). This is the escape hatch for reshapes that need judgement rather than mechanical rearrangement: compute the adapted inputs per trace up front, then have the hook look them up by sourceTraceId, keeping replay deterministic instead of calling a model mid-replay.
When the new signature has a genuinely new required input with no analog in the recorded trace, don’t fabricate one — there’s nothing faithful to map it to. Leave those traces unmapped (let them error) rather than inventing test inputs.
For anything beyond a one-liner, keep the adapter in its own file next to the replay registry and import it — the AdaptInputsFn type is exported for this:
Attaching a Code Change
Each replay creates an experiment. When you’re iterating on a function and replaying after every edit, attach the change so the dashboard can show exactly what was edited alongside the results. The agent reads each file before editing, edits, then reads it again — the two strings go straight intocodeChangeFiles. There’s no diff format to construct.
codeChangeDescription for a quick rationale-only annotation, or just codeChangeFiles to record the literal edits.
If you omit codeChangeFiles, replay() falls back to capturing your uncommitted working-tree changes against HEAD (best-effort, only inside a git repo), so an experiment shows the edit you are trying rather than your whole branch. When the working tree is clean, it diffs against the branch yours was cut from instead (the parent branch in a stack, otherwise the point where you split from the default branch: origin/HEAD, else origin/main, origin/master, main, or master), so changes you already committed still show. Set BITFAB_CODE_CHANGE_BASE to a ref such as origin/main to diff against its merge-base instead. This fallback uses Git rename detection: a renamed-and-edited file is compared once under its destination path, while an unchanged rename adds no content diff. A supplied codeChangeDescription is preserved while the files are captured. Passing codeChangeFiles explicitly always wins and is the way to record a precise per-edit before/after. To opt out for one replay run, pass codeChangeFiles: null (and optionally codeChangeDescription: null if you also want no description). Set BITFAB_DISABLE_CODE_CHANGE_CAPTURE to turn the fallback off for every replay in the process.
Notes:
- Use a single
Bitfabclient across instrumentation and replay. If your instrumented module constructsnew Bitfab()at import and your replay registry constructs another, they do not share registered trace functions — import the client from the instrumented module (or a shared singleton) rather than constructing a new one in the registry.
Resuming a stopped replay
A replay that stops partway, for example because the process crashed or you pressed Ctrl-C, leaves an experiment with some items finished. Bitfab shows it as interrupted, and nothing in it is graded yet. Pass that experiment’s ID asresume to run only the trace and attempt pairs that never finished. They run in the same experiment. The finished ones are kept, not replayed again.
carriedOver: true. They carry their originalTraceId, attempt, and replay traceId, but no input, result, or timings. Their error is set when they errored in the earlier run.
The experiment already fixed its traces, attempts, and graders. So resume cannot be combined with traceIds, limit, datasetId, datasetIds, attempts, graderIds, onlyWithAssertions, or dryRun. Database snapshots follow the setting the experiment started with, and a different dbBranch passed now is ignored with a warning.
Resuming an interrupted experiment sets it back to pending while the rest runs. Bitfab refuses to resume an experiment that:
- is already completed or failed
- was started before resume was available
- was started as an experimental selective replay, since the rest would run as full replays
- was started, resumed, or received replay results in the last two minutes, so a run replaying it may still be running
- was started from different code than the code running now
force: true to resume anyway in the last two cases. From the command line, run bitfab-replay --registry scripts/replayRegistry.ts <pipeline> --resume <experimentId>, with --force for the same two cases. When a run fails after its experiment was created, bitfab-replay prints a Resume with: line holding that command. See ReplayOptions in the TypeScript reference for the full behavior.
When a replay stops early
When a replay stops after its experiment was created, the SDK marks that experiment interrupted instead of leaving it pending. The dashboard shows an Interrupted pill, how many traces finished, and the resume command ready to copy.- Ctrl-C (SIGINT) or SIGTERM. The SDK marks the experiment interrupted, waiting up to five seconds for Bitfab to answer. It then calls
onInterruptwithexperimentId,experimentUrl,signal("SIGINT"or"SIGTERM"), andmarkedInterrupted, which isfalsewhen the request failed. WithoutonInterrupt, it prints[replay] Experiment <id> interrupted.to stderr, or[replay] Experiment <id> stopped, but Bitfab could not mark it interrupted. It will be marked interrupted within 15 minutes. A resume in the next two minutes may be refused as still running.when the request failed, and lets the signal end the process, or reach your own handler for it if you installed one. - A signal once the run has started finishing. The last request completes and grades the experiment, and can take up to two minutes. The SDK stops listening for signals just before it sends that request, so a signal arriving after that ends the process as it normally would, with no
onInterruptcall and no interrupted line. An experiment left pending by that kill is marked interrupted by Bitfab’s own 15-minute check. - An error that ends the run. The SDK marks the experiment interrupted, then throws the error as before.
onInterruptis not called. - A process killed outright, for example with SIGKILL. Bitfab marks the experiment interrupted itself once it has gone 15 minutes with no replay activity. It checks every five minutes.
onInterrupt is set, what happens next is up to it. The replay keeps running if it returns, so exit from it to stop:
bitfab-replay does exactly this. On SIGINT or SIGTERM it prints [replay] Experiment <id> interrupted. Resume with: bitfab-replay --registry <path> <pipeline> --resume <id> to stderr, or the longer line above followed by the same Resume with: command when Bitfab could not mark it interrupted, then exits with code 130 for SIGINT or 143 for SIGTERM. The printed command also carries any --mock, --params, and --param flags the run started with, because Bitfab does not store them, so the rest runs the same way.
An interrupted experiment waits for you to either resume it or mark it finished. Mark finished on the experiment page, or save_experiment with status: "completed" from your coding agent, grades the traces that already finished and leaves out the ones that never did. After that it can no longer be resumed.
Programmatic Replay Output
The installedbitfab-replay command owns result serialization and output for registry-based replay. If you call bitfab.replay() directly from custom programmatic tooling, BITFAB_REPLAY_RESULT_PATH writes the full ReplayResult JSON to that file. For a direct custom command, emit the full ReplayResult as a single stdout JSON block so a consumer can JSON.parse it and reason about every field, including the per-item originalDurationMs, originalTokens, originalModel, and tokens. Never print only lengths, counts, hashes, or truncated previews, and never replace the JSON block with ad-hoc per-field log lines.
Direct API example:
input, result, originalOutput, error, structured traceError and replayError, durationMs, originalDurationMs, originalTokens, originalModel, tokens, model, dbBranchTimings, and traceId, plus experimentId and experimentUrl (and the deprecated testRunId and testRunUrl with the same values). Import serializeReplayResult from @bitfab/sdk; raw JSON.stringify drops the useful fields on JavaScript Error objects. The installed command sets BITFAB_REPLAY_RESULT_PATH and writes the same structured JSON there.
Per-item errors are part of the contract. If the wrapped function throws while executing the replayed trace, bitfab.replay retains the actual exception in item.traceError, copies its message to item.error, leaves item.result undefined, and continues. If replay setup fails before the function starts (for example database warmup or input loading), the actual exception is instead in item.replayError. A database branch resolution failure is a DbBranchReplayError; inspect its code, message, and originalTraceId to distinguish failures such as branch_create_failed, snapshot_from_replaced_origin, and invalid_snapshot_ref without parsing item.error. A lease-endpoint HTTP, timeout, or network failure uses lease_request_failed and retains the original client exception as cause; unexpected resolver failures use internal_error. Treat either error kind as unreplayable, not as a failing output. If the whole run later throws, ReplayError.items still contains every collected item and ReplayError.cause retains the whole-run exception.
Don’t swallow per-item errors in the registered function or a direct API caller. A custom try/catch that returns a placeholder turns infrastructure failures into fake successes. Let the SDK record them.
Input serialization caveat. Replay deserializes historical span inputs and passes them back to your function. This works for strings, numbers, and plain objects. If your span wraps a function that takes hydrated domain objects (ORM models, class instances, DB records), they won’t round-trip through serialization — move the span to where inputs are IDs or plain data and let the function fetch objects internally, or reshape arguments in the wrapper.
Replay Registry
For experimental execution on your own GitHub Actions, see Cloud replay. Create a small registry module. Your project owns only the imports and the mapping from command names to the exact traced functions production calls. The SDK installs the standardbitfab-replay executable, so upgrading @bitfab/sdk updates argument parsing, progress events, code-change loading, result serialization, and summary output without regenerating project code.
replayMocks.ts can export a mock factory whose { match, value } result uses live replay inputs and caller-supplied parameters. optionsFactory receives JSON values loaded from --params <file> and repeated --param name=value; direct parameters override file values.
Run the executable with bitfab-replay --registry scripts/replayRegistry.ts <pipeline>. It loads TypeScript registry modules directly and turns on subtree tracing itself by loading @bitfab/sdk/transform/register, so a replay records the calls beneath each withTrace root the same way your transformed build does. If your application already preloads both loaders, keep tsx first (node --import tsx --import @bitfab/sdk/transform/register node_modules/@bitfab/sdk/dist/replayCli.js ...); the transform is not applied twice. If the transform cannot load, for example on a Node.js version it does not support, the command warns once and each replay records only its root span.
Put static function-specific behavior such as
adaptInputs, mockOverride, or dbBranch in options; build parameterized behavior in optionsFactory. Command-line values override overlapping scalar defaults without removing unrelated executable options.
Stopped with Ctrl-C or SIGTERM after its experiment was created, the command marks the experiment interrupted, prints the Resume with: command, and exits with code 130 for SIGINT or 143 for SIGTERM. See When a replay stops early.
When the traced function needs the app’s dependencies
Most agents need tools, clients, and config that the app builds at startup. The registry gets them the way production does, by calling the app’s own code.- Put the registry where production builds the call. That is the package that builds the tools, clients, and config and then calls the traced function. Its
package.jsonalready depends on all of them. Runbitfab-replayfrom that package’s directory so itsnode_modulesandtsconfig.jsonpath aliases resolve. When the traced function lives in a lower-level library, the registry still goes in the app package that calls it. Add@bitfab/sdkto that package if it is not there yet. Never copy the app’s dependencies into the library. - Build the function the way production builds it. Call the factory or constructor that production calls at startup, and register the traced function it returns. Replay runs that factory, so give it the settings it requires, such as URLs and API keys, even for tools whose calls are mocked. Never write a smaller toolset or a simplified agent for replay. The replay would test that copy instead of your code.
- Rebuild the arguments the recording cannot keep. A live object passed as an argument, such as a tool with its function, a client, or a callback, is recorded as data only. A tool keeps its name and description but loses the function that runs it. Rebuild these arguments in the entry’s
adaptInputswith the same code production uses. Never replace them with an empty list, a stub, orundefined. When production builds them inline, move that code into an exported function that production and the registry both call. - Know which tool calls run for real. Under
mock: "marked", a call inside a span markedmockOnReplay: truereturns its recorded output and never runs.mock: "all"does the same for every recorded call. Every other tool call runs for real during replay. A framework integration never marks its tool calls for replay mocking on its own. The Claude Agent SDK handler cannot mark them at all, so its tool calls always run for real. Put every call with an unsafe side-effect, such as sending an email or charging a card, behind a marked span before you replay. See Mark child spans. - Keep experiment settings out of the registry. Set a model swap or any other experiment setting through an environment variable the app already reads. Never add a second registry entry with the setting written into it.
- Check the wiring before a real run.
--dry-runresolves every item’s inputs without calling the function. Then replay one trace with--limit 1. With your code unchanged, the replay should call the same tools the original called.
AgentRunner.run in @acme/agent-core, and the app package builds the tools. The registry lives in the app package. It registers the runner’s method bound to an instance and rebuilds the tools for each replayed call.
.bind() keeps its trace function key in SDK version 0.64.2 and later. See Replay registry and command for the fields an entry accepts and how the command reports a registry that cannot load its imports.
Seeding traces
Replay needs a trace to replay. Until production has produced one, there is nothing to select, so a corpus you already hold (a dataset export, a spreadsheet, hand-written cases) cannot be run.seedTrace writes that corpus into Bitfab as replayable traces and returns each trace ID.
It has two forms, chosen by the second argument. Pass a function to run it once and record the execution. Pass a case to write a trace without running anything.
Seed by running the function once
runTicket("T-1") once and records the execution as an original trace: root span, first-party subtree, the real input, and whatever the run produced as the output. Capture stays off for everything else, so a seeding script does not have to run with tracing on for the rest of the process.
The trace lands under agent-turn with ingestion_type: seeded, replay selects it like any captured trace, and every replay of it links back as originalTraceId. Because it has a full recorded subtree, replay mocking works on it exactly as it does on a captured trace.
fn resolves the same way it does for replay. A withTrace- or withSpan-wrapped function records under its own key, which must match the key you pass, and a plain callable is wrapped under the key here. An exception is recorded on the root span, the trace still persists, and the exception is re-thrown. A call that records nothing (no API key resolved) rejects rather than handing back an ID replay could never find.
Seed from a case without running
input as its input and expected as its output, so a later replay reports the item against the value you expected rather than against a previous run. Passing fn checks the case against the function’s required argument count, so a case that could never run fails while you seed instead of during replay. Omit it when the seeding script cannot import the callable.
A case-seeded trace has no child spans, so replay mocking has nothing recorded to substitute. Supply mockOverride at replay time for calls that must not run for real. Neither seeding form pins a database, so dbBranch refuses a seeded trace.
Options both forms share
name, metadata, and sessionId apply to either form. name is the trace’s display title. Put lookup keys such as ticket IDs and dataset row names in metadata, where exact key/value filters can find them. Metadata is also handed to a later replay’s adaptInputs hook as ctx.metadata, so a case’s provenance rides with the trace instead of through the recorded inputs. In the run form, if the function also emits its own trace through an integration that exports trace metadata, the caller’s metadata is merged onto that export and wins on any shared key.
The case form additionally takes spanName and spanType, which label and type the root span it writes. The run form has no equivalent, since the executed function names its own root span.
Re-seeding a trace
A trace whose recorded run is wrong (it errored, or the world it ran against has moved on) can be re-seeded.reseedTrace reads the trace’s recorded inputs, name, session, and metadata, runs the function once the way seedTrace does, and asks Bitfab to adopt that run under the same trace id.
previousRunTraceId, with reseedOfTraceId pointing back at the case. Nothing is mocked and no experiment is created; a re-seed is a seed, not a replay. A run that throws is recorded but never adopted, so the trace is untouched, and Bitfab rejects a run that comes from another function or already belongs to a dataset. Graders on the datasets holding the trace re-run afterwards, and default replay selection skips previous runs.
From the shell, bitfab-seed <pipeline> --from-trace <id>[,<id>...] does the same through the replay registry:
Seeding a whole cases file
seedFromRegistry seeds through an already-registered pipeline, reusing its client, callable, and trace function key, so every case is written against the exact function the later replay selects. The installed command does the same from the shell:
cases.jsonl is a JSON object with an input array plus optional expected, metadata, and sessionId. A JSON array of those objects works too. With --run, the output is what the run produced, so a case carrying expected is rejected. The registration’s adaptInputs is a replay hook and is not applied at seed time, so a seeded trace is never adapted twice.
Replaying seeded traces
replay selects seeded traces the same way it selects captured ones, and each item reports its source’s ingestionType (a source with none reads as captured). The bitfab-replay summary counts a seeded item as matched or missed against its expected value rather than same or changed against a previous run, because a seeded trace’s recorded output is an assertion and not a prior run’s result. One run can replay both kinds, so both pairs of counts print when both are present.
See the reference for full signatures.
Datasets
client.datasets creates, reads, and modifies datasets programmatically, with the same operations your coding agent reaches through the Bitfab MCP tools. A dataset is a named bucket of traces under one trace function. Experiments replay against it and its graders score its members.
save is an upsert on the dataset name within its trace function, so re-running a script does not accumulate duplicates. update renames a dataset or changes its description while keeping its id, traces, and graders. An empty description clears it. Membership and grader calls accept up to 100 ids and report ids they skipped rather than failing the whole call. removeTraces only drops membership. Traces are never deleted. rerunGraders resolves once the run completes and rejects with GraderRerunTimeoutError if it is still going after 90 seconds (configurable), while the run keeps going on Bitfab. Hold the promise and await it later to do other work meanwhile. See the reference for every method and result type.
Verdicts
client.verdicts writes verdicts and reads them back, the same operations your coding agent reaches through the save_agent_verdicts, save_human_verdicts, and get_trace_verdicts MCP tools. A verdict says how a run that already happened turned out: "pass", "fail", or "abstained" when the evidence does not show what the assertion checks. Written per assertion, it is stored and read back per assertion, so a judge inside a replay process can score each assertion on its own and verify what landed without opening Bitfab. Add evidence: [{ spanId, text }] to ground its annotation in spans on the evaluated trace; get and getAll return that evidence with the verdict. client.verdicts.generateEvidence(traceId, assertionId) matches MCP’s generate_verdict_evidence call. Pass the original or replay trace being judged; the assertion must belong to it or be inherited through its replay lineage. The selected trace is narrowed to relevant spans before candidates return with spanId, text, spanName, signature-only parameters, spanType, and isMocked.
client.labels, generateLabelEvidence, graders.getLabels, saveLabel, saveLabelAll, the Label* types, and the label: boolean write field are the deprecated names for the same calls, kept for one minor.
Every agent verdict scores one approved assertion, so save, saveAll, and skip require assertionId. Leaving it out is a type error, and at runtime the call throws a BitfabError before sending anything. Verdicts are only written on assertions, never on a whole trace, so a trace with no approved assertions gets no agent verdict.
Use client.verdicts.skip({ originalTraceId, attempt, assertionId, annotation }, experimentId) when an attempt crashed or its assertion could not be checked, with annotation saying why the verdict was withheld. It is read back by client.verdicts.get, and it is what separates a verdict nobody could reach from a trace nobody looked at. Use client.verdicts.archive({ traceId, assertionId }) to clear a previous assertion verdict. archive is the one write that may omit assertionId, which clears an old whole-trace agent verdict.
humanNote carries people-only context on an assertion. Pass it on a saveAssertions entry to write it, omit it on an edit to keep what is stored, and pass null to clear it. Do not include it in the object sent to an LLM judge; verdict evidence is the assertion, its pass/fail criteria, its target, and the evaluated trace.
save and saveAll write the agent’s verdicts. saveHuman and saveHumanAll write verdicts recorded as written by a person, for cases a person has already decided, such as a production bug captured as a regression test, and each one names the assertion it scores with assertionId. Verdicts have no approval step, so both kinds count as soon as they are written. Both batches are all-or-nothing: a trace outside the organization, a repeated target, or an assertion that is not active on its trace rejects the call and writes nothing. get and getAll return each trace’s effective verdict plus one row per scored assertion. graders.getVerdicts is the per-grader breakdown the effective verdict folds together. graders.saveVerdict and graders.saveVerdictAll post verdicts for external graders, which you run yourself and Bitfab never runs. See the reference for every method and type.
Experiments
client.experiments reads runs back after the fact, for a dashboard or a report outside the replay process. It is a read surface; experiments are still created by replay. list pages the organization’s experiments newest first with every filter optional and combinable, get reads one with its provenance, and getRollup / getRollupAll return the pass/fail rollup the dashboard shows, with one row per category. getRollupAll takes up to 100 ids in one request that costs two queries, so read a month of runs in one call rather than one per run.
metadata on replay, or --metadata key=value on bitfab-replay, to tag a run with your own strings. They come back on every read and list matches them by containment. See the reference for every method and type.