Custom Exporters¶
If neither Langfuse nor OpenTelemetry fits — you want to log to a file, push to an internal service, or shape spans your own way — implement the TraceExporter contract. It's a handful of no-op-by-default hooks; override only what you need.
The contract¶
Every exporter receives an agent run's lifecycle as method calls, driven by the side-channel observer. All hooks are keyword-only with defaults, so the contract can grow without breaking existing exporters.
| Hook | Called when | Key arguments |
|---|---|---|
on_run_start | a run begins | name, input |
on_turn | one LLM turn completes | usage, model, stop_reason, input, output |
on_tool_start | a tool call begins | call_id, name, args |
on_tool_finish | a tool call returns | call_id, name, result, is_error |
on_event | mode change / compaction | kind, **data |
on_error | the run raised | message |
on_run_finish | the run ends | status, output |
flush | run end | — (push buffered data) |
close | process teardown | — (release resources) |
Every hook also receives the run's TraceContext (first positional arg) — carrying trace_id, span_id, parent_span_id, session_id, and metadata. Tool calls pair by call_id (present on both on_tool_start and on_tool_finish), so keep your own call_id → span map.
A minimal exporter¶
from neurosurfer.observability.exporters.base import TraceExporter
class LogExporter(TraceExporter):
name = "log"
def on_run_start(self, ctx, *, name, input=None):
print(f"[{ctx.trace_id}] run start: {name}")
def on_turn(self, ctx, *, usage, model, stop_reason, input=None, output=None):
print(f"[{ctx.trace_id}] turn: {model} "
f"{usage.input_tokens}->{usage.output_tokens} ({stop_reason})")
def on_tool_finish(self, ctx, *, call_id, name, result, is_error):
print(f"[{ctx.trace_id}] tool {name}: {'error' if is_error else 'ok'}")
def on_run_finish(self, ctx, *, status, output=None):
print(f"[{ctx.trace_id}] run {status}")
Registering it¶
Add an instance before your first run:
The built-in MemoryExporter captures every lifecycle call in-memory — ideal for tests:
from neurosurfer.observability.exporters.base import MemoryExporter
mem = MemoryExporter()
register_exporter(mem)
# ... run an agent ...
assert mem.hooks()[0] == "run_start" # ordered hook names
turns = mem.of("turn") # payloads for a given hook
Choosing exporters in code¶
Override environment auto-detection explicitly:
from neurosurfer.observability.exporters import configure_exporters
configure_exporters(["langfuse"], service_name="my-app") # replace the active set by name
configure_exporters(names, *, service_name=...)— set the active exporters by name, replacing any prior state. Call before the first run.register_exporter(instance)— add an already-constructed exporter.get_active_exporters()— the resolved active set (from the environment on first use).reset_exporters()— clear resolved state (tests re-resolve next call).
Isolation guarantees¶
The observer wraps every hook call in a guard: a misbehaving or raising exporter never breaks the agent run — its error is swallowed and logged. A missing backend SDK is warn-and-skipped, so a custom exporter can sit alongside Langfuse/OTel without either affecting the others. They share nothing but the read-only TraceContext.
What the dispatch worker means for you¶
Your hooks do not run on the agent's thread. They are submitted to a single daemon worker over a bounded FIFO. Three consequences for an exporter author:
You get ordering, so you can keep per-run state. One worker draining a FIFO means on_run_start reaches you before the on_turn that follows it. A dict keyed by span id is safe — and it is safe even when a map node runs its body concurrently, because every call lands on the same thread.
You do not get delivery. The queue is bounded and drops when full. Do not build anything that requires having seen every hook; a missing on_run_finish is a normal condition, not a bug.
Blocking is your own cost, not the run's — but it is still a cost. The worker is one thread, so a hook that blocks for eight seconds delays every exporter behind it. Keep network work batched, and let a backend SDK's own queue do the batching where it has one.
from neurosurfer.observability import dispatch
dispatch.drain(timeout=5.0) # in a test, before asserting on what you received
dispatch.dropped() # items the bounded queue discarded
Don't call force_flush in a hook
It exists to bypass a batch processor's queue and drain on the calling thread. Inline, that is what cost a run 74 seconds against an unreachable collector.
Next¶
- Overview — how the whole tracing layer fits together.
- Langfuse · OpenTelemetry — the built-in backends.