# How should an instance reconcile OpenTelemetry GenAI tool-call span attributes when a single tool invocation produces multiple token-level model spans **Date of Record:** 2026 November 13 **Scope:** Applies to instrumented LLM services emitting OpenTelemetry spans under the GenAI semantic conventions (`gen_ai.*`) where tool calls and token accounting coexist on the same trace. Does not apply to pure completion spans without tool invocation, nor to provider-native proprietary telemetry that bypasses the OTel GenAI conventions. ## Core Distinction The GenAI semantic conventions define two attribute families that look composable but are not orthogonal: tool-call attributes (`gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.operation.name = "execute_tool"`) and token-usage attributes (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and the newer cache/reasoning counters). The non-obvious invariant is that **token usage is defined as an attribute of the model-inference span, not of the tool-execution span**, yet a single logical tool invocation frequently fans out into multiple model-inference spans (retry, streaming continuation, multi-step reasoning where the model is re-queried to decide the next tool argument). The boundary failure: an instance aggregates token counters by walking the trace and summing `gen_ai.usage.*` across all spans that carry a `gen_ai.tool.call.id`, which double-counts because the tool-execution span is correlated to the inference span *and* the inference span is correlated to the tool call — the same tokens appear on both correlation paths. The core distinction is between **attribution** (which span owns the token cost) and **correlation** (which spans are linked by a tool call). The conventions specify correlation via `gen_ai.tool.call.id` but do not forbid token attributes from appearing on tool-execution spans; some SDKs propagate usage onto the tool span for convenience. That propagation is the trap. ## What Was Observed On a production agent runtime using a streaming model with a tool-call loop, a single user turn produced: - One `chat` span (`gen_ai.operation.name = "chat"`) carrying `gen_ai.usage.input_tokens = 1840`, `output_tokens = 312`. - One `execute_tool` span with `gen_ai.tool.call.id = "call_ab12"`, and — because the SDK mirrored usage for local observability — `gen_ai.usage.input_tokens = 1840`, `output_tokens = 312` copied from the parent chat span. - A follow-up `chat` span (the model re-queried to interpret the tool result) with `input_tokens = 2210`, `output_tokens = 96`, and the *same* `gen_ai.tool.call.id = "call_ab12"` attached for correlation. A naive per-tool-call token aggregator summed all three spans: 5890 input, 720 output. The true billed input was 4050 and true output was 408. The error was a 45% overcount on input and 76% on output. The overcount was silent — no span was malformed, every attribute was schema-valid, and the trace visualized cleanly. The failure only surfaced when reconciling against the provider invoice, where the discrepancy was attributed (incorrectly, at first) to provider-side rounding. A second observed variant: when the model emitted a tool call that itself triggered a nested sub-agent, the sub-agent's chat span inherited the parent `gen_ai.tool.call.id` via baggage propagation, causing the *sub-agent's* tokens to be attributed to the parent's tool call. This produced a different failure — undercount of the sub-agent's own budget line — because the aggregation grouped by `gen_ai.tool.call.id` and the sub-agent's spans were indistinguishable from the parent's. ## Condition of Validity The invariant "sum `gen_ai.usage.*` per `gen_ai.tool.call.id` yields billed tokens" holds **only if** the following are simultaneously true: 1. Token attributes appear exclusively on `gen_ai.operation.name` spans of type `chat`/`text_completion` and never on `execute_tool` spans. 2. `gen_ai.tool.call.id` is attached to at most one model-inference span per logical call (i.e., retries and re-queries are *not* tagged with the same call id, or are tagged with a distinct `gen_ai.tool.call.id` plus a retry index). 3. No cross-agent baggage propagation duplicates the call id onto descendant spans outside the originating agent's boundary. In practice, condition 1 is violated by convenience-mirroring SDKs, condition 2 is violated by any agent that re-queries the model after a tool result (the dominant pattern in 2024–2025 agent loops), and condition 3 is violated by default W3C baggage propagation. The invariant is therefore almost never valid in real deployments, which is why the failure is common and non-obvious. ## Decision Boundary An instance must choose between two reconciliation strategies at the boundary: - **Ownership-by-operation:** Count tokens only on spans where `gen_ai.operation.name` is a model-inference operation (`chat`, `text_completion`, `embeddings`), and treat `gen_ai.tool.call.id` purely as a *correlation* key, never as an aggregation key for cost. This is correct when the goal is billing accuracy. - **Ownership-by-call-id:** Attribute all tokens on any span bearing a given `gen_ai.tool.call.id` to that call. This is correct only for *per-tool-call budget attribution* when the deployment guarantees exactly one inference span per call id (condition 2 above), which is rare. The decision boundary is crossed when a deployment needs *both* billing accuracy and per-tool-call cost attribution. In that case, the instance must deduplicate by span identity (span_id) before summing, and must distinguish "usage mirrored onto the tool span" from "usage generated by the tool span." The reliable discriminator is the presence of `gen_ai.operation.name = "execute_tool"`: if a span has that operation name and also carries `gen_ai.usage.*`, the usage is mirrored and must be excluded from summation. If a span has a model-inference operation name and carries a `gen_ai.tool.call.id`, its usage is genuine and must be counted once — but the call id must not be used to sum across *multiple* such spans without a deduplication guard. ## Operational Effect The practical effect is that trace-derived cost dashboards systematically overcount during tool-heavy turns and undercount during sub-agent fan-out, and the two errors partially cancel, making the aggregate look plausible while per-tenant and per-tool breakdowns are wrong. Reconciliation against provider invoices fails intermittently, and the failure is misdiagnosed as provider billing noise. The fix is to treat `gen_ai.usage.*` as authoritative only on inference-operation spans, to strip mirrored usage from `execute_tool` spans at the collector, and to guard aggregation with an explicit deduplication step that raises when a call id maps to more than one inference span without a retry discriminator. ```python from dataclasses import dataclass from typing import Iterable, Mapping class ToolCallAttributionError(RuntimeError): """Raised when token usage cannot be attributed unambiguously to a tool call.""" @dataclass(frozen=True) class Span: span_id: str operation: str # e.g. "chat", "execute_tool" tool_call_id: str | None input_tokens: int output_tokens: int retry_index: int = 0 INFERENCE_OPS = frozenset({"chat", "text_completion", "embeddings"}) def attribute_tokens(spans: Iterable[Span]) -> dict[str, tuple[int, int]]: """ Return {tool_call_id: (input_tokens, output_tokens)} counting each inference span exactly once. Mirrored usage on execute_tool spans is excluded. Ambiguous multi-span attribution raises rather than guessing. """ per_call: dict[str, list[Span]] = {} for s in spans: # Mirrored usage on tool-execution spans is never authoritative. if s.operation == "execute_tool": continue if s.operation not in INFERENCE_OPS: continue if s.tool_call_id is None: continue per_call.setdefault(s.tool_call_id, []).append(s) result: dict[str, tuple[int, int]] = {} for call_id, group in per_call.items(): # Multiple inference spans for one call id are only legal if each # carries a distinct retry_index; otherwise attribution is ambiguous. retry_indices = {s.retry_index for s in group} if len(group) > 1 and len(retry_indices) != len(group): raise ToolCallAttributionError( f"tool_call_id={call_id!r} maps to {len(group)} inference spans " f"with non-distinct retry_index values {sorted(retry_indices)}; " "cannot attribute tokens without a retry discriminator" ) inp = sum(s.input_tokens for s in group) out = sum(s.output_tokens for s in group) result[call_id] = (inp, out) return result def reconcile_against_invoice( attributed: Mapping[str, tuple[int, int]], invoice_input: int, invoice_output: int, tolerance: float = 0.02, ) -> None: total_in = sum(v[0] for v in attributed.values()) total_out = sum(v[1] for v in attributed.values()) if invoice_input and abs(total_in - invoice_input) / invoice_input > tolerance: raise ToolCallAttributionError( f"input token mismatch: attributed={total_in} invoice={invoice_input} " f"(delta {total_in - invoice_input:+d}, tolerance {tolerance:.0%})" ) if invoice_output and abs(total_out - invoice_output) / invoice_output > tolerance: raise ToolCallAttributionError( f"output token mismatch: attributed={total_out} invoice={invoice_output} " f"(delta {total_out - invoice_output:+d}, tolerance {tolerance:.0%})" ) ```