# When must an agent span close its tool-call child before emitting token usage? **Date of Record:** 2026 November 6 **Scope:** Applies to OpenTelemetry GenAI instrumentations that emit `gen_ai.*` attributes on agent spans (`gen_ai.operation.name = "invoke_agent"`) with nested tool-execution spans (`gen_ai.operation.name = "execute_tool"`), where token usage is attributed per-span. Does not apply to single-shot LLM client spans without tool nesting, nor to batch/offline exporters that aggregate usage post-hoc. ## Core Distinction The GenAI semantic conventions define token usage attributes (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) as *per-model-invocation* counters, but agent spans aggregate multiple model invocations across a reasoning loop. The non-obvious invariant: **a tool-call child span must be ended before the parent agent span records the token usage of the model turn that *produced* the tool call.** If the tool span is still open when the parent emits usage, downstream processors that reconstruct "tokens attributable to a tool" by span-timestamp containment will double-count or misattribute the turn's output tokens — because the output tokens were generated by the model *to decide* the tool call, not *during* it. The distinction is between **causal ordering of emission** and **temporal ordering of span lifetime**. ## What Was Observed During a multi-turn agent run (LangGraph-style loop, `invoke_agent` root, N `execute_tool` children), the instrumentation emitted `gen_ai.usage.output_tokens` on the agent span at the moment the model response was parsed — *before* the tool child span was created and executed. The tool span then opened and closed. A downstream usage-attribution pipeline (span-containment based) attributed the parent's output tokens to the *previous* tool span's window, producing a 1-turn lag in per-tool cost accounting. In a second configuration, the tool span was opened *before* the model call (pre-registration of the planned tool) and closed *after* usage emission, causing those output tokens to be counted against a tool that had not yet run. Both are silent — no span status error, no dropped attribute. The error surfaces only in aggregated cost dashboards as a persistent offset. ## Condition of Validity The invariant holds only when: (1) the agent span is the token-usage carrier (per-turn usage is not emitted on a child `chat` span); (2) the collector or post-processor attributes usage by span temporal containment rather than by explicit `trace_id`/`span_id` linkage; and (3) the tool span and the model-turn usage share the same parent. When usage is emitted on a dedicated `gen_ai.operation.name = "chat"` child span (the recommended pattern), the containment ambiguity disappears and this ordering constraint is moot. When the backend joins usage to tool calls by explicit attribute (e.g., `gen_ai.tool.call.id` on both), ordering is also irrelevant. ## Decision Boundary The correct sequence for a single reasoning turn is: open `chat`/model span → emit input tokens → receive response → **close model span (emit output tokens here)** → if response contains a tool call, open `execute_tool` span → execute → close tool span → loop. The agent span itself should carry *aggregate* usage only, emitted at its own close, never per-turn. If your instrumentation cannot emit usage on a child model span, then the fallback is: emit the turn's usage on the agent span, then *immediately* open the tool span — never open the tool span before usage emission, and never leave a tool span open across a usage emission. The boundary condition is the moment of `span.set_attribute("gen_ai.usage.output_tokens", ...)` on a span that has an open or subsequently-opened tool child. ## Operational Effect Violating the invariant does not fail traces; it corrupts derived cost attribution by one turn and, in retry scenarios, can double-count. The fix is structural: either (a) move usage to a dedicated model child span, or (b) enforce that the usage-emission call and tool-span creation are adjacent and ordered (usage first, tool second) with no intervening awaits that could reorder under async instrumentation. Audit by checking, for every `invoke_agent` span with usage attributes and `execute_tool` children, that usage emission timestamp precedes every child's start timestamp. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Optional class SpanOrderingViolation(RuntimeError): """Raised when GenAI usage emission violates tool-call containment ordering.""" @dataclass class SpanRecord: name: str operation: str # "invoke_agent" | "chat" | "execute_tool" start_ns: int end_ns: Optional[int] = None usage_emitted_ns: Optional[int] = None parent_id: Optional[str] = None span_id: str = "" def validate_agent_span_ordering( agent: SpanRecord, children: list[SpanRecord] ) -> None: """ Enforce: on an agent span that carries usage, no execute_tool child may start before usage emission, and usage emission must not fall inside any open tool-span window. """ if agent.usage_emitted_ns is None: return # usage carried elsewhere; invariant does not apply tool_children = [c for c in children if c.operation == "execute_tool"] for tool in tool_children: if tool.start_ns < agent.usage_emitted_ns: raise SpanOrderingViolation( f"tool span {tool.span_id or tool.name!r} started at " f"{tool.start_ns} before agent usage emission at " f"{agent.usage_emitted_ns}; output tokens will be " f"misattributed to this tool by containment-based processors" ) if tool.end_ns is not None and tool.start_ns <= agent.usage_emitted_ns <= tool.end_ns: raise SpanOrderingViolation( f"agent usage emission at {agent.usage_emitted_ns} falls inside " f"open tool span {tool.span_id or tool.name!r} " f"[{tool.start_ns}, {tool.end_ns}]" ) def attribute_usage(agent: SpanRecord, children: list[SpanRecord]) -> dict[str, int]: """Attribute agent usage to tool calls under the valid ordering only.""" validate_agent_span_ordering(agent, children) # After validation, usage belongs to the model turn that produced the # *next* tool call, i.e. the earliest tool child starting after emission. eligible = sorted( (c for c in children if c.operation == "execute_tool"), key=lambda c: c.start_ns, ) if not eligible: return {} return {eligible[0].span_id or eligible[0].name: 1} ```