Installation
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
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. 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. Install the experimental build package and add one adapter at the point where your server code is first compiled:bitfab-transform, replace that dependency with
@bitfab/transform. Update imports and build configuration from
bitfab-transform/ to @bitfab/transform/.
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
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:
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 capture policy 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) andmaxSpans(default 500) bound each invocation. Hitting either limit warns once and marks the trace metadata as truncated 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 Studio 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), testRunId, 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. Functions passed directly or by reference to withSpan() and withTrace() are also left alone because those wrappers already own their span. This prevents an explicit span from gaining an automatic duplicate. Functions passed to withNode() remain eligible because the enclosing subtree trace still owns their span. 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. Two overlapping roots therefore double span volume in the region they share, and each root applies its own limits, exclusions, and capture policy to its copy. A node() or withNode() configuration applies in every root’s copy, including testRunId 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.exclude and capture: false opt-outs remain authoritative.
Without a compatible transform, both root APIs still emit their normal rich root and run unchanged; automatic descendants are absent. 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.
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
Declare the trace function key once and wrap multiple functions. This is the recommended 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.withSpan() remains the recommended default because it works with every supported TypeScript version and every kind of callable.
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/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"testRunId(optional): Link the span and, when it is the root, its trace to a test runmockOnReplay(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. Thefinalize option solves this: withSpan 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)— Arbitrary key-value metadata on the trace. Merges with existing metadata.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. 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 new keys into existing metadata.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. Repeated name matches default to the last span.
occurrence also accepts a zero-based integer. A missing trace or span 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
test run, 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 128 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. A span whose content is turned off in the plan still records its name, type, timing, errors, contexts, and links to other traces, but not its inputs and outputs, and it carriescontent_off_by_simulation_plan: true so Bitfab knows why the content is
missing. The plan is matched by the trace’s root trace function key and the
span’s name. The read starts as soon as you wrap a function or call
getFunction, is given a five second timeout, and it never blocks a traced
call. A span sent before the first read has succeeded is held back and sent
once the plan arrives, with the plan applied, so no span ever leaves the
process with content the plan turned off, not even the first one; held spans
go out on flushTraces(), on close(), and at exit, as soon as the plan has
loaded, and while records are held those paths wait up to the five second
read timeout for the plan before giving up on them. At most 1,000 records are
held per client, and past that the oldest
are dropped 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 every ten seconds while a record is held, and otherwise on the next
span: before the first success that keeps spans held, after it the last plan
stays 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). 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 Studio’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.
The same rule names the two trace outlines: originalTraceOutline is the original trace’s span tree and traceOutline is the replayed trace’s. An outline carries each span’s name, type, nesting, order, duration, tokens, model, errors, and whether it was mocked, and no inputs or outputs, so it is small enough to keep on every item. Both are filled in at completion (they are null in onItemFinish and against older servers) so a grader can compare the path the replay took against the original’s, not only its output. See TraceOutline in the TypeScript reference.
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— Optional display name for the resulting experiment/test run.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 batch. Pass the same ID across successivereplay()calls to link them together in the dashboard.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.
Replaying handler-instrumented functions
Workflows instrumented through a framework handler (getLangGraphCallbackHandler, getLangChainCallbackHandler, getClaudeAgentHandler, getOpenAiAgentHandler) have no 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 test run. 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-
withSpan-wrapped function under the same key also works (older SDKs require this 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 (test run). 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 working-tree diff against the trunk merge-base (best-effort, only inside a git repo), so an experiment still shows a diff. 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.
Replay Output Contract
Replay results are typically consumed by automation (CI logs, code reviewers, and coding agents). WhenBITFAB_REPLAY_RESULT_PATH is set, bitfab.replay() automatically writes the full ReplayResult JSON to that file. For direct/manual runs, 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, tokens, originalTraceOutline, and traceOutline. Never print only lengths, counts, hashes, or truncated previews, and never replace the JSON block with ad-hoc per-field log lines.
Recommended script tail (TypeScript):
input, result, originalOutput, error, structured traceError and replayError, durationMs, originalDurationMs, originalTokens, originalModel, tokens, model, dbBranchTimings, traceOutline, originalTraceOutline, and traceId, plus testRunId and testRunUrl. Import serializeReplayResult from @bitfab/sdk; raw JSON.stringify drops the useful fields on JavaScript Error objects. When the Bitfab plugin runs this script, it sets BITFAB_REPLAY_RESULT_PATH; the SDK writes the same structured JSON there, and the plugin reads that file into the replay run’s .bitfab/replays/<run-id>/events.jsonl while writing large per-item payloads under .bitfab/replays/<run-id>/items/.
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 script. A custom try/catch that returns a placeholder turns infra failures into fake successes. Let the SDK record them. The only allowed top-level catch is a fatal handler around main() that exits non-zero, so callers can tell a whole-replay crash from a clean run with some unreplayable items.
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
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.
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.
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 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 title and a searchable, filterable field. Put the case’s own label there (a ticket ID, a dataset row name) so the seeded trace can be found by it. metadata is stored on the trace and 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. 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 waits for the run by default (90 seconds, configurable) and returns whatever state it last saw. Pass wait: false to return immediately and poll with getGraderRerun. See the reference for every method and result type.
Labels
client.labels writes pass/fail verdicts and reads them back, the same operations your coding agent reaches through the save_agent_labels, save_human_labels, and get_trace_labels MCP tools. A verdict says how a run that already happened turned out. Written per assertion, it is stored and read back per assertion, so a judge inside a replay process can score each expectation on its own and verify what landed without opening Studio.
save and saveAll write the agent’s verdicts, which start unapproved until a person approves them in Studio. saveHuman and saveHumanAll write verdicts that are validated on write, for cases a person has already decided, such as a production bug captured as a regression test. 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.getLabels is the per-grader breakdown the effective verdict folds together. Approving a verdict is not on this surface, or on MCP, by design. See the reference for every method and type.