Start/End, and framework handlers
still create Bitfab spans with Bitfab trace IDs.
The design below is shared by all three 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 OTelTracerProviderand one boundedBatchSpanProcessor. - Framework integrations created by a
Bitfabclient reuse that sameHttpClient; creating handlers does not create additional OTel workers (Python). - Live and replay traffic use the same pipeline.
- A configured Collector replaces direct delivery; the SDK never mirrors the same traffic through both routes.
- 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.
- Collector acceptance is not treated as proof of Bitfab persistence.
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.get_trace(id)) are not carried here. They are
trace-API calls, not telemetry, and go straight to PATCH /api/sdk/traces/{id}
synchronously like 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.
Direct delivery
WithoutBITFAB_OTEL_EXPORTER_ENDPOINT, the flow is:
BITFAB_OTEL_EXPORT_CONCURRENCY to an integer from 1 through 64 to tune
that direct-request concurrency. Invalid values warn once and fall back to
32.
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 408, 425, 429, and
5xx responses. Permanent errors are not retried. Malformed carriers can be
rejected through the standard OTLP partialSuccess response without affecting
sibling requests.
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.
Collector delivery
SetBITFAB_OTEL_EXPORTER_ENDPOINT to an OTLP/HTTP receiver base URL such as
http://localhost:4318. The SDK appends /v1/traces and uses OTel’s official
OTLP/HTTP protobuf exporter. Ruby applications add the
opentelemetry-exporter-otlp gem to enable this route; without it the SDK
warns once and keeps delivering directly to Bitfab:
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. In direct mode, OTel hands up to 512 carriers to one synchronous exporter call; the direct 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. In Collector mode, OTel hands at most 32 carriers to the official OTLP/HTTP protobuf exporter. A Collector can deliberately rebatch downstream when its final destination supports a different limit. Count alone is not safe for Bitfab because captured inputs and outputs vary widely in size. Collector protobuf exports are also partitioned into requests of at most approximately 3 MB. Direct requests contain at most eight carriers and are packed using the exact encoded OTLP/JSON request size under that same target. 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 Collector or 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.
For direct delivery, a carrier that exceeds the limit by itself is rejected
before sending. A 413 Payload Too Large response from ingress also fails the
request. Tracing failures never interrupt the host application.
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, soflush_traces still
returns true: 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.
Behavior against a slow endpoint
Direct 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
The SDK tracks each OTel exporter’s result soflush_traces() returns False
when a batch fails or the deadline expires, instead of reporting only that the
processor queue drained. In Collector mode, a successful exporter response
still means the Collector accepted the carriers; it 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.
force_flush() or a Collector
acknowledgment.
Framework integrations (Python)
Core decorators, OpenAI Agents, LangGraph/LangChain, and the Claude Agent SDK all submit through the owningBitfab client’s HttpClient and the same
TraceTransport abstraction. Bitfab.close() therefore flushes and shuts down
the worker used by its decorators and framework integrations together.
Handlers instantiated directly, without a Bitfab client, own their
HttpClient; call the handler’s close() method or 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 ships no framework integrations; every traced method submits through its
client’s Bitfab::HttpClient.
Lifecycle and failure behavior
- Construction is lazy: a client that never sends a trace starts no OTel worker.
- The global flush (
flush_traces(timeout)in Python,Bitfab.flush_traces(timeout:)in Ruby) asks every active Bitfab processor in the process to flush within one deadline and returns false on exporter failure or timeout. It does not claim Bitfab persistence when delivery goes through a Collector. client.close(timeout)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 the destination exporter and its Collector HTTP session. - Submissions after shutdown are rejected without rebuilding an unowned pipeline.
Go specifics
Go shares the design above, with differences that follow from the runtime:- The Collector exporter is always linked. Go has no dynamic import, so
otlptracehttpand its transitive dependencies ship in every build rather than loading on demand. A consumer who never configures a Collector still carries it; in exchange, a configured Collector can never fail for want of a package. Adopting OTel also raises the SDK’s minimum Go version to 1.25, the floor the OpenTelemetry Go modules declare. - The Collector endpoint is validated before the exporter is built. Anything
that does not parse as an
http/httpsURL with a host is rejected and the client falls back to direct delivery with a one-time warning. The official exporter would otherwise log the problem and return an exporter aimed at its own default host, sending every span into the void. - The API key stays late-bound. OTel’s Go exporter takes static headers, so
the SDK supplies an
http.Clientwhose round tripper stamps the resolved key on every request. - Collector protobuf exports are partitioned by encoded JSON size. The
official exporter’s protobuf transformer is internal to its module, so the
JSON figure serves as a conservative bound (protobuf is strictly smaller for
these payloads), with
otlptracehttp.WithMaxRequestSizeas a backstop. - 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 below 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.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 send live or replay traffic through direct and Collector routes simultaneously.
- The SDK does not implement per-span transport acknowledgments on top of OTel.