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.
This commit is contained in:
yucheng 2026-10-17 05:42:47 -07:00
parent c15dadfd70
commit 316bdd8988
4 changed files with 53 additions and 8 deletions

View file

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

View file

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

View file

@ -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"}

View file

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