Skip to main content
The Bitfab TypeScript SDK captures your AI function calls to automatically generate evaluations. Re-run your prompts with different models, parameters, and inputs to iterate faster.

Installation

The SDK includes the transform required for the recommended opt-out subtree capture path. Wire its adapter into the application build as described under Experimental Subtree Tracing. When a 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 works with every supported TypeScript version and with class methods, standalone functions, class fields, accessors, and functions from other libraries.
The stable 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.
Need an API key? Get one from the Bitfab dashboard or see the API Keys guide for detailed setup instructions.
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, an import 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.
When no key is passed (or it resolves empty), the SDK falls back to reading 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:
If you load env with dotenv in a script, prefer loading it before the module graph is evaluated, for example node --env-file=.env script.ts, so every module-level read sees the key.

Tracing

Experimental subtree tracing

Experimental. trace() and withTrace() subtree expansion may change in a future release. withSpan() is the stable explicit instrumentation API. Opt-out is the recommended starting point despite that, because the trace shape you get by default is the one you want; see Instrumentation.
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 installed bitfab-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 and its server-side meta-frameworks use the ordinary Vite plugin shape:
vite.config.ts
For a direct TypeScript process, the registration hook both instruments and compiles repository modules, using the 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:
In a monorepo, run from the repository root to include shared packages in capture. The loader resolves its runtime from the SDK available to the transform installation, even when a shared package has no SDK dependency. To select a different application’s SDK, set BITFAB_SDK_RESOLVE_FROM to its package.json:
This selects the SDK installation; the working directory still determines capture scope and relative function IDs. It works for both ESM and CommonJS. Nest has a dedicated config wrapper. Select its webpack builder in nest-cli.json:
nest-cli.json
Then append the transform to Nest’s generated webpack configuration:
webpack.config.cjs
A Nest application using the 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:
For function-oriented code, use withTrace:
Both forms are supported. These docs lead with the handle because 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:
Pick whichever reads better at the call site. The inline key is fine for a single root in a module. The handle starts paying off as soon as a second surface under the same key appears, or when the key comes from configuration rather than a literal. The decorator and the 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:
  • name and type control the rich root span. Unconfigured descendants are function spans.
  • mockOnReplayDefault: true makes replay mocking the default for nodes under the trace’s default mock: "marked" replay strategy. A configured node with mockOnReplay: false overrides it. The option is off by default.
  • maxDepth (default 30) and maxCapturedSubtreeSpans (default 500) bound each invocation, and nothing caps the total number of spans one trace root sends. maxSpans was removed, so passing it is a type error. maxCapturedSubtreeSpans caps discovered calls sent with their inputs and outputs. Calls declared with node() or withNode(), 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. Past maxCapturedSubtreeSpans, 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 keys bitfab.truncated_by (the limits that fired, in the order max_depth, max_captured_subtree_spans) and bitfab.dropped_spans (how many calls were not recorded) because the emitted tree is incomplete.
  • exclude accepts 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: true is set.
Each invoked function in a transformed server module becomes a real nested span. Automatic child spans capture their full inputs, outputs, and thrown errors by default, with no 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.
Inside 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:
A blended stack produces a trace whose shape misrepresents how the code is instrumented, and the replay boundaries stop being predictable, so the SDK stops rather than recording it. To resolve it, pick one surface per workflow. Inside a subtree trace, reach for 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.
Your own functions that run inside an open OpenAI Agents SDK span, such as a function tool’s body, attach under that tool span inside a 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

Trace the whole workflow, not just its entrypoint. In the opt-in surface, a single span decorator or withSpan wrapper on the outer function records one input and one output for everything inside it, which leaves replay mocking, per-step diagnosis, and prompt iteration with nothing to work on. Spans exist only where you create them: nesting is automatic, but only between spans that exist.
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.
Skip trivial in-memory helpers, per-item work inside a large loop (wrap the loop or the batch), and internals a framework integration already captures. Worked examples, replay-mocking decisions, and common pitfalls: Instrumentation.

Declaring the trace function key

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:
Calling tracedProcessOrder(id) records one trace with four spans:
Wrapping only 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.
Spans from different files are automatically linked as parent-child when one wrapped function calls another.

