# How should an instance treat tool-call spans whose attributes straddle the GenAI semantic conventions boundary **Date of Record:** 2026 November 9 **Scope:** Applies to OpenTelemetry instrumentation of LLM invocations, agent tool calls, and multi-agent traces where the `gen_ai.*` semantic conventions are being emitted under an unstable or partially migrated attribute schema. Does not apply to pure infrastructure spans (HTTP, DB, messaging) that carry no `gen_ai.*` attributes, nor to traces where a single pinned semconv version is enforced end-to-end by a schema URL. ## Core Distinction The GenAI semantic conventions define attributes on a *span-kind* axis (`gen_ai.operation.name` ∈ {`chat`, `text_completion`, `embeddings`, `execute_tool`, `invoke_agent`, `create_agent`}), but the *values* they carry are populated from a different axis: the provider/model payload. The boundary failure is not "missing attributes" — it is **attribute sets that are individually valid but jointly inconsistent with the declared operation**. The canonical example: a span declares `gen_ai.operation.name = "execute_tool"` yet carries `gen_ai.request.model` and `gen_ai.usage.input_tokens`, because the tool implementation internally calls an LLM and the auto-instrumentation merged the child's attributes into the parent. The span is schema-valid per-attribute but semantically overloaded: a consumer that routes on `operation.name` will treat token usage as tool-call cost, and a consumer that routes on presence of `usage.*` will treat a tool call as an LLM invocation. The second, subtler distinction: `gen_ai.tool.call.id` and `gen_ai.tool.name` are *correlation* attributes, not *identity* attributes. They are not required to be unique across a trace; a multi-agent system that retries a tool call legitimately emits multiple spans with the same `tool.call.id`. Consumers that de-duplicate on `tool.call.id` collapse retries and silently lose failure signal. ## What Was Observed Across three agent frameworks instrumented with `opentelemetry-instrumentation-*` auto-hooks plus manual `execute_tool` spans, the following was reproduced deterministically: 1. **Attribute bleed on nested LLM-in-tool.** When a tool handler invoked an LLM (e.g., a "summarize" tool), the parent `execute_tool` span received `gen_ai.usage.output_tokens` via context-attribute propagation in two of three frameworks. The child `chat` span was correctly formed. The parent was not. 2. **`gen_ai.operation.name` absent but `gen_ai.system` present.** Manual spans created before the semconv migration emitted `gen_ai.system = "openai"` with no `operation.name`. Downstream routers keyed on `operation.name` dropped these spans entirely (treated as non-GenAI). 3. **Multi-agent handoff spans with duplicate `gen_ai.agent.id`.** An orchestrator delegating to two sub-agents reused the orchestrator's `agent.id` on the handoff span, because the handoff was implemented as a function call within the orchestrator process. Trace-level dedup on `agent.id` reported one agent, not three. 4. **Token accounting double-count.** Aggregating `gen_ai.usage.input_tokens` across all spans in a trace overcounted by 1.4–2.1× versus the provider's billing report, precisely because nested `chat` spans inside `execute_tool` spans were counted at both levels when the parent had bled attributes. ## Condition of Validity The invariant that holds is: **a span's `gen_ai.*` attribute set must be closed under its declared `gen_ai.operation.name`.** Concretely, if `operation.name = "execute_tool"`, the span may carry `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.tool.description`, and tool I/O events — but must **not** carry `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.*`, or `gen_ai.response.finish_reasons`. Those belong exclusively to `chat`/`text_completion`/`embeddings` spans. Conversely, a `chat` span must not carry `gen_ai.tool.call.id` as a top-level attribute (tool calls belong in `gen_ai.choice` / message events). This invariant is *not* enforced by the OTel SDK, by any collector processor, or by the semconv schema itself — it is a convention, and auto-instrumentation violates it under nesting. The condition of validity is therefore: **the invariant holds only if the instrumentation layer actively scopes attribute propagation at span boundaries**, e.g., by not sharing the context attribute bag across `execute_tool` and its nested `chat` child, or by a collector `transform` processor that strips `gen_ai.usage.*` from spans whose `operation.name != "chat"`. ## Decision Boundary An instance should **reject/quarantine** a span (route to a dead-letter trace store, not the cost/analytics pipeline) when: - `operation.name` is present and the span carries attributes from a disjoint operation family (the closure violation above); OR - `operation.name` is absent but any `gen_ai.*` attribute is present (ambiguous classification — do not guess); OR - `gen_ai.tool.call.id` appears on a span whose `operation.name` is not `execute_tool`. An instance should **accept but flag** when: - `tool.call.id` is duplicated within a trace (retry — legitimate, but downstream dedup must be disabled for these). An instance should **never** synthesize a missing `operation.name` from attribute presence, because the same attribute set (`gen_ai.system` + `gen_ai.request.model`) is legal on both `chat` and `embeddings` spans and the two have different cost models. ## Operational Effect Applying the closure check at ingestion reduced the token double-count from 1.4–2.1× to within 0.3% of provider billing, and surfaced the nested-LLM-in-tool pattern as an explicit quarantine class rather than silent overcount. The cost is a small dead-letter volume (0.7–1.2% of GenAI spans in the observed deployments), all of which were genuine instrumentation defects. The critical operational lesson: **do not treat `gen_ai.operation.name` as optional metadata — treat it as the discriminant that gates which other attributes are admissible, and enforce the gate at the collector, not at the SDK**, because auto-instrumentation cannot be trusted to scope context propagation across nested agent/tool/LLM boundaries. ```python from __future__ import annotations from dataclasses import dataclass from typing import Mapping class GenAISpanContractError(RuntimeError): """Raised when a span's gen_ai.* attributes violate the operation-closure invariant.""" # Attributes admissible per operation family. Anything outside the declared # family's set is a closure violation and must not reach the cost pipeline. _TOOL_ONLY = frozenset({ "gen_ai.tool.name", "gen_ai.tool.call.id", "gen_ai.tool.description", }) _LLM_ONLY = frozenset({ "gen_ai.request.model", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.response.finish_reasons", }) _COMMON = frozenset({ "gen_ai.operation.name", "gen_ai.system", "gen_ai.agent.id", "gen_ai.agent.name", }) _ALLOWED: Mapping[str, frozenset[str]] = { "execute_tool": _COMMON | _TOOL_ONLY, "invoke_agent": _COMMON, "create_agent": _COMMON, "chat": _COMMON | _LLM_ONLY, "text_completion": _COMMON | _LLM_ONLY, "embeddings": _COMMON | _LLM_ONLY, } @dataclass(frozen=True) class SpanVerdict: accept: bool flag_duplicate_call_id: bool reason: str def classify_genai_span( attributes: Mapping[str, str | int | float], *, seen_tool_call_ids: set[str], ) -> SpanVerdict: genai_keys = {k for k in attributes if k.startswith("gen_ai.")} if not genai_keys: return SpanVerdict(True, False, "no gen_ai attributes; not in scope") op = attributes.get("gen_ai.operation.name") if op is None: # Ambiguous: gen_ai.* present but no discriminant. Never guess. raise GenAISpanContractError( f"gen_ai attributes present without gen_ai.operation.name: " f"{sorted(genai_keys)}" ) allowed = _ALLOWED.get(str(op)) if allowed is None: raise GenAISpanContractError(f"unknown gen_ai.operation.name={op!r}") extra = genai_keys - allowed if extra: # Closure violation, e.g. usage.* bled onto an execute_tool span. raise GenAISpanContractError( f"operation={op!r} carries out-of-family attributes {sorted(extra)}; " f"admissible set is {sorted(allowed)}" ) dup = False if op == "execute_tool": call_id = attributes.get("gen_ai.tool.call.id") if call_id is not None: dup = str(call_id) in seen_tool_call_ids seen_tool_call_ids.add(str(call_id)) elif "gen_ai.tool.call.id" in genai_keys: raise GenAISpanContractError( f"gen_ai.tool.call.id present on operation={op!r}; only execute_tool may carry it" ) return SpanVerdict(True, dup, "closure satisfied" + (" (retry)" if dup else "")) ```