Architecture
On this page
Package diagram
Core design: a shared Span shape, pluggable sinks
Span (span.py) is deliberately a plain, mutable dataclass, not a
context manager itself. All context-manager behavior — creating a span,
tracking the currently-open one, finishing it, handing it off for export —
lives on Tracer. This separation means:
- Any code that wants to describe traced work only needs to know about
Span's fields and mutator methods (set_input,set_output,set_metadata,set_model,set_token_usage,mark_error,finish). - Any backend that wants to consume traced work only needs to implement
BaseExporter.export(span). - Adding a new backend (OpenTelemetry, Langfuse, a custom dashboard) never requires touching the code that produces spans.
Class diagram
Execution flow: Tracer.span()
No-op when disabled
Until Tracer.register_exporter() is
called (or Tracer.enable() is called directly), Tracer.enabled is
False. In that state, Tracer.span(...) takes the early branch in
tracing.py:
if not self.enabled:
yield Span(name=name, type=type, input=input, metadata=metadata or {})
return
The yielded Span is a fully real object — span.set_output(...) etc.
still work inside the with block and won't raise — but it is never
assigned an id/trace_id relationship to a parent, never pushed onto
the ContextVar, and never enqueued for export. This is what the
module's docstring means by "safe to leave sprinkled through hot paths":
the disabled path allocates one Span object and does nothing else.
Non-blocking export via a background thread
Tracer never calls exporter.export(span) on the calling thread.
Finished spans are put onto an internal queue.Queue (_enqueue), and a
single daemon thread (_drain_loop, started lazily by _ensure_worker)
pulls spans off that queue one at a time and hands each to every
registered exporter in turn. Two consequences follow directly from the
tracing.py source:
- A slow exporter (e.g. a LangSmith network call) never adds latency to the code that opened the span — that code already returned once the span was enqueued.
- A failing exporter never raises into your application.
_drain_loopwraps eachexporter.export(span)call in atry/except Exceptionand logs vialogger.exception(...)instead of propagating.
Because there is exactly one drain thread, exporters run in registration
order and effectively serially with respect to each other — a stuck
export() call in one exporter delays delivery to exporters registered
after it, but still never blocks the thread that produced the span.
Context propagation with contextvars
Span nesting (parent_id) and grouping (trace_id) are tracked via a
single module-level contextvars.ContextVar named fennec_current_span
in tracing.py, not via thread-locals or explicit parameters. Per the
contextvars module's own semantics (which this code relies on, though
it isn't re-implemented here), this means:
- Each
asyncioTask gets its own view of "the current span" once it starts, so concurrent async traces don't cross-contaminate. - Each thread that enters a
with tracer.span(...)block sets and later resets its own token, so concurrent threads don't cross-contaminate either.
Shutdown and flushing
Tracer.__init__ registers self.shutdown with atexit, so on normal
interpreter exit:
- If the worker thread is alive, a
Nonesentinel is pushed onto the queue and the worker is joined (up to 5 seconds) —_drain_loopexits itswhile Trueloop when it popsNone. - Every registered exporter's
shutdown()is called (wrapped in its owntry/except, logged on failure, never re-raised).
Tracer.flush(timeout=None) blocks on self._queue.join() until every
currently-queued span has been picked up and processed by the worker.
Note from the source: the timeout parameter is accepted but not
actually passed to queue.Queue.join() (which doesn't take one) — calling
flush(timeout=5) currently blocks indefinitely just like
flush(), regardless of the value passed.
How this fits into the rest of zerda
Tracer.record_external_span() exists specifically so that other
subsystems — the docstring names "the chain framework's own
ExecutionTracer" — can report spans they already computed end-to-end
(they pass start_time/end_time/error directly) without needing to
restructure their code around the with tracer.span(...) pattern. The
zerda.chain package referenced in the test suite is not part
of the provided source, so its ExecutionTracer and how it calls into
this method are outside the scope of this documentation.
Next: Configuration for exporter setup, or the API Reference for full signatures.