Wrapping Existing Functions Inline

When wrapping a function you didn’t define (e.g. an SDK or library call), pass it directly to withSpan and call the result immediately. This ensures the arguments are captured as span input.
Never wrap functions in an anonymous function like async () => fn(args). The SDK captures the wrapper function’s arguments as span input — an anonymous wrapper has no arguments, so the span records nothing.

Using withSpan() Directly

For a single span without linking to a function group:

Automatic Nesting

Spans nest automatically based on call stack:
For reusable helpers that should appear only inside an existing trace, use captureWhen: "nested":

Method Decorators (TypeScript 5+)

Decorators are an optional shorthand for class methods. Within opt-in tracing, prefer withSpan() 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
  • experimentalDecorators is disabled or omitted
  • emitDecoratorMetadata is disabled or omitted
The stable 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:
Calling 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:
Supported methods and limitations Decorators work on instance, static, and private methods. Arguments, return values, errors, nesting, span options, and 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
Projects using TypeScript 4.x can continue using every non-decorator API, including 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 spans
  • name (optional): Display name. Defaults to the function’s qualified name (Order.process for a method, process for a plain function), then the trace function key
  • type (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 evaluated
  • captureWhen (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. testRunId is a deprecated alias; passing both with different values throws
  • mockOnReplay (optional): Return this call’s recorded output under the default mock: "marked" replay strategy
  • finalize (optional): (result) => serializableView. Record a serializable view of a non-serializable result (a live stream). See Tracing streaming functions
Span Types:
Examples:

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. Because withTrace 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.)
The root and the model call share the chat-turn key, so the model span nests beneath the root:
Without 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:
For a raw 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

Use getCurrentSpan() 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:
Each addContext call pushes the entire object as one entry. Multiple calls accumulate entries:

Span IDs

Access the canonical Bitfab span and trace IDs from getCurrentSpan().id and getCurrentSpan().traceId. These are useful for persisted lookups, replay, or logging:
Outside a span context, both IDs are empty strings.

Span Prompt

Use getCurrentSpan() 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:
The prompt is metadata only. It records the prompt text for display and reference in the dashboard; it does not send the prompt to any model or change what the span executes. The last 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 replay

OpenAI 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

Use getCurrentTrace() 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.
Caller metadata accepts up to 50 keys. Keys are 1-40 characters using letters, numbers, _, ., :, 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.
The same method searches by the name 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.
Pass 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:
At least one of 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

Use client.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.
The 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

Use getTraceSpan 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

Use client.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.
When you only have a span ID, use 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:
Each error is classified by source. Errors thrown by your code are recorded with 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:
The return value is 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 from withSpan. 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 via call().
  • enabled: When false, all tracing is disabled. Wrapped functions still execute normally but no spans are sent.
  • simulationPlan: When false, 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., b from @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:
Pass replay() either an already-withTrace- or withSpan-wrapped function (both carry their trace function key, so replay() runs them as-is) or a plain callable (which replay() wraps under the key for you). Do not wrap an already-instrumented function in a fresh closure: a plain arrow like (input) => myWrappedFn(input) carries no trace function key, so replay() adds its own root span around it while myWrappedFn records its own root underneath, nesting a duplicate. If your root is already wrapped, pass it directly: bitfab.replay("my-function", myWrappedFn, ...).
Replay waits for each item’s trace (spans + completion) to be persisted server-side before completing the experiment, so 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 when traceIds, datasetId or datasetIds is 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 in traceIds.
  • traceIds: Specific trace IDs to replay (max 100). The ID count determines how many traces replay, and limit is 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 as baseline. 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: 1
  • codeChangeDescription: 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 automatically
  • codeChangeFiles: Optional list of edited files, each as { path, before, after } (use "" for newly created or deleted files); omit to capture automatically or pass null to suppress capture
  • mock: Mock strategy for child spans during replay: "marked" (default, only return historical output for child spans declared with mockOnReplay: 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 over registerMockOverride and the base mock strategy. 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 successive replay() calls to link them together in the dashboard; a new ID creates the group.
  • experimentGroupName: Optional name for the experiment group in experimentGroupId. Applied when this replay creates the group, or when the group exists without a name; a group that already has a name keeps it. Requires experimentGroupId.
  • datasetId: Optional dataset UUID. Replays that dataset’s traces and durably attributes the resulting experiment to it, and limit is ignored because the dataset determines the item count. Pass traceIds alongside 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 through datasetId and 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 with onItemFinish to distinguish queued items from in-flight items whose callback has not returned. A throwing callback never crashes the run. The installed bitfab-replay command uses reportReplayProgress for 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 replay traceId read 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 deprecated onProgress callback receives the same per-item events plus its legacy item-less terminal complete event, and is ignored when both are supplied. The installed bitfab-replay command uses the SDK’s ready-made reportReplayProgress callback 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-run ReplayResult JSON).
  • dbBranch: Optional boolean | DbBranchOptions. dbBranch: true requests a DB branch per replay item with the mirror’s own sizing; pass an object to tune it, and false or omission leaves branching off. Each replay worker resolves its branch from the source trace’s captured snapshot reference, so maxConcurrency also bounds live branches. getCurrentReplayBranch() hands the branch to you inside the replayed function, and the SDK releases it after the item. The accessor returns null when no branch was resolved (e.g. the trace predates snapshot capture, or DB branching isn’t configured), so branch?.databaseUrl ?? process.env.DATABASE_URL falls back to your live database. The fields tune the branch: minCu and maxCu are 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. warmupSql is 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, with resume only. 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.
How it fits together:
  • replay() fetches the handler-recorded production traces by the key string, and wraps a plain callable in withSpan under 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- or withSpan-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. The mock option lets the child return its recorded output so the root function can still run. Three strategies on replay():
  • "marked" (default): only descendants declared with mockOnReplay: true are 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 descendant withSpan returns 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.
Per-span opt-in via 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:
A flat or synthetic value (one that never calls getOriginalOutput) fetches no recorded outputs at all. Register overrides on the client to apply them to every replay:
Use the keyed form when one registration belongs to a known trace function. The second argument can be either a resolver or another { match, value } pair; in the latter case both the key and match must match:
For one client-wide resolver, pass a function directly. It runs for every child span, so it can route by trace function key. Return NO_MOCK_OVERRIDE to decline a span without using undefined (which remains a valid mocked output):
Precedence per span: per-call 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. The adaptInputs hook reshapes the recorded inputs onto the current signature so replay can still run:
The hook runs once per item, inside the same error boundary as the function: if it throws, that item’s 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:
That keeps the transform versioned and reviewable alongside the function it adapts, and you add the import only when a drift actually needs it.

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 into codeChangeFiles. There’s no diff format to construct.
Both options are optional and independent — you can pass just 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 Bitfab client across instrumentation and replay. If your instrumented module constructs new 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 as resume 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.
Items that finished in the earlier run come back with 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
Pass 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 onInterrupt with experimentId, experimentUrl, signal ("SIGINT" or "SIGTERM"), and markedInterrupted, which is false when the request failed. Without onInterrupt, 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 onInterrupt call 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. onInterrupt is 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.
When 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 installed bitfab-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:
The dumped object includes every item’s 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 standard bitfab-replay executable, so upgrading @bitfab/sdk updates argument parsing, progress events, code-change loading, result serialization, and summary output without regenerating project code.
Keep non-trivial executable configuration in a sibling module and import it into the registry. For example, 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.json already depends on all of them. Run bitfab-replay from that package’s directory so its node_modules and tsconfig.json path 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/sdk to 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 adaptInputs with the same code production uses. Never replace them with an empty list, a stub, or undefined. 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 marked mockOnReplay: true returns 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-run resolves 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.
The app builds the agent in a factory. The registry calls the same factory and registers the traced function it returns.
A library runs the agent and the app passes in the tools. The traced method is 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.
A decorated method bound with .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

This runs 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

The recorded root span carries 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.
The trace keeps its id, verdicts, assertions, dataset membership, name, and metadata, so anything you stored against it still names the same case. The previous run is kept as its own trace, 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:
A re-seed runs the function exactly as production does, side effects included, so treat it like running the code, not like a replay.

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:
Each line of 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.
Pass 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.