diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 5cd201a2f47..358ddb16319 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1535,6 +1535,18 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): if not self._capture_in_span(): return + # Per-request message redaction must apply to the raw payload span too: + # set_raw_request_attributes dumps llm..messages / choices + # verbatim, so without this guard the very content redaction is meant + # to keep out of traces still leaks through the raw span. This uses the + # same precedence as the main span (dynamic param > redaction headers > + # global turn_off_message_logging). + from litellm.litellm_core_utils.redact_messages import ( + should_redact_message_logging, # pyright: ignore[reportUnknownVariableType] # callee is untyped upstream + ) + + if should_redact_message_logging(kwargs): # pyright: ignore[reportUnknownArgumentType] # untyped by design + 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 c4fd7ed51c7..0682cca7d11 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -7793,3 +7793,55 @@ class TestOpentelemetryUnitTests: # Assert: error.message should be set from error_str using ErrorAttributes constant mock_span.set_attribute.assert_called_with(ErrorAttributes.ERROR_MESSAGE, "Fallback error message") + +class TestOpenTelemetryRawSpanRespectsRedaction(unittest.TestCase): + """#44504: per-request message redaction must also skip the raw payload span. + + ``set_raw_request_attributes`` dumps ``llm..messages`` / ``choices`` + verbatim from the un-redacted ``complete_input_dict`` and ``original_response``, + so without this guard a request that asked for redaction (the + ``x-litellm-enable-message-redaction`` header, a dynamic param, or the global + ``turn_off_message_logging`` switch) still leaks its content through the + ``raw_gen_ai_request`` span. + """ + + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) + def test_skips_raw_span_when_redaction_header_set(self): # test-quality-ok: negative-path test; the observable behavior is precisely that no raw span is created, which is only assertable on the tracer mock + """Redaction requested via the per-request header -> raw span is not created.""" + from litellm.integrations.opentelemetry import OpenTelemetry + + otel = OpenTelemetry() + otel.message_logging = True + + mock_tracer = MagicMock() + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer) + otel.set_raw_request_attributes = MagicMock() + otel._to_ns = MagicMock(return_value=1234567890) + + kwargs = {"litellm_params": {"metadata": {"headers": {"x-litellm-enable-message-redaction": "true"}}}} + otel._maybe_log_raw_request(kwargs, {}, datetime.now(), datetime.now(), MagicMock()) + mock_tracer.start_span.assert_not_called() + otel.set_raw_request_attributes.assert_not_called() + + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) + def test_creates_raw_span_when_no_redaction_requested(self): # test-quality-ok: positive-path twin of the test above; asserts the span name and raw-attribute hook via the tracer mock, matching the existing file style + """No redaction requested (empty headers dict) -> raw span is still created.""" + from litellm.integrations.opentelemetry import RAW_REQUEST_SPAN_NAME, OpenTelemetry + + otel = OpenTelemetry() + otel.message_logging = True + + mock_tracer = MagicMock() + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer) + otel.set_raw_request_attributes = MagicMock() + otel._to_ns = MagicMock(return_value=1234567890) + + kwargs = {"litellm_params": {"metadata": {"headers": {}}}} + otel._maybe_log_raw_request(kwargs, {}, datetime.now(), datetime.now(), MagicMock()) + mock_tracer.start_span.assert_called_once() + self.assertEqual(mock_tracer.start_span.call_args[1]["name"], RAW_REQUEST_SPAN_NAME) + otel.set_raw_request_attributes.assert_called_once()