# What structural signals indicate a silent tool-call span loss in OpenTelemetry GenAI agent telemetry **Date of Record:** 2026 November 13 **Scope:** Applies to agents emitting spans under the OpenTelemetry GenAI semantic conventions (`gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.agent.name`, `gen_ai.usage.*`) where tool invocations are instrumented as child spans of an agent span. Does not apply to pure LLM completion telemetry without tool dispatch, nor to systems where tool calls are recorded only as span events rather than spans. ## Core Distinction A tool call that *executes but is not represented as a span* is categorically different from a tool call that *fails and is represented as an error span*. The first is a telemetry-integrity failure; the second is a normal, observable outcome. The GenAI conventions make this boundary easy to cross silently because the agent span's `gen_ai.usage.*` and message attributes are populated from the model response, while child tool spans are populated by the instrumentation layer at dispatch time. When the dispatch instrumentation is bypassed — batching, async fan-out, retry wrappers, or a tool invoked from inside a nested agent that lacks a tracer — the agent span still looks complete. It has a finish reason, token counts, and a plausible duration. The only structural signal that a tool span is missing is a *mismatch between the agent's declared tool-call intent and the set of child spans actually parented to it*. ## What Was Observed In a multi-agent orchestration deployment, agent spans were routinely emitted with `gen_ai.operation.name = "invoke_agent"` and a `gen_ai.agent.name`, but with zero child spans of `gen_ai.operation.name = "execute_tool"` despite the agent message history containing `tool_calls` entries with non-empty `id` and `name`. The tool executions had occurred — side effects were present in downstream systems — but the tracer context was dropped across an `asyncio.gather` boundary because the tool wrapper created tasks before the span was started. The resulting trace showed an agent span of ~2.4s with no children, followed by an unrelated root span for each tool's side effect. The agent span's duration was *longer* than a no-tool agent span, which is the tell: the wall-clock time of tool execution is absorbed into the agent span when the child span is absent, whereas a correctly parented tool span would have its duration carved out as a child interval. The second structural signal was cardinality: `gen_ai.usage.output_tokens` for tool-calling turns is dominated by the tool-call JSON payload, and the number of `tool_calls` in the response message correlates with the expected child span count. A turn with three `tool_calls` and zero `execute_tool` children is a loss; a turn with three `tool_calls` and three children is nominal. The invariant is: **for every assistant message carrying `tool_calls`, the agent span must have exactly one `execute_tool` child per call id, or an explicit `gen_ai.tool.call.id` correlation attribute must exist elsewhere in the trace.** ## Condition of Validity This diagnostic is valid only when the agent span is emitted *after* tool dispatch completes and the agent span is the direct parent of tool spans per the conventions. If the deployment uses span links instead of parent-child nesting (permitted for async tool dispatch), the child-count check must be replaced by a link-count check against `gen_ai.tool.call.id`. The check is also invalid when the model response is streamed and the agent span closes before tool dispatch begins; in that case the tool spans are correctly siblings, not children, and the agent span is not expected to contain them. Finally, the check assumes `gen_ai.operation.name` is set on both agent and tool spans; if an older instrumentation omits it, the span-kind heuristic fails and must fall back to `gen_ai.tool.name` presence. ## Decision Boundary Treat a zero-child agent span as a **telemetry loss** (not a no-tool turn) if and only if all three hold: (1) the agent span's message attributes contain at least one `tool_calls` entry, (2) the agent span duration exceeds the p50 no-tool agent duration for that agent name by more than one standard deviation, and (3) no span in the trace carries a matching `gen_ai.tool.call.id`. If (1) holds but (2) does not, suspect a zero-latency tool (cache hit) and do not page. If (2) holds but (1) does not, suspect a mis-instrumented prompt template rather than span loss. Only the conjunction justifies emitting an integrity alert, because each signal alone has a benign explanation. ## Operational Effect When the boundary is crossed, the correct response is to fail the trace-integrity check rather than the request, and to attach the observed call ids to the alert so the missing spans can be backfilled from the tool execution logs. Downstream cost and latency attribution must treat the agent span's duration as *inclusive of unaccounted tool time* until backfill completes, otherwise tool latency is silently charged to the model. The following guard encodes the conjunction and is safe to run in an exporter processor: ```python from dataclasses import dataclass class ToolSpanLossError(RuntimeError): """Raised when an agent span declares tool calls but has no matching tool spans.""" @dataclass class SpanSummary: name: str operation: str tool_call_ids: set[str] child_tool_call_ids: set[str] duration_ms: float def assert_tool_spans_present( agent: SpanSummary, baseline_p50_ms: float, baseline_stddev_ms: float, ) -> None: declared = agent.tool_call_ids observed = agent.child_tool_call_ids missing = declared - observed if not missing: return latency_anomalous = agent.duration_ms > baseline_p50_ms + baseline_stddev_ms if latency_anomalous: raise ToolSpanLossError( f"agent span {agent.name!r} declares {len(declared)} tool calls " f"but {len(missing)} have no execute_tool child span: " f"{sorted(missing)}; duration {agent.duration_ms:.0f}ms exceeds " f"p50 {baseline_p50_ms:.0f}ms + 1σ {baseline_stddev_ms:.0f}ms" ) # Declared calls with no latency anomaly: likely cache hits; do not fail. ```