From acb7e42bdb2bbdb1eeb9f7f617cce38b135ad8d7 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 16 Oct 2026 15:25:18 +0000 Subject: [PATCH] fix(langfuse otel): drop prompt variables, MCP tool I/O and vector store queries from exported request metadata --- litellm/integrations/otel/model/metadata.py | 37 +++++++++++++- .../otel/test_otel_v2_vendor_mappers.py | 50 ++++++++++++++++++ tests/unit/integrations/test_langfuse_otel.py | 51 +++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 5047cbfe979..6ade4bdb539 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -60,6 +60,9 @@ 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]) +_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"}) REQUESTER_METADATA_KEY: Final = "requester_metadata" REQUESTER_METADATA_PATH: Final = f"{REQUESTER_METADATA_KEY}." @@ -484,7 +487,39 @@ def exported_request_metadata(payload: Mapping[str, object]) -> Mapping[str, obj ) redacted: Final = _REQUEST_METADATA_MAPPING_ADAPTER.validate_python(redact_metadata(dict(metadata))) exported: Final = _REQUEST_METADATA_MAPPING_ADAPTER.validate_python(log_requester_metadata(redacted)) - return MappingProxyType({key: value for key, value in exported.items() if value is not None}) + null_filtered: Final = MappingProxyType({key: value for key, value in exported.items() if value is not None}) + return _without_request_metadata_content(null_filtered) + + +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) + return {key: item for key, item in mapping.items() if key not in content_fields} + + +def _without_vector_store_content_fields(value: object) -> object: + if not isinstance(value, list): + return value + return tuple(_without_mapping_content_fields(item, _VECTOR_STORE_CONTENT_FIELDS) for item in value) + + +def _request_metadata_value_without_content(key: str, value: object) -> object: + match key: + case "prompt_management_metadata": + return _without_mapping_content_fields(value, _PROMPT_MANAGEMENT_CONTENT_FIELDS) + case "mcp_tool_call_metadata": + return _without_mapping_content_fields(value, _MCP_TOOL_CALL_CONTENT_FIELDS) + case "vector_store_request_metadata": + return _without_vector_store_content_fields(value) + case _: + return value + + +def _without_request_metadata_content(metadata: Mapping[str, object]) -> Mapping[str, object]: + return MappingProxyType( + {key: _request_metadata_value_without_content(key, value) for key, value in metadata.items()} + ) def _team_metadata_dict(value: object) -> Mapping[str, object] | None: 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 e658f24b041..bdc0b47586f 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -815,6 +815,56 @@ def test_langfuse_mapper_emits_request_metadata_and_identity(): assert attrs["langfuse.observation.metadata.provider"] == "openai" +def test_langfuse_mapper_omits_prompt_mcp_and_vector_store_content(): + payload: Final[dict[str, object]] = { + "call_type": "acompletion", + "custom_llm_provider": "openai", + "model": "gpt-4o", + "metadata": { + "prompt_management_metadata": { + "prompt_id": "prompt-1", + "prompt_variables": {"input": "prompt-variables-secret"}, + "prompt_integration": "prompt-service", + }, + "mcp_tool_call_metadata": { + "name": "lookup_weather", + "mcp_server_name": "weather-server", + "arguments": {"location": "mcp-arguments-secret"}, + "result": {"forecast": "mcp-result-secret"}, + }, + "vector_store_request_metadata": [ + { + "vector_store_id": "vs-1", + "custom_llm_provider": "openai", + "query": "vector-query-secret", + "vector_store_search_response": {"text": "vector-response-secret"}, + "start_time": 1.0, + "end_time": 2.0, + } + ], + }, + "response": {}, + } + attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload)) + observation_metadata: Final = attrs["langfuse.observation.metadata"] + serialized: Final = json.dumps(observation_metadata) + parsed: Final = json.loads(observation_metadata) + + assert "prompt-variables-secret" not in serialized + assert "mcp-arguments-secret" not in serialized + assert "mcp-result-secret" not in serialized + assert "vector-query-secret" not in serialized + assert "vector-response-secret" not in serialized + assert parsed["prompt_management_metadata"]["prompt_id"] == "prompt-1" + assert parsed["prompt_management_metadata"]["prompt_integration"] == "prompt-service" + assert parsed["mcp_tool_call_metadata"]["name"] == "lookup_weather" + assert parsed["mcp_tool_call_metadata"]["mcp_server_name"] == "weather-server" + assert parsed["vector_store_request_metadata"][0]["vector_store_id"] == "vs-1" + assert parsed["vector_store_request_metadata"][0]["custom_llm_provider"] == "openai" + assert parsed["vector_store_request_metadata"][0]["start_time"] == 1.0 + assert parsed["vector_store_request_metadata"][0]["end_time"] == 2.0 + + def test_langfuse_mapper_skips_empty_identity_values(): payload: Final[dict[str, object]] = { "call_type": "acompletion", diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index ab0e7669fe5..508d2cf9160 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -549,6 +549,57 @@ class TestLangfuseOtelIntegration: } assert not [key for key in actual if key.startswith("langfuse.trace.metadata.")] + def test_request_metadata_omits_prompt_mcp_and_vector_store_content(self) -> None: + request_metadata: Final = { + "prompt_management_metadata": { + "prompt_id": "prompt-1", + "prompt_variables": {"input": "prompt-variables-secret"}, + "prompt_integration": "prompt-service", + }, + "mcp_tool_call_metadata": { + "name": "lookup_weather", + "mcp_server_name": "weather-server", + "arguments": {"location": "mcp-arguments-secret"}, + "result": {"forecast": "mcp-result-secret"}, + }, + "vector_store_request_metadata": [ + { + "vector_store_id": "vs-1", + "custom_llm_provider": "openai", + "query": "vector-query-secret", + "vector_store_search_response": {"text": "vector-response-secret"}, + "start_time": 1.0, + "end_time": 2.0, + } + ], + } + kwargs: Final[dict[str, object]] = { + "litellm_params": {"metadata": {}}, + "standard_logging_object": {"metadata": request_metadata}, + } + + with patch("litellm.integrations.arize._utils.safe_set_attribute") as mock_safe_set_attribute: + LangfuseOtelLogger._set_langfuse_specific_attributes(MagicMock(), kwargs, None) + + actual: Final = {call.args[1]: call.args[2] for call in mock_safe_set_attribute.call_args_list} + observation_metadata: Final = actual["langfuse.observation.metadata"] + serialized: Final = json.dumps(observation_metadata) + parsed: Final = json.loads(observation_metadata) + + assert "prompt-variables-secret" not in serialized + assert "mcp-arguments-secret" not in serialized + assert "mcp-result-secret" not in serialized + assert "vector-query-secret" not in serialized + assert "vector-response-secret" not in serialized + assert parsed["prompt_management_metadata"]["prompt_id"] == "prompt-1" + assert parsed["prompt_management_metadata"]["prompt_integration"] == "prompt-service" + assert parsed["mcp_tool_call_metadata"]["name"] == "lookup_weather" + assert parsed["mcp_tool_call_metadata"]["mcp_server_name"] == "weather-server" + assert parsed["vector_store_request_metadata"][0]["vector_store_id"] == "vs-1" + assert parsed["vector_store_request_metadata"][0]["custom_llm_provider"] == "openai" + assert parsed["vector_store_request_metadata"][0]["start_time"] == 1.0 + assert parsed["vector_store_request_metadata"][0]["end_time"] == 2.0 + def test_request_metadata_drops_null_fields_and_skips_empty_trace_identities(self) -> None: request_metadata: Final = { "user_api_key_alias": "prod-key",