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 <mcusack@altoslabs.com>
This commit is contained in:
Michael Cusack 2026-06-20 14:57:09 -07:00
parent c712c20d0f
commit 69831afd85
3 changed files with 71 additions and 32 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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),
)