From cb4a355863e127348d29ef452a61fa5202900b21 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Sun, 4 Oct 2026 21:11:32 +0300 Subject: [PATCH 1/2] fix(otel): skip raw_gen_ai_request span when per-request message redaction applies --- litellm/integrations/opentelemetry.py | 4 ++++ tests/unit/integrations/test_opentelemetry.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8d588896b2f..ad222bbc268 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -29,6 +29,7 @@ from litellm.integrations.otel.model.metadata import flatten_metadata from litellm.integrations.otel.model.semconv import LiteLLM, Metric from litellm.integrations.otel.plumbing.otlp_tls import resolve_otlp_http_tls from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params +from litellm.litellm_core_utils.redact_messages import should_redact_message_logging from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.secret_redaction import redact_string from litellm.litellm_core_utils.service_tier_utils import ( @@ -1471,6 +1472,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): if not self._capture_in_span(): return + if should_redact_message_logging(kwargs): + return + litellm_params: Final = kwargs.get("litellm_params", {}) metadata: Final = litellm_params.get("metadata") or {} generation_name: Final = metadata.get("generation_name") diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 175bd95c263..66c4c20c37d 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -1754,6 +1754,25 @@ class TestOpenTelemetry(unittest.TestCase): mock_tracer.start_span.assert_not_called() + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) + @patch("litellm.turn_off_message_logging", False) + def test_maybe_log_raw_request_skips_when_request_header_enables_redaction(self): + otel = OpenTelemetry() + otel.message_logging = True + mock_tracer = MagicMock() + otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer) + + for header in ( + "x-litellm-enable-message-redaction", + "litellm-enable-message-redaction", + ): + kwargs = {"litellm_params": {"metadata": {"headers": {header: "true"}}}} + otel._maybe_log_raw_request( + kwargs, {}, datetime.now(), datetime.now(), MagicMock() + ) + + mock_tracer.start_span.assert_not_called() + class TestOpenTelemetryToNs(unittest.TestCase): """``_to_ns`` converts a span boundary to epoch nanoseconds. Service spans now From ccf02ad73e2b8b3ff760fb4103f57610ff449304 Mon Sep 17 00:00:00 2001 From: Tom Ron Date: Sun, 4 Oct 2026 21:57:23 +0300 Subject: [PATCH 2/2] test(otel): assert exported raw spans instead of mock calls --- tests/unit/integrations/test_opentelemetry.py | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 66c4c20c37d..bcddcf20048 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -1756,22 +1756,41 @@ class TestOpenTelemetry(unittest.TestCase): @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) @patch("litellm.turn_off_message_logging", False) - def test_maybe_log_raw_request_skips_when_request_header_enables_redaction(self): + def test_maybe_log_raw_request_exports_span_only_without_redaction_header(self): + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + from litellm.integrations.opentelemetry import RAW_REQUEST_SPAN_NAME + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer("test") otel = OpenTelemetry() otel.message_logging = True - mock_tracer = MagicMock() - otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer) + otel.get_tracer_to_use_for_request = MagicMock(return_value=tracer) + def exported_raw_spans(headers): + exporter.clear() + kwargs = {"litellm_params": {"metadata": {"headers": headers}}} + otel._maybe_log_raw_request( + kwargs, {}, datetime.now(), datetime.now(), tracer.start_span("parent") + ) + return [ + span + for span in exporter.get_finished_spans() + if span.name == RAW_REQUEST_SPAN_NAME + ] + + self.assertEqual(len(exported_raw_spans({})), 1) for header in ( "x-litellm-enable-message-redaction", "litellm-enable-message-redaction", ): - kwargs = {"litellm_params": {"metadata": {"headers": {header: "true"}}}} - otel._maybe_log_raw_request( - kwargs, {}, datetime.now(), datetime.now(), MagicMock() - ) - - mock_tracer.start_span.assert_not_called() + self.assertEqual(exported_raw_spans({header: "true"}), []) class TestOpenTelemetryToNs(unittest.TestCase):