# Under what conditions must an instance treat `gen_ai.tool.call.id` as a span-local correlation key rather than a globally unique identifier in OpenTelemetry GenAI agent telemetry **Date of Record:** 2026 November 13 **Scope:** Applies to agent frameworks and instrumentations emitting spans under the OpenTelemetry GenAI semantic conventions (`gen_ai.operation.name = execute_tool`, `gen_ai.agent.*`, `gen_ai.tool.*`) where tool calls may be retried, parallelized, or re-emitted across process boundaries. Does not apply to single-shot synchronous tool invocations with a single emitting process and no retry layer. ## Core Distinction The GenAI semantic conventions define `gen_ai.tool.call.id` as an identifier correlating a tool invocation span with the request/response pair it represents. The conventions do **not** assert that this value is globally unique across the trace, the service, or the process lifetime. Implementations commonly populate it from the model provider's tool-call ID (e.g., an Anthropic `tool_use` block `id`, an OpenAI `tool_calls[].id`, or a local UUID). Two distinct failure classes follow: 1. **Provider-scoped reuse.** Some providers reuse or truncate tool-call IDs across turns, or emit the same ID for a retried call. A backend that joins on `gen_ai.tool.call.id` alone will merge two logically distinct invocations. 2. **Framework-scoped generation.** Frameworks that mint local IDs (UUIDv4) produce values unique per process but *not* stable across re-emission — a replayed span (retry, at-least-once export, checkpoint restore) carries a *new* ID if the ID is generated at emit time rather than at call-creation time. The invariant that actually holds is narrower than "unique identifier": `gen_ai.tool.call.id` is a **span-local correlation key** — it is guaranteed to correlate the `execute_tool` span with its child events/attributes *within the emitting span's lifecycle*, and nothing stronger. ## What Was Observed In a multi-agent pipeline (planner → N parallel workers → tool executor), the tool executor emitted `execute_tool` spans with `gen_ai.tool.call.id` sourced from the upstream model's tool-call block. Under a retry wrapper (3 attempts, exponential backoff), the retry re-invoked the same logical call and re-emitted a span carrying the *same* `gen_ai.tool.call.id` but a *new* `trace_id`/`span_id`. The observability backend, configured to group by `gen_ai.tool.call.id` for a "tool call success rate" panel, counted 3 successes and 1 failure for a single logical call — a 75% success rate for a call that ultimately succeeded. Separately, a checkpoint-restore path regenerated tool-call IDs at emit time (UUIDv4 per emission). A span exported twice (once before crash, once after restore) appeared as two distinct tool calls, inflating tool-call counts and breaking parent-child linkage to the agent span, which had retained the original ID. Neither failure is visible in unit tests: both require the combination of (a) a retry/replay layer and (b) a downstream consumer that assumes global uniqueness. ## Condition of Validity `gen_ai.tool.call.id` may be treated as a stable correlation key *only* when all of the following hold: - The ID is generated **at tool-call creation time**, not at span-emission time, and is threaded through every retry/replay of that call. - The emitting instrumentation sets `gen_ai.tool.call.id` from a source that is stable across the call's full lifetime (the provider's tool-call block ID, or a framework-generated ID stored on the call object, not regenerated per span). - The consumer joins on the tuple `(trace_id, gen_ai.tool.call.id)` or `(gen_ai.agent.id, gen_ai.tool.call.id)` — never on `gen_ai.tool.call.id` alone. - No two concurrent agent instances in the same trace can legitimately share a provider-scoped ID (i.e., the provider guarantees per-request uniqueness, which is the common but not universal case). When any condition fails, the correct treatment is span-local correlation only. ## Decision Boundary The decision to promote `gen_ai.tool.call.id` to a join key is a **hard boundary**, not a heuristic: - **Promote** (use as join key) if and only if the ID is creation-time-stable *and* scoped by at least one of `trace_id`, `gen_ai.agent.id`, or `gen_ai.conversation.id` in every join. - **Do not promote** if the ID is emission-time-generated, or if the provider's uniqueness guarantee is unknown. In that case, derive a composite key: `(trace_id, parent_span_id, gen_ai.tool.name, call_ordinal)` where `call_ordinal` is the index of the call within its parent agent turn. - **Never** use `gen_ai.tool.call.id` as a primary key in a datastore without a compound uniqueness constraint that includes the trace or agent scope. The boundary is crossed silently: there is no schema-level signal that an ID is emission-time-generated. The instrumentation must be audited at the source. ## Operational Effect Treating the ID as globally unique produces three observable symptoms, in order of detection difficulty: 1. **Inflated call counts** under retry/replay — success-rate and latency panels drift upward in denominator without any corresponding increase in real work. 2. **Broken parent-child linkage** after checkpoint restore — orphaned `execute_tool` spans whose `parent_span_id` points to an agent span that has since been re-created with a different ID. 3. **Silent join fan-out** — a single logical call appears as N rows in a downstream aggregation, and any per-call cost or token attribution is multiplied by N. The corrective action is to enforce the composite-key discipline at the consumer and to pin ID generation to call creation at the producer. The following guard encodes the invariant so that a misconfigured emitter fails loudly rather than silently corrupting aggregates. ```python from dataclasses import dataclass from typing import Optional class ToolCallIdInvariantError(RuntimeError): """Raised when a tool-call span violates the span-local correlation invariant.""" pass @dataclass(frozen=True) class ToolCallSpan: trace_id: str parent_span_id: str gen_ai_tool_call_id: str gen_ai_tool_name: str call_ordinal: int id_generated_at_creation: bool def assert_join_safe( span: ToolCallSpan, *, join_on: str, provider_uniqueness_known: bool, ) -> None: """ Enforce that gen_ai.tool.call.id is only used as a join key when the creation-time-stability and scoping conditions both hold. """ if join_on == "gen_ai.tool.call.id" and not span.id_generated_at_creation: raise ToolCallIdInvariantError( f"tool call {span.gen_ai_tool_call_id!r} was generated at emission time; " f"it is span-local only and must not be joined on globally" ) if join_on == "gen_ai.tool.call.id" and not provider_uniqueness_known: raise ToolCallIdInvariantError( f"tool call {span.gen_ai_tool_call_id!r}: provider uniqueness is unknown; " f"join on (trace_id, gen_ai.tool.call.id) instead" ) def composite_key(span: ToolCallSpan) -> tuple: """The only join key that is safe under all conditions.""" return ( span.trace_id, span.parent_span_id, span.gen_ai_tool_name, span.call_ordinal, ) ```