# When must an instance Refuse to Emit a GenAI Agent Span: Double-Counted Tokens and Orphaned Tool-Call Attributes at the Span Boundary **Date of Record:** 2026 November 11 **Scope:** Applies to instrumentation that emits OpenTelemetry spans conforming to the GenAI semantic conventions (`gen_ai.*` attributes) for agent invocations, tool calls, and LLM operations, where token usage is aggregated across nested spans or where tool-call identifiers are resolved across span boundaries. Does not apply to single-span, single-model-call instrumentation with no nested agent or tool hierarchy, nor to metrics-only emitters that do not produce spans. ## Core Distinction The GenAI semantic conventions describe *per-operation* attributes: `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` are defined as the token counts for the model operation that the span represents. They are not defined as deltas, and they are not defined as child-inclusive. The non-obvious boundary is this: an *agent span* (a span with `gen_ai.operation.name` in the agent family) that wraps multiple child LLM spans must **not** re-emit the summed token counts of its children onto itself. If it does, any backend that sums `gen_ai.usage.*` across all spans in a trace — the default behavior of most trace-derived token dashboards — will count every token N times, where N is the nesting depth. The second, subtler invariant concerns tool calls. A tool call is identified by `gen_ai.tool.call.id`, and the convention expects that the span representing the tool execution carries the same `gen_ai.tool.call.id` and `gen_ai.tool.name` as the model span's tool-call request. When an agent executes tools asynchronously or in a different process than the model call, the tool span can be emitted *before* or *without* the parent model span that originated the call. An instance that emits a tool span with a `gen_ai.tool.call.id` for which no originating model span exists produces an orphaned tool-call attribute: the backend cannot join the call request to the call execution, and the attribute is silently dropped from tool-call analytics while still inflating span counts. ## What Was Observed In a multi-agent orchestration deployment, a parent agent span emitted aggregate token counts equal to the sum of its three child LLM spans. Trace-derived billing showed a 4.1× overcount on input tokens: once for each child, once for the parent, plus the root trace span which also carried aggregates. The overcount was invisible in per-span inspection because each span's attributes were individually "correct" in the sense that they reflected real tokens consumed by the subtree. The failure only manifested under the backend's cross-span summation, which is the documented and expected aggregation for trace-derived usage metrics. Separately, tool spans emitted by a worker pool carried `gen_ai.tool.call.id` values generated by the worker, not the ID assigned by the model's tool-call request. Because the worker did not receive the originating call ID in its task payload, it minted a fresh one. The result: 100% of tool spans were orphans relative to model spans. Tool latency dashboards joined on `gen_ai.tool.call.id` and reported zero rows; no error was raised anywhere, because each span was schema-valid. ## Condition of Validity The refusal rule holds whenever all of the following are true: 1. The span has `gen_ai.operation.name` denoting an agent or orchestration operation (not a single model inference). 2. The span has one or more descendant spans carrying `gen_ai.usage.input_tokens` or `gen_ai.usage.output_tokens`. 3. The emitting backend or downstream consumer is known to aggregate usage attributes across spans in a trace. If (3) is false — for example, a dedicated exporter that reads only leaf LLM spans — aggregate emission is harmless but still redundant. If (2) is false, the agent span is the sole token carrier and must emit usage. For tool spans, the invariant holds whenever a `gen_ai.tool.call.id` is emitted: the ID must be the one assigned by the originating model span's tool-call request, and the originating span must exist in the same trace (or the ID must be marked as locally-generated and excluded from join analytics). ## Decision Boundary The instance must choose between two mutually exclusive behaviors at the point of span finalization: - **Emit aggregate usage on the agent span** — valid only when no descendant emits usage, or when the consumer is explicitly configured for leaf-only aggregation. - **Suppress usage on the agent span** — required when descendants emit usage and the consumer aggregates across spans. There is no safe "emit both and let the backend dedupe" option, because the conventions provide no deduplication key for usage attributes. Similarly, a tool span must either carry the originating call ID (joinable) or carry no `gen_ai.tool.call.id` at all (explicitly non-joinable, and flagged as such). Emitting a locally-minted ID that *looks* joinable is the forbidden middle state: it is schema-valid and analytically wrong. ## Operational Effect Instrumentation that violates these boundaries produces traces that pass every schema validator and fail every aggregation. The observable symptoms are: token dashboards inflated by a factor equal to nesting depth; tool-call analytics silently empty; cost attribution split across spans that cannot be reconciled; and alerting on token budgets that fires at the wrong threshold. Because the spans are individually well-formed, the failure is not caught by convention linters and must be caught by a finalization-time invariant check. The check is cheap and belongs in the span processor, immediately before export. ```python from dataclasses import dataclass, field from typing import Optional class GenAISpanInvariantError(RuntimeError): """Raised when a GenAI span violates usage or tool-call join invariants.""" @dataclass class GenAISpan: operation_name: str has_descendant_usage: bool = False input_tokens: Optional[int] = None output_tokens: Optional[int] = None tool_call_id: Optional[str] = None tool_call_id_origin: Optional[str] = None # "model" | "local" originating_span_present: bool = True AGENT_OPERATIONS = {"invoke_agent", "create_agent", "execute_agent"} def validate_before_export(span: GenAISpan, consumer_aggregates_across_spans: bool) -> None: """Enforce the GenAI span boundary invariants at finalization time.""" # Invariant 1: agent spans must not re-emit descendant usage when the # consumer sums usage across all spans in the trace. emits_usage = span.input_tokens is not None or span.output_tokens is not None if ( span.operation_name in AGENT_OPERATIONS and span.has_descendant_usage and emits_usage and consumer_aggregates_across_spans ): raise GenAISpanInvariantError( f"agent span '{span.operation_name}' emits usage " f"(in={span.input_tokens}, out={span.output_tokens}) while descendants " f"also emit usage and consumer aggregates across spans; " f"this double-counts tokens by nesting depth" ) # Invariant 2: a tool call ID must be joinable to its originating model span. if span.tool_call_id is not None: if span.tool_call_id_origin == "local": raise GenAISpanInvariantError( f"tool span carries locally-minted gen_ai.tool.call.id=" f"'{span.tool_call_id}'; this looks joinable but is not. " f"Omit the attribute or propagate the model-assigned ID." ) if span.tool_call_id_origin == "model" and not span.originating_span_present: raise GenAISpanInvariantError( f"tool span references model-assigned gen_ai.tool.call.id=" f"'{span.tool_call_id}' but the originating model span is absent " f"from the trace; the tool-call attribute will be orphaned" ) ```