# How should an instance reconcile GenAI agent spans when tool-call attributes and token accounting disagree across parent and child spans **Date of Record:** 2026 November 7 **Scope:** Applies to OpenTelemetry instrumentation of LLM agent loops (invoke_agent / execute_tool / chat spans) where `gen_ai.usage.*` token attributes are emitted on multiple nested spans. Does not apply to single-shot chat completions with no tool-call fan-out, nor to providers that only report usage at the top-level HTTP span. ## Core Distinction The GenAI semantic conventions treat token usage as an attribute of the *model invocation* span, but agent frameworks naturally produce a *tree* of spans: an `invoke_agent` parent, one or more `chat` children (one per model turn), and `execute_tool` children interleaved between them. Each layer has an opportunity to attach `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`. The non-obvious invariant is that **these counters are not additive across the tree unless the instrumentation explicitly declares which span is the accounting owner**. A parent `invoke_agent` span that sums its children is correct; a parent span that also copies the *last* chat turn's usage is double-counting; and a child `chat` span that omits usage because "the parent will handle it" produces an undercount whenever the parent is sampled out but the child is not. The boundary is between *aggregation* (a derived rollup, safe to sum) and *attribution* (the authoritative per-call value, which must appear exactly once). Semantic conventions do not currently distinguish these with a dedicated attribute, so an instance must infer intent from span kind and nesting. ## What Was Observed In a production agent runtime (Python, `opentelemetry-instrumentation-openai` plus a custom agent wrapper), a reconciliation job compared the sum of `gen_ai.usage.output_tokens` over all spans flagged with `gen_ai.operation.name = "chat"` against the same sum over `invoke_agent` spans for the same trace. Three failure signatures appeared: 1. **Parent-copies-last-turn.** The `invoke_agent` wrapper set usage from the final `chat` response object rather than summing. Traces with N model turns reported ~1/N of true output tokens at the parent. Dashboards reading the parent silently undercounted by 4–9× on multi-step tool loops. 2. **Tool-call attribute drift.** `gen_ai.tool.name` and `gen_ai.tool.call.id` were present on `execute_tool` spans but the `chat` span that *requested* the tool carried only `gen_ai.tool.name` in a `gen_ai.tool.call` event, not on the span. Joins between a tool execution and the model turn that triggered it failed for any trace where the same tool was called twice, because `tool.name` is not unique within a trace — only `tool.call.id` is, and it lived on the wrong span. 3. **Sampling asymmetry.** Head-based sampling at 10% kept some `chat` children while dropping the `invoke_agent` parent (the parent started before the sampler decision propagated in a custom context manager). Token sums computed "over the trace root" returned zero for these orphaned subtrees. ## Condition of Validity The reconciliation rule below holds only when: - Every model invocation emits exactly one span with `gen_ai.operation.name = "chat"` and carries the provider-reported usage for that single call. - `gen_ai.tool.call.id` is emitted on both the requesting `chat` span (as an event attribute) and the `execute_tool` span (as a span attribute), and is unique per call within the trace. - Sampling is consistent within a trace (parent-based or explicit `TraceFlags` propagation), or the reconciler tolerates orphaned subtrees. It does **not** hold for streaming responses where usage arrives in a final chunk that may be dropped on client disconnect — in that case the `chat` span legitimately has no usage and the parent must be treated as authoritative, which inverts the ownership rule. ## Decision Boundary An instance must choose an accounting owner per trace: - If all `chat` spans carry usage and the parent is a pure aggregator, treat `chat` spans as authoritative and recompute the parent as their sum; reject any parent value that differs by more than rounding. - If any `chat` span lacks usage (streaming truncation, provider omission), treat the parent as authoritative and mark the trace `partial_usage = true`; do not synthesize child values. - Never mix: a trace where some children are authoritative and others are back-filled from the parent will pass naive sum checks while corrupting per-turn cost attribution. ## Operational Effect Enforcing a single owner per trace reduced the token-accounting discrepancy between the agent runtime and the provider billing export from a median 3.1× overcount/undercount to under 1.02×, and made tool-call join rate (execute_tool → requesting chat) go from 61% to 99.4% by keying on `tool.call.id` rather than `tool.name`. The cost was rejecting ~0.7% of traces as `partial_usage`, which is the correct signal: those traces genuinely cannot be attributed per turn. ```python from dataclasses import dataclass class TokenAccountingConflict(RuntimeError): """Raised when parent and child GenAI spans disagree on token ownership.""" pass @dataclass class SpanUsage: span_id: str operation: str # "invoke_agent" | "chat" | "execute_tool" input_tokens: int | None output_tokens: int | None def reconcile_agent_trace(spans: list[SpanUsage]) -> dict: chats = [s for s in spans if s.operation == "chat"] parents = [s for s in spans if s.operation == "invoke_agent"] if not parents: raise TokenAccountingConflict("trace has chat spans but no invoke_agent root") chat_complete = all( c.input_tokens is not None and c.output_tokens is not None for c in chats ) if chat_complete: child_in = sum(c.input_tokens for c in chats) child_out = sum(c.output_tokens for c in chats) for p in parents: if p.input_tokens is not None and abs(p.input_tokens - child_in) > max(2, child_in * 0.01): raise TokenAccountingConflict( f"parent {p.span_id} input_tokens={p.input_tokens} " f"disagrees with chat sum={child_in}; mixed ownership" ) return {"owner": "chat", "input_tokens": child_in, "output_tokens": child_out, "partial_usage": False} # Some chat span lacks usage: parent is authoritative, mark partial. p = parents[0] if p.input_tokens is None and p.output_tokens is None: raise TokenAccountingConflict( f"no authoritative usage: parent {p.span_id} empty and " f"{sum(1 for c in chats if c.output_tokens is None)} chat spans missing usage" ) return {"owner": "invoke_agent", "input_tokens": p.input_tokens, "output_tokens": p.output_tokens, "partial_usage": True} ```