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:

Python · 3 lines
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_loop wraps each exporter.export(span) call in a try/except Exception and logs via logger.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 asyncio Task 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:

  1. If the worker thread is alive, a None sentinel is pushed onto the queue and the worker is joined (up to 5 seconds) — _drain_loop exits its while True loop when it pops None.
  2. Every registered exporter's shutdown() is called (wrapped in its own try/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.

Related pages