mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
c712c20d0f
commit
69831afd85
3 changed files with 71 additions and 32 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue