From 3cc4091730b95e5625bb042fb0af672ff8ba4df1 Mon Sep 17 00:00:00 2001 From: 72004 Date: Sat, 10 Oct 2026 18:59:14 +0500 Subject: [PATCH 1/3] fix(otel): honor redact_user_api_key_info for span metadata and team attributes When litellm.redact_user_api_key_info is True, OTEL spans still emitted metadata.user_api_key_* attributes (hash, user id, email, team id) and stamped team_id/team_alias onto every child span. Filter span metadata through redact_user_api_key_info and skip team attributes when the flag is set. Fixes #36758 --- litellm/integrations/opentelemetry.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90383ddb60d..be4a6742f05 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1516,6 +1516,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): in ``standard_logging_object.metadata``; propagating that to every span only adds noise that makes traces look mis-instrumented. """ + if litellm.redact_user_api_key_info is True: + return if team_id: self.safe_set_attribute( span=span, @@ -2411,7 +2413,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ############################################# ############ LLM CALL METADATA ############## ############################################# - metadata: Final = standard_logging_payload["metadata"] + from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info + + metadata: Final = redact_user_api_key_info(metadata=dict(standard_logging_payload["metadata"])) for key, value in metadata.items(): self.safe_set_attribute(span=span, key=f"metadata.{key}", value=value) decision: Final = metadata.get("routing_decision") From 0c55922364c199737f976b592aebbc813286aaa1 Mon Sep 17 00:00:00 2001 From: 72004 Date: Sat, 10 Oct 2026 18:59:15 +0500 Subject: [PATCH 2/3] test(otel): cover user_api_key_info redaction on spans Fail without the fix (metadata.user_api_key_* and team attributes leak), with flag-off controls proving metadata is untouched when redaction is disabled. --- .../test_opentelemetry_redaction.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/unit/integrations/test_opentelemetry_redaction.py diff --git a/tests/unit/integrations/test_opentelemetry_redaction.py b/tests/unit/integrations/test_opentelemetry_redaction.py new file mode 100644 index 00000000000..e307a06b100 --- /dev/null +++ b/tests/unit/integrations/test_opentelemetry_redaction.py @@ -0,0 +1,63 @@ +"""Regression tests for ``redact_user_api_key_info`` in OTEL spans (#36758).""" + +from unittest.mock import MagicMock + +import pytest + +import litellm +from litellm.integrations.opentelemetry import OpenTelemetry + +_USER_API_KEY_METADATA = { + "user_api_key_hash": "hashed-secret", + "user_api_key_user_id": "uid-123", + "user_api_key_user_email": "user@example.com", + "user_api_key_team_id": "team-456", +} + + +def _metadata_attribute_keys(redact: bool, monkeypatch: pytest.MonkeyPatch) -> list[str]: + monkeypatch.setattr(litellm, "redact_user_api_key_info", redact) + otel_logger = OpenTelemetry() + otel_logger.safe_set_attribute = MagicMock() + otel_logger._set_inference_identity_attributes = MagicMock() + otel_logger._set_service_tier_attributes = MagicMock() + otel_logger._capture_in_span = MagicMock(return_value=False) + + otel_logger.set_attributes( + span=MagicMock(), + kwargs={ + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "call_type": "completion", + "metadata": {**_USER_API_KEY_METADATA, "generation_name": "test-gen"}, + }, + }, + response_obj=None, + ) + return [call.kwargs["key"] for call in otel_logger.safe_set_attribute.call_args_list] + + +def test_set_attributes_drops_user_api_key_metadata_when_redaction_enabled(monkeypatch): + attribute_keys = _metadata_attribute_keys(redact=True, monkeypatch=monkeypatch) + + assert "metadata.generation_name" in attribute_keys + for key in _USER_API_KEY_METADATA: + assert f"metadata.{key}" not in attribute_keys + + +def test_set_attributes_keeps_user_api_key_metadata_when_redaction_disabled(monkeypatch): + attribute_keys = _metadata_attribute_keys(redact=False, monkeypatch=monkeypatch) + + for key in _USER_API_KEY_METADATA: + assert f"metadata.{key}" in attribute_keys + + +@pytest.mark.parametrize("redact, expected_calls", [(True, 0), (False, 2)]) +def test_team_attributes_on_child_spans_respect_redaction(monkeypatch, redact, expected_calls): + monkeypatch.setattr(litellm, "redact_user_api_key_info", redact) + otel_logger = OpenTelemetry() + otel_logger.safe_set_attribute = MagicMock() + + otel_logger._set_team_attributes_on_span(span=MagicMock(), team_id="team-456", team_alias="team-alias") + + assert otel_logger.safe_set_attribute.call_count == expected_calls From 47cf62db9e9b8a69c88cd1fe9169f54c766f31e5 Mon Sep 17 00:00:00 2001 From: 72004 Date: Sun, 11 Oct 2026 18:52:46 +0500 Subject: [PATCH 3/3] fix(otel): filter redacted metadata keys without widening span metadata types Drop user_api_key_* keys while iterating the typed StandardLoggingMetadata instead of round-tripping through the untyped redact helper, so the type gates see no new unknown types. --- litellm/integrations/opentelemetry.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index be4a6742f05..89e39b02be1 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -49,6 +49,7 @@ from litellm.types.utils import ( Function, LLMResponseTypes, StandardCallbackDynamicParams, + StandardLoggingMetadata, StandardLoggingPayload, ) @@ -103,6 +104,13 @@ class _ResponseWithUsageView(TypedDict, total=False): _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) +# Metadata keys dropped from spans when `litellm.redact_user_api_key_info` is set. +_USER_API_KEY_METADATA_KEYS: Final = frozenset( + key + for key in StandardLoggingMetadata.__required_keys__ | StandardLoggingMetadata.__optional_keys__ + if key.startswith("user_api_key") +) + # Cap on credential-scoped providers held at once; each one owns an exporter thread. _MAX_DYNAMIC_TRACER_PROVIDERS: Final = 256 @@ -2413,10 +2421,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ############################################# ############ LLM CALL METADATA ############## ############################################# - from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info - - metadata: Final = redact_user_api_key_info(metadata=dict(standard_logging_payload["metadata"])) + metadata: Final = standard_logging_payload["metadata"] + redact_user_api_key_info: Final = litellm.redact_user_api_key_info is True for key, value in metadata.items(): + if redact_user_api_key_info and key in _USER_API_KEY_METADATA_KEYS: + continue self.safe_set_attribute(span=span, key=f"metadata.{key}", value=value) decision: Final = metadata.get("routing_decision") span.set_attributes(routing_decision_attributes(decision))