symfonic.services.observability¶
observability ¶
Segregated observability ports over the one kernel event stream (T4.2.3).
Callbacks, metrics, cost, traces and OpenTelemetry used to be five consumers with five sources: hand-placed emission sites in the engine, a boolean capture flag read at four of them, two cost calculations, and a trace context that only existed when an optional extra was installed.
This package gives all five one source — KernelEvent — and one seam each:
- :mod:
.ports— six narrow protocols; an observer implements what it cares about and hears nothing else. - :mod:
.bridge— the single consumer of the event stream, and the only place fan-out, error isolation and terminal cardinality are decided. - :mod:
.suite— composition: what observability a config buys, and the sink that wraps it (Nonewhen nobody is watching). - :mod:
.closing— releasing what a run that never terminated left held. - :mod:
.capture— the one answer to "may I record this payload?" (deny by default). - :mod:
.trace— W3C trace propagation with no third-party dependency. - :mod:
.cost— one accountant, delegating to the shipped pricing registry. - :mod:
.callbacks, :mod:.metrics, :mod:.otel— adapters that keep the shipped public schemas working from the new source.
Importing this package pulls zero opentelemetry modules.
CallbackHandlerObserver ¶
Renders observations into the shipped callback schemas.
Dispatch goes through the supplied CallbackManager, so per-handler
error isolation, partial-handler tolerance and the has_hook fast path
stay exactly as they are today — this observer adds a source, not a second
dispatcher.
Source code in src/symfonic/services/observability/callbacks.py
CostAccountant ¶
Prices one run's usage, and says whether the registry actually knew.
assess ¶
assess(scope: RunScope, usage: UsageDelta, *, text: str | None = None, duration_ms: float = 0.0) -> CostObservation
Price usage for scope's model.
A zero-usage run is still priced rather than skipped: "this run cost nothing" and "nobody looked" are different facts, and only the first one is worth reporting.
Source code in src/symfonic/services/observability/cost.py
CostObservation
dataclass
¶
CostObservation(scope: RunScope, model: str, usage: UsageDelta, cost_usd: float, pricing_unknown: bool, breakdown: Mapping[str, int] = (lambda: MappingProxyType({}))(), text: str | None = None, duration_ms: float = 0.0)
What the run cost, and whether the registry could actually say.
pricing_unknown is the discriminator that keeps "a $0 run" apart from
"a run whose model the pricing registry did not recognise" — two states
that look identical in a dashboard and mean opposite things on a bill.
CostObserver ¶
Bases: Protocol
What the run cost. Fed by the one accountant, never computed per observer.
DropObserver ¶
Bases: Protocol
Shed notices from a bounded adapter (BP-14).
ErrorObserver ¶
Bases: Protocol
The run's error terminal, already split into type and message.
EventsDropped
dataclass
¶
EventsDropped(scope: RunScope, index: int, dropped_kind: str, dropped_count: int, first_dropped_index: int | None, last_dropped_index: int | None)
A shed notice: what the adapter could not deliver, and over what range.
MetricsObserver ¶
Binds a run to its conversation row, then feeds the shipped collector.
It satisfies three ports — RunObserver, CostObserver and, since
TA8.20, ErrorObserver — because those are the three the shipped
callback rendering has anything to say about. Which ports an observer
satisfies is resolved once at wiring time from the methods it defines
(:func:~symfonic.services.observability.ports.resolve_ports), so a method
absent here is a hook the collector never receives, with nothing raised and
nothing logged.
Source code in src/symfonic/services/observability/metrics.py
on_run_failed
async
¶
The error terminal, rendered as on_node_error like the rest.
Added by TA8.20, and it closes a gap rather than adding a feature.
CallbackHandlerObserver renders four callback hooks and this
observer forwarded three of them, so a collector wired through
metrics_collector= was bound to the run and cost ports and to no
error port at all: a failing run reached on_agent_start and
on_agent_end and never on_node_error, while the legacy body
delivered all three. The shipped ConversationMetricsCollector
implements on_node_error as a no-op, so nothing in-tree changes —
but an adopter's collector that counts failures counted none, and a
silent hook is exactly the failure the observability envelope guard
existed to prevent.
Failure is not a terminal replacement: the bridge emits this and then
on_run_finished, which is the order the legacy body used too
(on_node_error before on_agent_end).
Source code in src/symfonic/services/observability/metrics.py
OTelObserver ¶
Drives the shipped OTEL span lifecycle from the kernel event stream.
Safe to build per run, and shareable across runs that are driven from
their own asyncio task. What one run holds — its root span and its carrier
token — belongs to :class:RunSpanTable, which owns the resource lifetime
so this class can own the event lifetime: which kernel observation opens
or closes which span.
Sharing is bounded by contextvars, not by that table: span nesting comes
from the OTEL current-span context stack that start_run_span attaches,
and only per-task context copies keep two runs' stacks apart. Runs
interleaved inside one task get correct span identities and durations but
cross-parented trees. See the module docstring.
Source code in src/symfonic/services/observability/otel.py
aclose
async
¶
Release every run this observer still holds. Never raises.
Process shutdown, not run cleanup: only runs whose terminal never
arrived are left here — the stream guarantees one terminal per run, but
a killed process does not. A caller that owns a single run wants
:meth:release_run; calling this instead ends the spans of every other
run in flight. It deliberately does not shut the exporter down: the
handles are process-scoped and shared (see :func:build_otel_observer),
so releasing them is :func:shutdown_otel's job, not one observer's.
Source code in src/symfonic/services/observability/otel.py
on_cost
async
¶
Open the LLM span, then let the delegate's LLMEndEvent close it.
CallbackBridge.on_llm_end is a close: it pops the span keyed on
(run_id, node_name) and returns when the key is absent. Without the
paired start there is no span to attach the usage to, and the cost of
the run reaches no exporter.
Source code in src/symfonic/services/observability/otel.py
on_run_failed
async
¶
Open the node span the delegate's NodeErrorEvent will close.
Same asymmetry as the LLM span: on_node_error calls end_node,
which is tolerant of a missing start and therefore silent about it.
Source code in src/symfonic/services/observability/otel.py
release_run
async
¶
Release one run's span and carrier. Never raises.
The call a run-scoped owner makes when its run ends without a terminal
— see ObservabilityBridge.aclose. Scoped rather than wholesale
because this observer may be shared by any number of overlapping runs,
and one cancelled run must not end the others' spans.
Source code in src/symfonic/services/observability/otel.py
ObservabilityBridge ¶
ObservabilityBridge(scope: RunScope, observers: Iterable[Any], *, capture: PromptCapturePolicy = CAPTURE_ALL, accountant: CostAccountant | None = None)
Projects one run's kernel events onto whichever ports are implemented.
Source code in src/symfonic/services/observability/bridge.py
aclose
async
¶
Release what a run that never terminated left held. Never raises.
Idempotent, and safe after a normal terminal: _close has already
cleared _tokens by then. Whoever owns the sink owns this call —
:func:compose_event_sink builds the object, it does not run it. Why
it exists at all, and why closing is duck-typed rather than a seventh
port, is in :mod:symfonic.services.observability.closing.
The release is scoped to this run: a bridge is per-run, an observer
need not be, and releasing a shared OTelObserver here would end
another live run's root span and reset its carrier under it.
The abandoned end is owed only by a bridge that OPENED (TA8.29): one composed but never fed an event announced no run, so finishing it would be a start-less end.
Source code in src/symfonic/services/observability/bridge.py
PromptCapturePolicy
dataclass
¶
PromptCapturePolicy(capture_prompts: bool = False, capture_outputs: bool = False, capture_tool_arguments: bool = False)
Three independent gates over the three payload classes.
They are independent because the risks are: an adopter may want the model's
output for quality review while never recording the user's prompt, and a
single flag cannot express that. from_config still maps the shipped
single flag onto all three, so the current configuration surface is
unchanged — the split is available, not imposed.
argument_names
staticmethod
¶
Sorted argument names, always available.
Names are structure; values are payload. Keeping the names when the
values are denied is what lets an operator see that a tool was called
with a customer_id without seeing which customer.
Source code in src/symfonic/services/observability/capture.py
from_config
classmethod
¶
The policy for in-process fan-out, which is not an egress boundary.
Deliberately does not read otel_capture_prompts: that flag
gates span attributes and is applied by the shipped SpanRecorder at
the exporter, where the data actually leaves the process. Reusing it
here would broaden its meaning from "do not put prompts in span
attributes" to "do not give any consumer the text", silently emptying
callback payloads that shipped code has always populated.
FrameworkConfig.observability_redact_payloads is the explicit
opt-in for adopters who want the bridge itself to withhold. It is read
through getattr so a duck-typed config still works, but it is a
declared field precisely because a getattr-only control on an
extra="ignore" model would be silently dropped and fail open.
Source code in src/symfonic/services/observability/capture.py
output_text ¶
prompt_text ¶
tool_arguments ¶
Tool call arguments, or None when withheld.
RunFailed
dataclass
¶
The run emitted its error terminal.
The kernel formats the error as "TypeName: message". Splitting it here
once means every downstream schema — span status, NodeErrorEvent, a log
line — gets the same two fields instead of re-parsing the string.
RunFinished
dataclass
¶
RunFinished(scope: RunScope, index: int, reason: str, text: str | None, usage: UsageDelta, duration_ms: float, stop_reason: str | None)
The run reached exactly one terminal event (EVT-1).
RunObserver ¶
Bases: Protocol
Run boundaries: the smallest port that can build a root span.
RunScope
dataclass
¶
RunScope(run_id: str, tenant_id: str | None, session_id: str | None, entry_point: str, model: str, provider_family: str = '', prompt: str = '', root_run_id: str = '', parent_run_id: str | None = None)
The identity every observation is attributed to.
Built once per invocation by whoever composes the sink, because the kernel
event stream carries a run_id and nothing else: tenant, session, model
and entry point are plan facts, and re-deriving them per event is how two
observers end up disagreeing about which tenant paid for a run.
RunStarted
dataclass
¶
The run has produced its first event.
TextEmitted
dataclass
¶
One text-bearing event from the stream, in stream order.
TextObserver ¶
Bases: Protocol
Model text, in stream order. The highest-volume port, deliberately alone.
ToolCompleted
dataclass
¶
A tool returned. The outcome is opaque, exactly as it is to the kernel.
ToolInvoked
dataclass
¶
ToolInvoked(scope: RunScope, index: int, call_id: str, name: str, arguments: Mapping[str, Any] | None, argument_names: tuple[str, ...] = ())
The loop dispatched an allowlisted tool call.
ToolObserver ¶
Bases: Protocol
Tool dispatch and completion.
TraceMode ¶
Bases: StrEnum
How much diagnostic information may leave the process.
TracePolicy
dataclass
¶
TracePolicy(mode: TraceMode = TraceMode.METADATA, sample_rate: float = 1.0, failed_turns_always: bool = True, content_ttl_hours: int = 24, max_artifact_bytes: int = 262144)
One deployment's tracing, sampling and content-retention contract.
from_config
classmethod
¶
Build from modern trace_* fields, then legacy OTEL flags.
Source code in src/symfonic/services/observability/trace_policy.py
bind_run ¶
Bind both carriers; returns the tokens :func:unbind_run needs.
build_otel_observer ¶
build_otel_observer(config: Any, *, builder: Callable[[Any], Any] | None = None) -> OTelObserver | None
Return the OTEL observer, or None when OTEL is off or unavailable.
None is a first-class answer here, not a failure: compose_event_sink
drops absent observers, so a disabled or un-installed OTEL simply removes
itself from the wiring without any call site learning it exists.
Lifetime. The returned observer is cheap and may be built per run; the
exporter behind it is not, and is therefore cached per configuration for
the life of the process and released by :func:shutdown_otel. An explicit
builder bypasses that cache entirely — the caller supplying the
exporter owns its lifetime, which is what test doubles want.
Source code in src/symfonic/services/observability/otel.py
compose_event_sink ¶
compose_event_sink(scope: RunScope, observers: Iterable[Any], *, config: Any | None = None, capture: PromptCapturePolicy | None = None, accountant: CostAccountant | None = None) -> ObservabilityBridge | None
Build the run's event sink, or None when nobody is watching.
Returning None is the zero-overhead guarantee expressed in a type:
ServiceBindings.event_sink left unbound means InvocationRunner
never constructs a CallbackEventAdapter, so a run with no observers
pays for no buffer, no worker and no per-event delivery.
config is what makes the adopter's redaction opt-in reachable from the
supported wiring path: pass the same object given to
:func:observers_from_config and
FrameworkConfig.observability_redact_payloads decides what the
observers are handed. capture remains the explicit override for a
caller who wants a policy the config cannot express (the three gates are
independent; the flag maps onto all three), and omitting both keeps the
shipped behaviour of delivering every payload in-process.
The sink it returns is not self-closing: the kernel's terminal releases
it, and a run that never reaches one is released by
ObservabilityBridge.aclose. Composing a sink does not make this module
responsible for running it.
Source code in src/symfonic/services/observability/suite.py
current_run_scope ¶
current_trace ¶
observers_from_config ¶
observers_from_config(config: Any, *, collector: Any | None = None, callback_handlers: Sequence[Any] | None = None, otel_builder: Any | None = None) -> tuple[Any, ...]
Return every observer this configuration asks for, in dispatch order.
Adopter handlers are wrapped once, not once per handler: the shipped
CallbackManager already owns fan-out, ordering and per-handler error
isolation, and a second layer of the same thing would only add a second
place for that ordering to be defined.
Source code in src/symfonic/services/observability/suite.py
parse_traceparent ¶
Parse a traceparent header, or return None.
Refusing is deliberate. A malformed header that is "best-effort repaired" produces a trace id that correlates with nothing, which is worse than no correlation at all because it looks like one.
Source code in src/symfonic/services/observability/trace.py
redact_trace_value ¶
Recursively redact credentials and bound strings before export.
The result is JSON-shaped. Unknown objects are represented by their type,
never by repr: a repr is allowed to contain precisely the secret the
redactor is meant to keep out of the collector.
Source code in src/symfonic/services/observability/trace_policy.py
shutdown_otel ¶
Flush and release every cached exporter. Safe to call more than once.
Registered with atexit the first time handles are cached, because a
BatchSpanProcessor holds spans that only shutdown flushes: a process
that exits without it exports nothing from its final batch. Exposed
publicly so a host that tears an application down deterministically — a
test, a worker that reconfigures — need not wait for interpreter exit.
Source code in src/symfonic/services/observability/otel_handles.py
trace_for_run ¶
Derive a trace scope for run_id.
The trace id is derived from the run id rather than drawn at random so the same run always lands in the same trace: a log line that only recorded a run id can still be joined to its trace after the fact, which is exactly the case where an operator needs the join and no longer has the process.
The span id is random, because two observers of the same run are two spans, not one.
Source code in src/symfonic/services/observability/trace.py
unbind_run ¶
Release the carriers bound by :func:bind_run.
A token minted in another context cannot be reset, and that happens for
real: a terminal event delivered from a drain worker runs in a different
task than the one that opened the run. Falling back to an explicit None
keeps the leak bounded to that context instead of raising inside teardown.
Source code in src/symfonic/services/observability/trace.py
usage_dict ¶
Kernel usage in the dict shape every shipped callback already parses.
total_tokens is deliberately absent: the shipped TokenUsage derives
totals itself, and handing it a third number invites the two to drift.
The cache and reasoning dimensions are carried through because the registry prices them: it subtracts cache reads and cache writes from the billable input and charges each at its own rate. Rendering only the two totals is not a simplification — it bills a cache-heavy run at roughly ten times what it cost.
cache_ttl rides along for the same reason one dimension over: it is the
rate selector for the cache writes, and TokenUsage.from_dict reads it
to pick the 1h write rate over the 5-minute default. Dropping it while
keeping the write count reports a number that looks right and bills wrong.