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

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. 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":

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 function name, then 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. The 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 handler that records a replayable framework root plus graph nodes, LLM calls, tools, and retrievers

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_metadata(dict) — Arbitrary key-value metadata on the trace. Merges with existing metadata.
  • 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, and are silent no-ops when the client is disabled.
  • add_context(context) — Appends a context entry. Existing entries are preserved.
  • set_metadata(metadata) — Shallow-merges new keys into existing metadata.
  • set_session_id(session_id) — Replaces any existing session ID.

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. Repeated name matches default to the last span.
occurrence also accepts a zero-based integer. A missing trace or span 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 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 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. A carrier larger than the ingress limit by itself is logged as an export failure because its replay payload cannot be divided without changing the span contract. 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 a test run with comparison data. This is useful for testing changes to your functions against real production inputs.
Pass replay() either an already-@span-decorated function (it carries its trace function key, so it runs 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 a @span-decorated function: replay() wraps the closure as the root span while the inner decorated function records its own span 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 @span decorator; replay("key", fn) takes an explicit key with any plain callable (the SDK wraps it internally). 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 or dataset_id is passed: an explicit ID list or dataset already determines how many traces replay.
  • trace_ids (optional): List of trace IDs to replay (max 100). The ID count determines how many traces replay; limit is ignored when both are passed.
  • name (optional): Display name for the resulting experiment/test run.
  • max_concurrency (optional): Maximum items processed in parallel. 1 for sequential, None for unlimited. Default: 10
  • 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
  • experiment_group_id (optional): UUID string that groups multiple replay runs into a single experiment batch. Pass the same ID across successive replay() calls to link them together in the dashboard.
  • 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. Bitfab plugin replay scripts pass 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.
Returns:
Replay waits for every submitted trace completion and expected span count to be persisted server-side before completing the test run, 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 that barrier times out, replay() raises a RuntimeError and does not finalize an incomplete run. If NO otherwise-completed item’s trace persisted (uploads wholesale failed, or the replayed function isn’t decorated with @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. Per-item duration_ms and model come from the historical trace that fed the item. tokens is the replayed run’s token usage (the same numbers Studio’s experiments view shows), so comparing each item’s tokens["total"] against the original trace’s recorded usage tells you how your change moved cost. Each field is None when it wasn’t captured.

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 @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 test run. 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 the runtime environment the trace never captured: framework config, dependency objects, API keys. Use safe no-op substitutes for side-effectful wiring (billing or credit callbacks, notification senders); replay should never charge or notify anyone.
Older SDKs (before explicit-key replay): decorate a wrapper in the replay script 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 descendant span returns its historical output. The root function still runs real, but every child is short-circuited. Useful for a quick sanity-check against recorded data; not the recommended iteration strategy because changes to descendants won’t actually execute.
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 no historical span matches a child call, execution falls through to the real function — never silent omission.

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.trace_function_key, node.span_name, node.type, node.original_span_id); value is a flat value injected as-is, or a callable that returns one.
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():
Precedence per span: per-call mock_override, then registered overrides, then the base mock strategy (a span no override matches falls back to it). Pass a single MockOverride or a list (first matcher wins).

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"}, with deprecated source_* aliases) 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 script 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 (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. 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 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 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 script, 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 @span — the trace function key is read from the decorator. When the production code has a decorated root, pass the decorated function itself, not an undecorated wrapper around it; the @span attribute is what identifies the trace key. An undecorated wrapper has no key, so replay() wraps it as the root and the inner decorated function then records its own span underneath, nesting a duplicate. For nested decorators (e.g. @retry(@cache(@span(fn)))), pass the outermost — replay walks the __wrapped__ chain to find @span. 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 script 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 replay script.
  • 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 a test run visible in the Bitfab dashboard
  • Works through nested decorators (e.g. @retry, @cache) — walks the __wrapped__ chain to find @span

Replay Output Contract

Replay results are typically consumed by automation (CI logs, code reviewers, and coding agents). When BITFAB_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.loads it and reason about every field, including the new per-item duration_ms, tokens, and model. 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:
The dumped object includes every item’s input, result, original_output, error, structured trace_error and replay_error, duration_ms, tokens, model, and trace_id, plus 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. 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 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 script. A custom try/except that returns a placeholder turns infra failures into fake successes. Let the SDK record them. The only allowed top-level except 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. Environment. Replay executes in the app’s own process — the instrumented function is imported as a library, and its DB clients, env vars, config loaders, and model IDs resolve from whatever environment the replay script is run under. The script must bootstrap the same environment the app uses (e.g. load_dotenv() at the top, or run via dotenv run -- python scripts/replay.py). Do not mock these — they’re the same dependencies the app resolves in production. For replay to see the same DB rows the trace was captured against, point the script at the trace’s source environment (the environment field on the trace — production / staging / development). 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 Script

Create a standalone script to regression-test your trace functions against production data with one command. The script maps pipeline names to their replay functions, accepts CLI flags, and prints a side-by-side comparison with delta summaries.
Adapt the imports, pipeline names, and per-pipeline replay functions to match your project’s instrumented workflows.

Advanced Configuration

  • env_vars: Pass LLM provider API keys for local execution (e.g., {"OPENAI_API_KEY": "..."})
  • enabled: When False, all tracing is disabled. Decorated functions still execute normally but no spans are sent.
  • baml_client: The generated BAML client instance (e.g., b from baml_client). See BAML framework guide for full usage.