symfonic.observability.tool_span_seam¶
tool_span_seam ¶
v7.23.3 — Tool-span emit-site wire-up.
External-adopter bug report (2026-06-10, Jarvio brain team via CORTEX
observability platform): no symfonic.tool.<name> spans were appearing
during agent execution despite observability/otel/_span_recorder.py
shipping fully-built start_tool() / end_tool() methods.
Cross-file trace (architect verdict 2026-06-10) confirmed the gap:
- Recorder surface present (
start_toolat_span_recorder.py:188,end_toolat_span_recorder.py:210) -- zero production callers. - Engine dispatch via raw
langgraph.prebuilt.ToolNodeatcore/presets.py:98-101-- no observability wrap. ObservabilityHook.on_tool_start/on_tool_endatcore/observability/protocol.py:53-62-- also zero callers (second dead surface; closing both with the same wire-up).
This module is the v7.23.3 fix. It introduces:
- A module-level
ContextVarcarrying the active SpanRecorder. The contextvar is OTel-agnostic (typed asAny) so this module can be imported fromcore/presets.pywithout pulling the optionalopentelemetrydependency at framework-without-OTel install time. set_active_recorder/reset_active_recorderhelpers that the OTel tracer'sstart_run_spancontext manager calls to install / remove the recorder for the duration of a run.InstrumentedToolNode(ToolNode)subclass that, on everyainvoke/invoke, inspects the contextvar. When a recorder is active, it iterates the inbound AIMessage'stool_calls, opens a span per call (correlated bytool_call_id), runs the wrapped tool, then closes each span with the tool result or an error attribution. When the contextvar is empty (OTel disabled / not configured), the wrapper falls through tosuper().ainvoke()with oneContextVar.get(None)check -- effectively zero overhead.make_tool_node(tools)factory returningInstrumentedToolNode. The zero-cost invariant lives in the runtime contextvar check rather than a factory-time branch onotel_enabled(architect's verdict accepts both shapes; the runtime check is simpler -- one place to reason about, zero coupling to FrameworkConfig).
Sequencing constraint (architect verdict): the v7.19.0 on_tool_call_dispatch
hook in core/nodes/react.py:1035-1116 mutates ai_message.tool_calls
INSIDE the react node, BEFORE LangGraph hands off to ToolNode. So
InstrumentedToolNode.ainvoke sees the already-rewritten calls. This is
the right ordering -- tool spans reflect the executed name/args, not the
original LLM output. Architecture composes correctly; no re-ordering needed.
Multi-call invariant: LangGraph's ToolNode handles multiple tool_calls
per AIMessage as parallel branches. Each tool_call_id keys a distinct
entry in the recorder's _tool_spans dict. Wrapper iterates and opens
a span per call before dispatch, then closes each as results arrive
(correlated by tool_call_id on the returned ToolMessages).
InstrumentedToolNode ¶
Bases: ToolNode
ToolNode subclass that opens a symfonic.tool.<name> span per
invoked tool call, correlated by tool_call_id.
See module docstring for the architectural rationale.
Source code in src/symfonic/observability/tool_span_seam.py
get_active_recorder ¶
make_tool_node ¶
Return a ToolNode that auto-instruments per-call spans when an OTel recorder is active for the current run; falls through to plain ToolNode semantics when no recorder is set.
Always returns InstrumentedToolNode. The zero-cost invariant
lives in :meth:InstrumentedToolNode.ainvoke's contextvar check, not
in this factory.
Source code in src/symfonic/observability/tool_span_seam.py
reset_active_recorder ¶
Reset the contextvar to whatever value it held before the matching
:func:set_active_recorder call.
set_active_recorder ¶
Install recorder as the active SpanRecorder for the current task.
Returns the ContextVar token; pass it to :func:reset_active_recorder
when the run scope ends. The OTel tracer's start_run_span context
manager owns the set/reset pair.