mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-04 07:27:51 +00:00
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:
parent
c15dadfd70
commit
316bdd8988
4 changed files with 53 additions and 8 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue