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

Installation

Python 3.10 or newer is required.

Quick Start

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.
Client capture off covers every integration. Framework handlers and processors remain safe to register and all wrapped functions continue to run, but no spans or local BAML execution traces are sent. Replay items and seed_trace still record when explicitly invoked.
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 decorated 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: a module that builds the client at import time can run before the entrypoint calls load_dotenv(), 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 imported, for example dotenv run -- python script.py, so every module-level read sees the key.

Tracing

Trace the whole workflow, not just its entrypoint. A single @span around 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.

Opt-in and opt-out tracing

Bitfab has two ways to decide what a trace records. Pick one per workflow, and start with opt-out. Opt-out gets a workflow traced in one decorator and you narrow from there, which is faster and less error-prone than deciding every boundary up front. It is still experimental, so use @span when you want a small exact set of spans or you are below Python 3.12, where the root span still records and its descendants are skipped. See Instrumentation for the full comparison. The two are never combined in one call stack. A @span entered beneath an active @trace, or a @trace or @node entered beneath an active @span, raises MixedTracingError naming both decorators. Inside a @trace subtree, configure a step with @node; inside a @span workflow, add more @span decorators.

Declaring the trace function key

Declare the trace function key once and link multiple spans together:
Calling process_order(id) records one trace with four spans:
Decorating only process_order 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 decorated function calls another.

Using @bitfab.span() 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 capture_when="nested":

Content capture from the sim plan

Flush and close wait for spans released by the first plan read to enter the outgoing queue before draining it. A trace function’s sim plan can turn content capture off for individual spans. The client reads your organization’s sim plan from GET /api/sdk/sim-plan in the background and applies it to every span it sends, so no code change is needed. A node is matched by the trace’s root trace function key plus the span’s name:
  • Once the plan has loaded, a span whose capture is off is not sent at all, whether trace() discovered it or it came from @span. 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. The trace’s completion leaves the dropped span out of its expected span count, so the trace still completes.
  • A trace’s root span and a span that recorded an error are still sent when their capture is off. They record their name, type, timing, errors, contexts, and links to nested traces, but not their inputs, outputs, or prompt, and carry content_off_by_simulation_plan: true so Bitfab knows why the content is missing. The SDK never serializes their inputs and outputs at all, so a large argument or return value adds no serialization work.
  • A node() and a nested trace() recorded inside an outer trace ignore the sim plan. They are always sent with their inputs and outputs and no marker, even while the plan cannot be read. A discovered call that shares a name with a node() is still dropped.
  • Spans recorded by a framework integration (the OpenAI Agents processor, the LangGraph handler and integration, the Claude Agent SDK handler) keep their content: every span carries a span_origin record (the SDK name and version, and instrumentation.name saying what recorded it), and the plan cannot turn those off.
  • The read starts when you construct the client, on a background thread with a five second timeout, and refreshes about once a minute while spans flow.
  • A root trace() or span() call that starts while that first read is still running waits for it, for at most five seconds, so the first trace already has the plan applied when it records. Once the first read has finished, whether it worked or not, no later call waits, so a process adds at most about five seconds even when Bitfab is unreachable. An async root waits without blocking the event loop. Nested roots and child spans never wait, and a disabled plan never waits.
  • A span sent while that first read is still running, for example after the wait ran out, is held back and sent with the plan applied once the read succeeds. flush_traces(), close(), and exit wait up to the five second read timeout for it. close() and exit never drop a span that is still held: they send it without its inputs and outputs. At most 1,000 spans are held per client, and past that the oldest are sent without their inputs and outputs.
  • If the first read fails (a network error, a timeout, an HTTP error other than 404, or a response the SDK does not understand), held spans and every later span are sent without their inputs and outputs, marked content_off_by_simulation_plan: true, until a read succeeds, and a one-time warning says so. The SDK skips serializing their inputs and outputs. A trace’s root span, spans recorded by a framework integration, node() calls, and nested trace() roots keep their content.
  • A trace’s completion waits behind that trace’s held spans, including spans still being released after the first plan read. Unrelated traces complete immediately. A trace made only of framework spans is never held back.
  • A failed read is retried on the next span once ten seconds have passed. After a read has succeeded, a failed refresh keeps the last plan in effect. A plan change takes effect within one refresh.
  • 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 with no sim plan feature answers the read with 404, which counts as an empty plan loaded: nothing is held and nothing is stripped.
  • Pass simulation_plan=False to Bitfab(...) 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.

Subtree Tracing

Experimental. trace() is new and its behavior may change in a future release. Requires Python 3.12 or newer. span() is the stable decorator.
span() records the function you decorate. trace() records that function and every one of your own functions it calls, at any depth, without decorating them:
Both forms are supported. These docs lead with the handle because get_function() binds the key once, so the trace root, its spans, and any framework handler read it from one place. The client-level form takes the key inline and behaves 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. That single decorator produces the whole tree:
Capture is scoped to the traced call: it turns on when triage is entered and off when it returns, so the rest of your application is unaffected. Use node() when one discovered function needs the same policy controls as an explicit span without becoming an independent instrumentation boundary:
node() is consumed only by the enclosing trace(). It never creates a span or trace when the function runs by itself. Its API mirrors the applicable span() options (name, type, experiment_id, mock_on_replay, and finalize), while capture controls whether trace() includes the node. trace(mock_on_replay_default=True) makes replay mocking the default for configured nodes under mock="marked"; a node with mock_on_replay=False overrides it. The trace option is off by default. With capture=False, the function is omitted and its captured descendants attach to the nearest captured parent. Recorded-output mocking requires a captured node, so combining capture=False with mock_on_replay=True raises ValueError. Variants. Set a variant whenever one node can run more than once at the same time in a trace, computed from the call’s arguments. The node keeps one name, each call records its variant, and replay gives each call the recorded outputs from its own variant rather than relying on the order the calls happened to run in.
Submitting through contextvars.copy_context().run is what carries the trace into each worker. The variant function receives exactly the call’s arguments and runs before the call, because a mocked node never runs its body. When the function works the value out inside its body, move that logic into a helper that both the body and the variant function call. What gets recorded. Only functions you wrote, meaning the package directory containing the decorated function. The standard library, site-packages, and Bitfab’s own code are never recorded. Lambdas, generator expressions, and decorator wrappers are skipped too, since they add spans without adding a step you would recognize in your own call tree. Bounds. Two limits cap what one call records, and nothing caps the total number of spans a trace root sends. max_captured_subtree_spans (default 500) caps discovered calls sent with their inputs and outputs, so a hot loop cannot produce an unbounded trace. A node(), a nested trace(), a framework integration span, and a span the loaded sim plan turned capture off for never count toward it, so they keep recording past 500. Past it, further discovered calls with capture on record nothing. Spans sent without inputs and outputs because the sim plan could not be read count toward it, so an outage never lets a trace grow past its limit. max_depth is unbounded by default and clips how deep discovery goes. Both limits apply to calls in the order they start, so a parent is never dropped after its children were sent. Hitting a limit logs a one-time warning naming the function, because a trace that stops partway looks like code that never ran, 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). max_spans was removed, and passing it raises TypeError. max_captured_subtree_spans falls back to the client (Bitfab(max_captured_subtree_spans=...)), then to BITFAB_MAX_CAPTURED_SUBTREE_SPANS, then to 500, so one place sets the policy for a whole application and a running service can be widened without a deploy.
On Python 3.11 and earlier, the decorated function still records its own span exactly as span() would, and a one-time warning explains that descendants were skipped. Your code runs the same either way.
Nested trace() roots. A nested trace() root starts a separate trace while its full subtree also appears in every outer trace() capture. Each trace gets its own span IDs and the same complete structure it would have recorded alone. 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 nested roots therefore double span volume in the nested region; each additional active trace() root records another independent copy. A node() inside the nested region keeps its configured name, type, experiment_id, and finalized output in every enclosing trace’s copy, with finalize running once, and so do the LangGraph integration’s tool and invoke spans. Framework handler spans (the OpenAI Agents processor, the Claude Agent SDK handler, the LangGraph callback handler) 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 a replay item or seed_trace a nested root starts no trace of its own: the item’s trace records it as an ordinary descendant. Use nested trace() roots when both boundaries need to stand alone as complete traces:
No span() inside trace(), no trace() inside span(). trace() and node() are the opt-out tracing surface; span() is the opt-in surface. Entering one beneath the other raises MixedTracingError:
Inside a trace() subtree, give a step its own name, type, or replay policy with node(). The check runs only while tracing is active: with capture_enabled=False and outside a replay item, decorated functions run as written. Framework handlers need no decorators of their own inside a trace() subtree. Spans from the OpenAI Agents tracing processor, the Claude Agent SDK handler, and the LangGraph callback handler join the trace and attach under the nearest captured call, keeping their own span types (llm, agent, function). The reverse holds for the OpenAI Agents SDK too: your own functions that run inside an open Agents SDK span, such as a function tool’s body, attach under that tool span, even when the tool hops threads through asyncio.to_thread or sync_to_async. The LangGraph integration’s tool and invoke wrappers (get_langgraph_integration) adapt at call time. Outside a trace() subtree they open opt-in spans, exactly as before. To get opt-out tracing around a graph, put @trace on the function that calls it. Everything beneath becomes opt-out, framework spans included: tool spans behave like node() calls, attaching under the nearest captured call, exempt from max_captured_subtree_spans and max_depth as declarations are, and inheriting mock_on_replay_default. Limitations. Work dispatched to ThreadPoolExecutor or threading.Thread inside a traced call is not captured, since capture rides contextvars. Async-generator capture is released between yielded items, so stopping iteration does not leave capture active; a generator abandoned before it finishes still records no span. Unconfigured descendants remain typed function and are not replay mock targets. Use node() for descendant naming, typing, capture, finalization, and replay-mocking policy.

Tracing Across Threads

