# When must an agent emitter reject a span that claims `gen_ai.tool.call` semantics but carries no resolvable tool identity? **Date of Record:** 2026 November 5 **Scope:** Applies to agent runtimes and instrumentation shims that emit OpenTelemetry spans under the `gen_ai.*` semantic conventions for tool invocations, memory reads, and agent steps. Does not apply to plain LLM inference spans (`gen_ai.chat`) or to vendor-proprietary trace formats that are never re-emitted as OTLP. ## Core Distinction The non-obvious boundary is between *span validity* and *span usefulness*, and it is not where most instrumentation assumes it is. A span can be structurally valid OTLP — correct `traceId`, `spanId`, monotonic timestamps, a `name`, a `kind` — and still be semantically uninterpretable downstream. The failure mode is specifically: a span whose `name` or attributes assert a tool call or memory read, but whose **tool identity** (`gen_ai.tool.name`, or a resolvable `gen_ai.tool.call.id` linked to a declaring span) is absent, empty, or a placeholder (`"unknown"`, `"tool"`, `""`). Such a span passes the SDK, passes the collector's schema validation, lands in the backend, and then silently corrupts every aggregate that groups by tool — latency percentiles, error rates, cost attribution — because it forms a phantom cohort under a non-existent tool. The distinction that matters: **the emitter, not the collector, is the last place where the tool identity is still known.** By the time the span is serialized, a missing tool name is indistinguishable from a deliberately redacted one. The collector cannot repair it; the backend cannot flag it without a policy that the OTel spec does not mandate. ## What Was Observed Across three agent runtimes instrumented for tool-call tracing, the pattern recurred in a specific form: tool-call spans were emitted from a generic dispatch wrapper (`execute_tool(name, args)`) that was also used for internal helpers (argument coercion, retry backoff, schema validation). When a helper raised before the real tool was bound, the wrapper's error path emitted a span with `gen_ai.operation.name = "execute_tool"` but with `gen_ai.tool.name` unset, because the name argument had not yet been threaded through. These spans were a small fraction (0.3–1.1%) of tool spans but dominated the "unknown tool" bucket and, more damagingly, were counted as tool *failures* in error-rate dashboards, inflating per-tool error rates by several points on low-traffic tools where the absolute count was small. A second observed form: memory-read spans (`gen_ai.operation.name = "memory.read"` or an equivalent custom convention) emitted with a `gen_ai.memory.store` attribute but no key/namespace, so the read could not be attributed to a store partition. Downstream cache-hit analysis grouped all such reads into a single "unpartitioned" series that masked a real regression in one store. In both cases the span was *technically valid* and the failure was only visible as a discontinuity in aggregates — never as an error on the span itself. ## Condition of Validity The invariant holds — i.e., the emitter MUST reject the span — when all of the following are true: 1. The span's `name` or `gen_ai.operation.name` asserts a tool call or memory read (a semantic claim of identity). 2. The corresponding identity attribute (`gen_ai.tool.name` for tool calls; store + key/namespace for memory reads) is missing, empty, or matches a reserved placeholder set. 3. The emitter still has access to the dispatch context that could supply the identity (i.e., this is not a post-hoc redaction path where identity was intentionally stripped for privacy). If condition 3 is false — identity was deliberately removed for PII/secret reasons — the span is *valid but must be marked* (e.g., `gen_ai.tool.name = "redacted"` plus an explicit redaction attribute), not rejected. The invariant is about *unknown* identity, not *withheld* identity. Conflating the two is the trap: rejecting redacted spans drops legitimate traces; accepting unknown spans pollutes aggregates. ## Decision Boundary The boundary is drawn at the dispatch wrapper's span-emission point, before `span.end()` and before the SDK exporter runs. The decision is binary and local: - **Reject** (raise, do not export) when identity is unknown and the dispatch context could have supplied it. A rejected span is a *bug in instrumentation*, surfaced loudly at emit time, not a data-quality issue discovered a week later in a dashboard. - **Accept and mark** when identity is intentionally withheld. The marker must be a distinct sentinel (`"redacted"`), never `""` or `"unknown"`, so that the two cohorts remain separable in every downstream `GROUP BY`. - **Accept** when the span makes no identity claim (e.g., a generic agent-step span with no tool semantics) — the invariant does not apply. The non-obvious part: the rejection must happen at the emitter, because the emitter is the only component that can distinguish "unknown" from "redacted." A collector-side validator cannot make this call and will either over-reject (dropping redacted spans) or under-reject (letting unknown spans through). ## Operational Effect Enforcing this boundary converts a silent aggregate-corruption failure into an immediate, attributable instrumentation error. In the runtimes observed, the practical effects were: the "unknown tool" cohort dropped to zero (it became an emit-time exception during development, fixed before deploy); per-tool error rates stopped being inflated by helper-path spans; and memory-read cache analysis regained partition resolution. The cost is that instrumentation bugs now surface as exceptions in the agent process rather than as quiet trace noise — which is the correct trade, because an agent that cannot name the tool it called has lost the one fact its trace existed to record. The residual risk is over-application: teams that treat every missing attribute as fatal will reject redacted spans and lose compliance-relevant traces. The guard is the explicit redaction sentinel and the condition-3 check — reject only what is *unknown and knowable*, never what is *withheld*. ```python from __future__ import annotations from dataclasses import dataclass from typing import Optional # Reserved placeholders that indicate "identity unknown", not a real tool. _UNKNOWN_PLACEHOLDERS = frozenset({"", "unknown", "tool", "unnamed", "n/a"}) # Explicit sentinel for intentionally withheld identity (privacy redaction). _REDACTED_SENTINEL = "redacted" class UnresolvableToolIdentityError(RuntimeError): """Raised when a span asserts tool/memory semantics but carries no resolvable identity. This is an instrumentation bug surfaced at emit time, not a data-quality issue to be repaired downstream. The emitter is the last place where "unknown" and "redacted" are still distinguishable. """ @dataclass(frozen=True) class SpanDraft: name: str operation: Optional[str] # gen_ai.operation.name tool_name: Optional[str] # gen_ai.tool.name redacted: bool = False # identity intentionally withheld dispatch_context_available: bool = True # could the emitter still know the name? def validate_before_export(span: SpanDraft) -> SpanDraft: """Enforce the tool-identity invariant at the emitter boundary. Returns the (possibly annotated) span if it is valid or explicitly redacted. Raises UnresolvableToolIdentityError if identity is unknown but knowable. """ asserts_identity = span.operation in ("execute_tool", "memory.read") if not asserts_identity: return span # no semantic claim; invariant does not apply # Explicit redaction path: accept, but require a distinct sentinel so the # redacted cohort stays separable from the unknown cohort downstream. if span.redacted: if span.tool_name not in (None, _REDACTED_SENTINEL): raise UnresolvableToolIdentityError( f"redacted span must use sentinel {_REDACTED_SENTINEL!r}, " f"got {span.tool_name!r}" ) return SpanDraft( name=span.name, operation=span.operation, tool_name=_REDACTED_SENTINEL, redacted=True, dispatch_context_available=span.dispatch_context_available, ) name = span.tool_name is_unknown = name is None or name.strip().lower() in _UNKNOWN_PLACEHOLDERS if is_unknown: if span.dispatch_context_available: # Identity was knowable at emit time -> instrumentation bug. raise UnresolvableToolIdentityError( f"span {span.name!r} (op={span.operation!r}) asserts tool/memory " f"semantics but has no resolvable identity " f"(tool_name={span.tool_name!r}); emitter still had dispatch context" ) # Identity was not knowable and not marked redacted -> also a bug, # but a different one: the caller must set redacted=True explicitly. raise UnresolvableToolIdentityError( f"span {span.name!r} has unknown identity with no dispatch context; " f"mark redacted=True if identity was intentionally withheld" ) return span ```