Traces
Every primitive emits structured events. The trace is the debugger.
A Trace is an ordered list of Events. Every primitive opens a call_start, emits events for what happens inside, and closes with a call_end. Wrappers nest under their inner call. Tools, model turns, retries, cache hits — all events.
The trace is not optional and not opt-in. Bridle builds one for every run; you choose whether to capture it.
What the trace records
Nine event kinds. Each is a single line in the structured output, each has a parent_id so the events nest:
| Kind | When it fires |
|---|---|
call_start | A primitive or wrapper begins evaluation. |
call_end | A primitive or wrapper finishes. Carries error if it raised. |
model_request | A turn is dispatched to the model client. |
model_response | The model returned, with stop reason and token counts. |
tool_call | The model invoked a tool (including the synthetic __bridle_return__). |
tool_result | A tool finished — ok: True or an error string. |
cache_hit | A cache wrapper found the key. |
cache_miss | A cache wrapper had to compute. |
retry | A retry/fallback/schema-recovery attempt fired. |
Each event carries a kind, call_kind (which primitive or wrapper), label, timestamp, parent_id, payload, and an error field.
Capturing a trace
Two ways. Set one at the top of a run, then resolve any agent or call:
from bridle import Trace
from bridle.trace import set_active_trace
trace = Trace()
set_active_trace(trace)
bridle.resolve(brief_writer("Mars weather"))
print(trace.to_jsonl()) # one JSON line per event
print(trace.tree()) # nested view by parent_idOr read the trace that the active call built — every primitive enters its own ambient trace if none is set, and you can pull it from the Call's context. Setting one yourself is the predictable path.
Inspecting
Three views, one source of truth.
Flat list. trace.events returns the events in order. Iterate and filter:
model_calls = [e for e in trace if e.kind == "model_response"]
total_tokens = sum(
e.payload["input_tokens"] + e.payload["output_tokens"]
for e in model_calls
)JSON Lines. trace.to_jsonl() returns one JSON document per line. Pipe to a file, search with jq, ship to a log aggregator.
Tree. trace.tree() groups events by parent_id so the nesting is explicit. Print it to see the agent → step → model_request → tool_call shape.
Streaming events live
trace.subscribe(fn) registers an observer that fires on every emit, synchronously, in event order. Returns an unsubscribe callable.
def on_event(event):
if event.kind == "tool_call":
print(f"calling {event.payload['name']}")
unsubscribe = trace.subscribe(on_event)
try:
bridle.resolve(brief_writer("Mars weather"))
finally:
unsubscribe()The log wrapper is built on this — it subscribes a logger handler for the duration of the wrapped subtree, then unsubscribes. See wrappers.
What the trace is for
Three jobs, in order of how often you reach for them:
- Debugging. A run did the wrong thing. Read the trace top to bottom. Find the model turn where it went sideways.
- Cost accounting. Sum input and output tokens across
model_responseevents. Group bylabelto see which step burned the budget. - Replay and assertions. Tests can mock dispatch and assert that the trace contains the expected nesting — "the agent called
steponce, thenbranchonce, thenstepagain."
Concurrency and contextvars
The active trace lives in a ContextVar, not a global. When you spawn an asyncio.Task or copy the context with contextvars.copy_context(), the trace travels with it. The timeout wrapper relies on this — it runs the inner call in a worker thread and uses copy_context() so the worker sees the same trace.
If you start your own threads without copying context, those threads will not append to the trace. Either pass the Trace explicitly and emit into it, or copy context the way timeout does.