Span nesting rides Python contextvars, which do not reach ThreadPoolExecutor.submit / loop.run_in_executor work items or threading.Thread targets: a decorated function called there roots its own single-span trace, and replay mocking never fires for it. If instrumented functions are dispatched that way (common in agent tool executors), enable propagation on the client:
True: on. False: off. None (default): on only when BITFAB_TRACE_ACROSS_THREADS=1 is set. Process-global once installed. asyncio tasks and asyncio.to_thread need nothing; pre-created queue consumer tasks and other processes are out of its reach.

Span Options

Parameters:
  • trace_function_key (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 or a closure), 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
  • capture_when (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"
  • finalize (optional): Callable[[result], serializable]. 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 hands chunks to the caller as they arrive; the raw stream isn’t serializable as a trace output, and consuming it to record a summary would break streaming. Because @trace 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 @trace. The @span finalize option records a serializable, replayable view of the stream while the caller still receives every chunk. Because Python streams are single-consumer (unlike a JS stream you can tee), the non-destructive way to trace streaming is an async generator that yields its chunks. The span collects the chunks as they pass through to the caller, and finalize turns the collected chunks into a summary. Use the prebuilt finalizers.openai_chunks or finalizers.anthropic_events:
finalize may also be a plain callable that builds whatever shape you want from the collected chunks:
For a non-generator function, finalize receives the return value instead of the collected chunks and is applied inline before the span is recorded (awaited on an async span). The caller’s return value is always the raw result, but a live single-consumer stream returned here will be blocked on and consumed, so use an async generator for streaming, and reserve the non-generator form for plain return values or results with non-destructive accessors. A finalize that raises records an error on the span instead of crashing the host. Inputs to the wrapped function must still be serializable for the trace to replay.

Span Context

Use get_current_span() to get a handle to the active span, then call .add_context() to attach contextual key-value pairs from inside a traced function — useful for runtime values like request IDs, computed scores, or dynamic context:
Each add_context call pushes the entire dictionary as one entry. Multiple calls accumulate entries:
get_current_span().id and .trace_id expose the canonical Bitfab span and trace IDs. Both are empty strings outside a span context.

Span Prompt

Use get_current_span() 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 set_prompt call wins — it overwrites any previously set prompt on the span. Calling set_prompt 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

Trace Context

Use get_current_trace() 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:
  • set_session_id(id): Groups traces by user session. Stored as a database column for efficient filtering.
  • set_name(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.
  • set_metadata(dict): 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 result["nextCursor"] back as cursor for another page.
The same method searches by the name set_name recorded. name matches the name’s words in order, so name set to "Order ABC" also finds "Order ABC 123". name_contains 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 experiment_id to list only the replay traces one experiment produced. Every result also carries experimentId (None for a captured trace), durationMs (the trace’s wall-clock time, None when it has no recorded end time), and tokens ({"input", "output", "cached", "total"} summed across the trace’s spans, None when none were recorded). That is enough to compare two experiments on time and token spend without opening each trace:
At least one of caller_metadata, name, name_contains, or experiment_id 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.
  • add_context(dict): 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 raises into your application.

Detached Trace

Use client.get_trace(trace_id) 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 trace_id is Bitfab’s canonical trace ID, the same UUID exposed by get_current_span().trace_id for native SDK traces and used in Bitfab trace URLs. All methods are blocking, like get_trace_span(): each returns once the server has applied the change, so a later read always observes the write. They raise if the server rejects the update. They write whether or not capture is on, because the trace they name already exists and capture_enabled governs recording new runs.
  • add_context(context): Appends a context entry. Existing entries are preserved.
  • set_metadata(metadata): Shallow-merges searchable metadata using the limits above.
  • set_session_id(session_id): Replaces any existing session ID.
  • set_name(name): Replaces any existing trace name.

Read One Persisted Span

Use get_trace_span 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 a span up by id alone, or by name and/or type ("llm", "agent", "function", "guardrail", "handoff", or "custom"). Repeated name or type matches default to the last span.
occurrence also accepts a zero-based integer. A missing trace or span returns None. Passing id together with name or type, or an unknown type, raises ValueError before any request is sent. To walk every span of one trace in the order they ran, use client.spans.list. It takes optional span_type, span_name, and fields (any of input, output, reasoning, content, errors, contexts), and returns one page of up to limit spans (1 to 200, or 50 when fields is set, default 50) with nextCursor and hasMore. Each span carries id, traceId, parentSpanId, name, type, startedAt, and endedAt, plus the fields asked for. A trace outside your API key’s organization raises ValueError.
When you only have a span ID, use get_span. 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 None.

Error Handling

Errors are captured in the span and re-raised:
Each error is classified by source. Errors raised by your code are recorded with error_source: "code". SDK-internal errors are recorded with source: "sdk". Both appear in the span’s errors array in the Bitfab dashboard.

Flushing Traces

The return value is False when an export fails or the deadline expires. Traces also flush automatically on process exit via an atexit hook.

OpenTelemetry Transport

The Python 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. Decorators and framework handlers submit the same replay-safe Bitfab payloads through a 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 eight carriers and is packed using their exact encoded size. 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. Flush and shutdown share one total caller-supplied deadline. 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, use Bitfab as a context manager or call client.close(timeout=30.0) 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 the client. A shared application client can remain open and will still shut down automatically at process exit.

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 a function and create an experiment with comparison data. This is useful for testing changes to your functions against real production inputs.
Pass replay() either an already-@trace- or @span-decorated function (both carry their trace function key, so they run as-is) or, with an explicit key, a plain callable that re-invokes a raw entrypoint (which replay() wraps for you). Do not pass a plain closure that itself calls an already-decorated function: replay() wraps the closure as the root span while the inner decorated function records its own root underneath, nesting a duplicate. If your root is already decorated, pass it directly: bitfab.replay(my_function, limit=5).
Parameters:
  • fn (required): The function to replay. Two call forms: replay(decorated_fn) reads the trace function key from the @trace or @span decorator; replay("key", fn) takes an explicit key with any plain callable (the SDK wraps it internally). That wrapper’s root span belongs to neither tracing surface, so the callable may call a @trace root, which nests beneath it. Use the explicit-key form for handler-instrumented functions with no decorated root in the app; see Replaying handler-instrumented functions below.
  • limit (optional): Maximum number of recent traces to replay. Default: 5; maximum: 5,000. Ignored when trace_ids, dataset_id or dataset_ids 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 trace_ids.
  • trace_ids (optional): List of 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 (optional): What this run is testing, in a few words, such as baseline or shorter system prompt. Bitfab records the commit, branch, tree state, datasets, and who ran it with every experiment, so do not repeat them here.
  • notes (optional): Run conditions Bitfab cannot see on its own, such as an environment override or a forced feature flag. Kept on the experiment next to its name.
  • max_concurrency (optional): Maximum items processed in parallel. 1 for sequential, None for unlimited. Default: 10
  • attempts (optional): 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.
  • concurrency (optional): A ReplayConcurrency(attempts=..., primitive=..., max_concurrency=...) carrying the two settings above plus the primitive that runs them. Passing it alongside attempts or max_concurrency raises rather than merging, so the two spellings can never disagree. primitive="async" is the default and is exactly today’s behavior: every work item is a coroutine in one process, max_concurrency defaulting to 10. primitive="process" gives each work item its own child interpreter, with max_concurrency defaulting to 4 and None refused, and is the only primitive that accepts on_item_finish_in_child_process. Reach for it when your replayed code keeps its world in process-global state, such as a settings module read once at import, a per-item database, or a module-level registry, so that two items cannot safely share one process. The unit is the work item rather than the attempt, so N traces at K attempts is N x K child processes taken from a single queue. Because process mode re-runs the replay command once per item, it works only under bitfab-replay --registry and raises from a plain client.replay(...) call. Under process mode it also carries memory_throttle, on by default, which launches a child only once the machine has memory for it so a run on a loaded laptop degrades to fewer children in parallel rather than being killed by the operating system. It never raises concurrency above max_concurrency and never holds back the only child in flight, so a machine under lasting pressure still finishes one item at a time. Set BITFAB_REPLAY_MEMORY_THROTTLE=off to turn it off for a single run.
  • code_change_description (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
  • code_change_files (optional): List of edited files, each as {"path": str, "before": str, "after": str} (use "" for newly created or deleted files); omit to capture automatically or pass None to suppress capture
  • mock (optional): Mock strategy for descendant spans: "marked" (default), "none", or "all".
  • mock_override (optional): One MockOverride, or a list of them, that substitutes a supplied output for matched spans. Per-call overrides take precedence over registered overrides and the base mock strategy.
  • experiment_group_id (optional): UUID string that groups multiple replay runs into a single experiment batch. The run joins that group, and an ID no group has yet creates it. Pass the same ID across successive replay() calls to link them together in the dashboard.
  • experiment_group_name (optional): Name for the experiment group, 1 to 120 characters. Applied when this replay creates the group or the group has no name yet; a group that already has a name keeps it. Requires experiment_group_id, and raises ValueError without it.
  • dataset_id (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 trace_ids alongside it to replay only those members.
  • dataset_ids (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 dataset_id and several through this.
  • grader_ids (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.
  • adapt_inputs (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.
  • on_item_start (optional): Callback fired when a worker begins processing an item, before replay setup and customer code run. Pair it with on_item_finish to distinguish queued items from in-flight items whose callback has not returned. A raising callback never crashes the run.
  • on_item_finish (optional): Callback fired exactly once per item as it finishes, always with that item plus running totals (original/source trace id, the server replay trace_id 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 raising callback never crashes the run. The deprecated on_progress 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 passes the SDK’s ready-made report_replay_progress callback as both on_item_start and on_item_finish; it writes lifecycle events to stderr, which the plugin uses to identify in-flight traces, report finished items, and write per-item result files while stdout remains available for direct-run ReplayResult JSON.
  • db_branch (optional): True or a DbBranchOptions dict. db_branch=True requests a DB branch per replay item with the mirror’s own sizing; pass a dict 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 max_concurrency also bounds live branches. get_current_replay_branch() hands the branch to you inside the replayed function, and the SDK releases it after the item. The accessor returns None when no branch was resolved (e.g. the trace predates snapshot capture, or DB branching isn’t configured), so branch.database_url if branch else os.environ["DATABASE_URL"] falls back to your live database. The keys tune the branch: min_cu and max_cu 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. warmup_sql 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. Default False.
  • on_interrupt (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.
Returns:
Replay waits for every submitted trace completion and expected span count to be persisted server-side before completing the experiment, so trace_id is a real server trace ID for completed items. Persistence is checked by Bitfab after the shared OTel pipeline is flushed. If Bitfab still has not confirmed a replay when that check runs out of time, replay() logs one warning for it, [bitfab] replay <original trace id>#<attempt> could not confirm all spans reached Bitfab within <N>s. The run continues; Bitfab will show this replay as capture incomplete., and carries on: the run completes, the item is not marked errored, and its trace_id stays None unless the final mapping fills it. Bitfab shows that replay as capture incomplete and counts the run’s pass rate without it. Under primitive="process", the child’s warning reaches the command’s stderr as a [replay] span delivery problem for ... line. If NO otherwise-completed item’s trace persisted (uploads wholesale failed, or the replayed function isn’t decorated with @trace or @span), replay() also raises instead of silently returning None trace IDs. If only SOME completed items are absent from the final mapping, those items get None trace IDs with a logged error and the rest of the run is returned intact. Anything describing the trace being replayed carries the original_ prefix: original_duration_ms, original_model, and original_tokens. Unprefixed fields are the replay’s own: duration_ms 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 original_tokens["total"] tells you how your change moved cost. Each field is None when it wasn’t captured. model remains as a deprecated alias for original_model. Note that duration_ms changed meaning: it used to report the original trace’s duration and now reports the replay’s.

Replaying handler-instrumented functions

Workflows instrumented through a framework handler (get_langgraph_callback_handler, get_langchain_callback_handler, get_claude_agent_handler, get_openai_agent_handler) have no @trace- or @span-decorated 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. These traces are fully replayable. Pass the handler’s trace function key explicitly, plus any plain callable that re-invokes the framework entrypoint:
The OpenAI Agents SDK uses get_openai_agent_handler(key).wrap_run(agent, input) (a drop-in for Runner.run) for the replayable root; the bare get_openai_tracing_processor 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 (wrap_query(stream, input=prompt), or wrap_response(stream, input=prompt)) for the handler to record a replayable root.
How it fits together:
  • replay("key", fn) fetches the handler-recorded production traces under the key and wraps fn in a span under that key internally, so each replayed invocation records a trace tied to the experiment. No decorator needed; the key is the only link between the production traces and the replay callable.
  • When the SDK auto-wraps a plain callable this way, a recorded dict root input (e.g. a LangGraph state) is passed to fn as a single positional argument (matching the TypeScript SDK) and reported faithfully on item["input"]. Decorated functions keep the decorated-path keyword-args semantics even when a matching key is also passed.
  • 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 config, 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.
Older SDKs (before explicit-key replay): decorate a wrapper used by programmatic replay with the same key instead: @bitfab.span("my-agent") on def replay_my_agent(**state) (on that path the recorded dict splats into keyword args and item["input"] reports []), then call bitfab.replay(replay_my_agent, limit=10).

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 keyword lets the child return its recorded output so the root function can still run. Three strategies on replay():
  • "marked" (default): only descendants declared with mock_on_replay=True are short-circuited; everything else runs real. This is the iteration-friendly mode.
  • "none": every child span runs real code.
  • "all": every matched recorded descendant span 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.
Async-generator spans are not mockable yet. When mock="all", mock="marked" selects one, or an override matches, the replay item fails without iterating the real generator. Move unsafe work inside the generator to a mockable sync or coroutine descendant.
Per-span opt-in via the mock_on_replay kwarg on @client.span(...):
mock_on_replay 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, the item errors and the real child does not execute.

Injecting custom values with overrides

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 MockOverride(match, value): match selects spans by structural metadata (node.span_name, node.type, node.trace_function_key, node.original_span_id); value is a flat value injected as-is, or a callable that returns one. The decorated function’s trace function key 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.trace_function_key is also valid.
A callable value receives a context with the live positional inputs, the live keyword kwargs (empty when the call used none), and get_original_output() (synchronous in Python) to tweak the recorded output instead of replacing it:
Under marked/override replay the recorded output is fetched lazily on first access, so get_original_output() (and a marked span’s own recorded output) may block on a short HTTP request. Replay offloads that fetch off the event loop for async spans, so concurrent items are not stalled. A synchronous span tagged mock_on_replay (or matched by an override), when called from an async replay root, cannot offload and does the fetch on the loop thread, briefly serializing concurrent items. Make such a span async, or use mock="all" (eager, no per-span fetch), to avoid it.
Register overrides on the client to apply them to every replay (object or ordered form), and reset with clear_mock_overrides():
Use the keyed form when one registration belongs to a known trace function. The second argument can be a resolver or MockOverride; for an override, both the registered trace function key and its match predicate must match:
Pass one callable directly for a client-wide resolver that routes by trace function key. Return NO_MOCK_OVERRIDE to decline a span; None remains a valid mocked output:
Precedence per span: per-call mock_override, then registered overrides, then the base mock strategy. NO_MOCK_OVERRIDE continues at the next override, then falls back to that base strategy. Pass a single MockOverride, resolver, or list.

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 calls the current function with them. If the signature drifted since capture (a param renamed, reordered, folded into a dict, or a new required arg added), fn(*args, **kwargs) no longer lines up and raises. The adapt_inputs hook reshapes the recorded inputs onto the current signature so replay can still run:
The hook receives the deserialized (args, kwargs) plus a per-trace ctx ({"original_trace_id", "original_span_id", "metadata"}, with deprecated source_* aliases; metadata is the original trace’s stored metadata, what seed_trace or get_current_trace().set_metadata put on it, merged so that the caller’s keys win over the metadata an integration’s own trace export carries for the same trace, and is fetched only when a hook is registered) and returns the (args, kwargs) actually passed to the function. The returned args is what item["input"] reports. It runs once per item, inside the same error boundary as the function: if it raises, that item’s error is set and the run continues, so one unmappable trace never crashes the batch. ctx["original_trace_id"] (the original Bitfab trace ID) lets a table-driven adapter look up a per-trace transform. That’s 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 original_trace_id, 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 raise) 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:
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. Read each file before editing, edit, then read it again — the two strings go straight into code_change_files. There’s no diff format to construct.
Both options are optional and independent — you can pass just code_change_description for a quick rationale-only annotation, or just code_change_files to record the literal edits. If you omit code_change_files, 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 code_change_description is preserved while the files are captured. Passing code_change_files explicitly always wins and is the way to record a precise per-edit before/after. To opt out for one replay run, pass code_change_files=None (and optionally code_change_description=None if you also want no description). Set BITFAB_DISABLE_CODE_CHANGE_CAPTURE to turn the fallback off for every replay in the process. Notes:
  • In the replay(fn) form the function must be decorated with @trace or @span; the trace function key is read from the decorator. When production has a decorated root, pass the decorated function itself, not an undecorated wrapper around it; the decorator attribute identifies the trace key. An undecorated wrapper has no key, so replay() wraps it as the root and the inner decorated function then records another root underneath, nesting a duplicate. For nested decorators (for example, @retry(@cache(@trace(fn)))), pass the outermost; replay walks the __wrapped__ chain to find the Bitfab trace key. For handler-instrumented functions with no decorated root, use the explicit-key form replay("key", fn) with any plain callable (see Replaying handler-instrumented functions above). Passing an explicit key that contradicts the decorator’s key raises.
  • For decorated methods on classes, pass the unbound function on the class (MyClass.method) to replay traces for all instances, or a bound method on a specific instance (instance.method) to replay through that instance’s state. Both resolve to the same trace function key.
  • Use a single Bitfab client across instrumentation and replay. If your instrumented module constructs 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.
  • The function can be sync or async (async functions are detected and run automatically)
  • If the function raises an error for one input, replay continues with the remaining inputs
  • Each replay creates an experiment visible in the Bitfab dashboard
  • Works through nested decorators (e.g. @retry, @cache) — walks the __wrapped__ chain to find @span

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 carried_over=True. They carry their original_trace_id, attempt, and replay trace_id, 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 trace_ids, limit, dataset_id, dataset_ids, attempts, grader_ids, only_with_assertions, or dry_run. Database snapshots follow the setting the experiment started with, and a different db_branch 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/replay_registry.py <pipeline> --resume <experiment_id>, 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 replay in the Python 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 on_interrupt with a ReplayInterrupt holding experiment_id, experiment_url, signal ("SIGINT" or "SIGTERM"), and marked_interrupted, which is False when the request failed. Without on_interrupt, 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. Either way the signal then carries on as it would have without Bitfab: KeyboardInterrupt is raised, a handler you installed before the run is called, or SIGTERM ends the process. SIGTERM is caught only when replay() runs on the main thread.
  • 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 on_interrupt call and no interrupted line. An experiment left pending by that kill is marked interrupted by Bitfab’s own 15-minute check.
  • An exception that ends the run. The SDK marks the experiment interrupted, then raises the exception as before. on_interrupt 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.
bitfab-replay passes its own on_interrupt. 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.loads it and reason about every field, including the per-item original_duration_ms, original_tokens, original_model, 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, original_output, error, structured trace_error and replay_error, duration_ms, original_duration_ms, original_tokens, original_model, tokens, model, db_branch_timings, and trace_id, plus experiment_id and experiment_url (and their deprecated aliases test_run_id and test_run_url). Import serialize_replay_result from bitfab; json.dumps(..., default=str) reduces exceptions to strings and loses their structured fields. 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 raises while executing the replayed trace, bitfab.replay retains the actual exception in item['trace_error'], copies its message to item['error'], leaves item['result'] as None, and continues. If replay setup fails before the function starts (for example database warmup or input loading), the actual exception is instead in item['replay_error']. A database branch resolution failure is a DbBranchReplayError; inspect its code, message, and original_trace_id 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 raises, 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/except 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 dicts. 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 registrations; installing the SDK also installs the standard bitfab-replay executable, which owns command-line flags, lifecycle progress, code-change loading, result serialization, and summary output.
Keep non-trivial executable configuration in a sibling module and import it into the registry. For example, replay_mocks.py can export a MockOverride factory whose value callable uses live replay inputs and caller-supplied parameters. options_factory 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/replay_registry.py <pipeline>. The registry module must define the variable registry. See Make the app importable when the registry imports the app by package name. As soon as the server creates the experiment, the command prints [replay] Experiment <id>: <url> to stderr, and a run that fails with ReplayError prints Experiment <id>: <url> again just before the error message. Pass static function-specific behavior such as adapt_inputs, mock_override, or db_branch to register; build parameterized behavior with options_factory. Command-line values override overlapping scalar defaults without removing unrelated executable options. Unknown registry option names fail when the module loads instead of reaching replay as misspelled keywords. 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.

Grading each attempt as it finishes

on_item_finish is the one lifecycle callback a registry entry can set. The command runs its own progress reporter first, then calls yours with the same finished item. That is the earliest point a replay verdict can be written: the row is keyed by lineage, so the server only resolves it once the attempt’s own trace has been flushed.
It is skipped under --dry-run, since nothing ran and there is no replay trace to grade, and a callback that raises is reported on stderr naming the trace and attempt instead of failing the run. Every agent verdict scores one approved assertion, so save, save_all, and skip require assertion_id and raise ValueError before sending anything when it is missing. Agents do not write a single pass or fail for the whole attempt. Verdicts are only written on assertions, never on a whole trace, so a trace with no approved assertions gets no agent verdict. humanNote carries people-only context on an assertion. Pass it on a save_assertions entry to write it, omit it on an edit to keep what is stored, and pass None 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. skip takes assertion_id too, so an assertion whose target could not be resolved is withheld on its own without touching the others. archive is the one write that may omit it, which clears an old whole-trace agent verdict. skip also takes annotation, which is where the reason for withholding lives. Read back by verdicts.get, it is what separates a verdict nobody could reach from a trace nobody looked at, and a coverage check that cannot tell those apart reports a grading outage as a clean run. A skip sent without it clears the note the previous verdict carried, so a stale reason never sits under a withheld verdict.

Grading inside the process that replayed the item

on_item_finish on the registry entry runs in the process that owns the run. Under primitive="process" that is the parent, which never executed a line of your code, so whatever the replayed run left in memory is gone by the time it fires. Assertions the run accumulated, a sandbox database it opened, a module-level registry it populated: none of it exists there. ReplayConcurrency takes on_item_finish_in_child_process for that case. It runs in the child interpreter that replayed the item, after the item’s spans are confirmed delivered and its result file is written, and before that child exits. The name says where it runs, and it lives on the concurrency object because that object is what creates the child, so both halves of the answer are visible at the point you declare it.
It is refused under any other primitive, since there is no child process to run it in, and the error points you at the registry option instead. That is deliberate: a hook whose whole purpose is the child should not quietly relocate when you change how work is fanned out. item["trace_id"] is the server’s own ID for the replay trace this item produced, so a verdict can be keyed by it directly instead of by original_trace_id plus attempt plus experiment_id. It is None only when delivery could not be confirmed in time, which leaves lineage as the key.
Setting both
The two hooks compose rather than compete, and setting both is the expected shape. Each fires exactly once per item. The child’s runs first, because the parent only learns an item exists once its child has exited, and the child runs the hook before exiting. So a slow judge in the child delays that item’s progress event in the parent, and never the reverse. Split the work along what each one can see. The child grades, because it is the only place the run’s own state exists. The parent streams, because running totals are only countable where every item lands, and it is the only place an item whose child died shows up at all, which is where a crashed attempt gets recorded as skipped.
ReplayItemFinishEvent carries experiment_id and the finished item, and both keys also appear on ReplayItemFinishProgress, so one function can serve either hook if it reads nothing else.
Operational notes
Anything the child hook prints goes to that child’s log, which the command surfaces only when the item fails. Record verdicts through the verdicts API rather than stdout. Both hooks are skipped under --dry-run, since nothing ran and there is no replay trace to grade. A callback that raises is reported instead of failing the run, naming the trace and attempt, and each reports under its own name, so you can tell on_item_finish_in_child_process failed from on_item_finish failed. A child hook’s failure is forwarded to the command’s own stderr as well, since the child’s log is otherwise read only when the item itself failed. A child writes its result file before running the hook, so the parent’s copy of the item never depends on what the hook does. The one case the child hook can be skipped is a child killed in the window between writing its result and finishing the hook. Cross-item state does not belong in the child hook. Each child sees only its own item, so a running tally kept there counts to one. Keep those in the parent hook or read them off the returned ReplayResult.

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 project that builds the tools, clients, and config and then calls the traced function. Its pyproject.toml or requirements already include all of them. Run bitfab-replay from that project’s directory, inside its virtual environment. When the traced function lives in a lower-level library, the registry still goes in the app that calls it. Add bitfab to the app’s dependencies if it is not there yet. Never copy the app’s dependencies into the library.
  • Make the app importable. bitfab-replay adds only the registry file’s own folder to the import path. When the registry imports the app by package name, such as from agent.support_agent import ..., install the app in the active environment or run the command with PYTHONPATH=. from the project root.
  • Build the function the way production builds it. Call the factory or constructor that production calls at startup, and register the traced function or bound method 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. Rebuild these arguments in the entry’s adapt_inputs with the same code production uses. Never replace them with an empty list, a stub, or None. When production builds them inline, move that code into a function that production and the registry both call.
  • Know which tool calls run for real. Under mock="marked", a call inside a span marked mock_on_replay=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 the agent_core library, and the app builds the tools. The registry lives in the app. It registers the runner’s bound method and rebuilds the tools for each replayed call.

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. seed_trace runs your function once against a case and records that run as a replayable original trace, returning its trace ID.
The recorded trace carries the root span, the full first-party subtree, the real inputs, 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 original_trace_id. Because a seeded trace has a full recorded subtree, replay mocking works on it exactly as it does on a captured trace. It carries no database pin, so db_branch refuses it. fn resolves the same way it does for replay. A decorated 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-raised. A call that records nothing (no API key resolved, or fn is a generator) raises rather than handing back an ID replay could never find. Call seed_trace from synchronous code. An async def function runs to completion on a fresh event loop, and calling from inside a running loop raises. With trace_across_threads=True, spans from worker threads inside the call nest under the seeded root. 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 adapt_inputs hook as ctx["metadata"], so a case’s provenance rides with the trace instead of through the recorded inputs. 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.

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. reseed_trace reads the trace’s recorded inputs, name, session, and metadata, runs the function once the way seed_trace 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, previous_run_trace_id, 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 raises 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

seed_from_registry seeds through an already-registered pipeline, reusing its client, callable, and trace function key, so every case runs through 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 list plus optional kwargs, metadata, and session_id. input and kwargs are the call itself, recorded as-is. A case carrying expected is rejected, because the output is what the run produced. The registration’s adapt_inputs is a replay hook and is not run at seed time, so a seeded trace is never adapted twice. Replay reports a seeded item exactly as it reports a captured one, since a seeded output is a real run rather than an assertion. Each item’s source ingestion type is available on the item, and a source with none reads as captured. See the reference for full signatures.

Advanced Configuration

  • env_vars: Pass LLM provider API keys for local execution (e.g., {"OPENAI_API_KEY": "..."})
  • capture_enabled: When False, decorated functions still execute normally but no spans are sent. Replay records inside each item regardless, and seed_trace records the one call it runs regardless, so one client with capture off can still replay and seed. Left at None, BITFAB_CAPTURE_ENABLED (0 or 1) decides, and capture stays on when it is unset. enabled is a deprecated alias.
  • simulation_plan: When False, the sim plan is never read, no root call waits for it, and content capture is never narrowed. See Content capture from the sim plan.
  • baml_client: The generated BAML client instance (e.g., b from baml_client). See BAML framework guide for full usage.

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. remove_traces only drops membership. Traces are never deleted. rerun_graders blocks until the run completes and raises GraderRerunTimeoutError if it is still going after 90 seconds (configurable), while the run keeps going on Bitfab. rerun_graders_async is the coroutine version for code that wants to do other work meanwhile. See the reference for every method and result type.

Verdicts

client.verdicts writes pass/fail 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 get_all return that evidence with the verdict. client.verdicts.generate_evidence(trace_id, assertion_id) 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.
save and save_all write the agent’s verdicts. Each one names the approved assertion it scores with assertion_id ("assertionId" in a save_all entry). Without it, save, save_all, and skip raise ValueError before any request, since verdicts are only written on assertions. archive may omit it to clear an old whole-trace agent verdict. save_human and save_human_all 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. They take "pass" or "fail" and require assertion_id the same way. 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 get_all return each trace’s effective verdict plus one row per scored assertion. graders.get_verdicts is the per-grader breakdown the effective verdict folds together. graders.save_verdict and graders.save_verdict_all post verdicts for external graders, which you run yourself and Bitfab never runs. See the reference for every method and type. client.verdicts was named client.labels, and graders.get_verdicts, save_verdict, and save_verdict_all were get_labels, save_label, and save_label_all. The old names, and the label=True / label=False argument in place of verdict, still work for one more minor release and log a deprecation warning.

Experiments

client.experiments reads runs back after the fact, for a dashboard or a report outside the replay process. list pages newest first with every filter optional and combinable, get reads one with its provenance, and get_rollup / get_rollup_all return the pass/fail rollup the dashboard shows, with one row per category. get_rollup_all takes up to 100 ids in one request, so read a month of runs in one call rather than one per run.
Pass metadata= on replay, or --metadata KEY=VALUE on the replay command, 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.