The mental model
Your code builds and queues a span in microseconds; the worker thread does the slow network work on its own schedule.track_ai call. The HTTP POST happens on T_worker, a separate OS thread that releases the GIL during socket I/O.
What runs where
Each step in atrack_ai call lands on one of the two threads.
The 10-second
urlopen timeout blocks the worker thread only. T_caller went on its way microseconds ago.
When the worker exports
Four conditions wake T_worker:- The timer elapses, every 5 seconds by default.
- The queue passes its threshold. When the queue exceeds 512 spans, T_caller calls
Event.set()to wake the worker right away. - You call
bento.flush(). This exports synchronously on the caller’s thread and holds_export_lockso it can’t race the worker. - You call
bento.shutdown(). This drains the entire queue one last time on the way out.
collections.deque(maxlen=2048). Past 2048, the oldest span is dropped and a WARNING is logged. The deque’s append/pop are atomic at the C level, so the producer takes no Python-level lock.
Async and context
bento.begin() stores the trajectory’s OTel context in a ContextVar. That context is:
- Per-thread for synchronous code.
- Per-task for asyncio, because
asyncio.create_taskcopies the currentContext.
track_ai inside handler A doesn’t bleed into handler B’s trajectory.
Async behavior
Theon_end → emit code path is entirely synchronous. There’s no asyncio anywhere on T_caller. A FastAPI handler that calls track_ai 10 times pays 10 × 10us = 100us total on the event loop thread, awaits nothing, and doesn’t yield control.
The HTTP POST happens on T_worker, off the loop. During the POST, T_worker is blocked in a C-level recv syscall that releases the GIL, so the event loop keeps running other tasks.
Fork safety
uvicorn --workers N, multiprocessing.Pool, and any other fork()-based parallelism work without extra setup. OTel registers an os.register_at_fork hook that rebuilds the worker thread, lock, event, and queue in each child process. A PID-mismatch guard inside emit adds defense-in-depth.
Your code needs no special handling.
Shutdown semantics
What survives shutdown depends on how the process exits.Verify on your machine
See the worker thread:bento.flush() if you want to wait for them.
Why this design
Every production tracing SDK (Sentry, Datadog, Langfuse, Logfire) converges on the same pattern: a single daemon worker, a bounded in-memory queue, synchronous HTTP from the worker, drop-on-full backpressure, and a flush-on-shutdown API. The alternatives all lose:- HTTP on T_caller adds 50ms to 500ms per traced operation. Latency-sensitive paths die.
- asyncio on the host loop forces sync hosts to adopt async and risks loop scheduling interference.
- Subprocess + IPC adds operational complexity for a deploy-time benefit (the Datadog Agent pattern).
- A thread pool is moot under the GIL for Python-level work.
- An unbounded queue OOMs the host under load. Telemetry shouldn’t kill the thing you’re observing.
- Blocking on full couples host latency to ingest latency. Telemetry becomes a back-pressure source on the request path.