Cookbook
On this page
Practical recipes built directly from the module's actual API surface.
Trace to multiple backends at once
from zerda.observability import get_tracer, ConsoleExporter, JSONExporter
get_tracer().register_exporter(ConsoleExporter())
get_tracer().register_exporter(JSONExporter(path="traces.jsonl"))
Every span now reaches both exporters (dispatched in registration order by the single background drain thread — see Architecture).
Toggle tracing on and off without losing exporter setup
from zerda.observability import enable_console, disable_tracing, get_tracer
enable_console()
# ... traced section ...
disable_tracing()
# ... untraced section: Tracer.span() takes the cheap no-op path ...
get_tracer().enable()
# ... tracing resumes to the same ConsoleExporter, no re-registration needed ...
Capture spans in memory for assertions in tests
This mirrors the pattern the project's own test suite uses (a
RecordingExporter subclassing BaseExporter):
from zerda.observability import BaseExporter, Span, get_tracer
class RecordingExporter(BaseExporter):
def __init__(self):
self.spans = []
def export(self, span: Span) -> None:
self.spans.append(span)
recorder = RecordingExporter()
get_tracer().clear_exporters() # isolate from any prior test/state
get_tracer().register_exporter(recorder)
with get_tracer().span("Step", type="tool") as span:
span.set_output("done")
get_tracer().flush() # block until the drain thread has processed it
assert recorder.spans[0].name == "Step"
assert recorder.spans[0].success is True
Or, without writing your own class, use the built-in
JSONExporter in its in-memory mode
for the same purpose:
from zerda.observability import get_tracer, JSONExporter
exporter = JSONExporter() # no path -> in-memory
get_tracer().register_exporter(exporter)
with get_tracer().span("Step", type="tool"):
pass
get_tracer().flush()
assert exporter.spans[0]["name"] == "Step"
Bridge an existing, already-computed trace into this module
If some other subsystem already measured a span's timing end-to-end
(rather than wrapping live code with with tracer.span(...)), report it
via record_external_span()
instead of trying to fabricate a with block around code that already
ran:
from zerda.observability import get_tracer
get_tracer().record_external_span(
name="ExecutionTracer step",
span_type="chain",
start_time=step.start_ts,
end_time=step.end_ts,
input=step.input,
output=step.output,
error=step.error_message, # a string, not an exception object
trace_id=step.trace_id, # pass explicitly — this is NOT read from the
parent_id=step.parent_id, # currently-open span's ContextVar
)
Writing a custom exporter
Subclass BaseExporter and implement
export(). Keep it defensive even though Tracer also isolates
failures — the contract expects it:
import logging
from zerda.observability import BaseExporter, Span, get_tracer
logger = logging.getLogger(__name__)
class OpenTelemetryExporter(BaseExporter):
"""Sketch — adapt to your actual OTel SDK setup."""
def __init__(self, otel_tracer):
self._otel_tracer = otel_tracer
def export(self, span: Span) -> None:
try:
with self._otel_tracer.start_as_current_span(span.name) as otel_span:
otel_span.set_attribute("fennec.span_id", span.id)
otel_span.set_attribute("fennec.trace_id", span.trace_id)
otel_span.set_attribute("fennec.type", span.type)
if span.error:
otel_span.set_status_error(span.error)
except Exception:
logger.exception("failed to export span %s to OpenTelemetry", span.id)
def shutdown(self) -> None:
# Flush/close whatever your OTel SDK setup requires.
pass
get_tracer().register_exporter(OpenTelemetryExporter(my_otel_tracer))
[!NOTE] This example is a sketch showing where the integration point is (
export()receiving a FennecSpan), not a verified integration — the actual OpenTelemetry SDK calls depend on which OTel Python API version you use, which is outside the scope of this module's source.
Instrumenting a function vs. wrapping a block
Prefer the trace() decorator when a
whole function is the unit of work and you don't need to touch the
span mid-function; prefer
get_tracer().span(...) directly when you need to
call span.set_input(...) with something other than the raw call
arguments, or need to read/mutate the span partway through:
from zerda.observability import trace, get_tracer
# Whole function is the unit of work:
@trace("EmbedQuery", type="embedding")
def embed_query(text: str) -> list[float]:
return embedding_model.encode(text)
# Need finer control (custom input shape, mid-function metadata):
def embed_and_search(text: str):
with get_tracer().span("EmbedAndSearch", type="retriever") as span:
span.set_input({"query": text, "top_k": 5})
vec = embedding_model.encode(text)
span.set_metadata(embedding_dim=len(vec))
results = vector_store.search(vec, top_k=5)
span.set_output(results)
return results
Related: Quickstart · API Reference · FAQ & Troubleshooting