From 69831afd8571e004620a660caa836ee2cd5895e6 Mon Sep 17 00:00:00 2001 From: Michael Cusack Date: Sat, 20 Jun 2026 14:57:09 -0700 Subject: [PATCH] fix(opentelemetry): JSON-serialize dict/list span attributes instead of str() The shared OTEL primitive cast fell back to str() for non-primitive values, turning dict/list span attributes into a Python repr (single-quoted) rather than JSON. That repr is not valid JSON, so downstream consumers that parse span attributes such as metadata.requester_metadata / metadata.applied_guardrails / metadata.usage_object cannot json-decode them. Inconsistent within the same module: the metric/common-attribute and message/choices paths already serialize structured values with safe_dumps (JSON). Branch dict/list to safe_dumps before the str() fallback. The cast logic was duplicated three ways (an OpenTelemetry private method, an unused OpenTelemetry public method, and the module-level cast_as_primitive_value_type in base_otel_llm_obs_attributes that backs the Arize, Weave and Langfuse paths). Consolidate onto the module-level function as the single source of truth, fix it there, and have OpenTelemetry.cast_as_primitive_value_type delegate to it; this also fixes the Arize/Weave/Langfuse paths and keeps the public method intact for backwards compatibility. Adds regression tests on the shared function and on the delegate. Signed-off-by: Michael Cusack --- litellm/integrations/opentelemetry.py | 42 ++++------------ .../base_otel_llm_obs_attributes.py | 11 +++- .../integrations/test_opentelemetry.py | 50 +++++++++++++++++++ 3 files changed, 71 insertions(+), 32 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 6b50ef49b49..44899d53b67 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -21,6 +21,9 @@ from litellm.integrations._types.open_inference import ( SpanAttributes, ) from litellm.integrations.custom_logger import CustomLogger +from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes import ( + cast_as_primitive_value_type as cast_primitive_value_type, +) from litellm.integrations.opentelemetry_utils.gen_ai_semconv import ( OTEL_SEMCONV_STABILITY_OPT_IN_ENV, OTELGenAISemconvMixin, @@ -2159,23 +2162,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ) pass - def cast_as_primitive_value_type(self, value) -> Union[str, bool, int, float]: - """ - Casts the value to a primitive OTEL type if it is not already a primitive type. - - OTEL supports - str, bool, int, float - - If it's not a primitive type, then it's converted to a string - """ - if value is None: - return "" - if isinstance(value, (str, bool, int, float)): - return value - try: - return str(value) - except Exception: - return "" - @staticmethod def _tool_calls_kv_pair( tool_calls: List[ChatCompletionMessageToolCall], @@ -2583,28 +2569,22 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): "OpenTelemetry logging error in set_attributes %s", str(e) ) - def _cast_as_primitive_value_type(self, value) -> Union[str, bool, int, float]: + def cast_as_primitive_value_type(self, value) -> Union[str, bool, int, float]: """ - Casts the value to a primitive OTEL type if it is not already a primitive type. + Casts the value to a primitive OTEL type (str / bool / int / float). - OTEL supports - str, bool, int, float - - If it's not a primitive type, then it's converted to a string + Delegates to the shared cast in ``base_otel_llm_obs_attributes`` so the + OTEL, Arize, Weave and Langfuse paths serialize dict/list span + attributes identically (JSON via ``safe_dumps``, not a non-parseable + Python repr). """ - if value is None: - return "" - if isinstance(value, (str, bool, int, float)): - return value - try: - return str(value) - except Exception: - return "" + return cast_primitive_value_type(value) def safe_set_attribute(self, span: Span, key: str, value: Any): """ Safely sets an attribute on the span, ensuring the value is a primitive type. """ - primitive_value = self._cast_as_primitive_value_type(value) + primitive_value = self.cast_as_primitive_value_type(value) span.set_attribute(key, primitive_value) def _transform_messages_to_otel_semantic_conventions( diff --git a/litellm/integrations/opentelemetry_utils/base_otel_llm_obs_attributes.py b/litellm/integrations/opentelemetry_utils/base_otel_llm_obs_attributes.py index f74da8231f3..1dbd4feb2d3 100644 --- a/litellm/integrations/opentelemetry_utils/base_otel_llm_obs_attributes.py +++ b/litellm/integrations/opentelemetry_utils/base_otel_llm_obs_attributes.py @@ -1,6 +1,8 @@ from abc import ABC from typing import TYPE_CHECKING, Any, Dict, Union +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + if TYPE_CHECKING: from opentelemetry.trace import Span @@ -17,12 +19,19 @@ class BaseLLMObsOTELAttributes(ABC): def cast_as_primitive_value_type(value) -> Union[str, bool, int, float]: """ - Converts a value to an OTEL-supported primitive for Arize/Phoenix observability. + Converts a value to an OTEL-supported primitive (str / bool / int / float). + + dict / list values are JSON-serialized so structured span attributes stay + machine-parseable; ``str()`` on them emits a single-quoted Python repr that + is not valid JSON and breaks downstream consumers. Any other non-primitive + type falls back to ``str()``. """ if value is None: return "" if isinstance(value, (str, bool, int, float)): return value + if isinstance(value, (dict, list)): + return safe_dumps(value) try: return str(value) except Exception: diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index e47e437a131..6938bbe0d5a 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -27,6 +27,9 @@ from litellm.integrations.opentelemetry import ( OTELSemconvCategory, _normalize_team_metadata_keys, ) +from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes import ( + cast_as_primitive_value_type, +) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps @@ -5749,3 +5752,50 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase): exporter="console", attributes=attributes ) ) + + +class TestOpenTelemetryPrimitiveCast(unittest.TestCase): + """`cast_as_primitive_value_type` must JSON-serialize dict/list span + attributes rather than str()-ing them into a non-parseable Python repr. + + Regression: `metadata.*` span attributes that are dict/list (e.g. + requester_metadata, applied_guardrails) were emitted as single-quoted + Python repr, which is not valid JSON, breaking downstream consumers that + parse span attributes. The shared cast backs the OTEL, Arize, Weave and + Langfuse paths, so the fix is verified on the shared function and on the + OpenTelemetry delegate that forwards to it. + """ + + def test_dict_and_list_cast_as_json(self): + nested_dict = {"requester_metadata": {"tags": ["a", "b"]}, "count": 1} + cast_dict = cast_as_primitive_value_type(nested_dict) + self.assertIsInstance(cast_dict, str) + # valid JSON that round-trips; a Python repr would raise here + self.assertEqual(json.loads(cast_dict), nested_dict) + self.assertEqual(cast_dict, safe_dumps(nested_dict)) + + list_value = ["bedrock-guardrail-tag-router", "other-guardrail"] + cast_list = cast_as_primitive_value_type(list_value) + self.assertIsInstance(cast_list, str) + self.assertEqual(json.loads(cast_list), list_value) + + def test_primitives_pass_through_unchanged(self): + self.assertEqual(cast_as_primitive_value_type("s"), "s") + self.assertEqual(cast_as_primitive_value_type(3), 3) + self.assertIs(cast_as_primitive_value_type(True), True) + self.assertEqual(cast_as_primitive_value_type(None), "") + + def test_opentelemetry_delegate_matches_shared_cast(self): + otel = OpenTelemetry() + for value in ( + {"requester_metadata": {"tags": ["a", "b"]}}, + ["a", "b"], + "s", + 3, + True, + None, + ): + self.assertEqual( + otel.cast_as_primitive_value_type(value), + cast_as_primitive_value_type(value), + )