withSpan, decorators, traced methods, manual spans, Start/End, and
framework handlers still create Bitfab spans with Bitfab trace IDs.
The design below is shared by all four SDKs. Where a name differs:
OpenTelemetry is a transport dependency here, not the source of Bitfab’s trace
model. This boundary lets the SDK use standard OTel infrastructure without
merging an application’s OTel traces into Bitfab traces or exposing OTel types
through the Bitfab API.
Design invariants
- Each
Bitfabclient’sHttpClientlazily owns exactly one private OTel provider and one boundedBatchSpanProcessor. - Framework integrations created by a
Bitfabclient reuse that sameHttpClient; creating handlers does not create additional OTel workers. - Live and replay traffic use the same pipeline.
- The private provider does not replace the application’s global OTel provider or export application spans.
- OTel owns queueing, count-based batching, scheduling, flushing, shutdown, and its batch worker.
- Bitfab owns logical trace identity, payload validation, persistence, idempotency, and replay readiness.
- Each carrier is encoded exactly once; a request body is assembled from those encodings rather than re-encoded to measure its size.
Components and ownership
Carrier spans versus Bitfab spans
Every Bitfab external-span, trace-completion, orcall() BAML-trace payload is
placed inside an internal OTel carrier span with two attributes:
bitfab.operation:external_span,external_trace, orinternal_tracebitfab.payload: the JSON-encoded Bitfab payload
client.getTrace(id) / client.get_trace(id)) are not
carried here. They are trace-API calls, not telemetry, and go straight to
PATCH /api/sdk/traces/{id} like getTraceSpan / get_trace_span.
The carrier’s OTel trace and span IDs are transport details. A carrier may
inherit the process’s ambient OTel context because OTel context propagation is
process-wide, but Bitfab ingress does not use that topology to construct the
stored trace tree. It extracts bitfab.payload and uses the Bitfab
sourceTraceId, span ID, and parent ID inside that payload.
This is why an application can have its own OTel trace while recording one or
more Bitfab traces safely: the exporter and processor are private, and Bitfab
trace grouping comes from the payload rather than the carrier topology. Two
Bitfab trace IDs carried inside one ambient OTel trace remain two stored Bitfab
traces.
Delivery
The flow is:BITFAB_OTEL_EXPORT_CONCURRENCY to an integer from 1 through 64 to tune
that request concurrency. Invalid values warn once and fall back to
32.
Each carrier is encoded once, up front, and its byte count is carried alongside
it. Packing sums those counts against the request limit and the body is built by
concatenating the encodings, so the largest thing in a request, the
bitfab.payload string, is escaped exactly once no matter how many carriers a
window holds.
This preserves OTel’s bounded queue, batch scheduling, force-flush, shutdown,
and single batch worker while restoring the small independent requests that
Bitfab’s serverless ingress is designed to scale.
Each request retries transient network errors and the responses that mean the
server is busy or unreachable rather than unhappy with the payload: 429,
502, 503, 504, and 500. (500 is not in OTLP’s retryable set. Bitfab
ingestion answers every unhandled error with it, so a connection blip is
indistinguishable from a permanent fault and is worth a second attempt.) Every
other status is a verdict that will not change, and is not retried. Malformed
carriers can be rejected through the standard OTLP partialSuccess response
without affecting sibling requests.
A server that sends Retry-After is asking for a specific wait, and that wait
is honored exactly rather than shortened. It is refused only when it cannot be
served inside the export budget the batch processor enforces, since a wait
outliving that budget is killed mid-wait and loses the batch anyway. Half of the
remaining budget is reserved for the request itself, so a wait is only taken
when what follows it can still carry the send. Absent that instruction,
attempts back off exponentially with jitter, so a struggling server is not hit
on a fixed cadence and a fleet of clients does not return in lockstep. While a
throttle is active it applies to the whole transport, not only the request that
was refused.
Retrying is safe for spans and trace completions, which ingestion keys
idempotently on their source span and trace IDs. A call() BAML trace carries
no such key, so a retried batch holding one can create a duplicate trace, and a
request that times out client-side may still be persisted server-side. This is
a known limitation in every SDK that executes BAML; resolving it needs a
client-supplied idempotency key that ingestion dedupes on.
Bitfab ingress validates the complete request before processing accepted
carriers through a flat bounded worker pool. The pool defaults to eight
concurrent carriers and can be changed with
BITFAB_OTLP_INGEST_CONCURRENCY, up to a safety ceiling of 32. Carrier
processing does not rely on arrival order, matching the existing ingestion
contract for independently delivered SDK requests. The endpoint acknowledges
only after every accepted carrier has persisted.
Batching and payload size
OTel’s processor batches by count and time. The current SDK defaults are a bounded queue of 8,192 carriers and a five-second scheduled export delay. OTel hands up to 512 carriers to one exporter call; the exporter immediately packs that window into count-and-byte-bounded HTTP requests and sends those complete requests concurrently. It does not own a second queue, timer, or retained batch state. Count alone is not safe for Bitfab because captured inputs and outputs vary widely in size. Requests contain at most eight carriers and are packed using the exact encoded OTLP/JSON request size under a target of approximately 3 MB. This leaves headroom beneath Bitfab’s fixed ingress request limit. SetBITFAB_OTEL_MAX_REQUEST_BYTES to a positive integer no greater than
3000000 to use a smaller request target for a proxy with a stricter limit. The value is read when the client’s lazy OTel transport is
created. Invalid, non-positive, or larger values warn once and fall back to
3000000; the setting cannot raise the Bitfab-safe ceiling.
A carrier above the raw packing target gets its own request. If that request
compresses beneath the configured byte target and stays below the 8 MB
decompressed ingress ceiling, it is sent intact. Otherwise the carrier takes
the trimming fallback below. A 413 Payload Too Large response from ingress
still fails the request. Tracing failures never interrupt the host application.
The per-span payload budget
The SDK first preserves up to 7,800,000 bytes of a span’s whole payload—its input, output, contexts, prompt, and metadata together—when the resulting single-span request compresses beneath the wire target. The ceiling leaves room under ingress’s 8 MB decompressed request limit. Both ceilings are measured on the carrier, the payload as it is re-escaped into the OTLP attribute, because that is the size the request limit applies to. Measuring the payload itself would not bound anything: the second escaping only has to escape" and \\, but it doubles each one, so
backslash-dense content (escaped JSON, Windows paths, regexes) grows up to 2x on
the way out. A payload sized to a 2.4 MB raw cap produced a 2.4 MB carrier as
prose and a 4.8 MB carrier as backslashes, and the exporter dropped the latter.
Measuring the carrier costs nothing on ordinary spans. Escaping can never shrink
a body and can at most double it, so a payload under half the budget always fits
and one past the budget never does; both are integer comparisons. Only a payload
between those bounds is scanned exactly, and that scan counts bytes without
allocating a second copy.
The cap is on the payload as a whole, not on each value. Capping values
independently cannot deliver this guarantee: two values that each fit can still
add up to a span that has to be dropped. It also means a single legitimately
large value, a document, a long message history, an agent state, may use the
entire budget when the rest of the span is small.
A payload past 7,800,000 carrier bytes is trimmed immediately. A smaller
oversized carrier is tried intact once; if gzip is unavailable or its request
still exceeds the wire target, the SDK trims it to the 2,800,000-byte fallback
budget and prepares the request again. Trimming replaces the largest fields
with <unserializable: too_large_N_bytes> placeholders, largest first, until
the encoded payload fits, and never trims fields that identify the span. Each
trim is recorded in the payload’s own errors
under a payload_budget step, so the trace is flagged as incomplete and not
replayed as though it were faithful, and the SDK warns once in the host
application’s logs.
Compression
A packed request is gzipped before it is sent once its body reaches 8,192 bytes and gzip makes it smaller. Smaller or incompressible requests go uncompressed. Ordinary batches are packed under the raw byte target and compressed exactly once. Only a single carrier above that raw target checks the prepared wire size; it is compressed a second time only when the first result does not fit and the existing trimming fallback must rebuild it. A request already above the 8,000,000-byte decompressed ceiling is trimmed before gzip, because compression cannot make it admissible under that raw limit. The SDKs use their runtimes’ standard compression level; Python is explicitly pinned to level 6 instead ofgzip.compress’s CPU-heavier level-9 default.
Compression never runs on a thread the host application is waiting on: the
TypeScript SDK uses Node’s asynchronous gzip, which runs on the libuv
threadpool, and Python, Ruby, and Go release the interpreter lock or compress
on their own goroutine.
Compression is best-effort: if it fails, the SDK sends the original body rather
than dropping the span. Set BITFAB_DISABLE_COMPRESSION to any value to send
everything uncompressed. In the browser, the TypeScript SDK uses
CompressionStream and falls back to uncompressed where it is unavailable.
Burst limits
The queue holds 8,192 carriers, and a span and its trace completion are two carriers. A burst that queues faster than the exporter drains loses the excess: OTel drops the oldest carriers to keep the queue bounded, which is what keeps a tracing backlog from growing into the host application’s memory. A measured example, against a sink that answers immediately: 20,000 spans submitted in 1.4 seconds queued 40,000 carriers, of which 8,704 were delivered. Staying inside the queue’s capacity delivers everything, exactly once. Those drops happen before delivery is attempted, so the global flush still reports success: its verdict covers what reached the exporter, not what the queue had to discard. Treat it as “everything queued was delivered,” and keep bursts under the queue size when every span matters. A second limit is server-side rather than client-side. A carrier that opens a new trace costs substantially more to ingest than one appending a span to an existing trace, so a burst of many independent traces can exceed the export deadline even when the same span count spread across one trace tree would not. Nested workloads are unaffected.Behavior against a slow endpoint
Delivery runs up to 32 concurrent requests, each with a 30-second deadline, and retries a request that times out or returns a retryable status. Against an endpoint slower than that concurrency (a laptop dev server, a constrained self-hosted proxy), requests can exceed the deadline and be re-sent while the first attempt is still being processed. Bitfab ingestion is idempotent per span ID, so the retries cost bandwidth rather than duplicate rows: one measured run re-sent 46% of its carriers and still produced exactly one row per span. LowerBITFAB_OTEL_EXPORT_CONCURRENCY to match what the
endpoint can absorb.
Replay persistence barrier
Replay is implemented in the TypeScript, Python, and Ruby SDKs; the Go SDK has no replay pipeline, so this section does not apply to it. The SDK tracks each OTel exporter’s result so the global flush reports failure when a batch fails or the deadline expires, instead of reporting only that the processor queue drained. A successful exporter response still does not prove that Bitfab has committed the trace and all of its spans. Replay therefore adds a server-authoritative barrier without adding another transport pipeline:- Without
expectedSpanCounts, it returns every trace mapping currently associated with the test run. - With
expectedSpanCounts, it returns a mapping only when that trace has a final status and at least the expected number of persisted spans.
Framework integrations
withSpan, decorators, OpenAI Agents, LangGraph/LangChain, the Claude Agent
SDK, and (in TypeScript) the Vercel AI SDK middleware all submit through the
owning Bitfab client’s HttpClient and the same TraceTransport
abstraction. Closing the client therefore flushes and shuts down the worker
used by its instrumented functions and framework integrations together.
Handlers instantiated directly, without a Bitfab client, own their
HttpClient; call the handler’s close method (or, in Python, use it as a
context manager) to release that worker. Replay still flushes all live Bitfab
OTel transports in the process so multiple intentional Bitfab clients are
covered before server readiness is checked.
The LangGraph/LangChain handler keeps langsmith:hidden scheduler callbacks
only as local parenting state. It reparents visible children to the nearest
visible ancestor and does not submit hidden callbacks through OTel. The server
retains its hidden-span filter for older SDK versions.
Framework integrations do not create a separate replay exporter or special
single-item batches.
Ruby and Go ship no framework integrations; every traced method submits through
its client’s HttpClient.
Lifecycle and failure behavior
- Construction is lazy: a client that never sends a trace starts no OTel worker.
- The global flush asks every active Bitfab processor in the process to flush within one deadline and returns false on exporter failure or timeout.
- The per-client close flushes and permanently shuts down that client’s
processor. In Python,
with Bitfab(...) as client:closes it on context exit. - An exit handler gives remaining transports a bounded shutdown attempt.
- After
fork(), OTel reinitializes its batch worker, queue, and export lock; Bitfab rebuilds its own pipeline in the child, including its exporter. - Submissions after shutdown are rejected without rebuilding an unowned pipeline.
TypeScript specifics
TypeScript shares the design above, with differences that follow from the runtime:- The private provider is constructed with every option supplied
explicitly, including its sampler and span limits. Left to its defaults it
would read
OTEL_*environment variables, so a host application’s global sampler or attribute-length limit could silently drop or truncate Bitfab payloads. - Every OTel package the SDK depends on is isomorphic, so the SDK still builds and runs in browsers.
- A failed flush is a hint, not a verdict. The export deadline can fire while requests are still in flight and the server can still persist every one of them, so replay does not fail on the flush result alone: it polls, and fails only if the server itself never confirms. When both fail, the error names the flush failure as the likely cause. Python raises on the flush result instead.
- No fork handling. Node.js has no
fork()that duplicates the event loop, so there is no post-fork worker to rebuild. - Deferred spans are tracked per client. A span recorded through
finalizereaches the transport only after its finalize chain settles, because the caller gets the live stream back untouched. That deferred work is tracked per client, soclient.close()waits for its own spans without being failed by another client’s slow finalize;flushTraces()remains process-wide and waits for all of it. Python’s finalize runs inline, so it has nothing to track. - A
beforeExithandler gives remaining transports a bounded shutdown attempt, which is what lets a plain script exit with its spans delivered.
Go specifics
Go shares the design above, with differences that follow from the runtime:- Adopting OTel raises the SDK’s minimum Go version to 1.25, the floor the OpenTelemetry Go modules declare. Only the OpenTelemetry trace SDK is linked, so protobuf and gRPC stay out of consumers’ builds.
- The request envelope is derived from an encoded empty request. Go orders map keys when marshalling, so the head and tail are cut from a marshalled request with an empty span list rather than written by hand, keeping an assembled body byte-identical to marshalling the whole request.
- No fork handling. A Go program that forks does not carry its goroutines into the child, so there is no post-fork worker to rebuild.
- No automatic last-chance flush. Go has no
atexitequivalent, so calldefer client.Close(5 * time.Second)inmain, orFlushTracesbefore exit. - No replay pipeline. Replay is not implemented in the Go SDK, so the persistence barrier above does not apply to it.
- Carriers start from a background context rather than the ambient one, and a submission is a non-blocking enqueue: spans and trace completions are queued in the order they occur, and ingress processes carriers idempotently without relying on arrival order.
Deployment order
The replay status request is backward-compatible, but the expected-span readiness behavior lives on the server. Deploy the Bitfab web/server change before publishing an SDK version that relies on the stronger barrier. That change shipped with the Python SDK’s adoption, so the SDKs that adopted the transport afterwards needed no further server deploy.Deliberate non-goals
- The SDK does not install a Bitfab processor or exporter on the application’s global OTel provider.
- The SDK does not infer Bitfab trace grouping from OTel carrier trace IDs.
- The SDK does not expose transport acknowledgments as public API or treat them as the sole persistence authority.