# When must an instance treat OpenTelemetry GenAI tool-call spans as semantically incomplete **Date of Record:** 2026 November 14 **Scope:** Applies to instrumentation that emits `gen_ai.*` spans wrapping LLM tool/function invocations under OpenTelemetry GenAI semantic conventions (v1.27+ attribute set). Does not apply to plain HTTP client spans, non-GenAI RPC spans, or traces where tool calls are modeled only as log events rather than spans. ## Core Distinction The GenAI semantic conventions define attributes for *what the model requested* (`gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.tool.call.arguments`) and, on a separate span or event, *what the tool returned* (`gen_ai.tool.call.result`). They do **not** define a single attribute that binds the two into an atomic, replayable pair. The boundary is this: a span that carries a tool *request* is not the same object as a span that carries a tool *response*, and the convention provides no required correlation key beyond `gen_ai.tool.call.id`. Instrumentation that assumes "one tool call = one span with both attributes" silently produces traces that are individually valid but collectively unreconstructable. ## What Was Observed During reconstruction of agent traces from a production collector pipeline, spans labeled `execute_tool` were observed with `gen_ai.tool.name` and `gen_ai.tool.call.arguments` present but `gen_ai.tool.call.result` absent — not because the tool failed, but because the result was emitted on a *sibling* span emitted by a different instrumentation library (the tool runtime vs. the agent loop). Both spans shared a trace ID and parent span ID, but only the agent-loop span carried `gen_ai.tool.call.id`. The tool-runtime span carried `gen_ai.tool.name` only. The consequence: a downstream consumer that groups by `gen_ai.tool.call.id` sees a request with no result; a consumer that groups by `(trace_id, parent_span_id, gen_ai.tool.name)` sees a request/response pair but cannot distinguish two concurrent invocations of the *same* tool under the same parent (a common pattern in parallel tool-calling). Neither consumer is wrong under the spec; both are wrong under the operational assumption that a tool call is a single correlated unit. A second observed failure: when `gen_ai.tool.call.id` was present on both spans but the tool runtime was instrumented by an older SDK that emitted `gen_ai.tool.call.id` as an integer while the agent loop emitted it as a string, equality checks across the two failed silently in JSON-based join logic (string `"42"` ≠ integer `42`). The spec does not mandate a type for the call ID beyond "string" in the current revision, but pre-1.27 emitters used numeric IDs. ## Condition of Validity The invariant "a `gen_ai.tool.call.id` uniquely identifies a request/response pair within a trace" holds **only when** all of the following are true: 1. Both the request span and the response span are emitted by instrumentation that conforms to the same semantic-convention revision (≥1.27). 2. The call ID is emitted as a string on both sides and is stable across the model's request and the tool's response. 3. No two concurrent tool invocations under the same parent share a call ID (guaranteed by the model provider, not by OTel). 4. The response span is a child of the request span, or carries an explicit link to it. If any condition fails — most commonly (1) and (4) in mixed-vendor fleets — the invariant is false and any join keyed on it is unsound. In practice, condition (4) fails whenever the tool runtime is instrumented independently of the agent loop, which is the default for out-of-process tool executors. ## Decision Boundary An instance must treat a GenAI tool-call span as **semantically incomplete** — and must not publish it to a downstream evaluator or replay buffer as a complete call record — when *any* of the following hold: - `gen_ai.tool.call.id` is present but no sibling or child span within the trace carries a matching call ID and a `gen_ai.tool.call.result`. - `gen_ai.tool.call.result` is present but `gen_ai.tool.call.id` is absent (result is unattributable). - The call ID type differs between request and response spans (numeric vs. string). - Two spans in the same trace share a call ID but differ in `gen_ai.tool.name` (ID collision or reuse). Conversely, an instance may treat the span as complete when the call ID is a string, present on both sides, unique within the trace, and the response is either a child or an explicit link target. The boundary is *not* "result attribute exists" — that is necessary but not sufficient; the correlation must also be structurally sound. ## Operational Effect When the boundary is respected, replay and evaluation pipelines see a clean partition: complete call records (joinable) vs. incomplete ones (quarantined, counted, and surfaced as an instrumentation-health metric). When it is ignored, the failure mode is not a crash but a *silent* one: evaluations run against request-only records, tool-use accuracy is scored on half the data, and the missing half is attributed to "model didn't call the tool" rather than "instrumentation didn't correlate the result." The observable symptom is a tool-call success rate that is suspiciously low relative to the tool runtime's own logs, with no error spans to explain the gap. The correct operational response is to fail closed at the boundary: refuse to emit an incomplete record as complete, emit a health signal, and let the evaluator operate on the joinable subset only. ```python from dataclasses import dataclass from typing import Optional class IncompleteToolCallSpanError(RuntimeError): """Raised when a GenAI tool-call span cannot be treated as a complete, correlated request/response record under OTel GenAI semantic conventions.""" @dataclass class ToolCallSpan: trace_id: str span_id: str parent_span_id: Optional[str] tool_name: Optional[str] call_id: object # may be str or int depending on emitter revision has_result: bool linked_span_ids: tuple = () def assert_tool_call_complete( request: ToolCallSpan, response: Optional[ToolCallSpan], siblings: tuple, ) -> None: if request.call_id is None: raise IncompleteToolCallSpanError( f"span {request.span_id} has no gen_ai.tool.call.id; result is unattributable" ) if not isinstance(request.call_id, str): raise IncompleteToolCallSpanError( f"span {request.span_id} call_id is {type(request.call_id).__name__}, " "expected str per GenAI semconv >=1.27" ) if response is None: raise IncompleteToolCallSpanError( f"span {request.span_id} call_id={request.call_id!r} has no correlated response span" ) if response.call_id != request.call_id: raise IncompleteToolCallSpanError( f"call_id mismatch: request={request.call_id!r} response={response.call_id!r}" ) if not response.has_result: raise IncompleteToolCallSpanError( f"response span {response.span_id} lacks gen_ai.tool.call.result" ) structurally_linked = ( response.parent_span_id == request.span_id or response.span_id in request.linked_span_ids ) if not structurally_linked: raise IncompleteToolCallSpanError( f"response span {response.span_id} is neither child nor link of {request.span_id}" ) collisions = [ s for s in siblings if s.span_id != request.span_id and s.call_id == request.call_id and s.tool_name != request.tool_name ] if collisions: raise IncompleteToolCallSpanError( f"call_id {request.call_id!r} collides across tool names in trace {request.trace_id}" ) ```