From 30e4c56375f40575935b2a1215c945e724ea6d16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:38 +0000 Subject: [PATCH] fix(llmobs): make span data JSON-safe at span finish (#19758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Some integrations pass raw SDK request/config objects straight into the `metadata`/`config` dicts handed to `_annotate_llmobs_span_data()` — for example, the Google GenAI integration puts `SafetySetting` objects from the request config into span metadata. Those values ride along on the APM span's `_llmobs` meta_struct, which gets JSON-encoded by the agentless APM exporter later. That encoder has no fallback for non-JSON-serializable objects, so a `TypeError` there causes the **entire trace payload to be silently dropped** — not just the offending span. Observed in production as: ``` TypeError: Object of type SafetySetting is not JSON serializable when serializing list item 0 when serializing dict item 'safety_settings' when serializing dict item 'metadata' when serializing dict item 'meta' when serializing dict item '_llmobs' when serializing dict item 'meta_struct' when serializing list item N when serializing dict item 'spans' ``` This has shown up per-integration so far (Google GenAI's `safety_settings`, LangGraph's `tool_config` in #19711). Rather than chase it integration-by-integration — or field-by-field — this enforces JSON-safety once, **at span finish**. Claude session: `e0d1ad2b-5a78-4d3f-a480-4179abc30060` Resume: `claude --resume e0d1ad2b-5a78-4d3f-a480-4179abc30060` Co-authored-by: yun.kim (cherry picked from commit b16f02e665889db23aa58dedadeb400579f00697) Co-authored-by: Yun Kim <35776586+Yun-Kim@users.noreply.github.com> --- ddtrace/llmobs/_llmobs.py | 9 +- ddtrace/llmobs/_utils.py | 57 +++-- ...metadata-json-safety-9c3f7a1e4b2d6f80.yaml | 6 + tests/contrib/google_genai/utils.py | 18 +- tests/contrib/litellm/utils.py | 6 +- tests/llmobs/test_llmobs.py | 83 +++++++ tests/llmobs/test_utils.py | 215 +++++++++++++++++- 7 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 releasenotes/notes/llmobs-metadata-json-safety-9c3f7a1e4b2d6f80.yaml diff --git a/ddtrace/llmobs/_llmobs.py b/ddtrace/llmobs/_llmobs.py index bb552b0749f..f9fd7290dd5 100644 --- a/ddtrace/llmobs/_llmobs.py +++ b/ddtrace/llmobs/_llmobs.py @@ -154,7 +154,7 @@ from ddtrace.llmobs._utils import _get_parent_prompt from ddtrace.llmobs._utils import _normalize_wire_trace_id_to_hex from ddtrace.llmobs._utils import _resolve_parent_agent -from ddtrace.llmobs._utils import _sanitize_span_event_depth +from ddtrace.llmobs._utils import _sanitize_span_event_data from ddtrace.llmobs._utils import _stamp_agent_attribution from ddtrace.llmobs._utils import _trace_id_to_wire from ddtrace.llmobs._utils import _validate_prompt @@ -713,7 +713,7 @@ def _prepare_llmobs_span_data(self, span: Span, span_kind: Optional[str]) -> boo # reaches every span in its block, but only agent spans carry the tags. agent_annotation = span._get_ctx_item(AGENT_ANNOTATION) if agent_annotation and span_kind == "agent": - llmobs_data.setdefault(LLMOBS_STRUCT.TAGS, {})[AGENT_VERSION_TAG_KEY] = agent_annotation + llmobs_data.setdefault(LLMOBS_STRUCT.TAGS, {})[AGENT_VERSION_TAG_KEY] = str(agent_annotation) llmobs_meta = llmobs_data.setdefault(LLMOBS_STRUCT.META, _Meta()) llmobs_input = llmobs_meta.get(LLMOBS_STRUCT.INPUT) or _MetaIO() @@ -738,7 +738,10 @@ def _prepare_llmobs_span_data(self, span: Span, span_kind: Optional[str]) -> boo output_type, export_to_llmobs=self._export_mode != LLMObsExportMode.APM_AGENTLESS, ) - llmobs_data[LLMOBS_STRUCT.META] = _sanitize_span_event_depth(llmobs_meta) + llmobs_data[LLMOBS_STRUCT.META] = _sanitize_span_event_data(llmobs_meta) + config = llmobs_data.get(LLMOBS_STRUCT.CONFIG) + if config is not None: + llmobs_data[LLMOBS_STRUCT.CONFIG] = _sanitize_span_event_data(config) if self._export_mode == LLMObsExportMode.APM_AGENTLESS: # APM agentless ingestion treats dots in tag keys as nested-path separators; # replace them with underscores before encoding. diff --git a/ddtrace/llmobs/_utils.py b/ddtrace/llmobs/_utils.py index 436ca2e46d9..f728a776846 100644 --- a/ddtrace/llmobs/_utils.py +++ b/ddtrace/llmobs/_utils.py @@ -252,16 +252,21 @@ def _unserializable_default_repr(obj): _MAX_NESTED_META_DEPTH = 12 -def _sanitize_span_event_depth(obj: Any) -> Any: - """Return a sanitized copy of obj with any container value that exceeds - _MAX_NESTED_META_DEPTH levels from the root replaced by its JSON string representation, - and every mapping key stringified. The original structure is never mutated. - A debug log is emitted for each stringified field, including its dotted path. +def _sanitize_span_event_data(obj: Any) -> Any: + """Return a copy of obj that is safe to encode into a span event. + + Three guarantees, applied to every node: any container that exceeds _MAX_NESTED_META_DEPTH + levels from the root is replaced by its JSON string representation, every mapping key is + stringified, and every leaf value is made JSON-serializable. The original structure is never + mutated. A debug log is emitted for each stringified over-depth field, including its dotted path. + + This is the last line of defense before the agentless APM exporter, which drops the whole trace + payload on a single unserializable object. It runs after the user span processor for that reason. """ def _walk(node: Any, depth: int, path: str) -> Any: if not isinstance(node, (dict, list)): - return node + return _sanitize_leaf(node, depth, path) if depth >= _MAX_NESTED_META_DEPTH: log.debug( "LLMObs: span event field %r exceeds the maximum nested depth of %d and will be " @@ -274,6 +279,21 @@ def _walk(node: Any, depth: int, path: str) -> Any: return {str(k): _walk(v, depth + 1, f"{path}.{k}" if path else str(k)) for k, v in node.items()} return [_walk(v, depth + 1, f"{path}[{i}]" if path else str(i)) for i, v in enumerate(node)] + def _sanitize_leaf(node: Any, depth: int, path: str) -> Any: + if node is None or isinstance(node, (str, int, float, bool)): + return node + # load_data_value keeps structure where it can (models become dicts) instead of flattening + # to a string, so nested objects stay queryable. Re-walk it to apply the depth limit. + try: + loaded = load_data_value(node) + except Exception: + log.debug("LLMObs: span event field %r could not be converted; falling back to str.", path) + try: + return str(node) + except Exception: + return "[Unserializable object of type {}]".format(type(node).__name__) + return _walk(loaded, depth, path) if isinstance(loaded, (dict, list)) else loaded + return _walk(obj, 0, "") @@ -310,13 +330,17 @@ def load_data_value(value): elif isinstance(value, type): return value.__name__ elif hasattr(value, "model_dump"): - return value.model_dump(exclude_none=True) + # model_dump() can leave non-primitive field values (datetime, Enum, ...) unconverted. + return load_data_value(value.model_dump(exclude_none=True)) elif is_dataclass(value): - return asdict(value) + # asdict() deep-copies non-dataclass field types as-is, leaving them unconverted. + return load_data_value(asdict(value)) elif isinstance(value, (int, float, str, bool)) or value is None: return value else: value_str = safe_json(value) + if value_str is None: # safe_json swallows its failure and returns None + return str(value) try: return json.loads(value_str) except json.JSONDecodeError: @@ -678,9 +702,13 @@ def _sanitize_metric_key(key): LLMObs ingestion interprets dots in a metric key as nested-path separators, which breaks decoding of the flat numeric metrics map and causes the enclosing span batch to be dropped. - Non-string keys are returned unchanged (value validation happens upstream in ``annotate``). + Non-string keys are stringified first, since a metric key is serialized as a string either way + and the encoder has no representation for other key types -- and stringifying can itself + introduce a dot (1.5 -> "1.5"). (Value validation happens upstream in annotate.) """ - if not isinstance(key, str) or "." not in key: + if not isinstance(key, str): + key = str(key) + if "." not in key: return key sanitized = key.replace(".", "_") log.warning( @@ -770,7 +798,8 @@ def _annotate_llmobs_span_data( if model_provider is not None: meta[LLMOBS_STRUCT.MODEL_PROVIDER] = model_provider if metadata is not None: - # Metadata keys are serialized as strings, so coerce non-string keys here. + # Metadata keys are serialized as strings, so coerce non-string keys here. Values are + # left as-is for in-process consumers; _sanitize_span_event_data handles them at finish. meta[LLMOBS_STRUCT.METADATA].update({str(k): v for k, v in metadata.items()}) if agent_manifest is not None or cost_tags is not None: # Initialize metadata_dd here to avoid unnecessary empty dict allocations in the top-level metadata dict. @@ -785,11 +814,11 @@ def _annotate_llmobs_span_data( if metrics is not None: llmobs_span_data[LLMOBS_STRUCT.METRICS].update({_sanitize_metric_key(k): v for k, v in metrics.items()}) if tags is not None: - # Tag values are serialized as strings, so coerce non-string values here. - llmobs_span_data[LLMOBS_STRUCT.TAGS].update({k: str(v) for k, v in tags.items()}) + # Tag keys and values are both serialized as strings, so coerce non-string ones here. + llmobs_span_data[LLMOBS_STRUCT.TAGS].update({str(k): str(v) for k, v in tags.items()}) if session_id is not None: llmobs_span_data[LLMOBS_STRUCT.SESSION_ID] = session_id - llmobs_span_data[LLMOBS_STRUCT.TAGS]["session_id"] = session_id + llmobs_span_data[LLMOBS_STRUCT.TAGS]["session_id"] = str(session_id) span._set_ctx_item(SESSION_ID, session_id) if span_links is not None: llmobs_span_data[LLMOBS_STRUCT.SPAN_LINKS] = span_links diff --git a/releasenotes/notes/llmobs-metadata-json-safety-9c3f7a1e4b2d6f80.yaml b/releasenotes/notes/llmobs-metadata-json-safety-9c3f7a1e4b2d6f80.yaml new file mode 100644 index 00000000000..3d8b865dc79 --- /dev/null +++ b/releasenotes/notes/llmobs-metadata-json-safety-9c3f7a1e4b2d6f80.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + LLM Observability: Fixes an issue where the agentless exporter dropped traces due to non-JSON-serializable objects being stored on a span. + All LLM Observability span data is now made JSON-serializable before the span is submitted. Non-string span tag keys, span metric keys, + and agent versions supplied via ``LLMObs.annotate(agent=...)`` are also now coerced to strings. diff --git a/tests/contrib/google_genai/utils.py b/tests/contrib/google_genai/utils.py index a551ef8b63c..6321c402f39 100644 --- a/tests/contrib/google_genai/utils.py +++ b/tests/contrib/google_genai/utils.py @@ -224,10 +224,24 @@ def get_current_weather(location: str, unit: str = "fahrenheit") -> dict[str, ob ) +def _as_submitted(value): + """Mirror the conversion a metadata value undergoes on its way onto the span. + + Span data is made JSON-serializable at span finish, which turns pydantic config objects such as + SafetySetting into plain dicts. Dumping here rather than hardcoding the result keeps these + expectations correct across the google-genai versions in the test matrix. + """ + if isinstance(value, list): + return [_as_submitted(v) for v in value] + if hasattr(value, "model_dump"): + return value.model_dump(exclude_none=True) + return value + + def get_expected_metadata(): metadata = {} for param in GENERATE_METADATA_PARAMS: - metadata[param] = getattr(FULL_GENERATE_CONTENT_CONFIG, param, None) + metadata[param] = _as_submitted(getattr(FULL_GENERATE_CONTENT_CONFIG, param, None)) return metadata @@ -235,6 +249,6 @@ def get_expected_metadata(): def get_expected_tool_metadata(): metadata = {} for param in GENERATE_METADATA_PARAMS: - metadata[param] = getattr(TOOL_GENERATE_CONTENT_CONFIG, param, None) + metadata[param] = _as_submitted(getattr(TOOL_GENERATE_CONTENT_CONFIG, param, None)) return metadata diff --git a/tests/contrib/litellm/utils.py b/tests/contrib/litellm/utils.py index 90dfafc1272..e6de39d2ae0 100644 --- a/tests/contrib/litellm/utils.py +++ b/tests/contrib/litellm/utils.py @@ -237,7 +237,11 @@ def parse_response(resp, is_completion=False): ] expected_router_settings = { - "router_general_settings": RouterGeneralSettings(async_only_mode=False, pass_through_all_models=False), + # Dumped rather than kept as a model: span data is made JSON-serializable at span finish, so + # what lands on the span is a plain dict. + "router_general_settings": RouterGeneralSettings(async_only_mode=False, pass_through_all_models=False).model_dump( + exclude_none=True + ), "routing_strategy": "simple-shuffle", "routing_strategy_args": {}, "provider_budget_config": None, diff --git a/tests/llmobs/test_llmobs.py b/tests/llmobs/test_llmobs.py index 732129a1afe..20962f9d4f9 100644 --- a/tests/llmobs/test_llmobs.py +++ b/tests/llmobs/test_llmobs.py @@ -1,4 +1,5 @@ import asyncio +import json import os from textwrap import dedent from typing import Optional @@ -9,6 +10,7 @@ from ddtrace.ext import SpanTypes from ddtrace.internal.utils.formats import format_trace_id from ddtrace.llmobs import LLMObsSpan +from ddtrace.llmobs._constants import AGENT_VERSION_TAG_KEY from ddtrace.llmobs._constants import LANGCHAIN_APM_SPAN_NAME from ddtrace.llmobs._constants import LLMOBS_APM_SHADOW_CACHE_READ_INPUT_TOKENS_METRIC_KEY from ddtrace.llmobs._constants import LLMOBS_APM_SHADOW_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY @@ -17,6 +19,7 @@ from ddtrace.llmobs._constants import LLMOBS_APM_SHADOW_OUTPUT_TOKENS_METRIC_KEY from ddtrace.llmobs._constants import LLMOBS_APM_SHADOW_SPAN_KIND_TAG_KEY from ddtrace.llmobs._constants import LLMOBS_APM_SHADOW_TOTAL_TOKENS_METRIC_KEY +from ddtrace.llmobs._constants import LLMOBS_STRUCT from ddtrace.llmobs._constants import LLMOBS_SUBMITTED_TAG_KEY from ddtrace.llmobs._constants import ROOT_PARENT_ID from ddtrace.llmobs._constants import UNKNOWN_MODEL_PROVIDER @@ -1432,3 +1435,83 @@ def test_sampling_decisions_follow_configured_rate(): assert abs(sampled - expected) <= 25, ( f"rate={configured_rate}: expected ~{expected} sampled out of {n}, got {sampled}" ) + + +class TestSpanEventJSONSafety: + """Span data must be JSON-safe by span finish, or agentless APM exporter will drop the entire trace. + + Sanitizing at finish rather than at annotation time also covers what the user span processor adds. + """ + + class RawObject: + """Stands in for the live SDK objects integrations hand us (e.g. a safety-settings enum).""" + + def __init__(self, value): + self.value = value + + def __str__(self): + return "RawObject({})".format(self.value) + + def test_agent_version_tag_is_stringified(self, llmobs): + """annotate(agent=...) is not type-validated, and the version is written into tags at finish, + after the string coercion in _annotate_llmobs_span_data has already run. + """ + with llmobs.agent(name="my_agent") as span: + llmobs.annotate(span=span, agent={"version": self.RawObject("v2")}) + tags = _get_llmobs_data_metastruct(span)[LLMOBS_STRUCT.TAGS] + assert tags[AGENT_VERSION_TAG_KEY] == "RawObject(v2)" + + def test_metadata_is_sanitized_at_finish(self, llmobs, tracer): + with tracer.trace("root", span_type=SpanTypes.LLM) as span: + _annotate_llmobs_span_data( + span, + kind="llm", + metadata={"safety_settings": [self.RawObject("BLOCK_NONE")], "nested": {"cfg": self.RawObject("LOW")}}, + ) + assert get_llmobs_metadata(span) == { + "safety_settings": ["RawObject(BLOCK_NONE)"], + "nested": {"cfg": "RawObject(LOW)"}, + } + + def test_config_is_sanitized_at_finish(self, llmobs, tracer): + """config sits at the top level of the struct rather than under meta, so it gets its own pass.""" + with tracer.trace("root", span_type=SpanTypes.LLM) as span: + _annotate_llmobs_span_data(span, kind="llm", config={"safety_settings": [self.RawObject("BLOCK_NONE")]}) + data = _get_llmobs_data_metastruct(span) + assert data[LLMOBS_STRUCT.CONFIG] == {"safety_settings": ["RawObject(BLOCK_NONE)"]} + + def test_metadata_from_user_span_processor_is_sanitized(self, llmobs, tracer): + """A user processor writing a raw object into metadata must not be able to poison the payload. + + This is the case write-time enforcement in _annotate_llmobs_span_data cannot cover: the + processor runs at finish, after every annotation has already been stored. + """ + raw = self.RawObject("from-processor") + + def _sp(span): + span.metadata["injected"] = raw + return span + + llmobs.register_processor(_sp) + try: + with tracer.trace("root", span_type=SpanTypes.LLM) as span: + _annotate_llmobs_span_data(span, kind="llm") + assert get_llmobs_metadata(span)["injected"] == "RawObject(from-processor)" + finally: + llmobs.register_processor(None) + + def test_whole_struct_survives_json_encoding(self, llmobs, tracer): + """Reproduces the actual failure mode: json.dumps over the whole struct, the way the agentless + APM exporter encodes it. The per-field tests above assert sanitized values; this one asserts + that no other field (tags, metrics, _dd, ...) can still raise TypeError and drop the payload. + """ + with tracer.trace("root", span_type=SpanTypes.LLM) as span: + _annotate_llmobs_span_data( + span, + kind="llm", + metadata={"raw": self.RawObject("m")}, + config={"raw": self.RawObject("c")}, + tags={self.RawObject("k"): self.RawObject("v")}, + metrics={"input_tokens": 1}, + ) + json.dumps(_get_llmobs_data_metastruct(span)) diff --git a/tests/llmobs/test_utils.py b/tests/llmobs/test_utils.py index b8b08e4f85e..13d4e45c0bb 100644 --- a/tests/llmobs/test_utils.py +++ b/tests/llmobs/test_utils.py @@ -1,12 +1,18 @@ +from dataclasses import dataclass +import json + from pydantic import BaseModel import pytest from ddtrace.internal.utils.formats import format_trace_id from ddtrace.llmobs._constants import LLMOBS_STRUCT +from ddtrace.llmobs._utils import _MAX_NESTED_META_DEPTH from ddtrace.llmobs._utils import _annotate_llmobs_span_data from ddtrace.llmobs._utils import _normalize_wire_trace_id_to_hex -from ddtrace.llmobs._utils import _sanitize_span_event_depth +from ddtrace.llmobs._utils import _sanitize_metric_key +from ddtrace.llmobs._utils import _sanitize_span_event_data from ddtrace.llmobs._utils import _trace_id_to_wire +from ddtrace.llmobs._utils import load_data_value from ddtrace.llmobs._utils import safe_json from ddtrace.llmobs.utils import Documents from ddtrace.llmobs.utils import Messages @@ -455,6 +461,54 @@ def __str__(self): assert encoded_obj == '"Class"' +def test_load_data_value_pydantic_model_with_unserializable_nested_field(): + """model_dump() without mode="json" can leave non-primitive nested field values (e.g. a raw + SDK object) unconverted -- load_data_value must recurse into the dumped result to finish + sanitizing them, not just return it as-is. + """ + + class RawObject: + def __init__(self, value): + self.value = value + + def __str__(self): + return "RawObject({})".format(self.value) + + class Model(BaseModel): + class Config: + arbitrary_types_allowed = True + + name: str + raw: RawObject + + result = load_data_value(Model(name="hello", raw=RawObject("world"))) + json.dumps(result) # raises TypeError if not JSON-serializable + assert result == {"name": "hello", "raw": "RawObject(world)"} + + +def test_load_data_value_dataclass_with_unserializable_nested_field(): + """asdict() only recurses into nested dataclasses/dicts/lists/tuples -- other field types are + deep-copied as-is, so load_data_value must recurse into the dumped result to finish sanitizing + them. + """ + + class RawObject: + def __init__(self, value): + self.value = value + + def __str__(self): + return "RawObject({})".format(self.value) + + @dataclass + class Model: + name: str + raw: RawObject + + result = load_data_value(Model(name="hello", raw=RawObject("world"))) + json.dumps(result) # raises TypeError if not JSON-serializable + assert result == {"name": "hello", "raw": "RawObject(world)"} + + class TestAnnotateLLMObsSpanData: def test_populates_meta_struct(self, llmobs): """All annotated fields are stored in the correct nested positions.""" @@ -506,6 +560,21 @@ def test_metadata_keys_are_stringified(self, llmobs): metadata = span._get_struct_tag(LLMOBS_STRUCT.KEY)[LLMOBS_STRUCT.META][LLMOBS_STRUCT.METADATA] assert metadata == {"42": "a", "2.5": "b", "True": "c", "None": "d"} + def test_tag_keys_and_values_are_stringified(self, llmobs): + """Tags are serialized as string-to-string pairs, so both sides are coerced.""" + with llmobs.task(name="test_span") as span: + _annotate_llmobs_span_data(span, tags={42: "a", None: 7, "obj": object.__new__(object)}) + tags = span._get_struct_tag(LLMOBS_STRUCT.KEY)[LLMOBS_STRUCT.TAGS] + assert tags["42"] == "a" + assert tags["None"] == "7" + assert tags["obj"].startswith("