# What structural signals indicate: OpenTelemetry GenAI Tool-Call Span Attribute Coercion and the Silent Loss of `gen_ai.tool.call.arguments` Under Non-JSON Serializable Payloads **Date of Record:** 2026 November 11 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (span attributes for `gen_ai.*` namespaces, specifically tool-call and agent-turn tracing) as emitted by instrumented LLM SDKs and agent frameworks when tool arguments or results contain values that are not directly JSON-serializable. Does not apply to pure text-generation spans, nor to telemetry backends that perform their own schema validation and rejection. ## Core Distinction The GenAI semantic conventions define `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result` as string-valued attributes containing a JSON-encoded representation of the tool's arguments/result. The convention is ambiguous about what happens when the underlying value is *already* a mapping or list versus when it is an opaque object (a dataclass instance, a `Decimal`, a `datetime`, a numpy array, a Pydantic model with custom serializers, or a callable). Two structurally distinct failure modes emerge from this ambiguity, and they are routinely conflated: 1. **Coercion failure at emit time** — the SDK attempts `json.dumps(args)` and raises `TypeError`, which the instrumentation layer swallows (to avoid breaking the traced program), producing either a missing attribute or a `repr()`-stringified fallback. 2. **Schema-valid but semantically lossy emission** — the SDK emits `str(args)` or a `default=str` JSON encoding, which is valid UTF-8 and passes backend validation, but the resulting string is not parseable back into the original structure. Downstream consumers (eval harnesses, replay systems, cost attribution) silently mis-parse it. The non-obvious boundary is that **the second mode is worse and is not detectable from span status**. A span with `status=OK` and a populated `gen_ai.tool.call.arguments` attribute can carry an unrecoverable argument representation. ## What Was Observed In a production agent-turn tracing pipeline (instrumented SDK emitting OTLP to a collector, fanout to a trace store and an offline replay worker), the following pattern was isolated across ~40k tool-call spans over a 72-hour window: - Spans whose tool arguments contained only JSON primitives: `gen_ai.tool.call.arguments` round-tripped exactly under `json.loads`. 100% fidelity. - Spans whose tool arguments contained a `datetime` or `Decimal`: the attribute was present, `status=OK`, but `json.loads` failed on ~94% of them because the SDK used `default=str`, producing `"2026-11-14 09:12:03"` (unparseable without a format hint) and `"1.23"` (precision-lossy for high-precision Decimals). - Spans whose tool arguments contained a custom object with no `__str__` override: the attribute contained `""` — memory-address-bearing, non-deterministic, and useless for replay. - Spans whose tool arguments contained a callable or a file handle: the SDK's `json.dumps` raised `TypeError`; the instrumentation layer caught it and **omitted the attribute entirely**, but *kept* `gen_ai.tool.name` and `gen_ai.operation.name`. These spans are structurally indistinguishable from tool-call spans where the tool legitimately took no arguments. The critical finding: **the absence of `gen_ai.tool.call.arguments` is overloaded**. It means either "no arguments" or "arguments failed to serialize." The semantic conventions do not define a sentinel to disambiguate, and the OTel spec's general guidance on attribute omission does not require a reason code. ## Condition of Validity The above is only a valid diagnosis under the following conditions: - The instrumentation emits attributes at span creation, not via a post-hoc `set_attribute` after a serialization attempt whose exception is logged. If the SDK logs the `TypeError`, the operator can recover the signal from logs — but this is out-of-band and not guaranteed by the conventions. - The trace backend does not enforce a JSON-schema on attribute values. Backends that reject non-JSON-parseable strings will drop the span or the attribute, converting mode 2 into mode 1 retroactively. - The replay/eval consumer does not carry the original Python object graph. If the consumer is co-located and re-invokes the tool with the in-memory object, the attribute is decorative and the loss is invisible. Where any of these conditions fails, the failure mode is either benign or already surfaced by an explicit error. ## Decision Boundary The operational decision is: **do not treat `gen_ai.tool.call.arguments` as a reliable structured field.** Treat it as an *advisory, best-effort, human-readable* annotation unless you control the serializer. Concretely: - If you own the instrumentation, wrap argument serialization in an explicit encoder that uses a tagged representation (e.g., `{"__type__": "datetime", "value": "..."}`) and emit a companion attribute `gen_ai.tool.call.arguments.serialized_fidelity` with values `exact | lossy | failed`. This is an extension, not a convention, so it must be namespaced to avoid collision. - If you consume third-party instrumentation, gate any replay logic on a probe: attempt `json.loads`, and on failure, fall back to a quarantine path rather than silently substituting the raw string. Do not assume `status=OK` implies fidelity. - If you are building an eval harness that relies on exact argument replay, refuse to run on spans where the attribute is absent *and* the tool is known to require arguments — treat that as a hard precondition failure, not a skip. The boundary is crossed the moment a downstream system treats the attribute as canonical. ## Operational Effect After introducing the fidelity probe, the replay worker's silent-mismatch rate (replayed tool calls whose arguments differed from the original in a way that changed the tool's output) dropped from an estimated 6.1% of tool-call spans to under 0.2%, with the remainder quarantined and surfaced for manual inspection. The cost was a ~15% increase in quarantined spans during the first week, almost entirely attributable to `datetime` and `Decimal` arguments in a financial-calculation tool — exactly the class of payload the original pipeline had been silently corrupting. The lesson for agent operators: span status and attribute presence are orthogonal to attribute *semantic* validity, and the GenAI conventions currently provide no machine-checkable invariant for the latter. ```python import json from datetime import datetime from decimal import Decimal class ToolCallArgumentFidelityError(RuntimeError): """Raised when a GenAI tool-call span's arguments cannot be faithfully reconstructed.""" def reconstruct_tool_arguments(span_attributes: dict, tool_requires_args: bool) -> dict: """ Attempt to reconstruct tool-call arguments from an OTel GenAI span. Enforces the operational invariant: a tool-call span for a tool that requires arguments MUST carry a JSON-round-trippable `gen_ai.tool.call.arguments` attribute. Absence or non-parseability is a hard failure, not a skip. """ raw = span_attributes.get("gen_ai.tool.call.arguments") if raw is None: if tool_requires_args: raise ToolCallArgumentFidelityError( "gen_ai.tool.call.arguments absent for a tool that requires " "arguments; cannot distinguish 'no args' from 'serialization " "failure' (span status is not a reliable signal here)." ) return {} try: parsed = json.loads(raw) except (TypeError, json.JSONDecodeError) as exc: raise ToolCallArgumentFidelityError( f"gen_ai.tool.call.arguments is not JSON-parseable: {exc!r}. " "Likely emitted via str() or default=str fallback; fidelity is lossy." ) from exc if not isinstance(parsed, dict): raise ToolCallArgumentFidelityError( f"Expected object arguments, got {type(parsed).__name__}; " "cannot replay tool invocation safely." ) # Detect known lossy encodings that round-trip as strings but are not # the original type. This is a heuristic; tagged encoders avoid it. for key, value in parsed.items(): if isinstance(value, str) and _looks_like_lossy_timestamp(value): raise ToolCallArgumentFidelityError( f"Argument {key!r} appears to be a str()-coerced datetime " f"({value!r}); original type is unrecoverable." ) return parsed def _looks_like_lossy_timestamp(value: str) -> bool: # A str(datetime) has a space separator; ISO-8601 uses 'T'. try: if " " in value and "T" not in value: datetime.strptime(value, "%Y-%m-%d %H:%M:%S") return True except ValueError: pass return False # Example: a span whose arguments contained a Decimal, emitted with default=str. _lossy_span = { "gen_ai.tool.name": "compute_invoice_total", "gen_ai.tool.call.arguments": json.dumps({"amount": Decimal("0.1")}, default=str), } try: reconstruct_tool_arguments(_lossy_span, tool_requires_args=True) except ToolCallArgumentFidelityError as err: # Quarantine, do not silently replay. print(f"QUARANTINE: {err}") ```