From 316bdd8988bd8ece85155cdaa180a47d65d775ec Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 17 Oct 2026 05:42:47 -0700 Subject: [PATCH] fix(langfuse_otel): keep span export alive when nested request metadata has non-string keys The content strip validated prompt_management_metadata, mcp_tool_call_metadata and vector_store_request_metadata items as str-keyed mappings, so an SDK caller passing one with a non-string key raised inside exported_request_metadata: OTEL v2 dropped the LLM span for every vendor and v1 langfuse_otel lost the request metadata and every attribute after it. Validate those nested mappings with object keys instead. Also hoists the langfuse_otel imports. --- .../integrations/langfuse/langfuse_otel.py | 12 ++++----- litellm/integrations/otel/model/metadata.py | 3 ++- .../otel/test_otel_v2_vendor_mappers.py | 21 ++++++++++++++++ tests/unit/integrations/test_langfuse_otel.py | 25 +++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index a663356ad7b..aa83b76335a 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -13,6 +13,9 @@ from litellm.integrations.langfuse.langfuse_otel_attributes import ( LangfuseLLMObsOTELAttributes, ) from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig +from litellm.integrations.otel.mappers.langfuse import TRACE_IDENTITY_FIELDS +from litellm.integrations.otel.model.metadata import exported_request_metadata +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.types.integrations.langfuse_otel import ( LangfuseSpanAttributes, @@ -135,11 +138,6 @@ class LangfuseOtelLogger(OpenTelemetry): span: Span, kwargs: dict[str, object], # mutable-ok: required callback signature accepts kwargs as a dict ) -> None: - from litellm.integrations.arize._utils import safe_set_attribute - from litellm.integrations.otel.mappers.langfuse import TRACE_IDENTITY_FIELDS - from litellm.integrations.otel.model.metadata import exported_request_metadata - from litellm.litellm_core_utils.safe_json_dumps import safe_dumps - raw_standard_logging_object: Final = kwargs.get("standard_logging_object") if not isinstance(raw_standard_logging_object, dict): return @@ -148,7 +146,7 @@ class LangfuseOtelLogger(OpenTelemetry): return observation_metadata: Final = exported_request_metadata(standard_logging_object) if observation_metadata: - safe_set_attribute( + _utils.safe_set_attribute( span, LangfuseSpanAttributes.OBSERVATION_METADATA.value, safe_dumps(dict(observation_metadata)), @@ -156,7 +154,7 @@ class LangfuseOtelLogger(OpenTelemetry): trace_prefix: Final = LangfuseSpanAttributes.TRACE_METADATA.value for field in TRACE_IDENTITY_FIELDS: if value := observation_metadata.get(field): - safe_set_attribute(span, f"{trace_prefix}.{field}", value) + _utils.safe_set_attribute(span, f"{trace_prefix}.{field}", value) @staticmethod def _set_observation_output(span: Span, response_obj): diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 6ade4bdb539..f3185387d68 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -60,6 +60,7 @@ if TYPE_CHECKING: _INTERNAL_CALL_ORIGINS: Final[frozenset[str]] = frozenset(get_args(InternalCallOrigin)) _AUTH_ATTRIBUTE: Final = TypeAdapter(object) _REQUEST_METADATA_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) +_NESTED_METADATA_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[object, object]]] = TypeAdapter(Mapping[object, object]) _PROMPT_MANAGEMENT_CONTENT_FIELDS: Final[frozenset[str]] = frozenset({"prompt_variables"}) _MCP_TOOL_CALL_CONTENT_FIELDS: Final[frozenset[str]] = frozenset({"arguments", "result"}) _VECTOR_STORE_CONTENT_FIELDS: Final[frozenset[str]] = frozenset({"query", "vector_store_search_response"}) @@ -494,7 +495,7 @@ def exported_request_metadata(payload: Mapping[str, object]) -> Mapping[str, obj def _without_mapping_content_fields(value: object, content_fields: frozenset[str]) -> object: if not isinstance(value, dict): return value - mapping: Final = _REQUEST_METADATA_MAPPING_ADAPTER.validate_python(value) + mapping: Final = _NESTED_METADATA_MAPPING_ADAPTER.validate_python(value) return {key: item for key, item in mapping.items() if key not in content_fields} diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py index bdc0b47586f..a9351f6dfbf 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -919,3 +919,24 @@ def test_langfuse_mapper_omits_observation_metadata_without_payload_metadata(): attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload)) assert json.loads(attrs["langfuse.observation.metadata"]) == {"requester_metadata": {}} + + +@pytest.mark.parametrize( + "unexpected_shape", + [ + {"prompt_management_metadata": {1: "one", "prompt_id": "prompt-1"}}, + {"mcp_tool_call_metadata": {1: "one", "name": "lookup_weather"}}, + {"vector_store_request_metadata": [{1: "one", "vector_store_id": "vs-1"}]}, + ], +) +def test_span_data_builds_from_request_metadata_of_unexpected_shape(unexpected_shape: dict[str, object]) -> None: + payload: Final[dict[str, object]] = { + "call_type": "acompletion", + "custom_llm_provider": "openai", + "model": "gpt-4o", + "metadata": {**unexpected_shape, "spend_logs_metadata": {"ticket": "LIT-8283"}}, + "response": {}, + } + attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload)) + + assert json.loads(attrs["langfuse.observation.metadata"])["spend_logs_metadata"] == {"ticket": "LIT-8283"} diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index 508d2cf9160..04d37738cdb 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -644,6 +644,31 @@ class TestLangfuseOtelIntegration: assert [key for key in actual if key.startswith("langfuse.trace.metadata")] == ["langfuse.trace.metadata"] assert "langfuse.observation.metadata" not in actual + @pytest.mark.parametrize( + "unexpected_shape", + [ + {"prompt_management_metadata": {1: "one", "prompt_id": "prompt-1"}}, + {"mcp_tool_call_metadata": {1: "one", "name": "lookup_weather"}}, + {"vector_store_request_metadata": [{1: "one", "vector_store_id": "vs-1"}]}, + ], + ) + def test_request_metadata_of_unexpected_shape_keeps_the_observation_output( + self, unexpected_shape: dict[str, object] + ) -> None: + kwargs: Final[dict[str, object]] = { + "litellm_params": {"metadata": {}}, + "standard_logging_object": {"metadata": {**unexpected_shape, "spend_logs_metadata": {"env": "prod"}}}, + } + response_obj: Final = {"choices": [{"message": {"role": "assistant", "content": "hello"}}]} + + with patch("litellm.integrations.arize._utils.safe_set_attribute") as mock_safe_set_attribute: + LangfuseOtelLogger._set_langfuse_specific_attributes(MagicMock(), kwargs, response_obj) + + actual: Final = {call.args[1]: call.args[2] for call in mock_safe_set_attribute.call_args_list} + + assert json.loads(actual["langfuse.observation.metadata"])["spend_logs_metadata"] == {"env": "prod"} + assert json.loads(actual["langfuse.observation.output"]) == {"role": "assistant", "content": "hello"} + class TestLangfuseOtelKeyDynamicConfig: """Key/team-scoped Langfuse credentials must define the full export target