From 581ae1443d3e80af03030641fe7674e8e12d6c65 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Thu, 7 May 2026 09:44:39 -0700 Subject: [PATCH 1/2] honor OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT --- litellm/integrations/opentelemetry.py | 91 ++++++++++++-- .../integrations/test_opentelemetry.py | 111 ++++++++++++++++++ 2 files changed, 193 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 77833e5de0..e49f0fabda 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -57,6 +57,17 @@ LITELLM_PROXY_REQUEST_SPAN_NAME = "Received Proxy Server Request" RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request" LITELLM_REQUEST_SPAN_NAME = "litellm_request" +CAPTURE_MODE_NO_CONTENT = "NO_CONTENT" +CAPTURE_MODE_SPAN_ONLY = "SPAN_ONLY" +CAPTURE_MODE_EVENT_ONLY = "EVENT_ONLY" +CAPTURE_MODE_SPAN_AND_EVENT = "SPAN_AND_EVENT" +_VALID_CAPTURE_MODES = { + CAPTURE_MODE_NO_CONTENT, + CAPTURE_MODE_SPAN_ONLY, + CAPTURE_MODE_EVENT_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, +} + @dataclass class OpenTelemetryConfig: @@ -71,6 +82,9 @@ class OpenTelemetryConfig: ignore_context_propagation: Optional[bool] = None # When True, create a private TracerProvider instead of reusing or setting the global one. skip_set_global: bool = False + # Programmatic override for OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. + # One of NO_CONTENT, SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT (or "true" as legacy alias). + capture_message_content: Optional[str] = None def __post_init__(self) -> None: # If endpoint is specified but exporter is still the default "console", @@ -182,6 +196,9 @@ class OpenTelemetry(CustomLogger): super().__init__(**kwargs) self._init_metrics(meter_provider) self._init_logs(logger_provider) + # Sample env-var / config / message_logging at init so subsequent + # _capture_in_span / _capture_in_event calls are deterministic. + self._capture_mode_cached = self._compute_capture_mode_from_init_state() self._init_otel_logger_on_litellm_proxy() @staticmethod @@ -306,6 +323,59 @@ class OpenTelemetry(CustomLogger): hasattr(self, "callback_name") and self.callback_name == "langfuse_otel" ) + def _compute_capture_mode_from_init_state(self) -> Optional[str]: + """Sample explicit settings at init. Returns the resolved mode or + None if nothing explicit is set (in which case the legacy + ``self.message_logging`` flag is consulted dynamically per request). + + ``"true"``/``"1"`` map to ``EVENT_ONLY`` per the contrib convention. + Unknown values are ignored. + """ + explicit = self.config.capture_message_content or os.getenv( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + ) + if not explicit: + return None + normalized = explicit.upper() + if normalized in ("TRUE", "1"): + return CAPTURE_MODE_EVENT_ONLY + if normalized in _VALID_CAPTURE_MODES: + return normalized + return None + + def _resolve_capture_mode(self) -> str: + """Return the active capture mode for this request. + + Precedence: + 1. ``litellm.turn_off_message_logging=True`` forces ``NO_CONTENT`` + (kill-switch checked dynamically). + 2. Explicit setting sampled at init from + ``OpenTelemetryConfig.capture_message_content`` or + ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``. + 3. Legacy ``self.message_logging`` (checked dynamically). + """ + if litellm.turn_off_message_logging: + return CAPTURE_MODE_NO_CONTENT + if self._capture_mode_cached is not None: + return self._capture_mode_cached + return ( + CAPTURE_MODE_SPAN_AND_EVENT + if self.message_logging + else CAPTURE_MODE_NO_CONTENT + ) + + def _capture_in_span(self) -> bool: + return self._resolve_capture_mode() in ( + CAPTURE_MODE_SPAN_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, + ) + + def _capture_in_event(self) -> bool: + return self._resolve_capture_mode() in ( + CAPTURE_MODE_EVENT_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, + ) + def _init_tracing(self, tracer_provider): from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -825,8 +895,7 @@ class OpenTelemetry(CustomLogger): from opentelemetry import trace from opentelemetry.trace import Status, StatusCode - # only log raw LLM request/response if message_logging is on and not globally turned off - if litellm.turn_off_message_logging or not self.message_logging: + if not self._capture_in_span(): return litellm_params = kwargs.get("litellm_params", {}) @@ -1117,9 +1186,14 @@ class OpenTelemetry(CustomLogger): } if role == "tool" and msg.get("id"): attrs["id"] = msg["id"] - if self.message_logging and msg.get("content"): + capture_event_content = self._capture_in_event() + if capture_event_content and msg.get("content"): attrs["gen_ai.prompt"] = msg["content"] + body = msg.copy() + if not capture_event_content: + body.pop("content", None) + log_record = SdkLogRecord( timestamp=self._to_ns(datetime.now()), trace_id=parent_ctx.trace_id, @@ -1127,7 +1201,7 @@ class OpenTelemetry(CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=msg.copy(), + body=body, attributes=attrs, ) otel_logger.emit(log_record) @@ -1141,14 +1215,15 @@ class OpenTelemetry(CustomLogger): "finish_reason": choice.get("finish_reason"), } body_msg = choice.get("message", {}) - if self.message_logging and body_msg.get("content"): + capture_event_content = self._capture_in_event() + if capture_event_content and body_msg.get("content"): attrs["message.content"] = body_msg["content"] body = { "index": idx, "finish_reason": choice.get("finish_reason"), "message": {"role": body_msg.get("role", "assistant")}, } - if self.message_logging and body_msg.get("content"): + if capture_event_content and body_msg.get("content"): body["message"]["content"] = body_msg["content"] log_record = SdkLogRecord( @@ -1674,9 +1749,7 @@ class OpenTelemetry(CustomLogger): ########## LLM Request Medssages / tools / content Attributes ########### ######################################################################### - if litellm.turn_off_message_logging is True: - return - if self.message_logging is not True: + if not self._capture_in_span(): return if optional_params.get("tools"): diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 962806c5b5..97e57240a9 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -442,6 +442,103 @@ class TestOpenTelemetryDualHandlerIsolation(unittest.TestCase): ) +class TestOpenTelemetryCaptureMessageContent(unittest.TestCase): + """OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT and the + OpenTelemetryConfig.capture_message_content programmatic override + drive what the handler captures in spans vs events.""" + + @staticmethod + def _make(env=None, config_value=None, message_logging=True): + env_dict = ( + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": env} + if env is not None + else {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""} + ) + with patch.dict(os.environ, env_dict): + handler = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content=config_value + ) + ) + handler.message_logging = message_logging + return handler, handler._resolve_capture_mode() + + def test_no_explicit_setting_falls_back_to_message_logging_true(self): + _, mode = self._make() + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_no_explicit_setting_falls_back_to_message_logging_false(self): + _, mode = self._make(message_logging=False) + self.assertEqual(mode, "NO_CONTENT") + + def test_env_var_no_content(self): + _, mode = self._make(env="NO_CONTENT") + self.assertEqual(mode, "NO_CONTENT") + + def test_env_var_span_only(self): + _, mode = self._make(env="SPAN_ONLY") + self.assertEqual(mode, "SPAN_ONLY") + + def test_env_var_event_only(self): + _, mode = self._make(env="EVENT_ONLY") + self.assertEqual(mode, "EVENT_ONLY") + + def test_env_var_span_and_event(self): + _, mode = self._make(env="SPAN_AND_EVENT") + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_env_var_legacy_true_maps_to_event_only(self): + _, mode = self._make(env="true") + self.assertEqual(mode, "EVENT_ONLY") + + def test_env_var_unknown_value_falls_through_to_legacy(self): + _, mode = self._make(env="garbage", message_logging=True) + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_config_field_overrides_env(self): + _, mode = self._make(env="EVENT_ONLY", config_value="SPAN_ONLY") + self.assertEqual(mode, "SPAN_ONLY") + + def test_turn_off_message_logging_forces_no_content(self): + with patch("litellm.turn_off_message_logging", True): + _, mode = self._make(env="SPAN_AND_EVENT", message_logging=True) + self.assertEqual(mode, "NO_CONTENT") + + def test_capture_in_span_and_event_predicates(self): + cases = { + "NO_CONTENT": (False, False), + "SPAN_ONLY": (True, False), + "EVENT_ONLY": (False, True), + "SPAN_AND_EVENT": (True, True), + } + for mode, (in_span, in_event) in cases.items(): + handler, _ = self._make(env=mode) + self.assertEqual(handler._capture_in_span(), in_span, msg=mode) + self.assertEqual(handler._capture_in_event(), in_event, msg=mode) + + def test_two_handlers_can_have_different_modes(self): + # FIL's stated requirement: one handler strips content, the other keeps it. + with patch.dict( + os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""} + ): + stripped = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content="NO_CONTENT" + ) + ) + kept = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content="SPAN_AND_EVENT" + ) + ) + self.assertEqual(stripped._resolve_capture_mode(), "NO_CONTENT") + self.assertEqual(kept._resolve_capture_mode(), "SPAN_AND_EVENT") + self.assertFalse(stripped._capture_in_span()) + self.assertFalse(stripped._capture_in_event()) + self.assertTrue(kept._capture_in_span()) + self.assertTrue(kept._capture_in_event()) + + class TestOpenTelemetry(unittest.TestCase): POLL_INTERVAL = 0.05 POLL_TIMEOUT = 2.0 @@ -1067,6 +1164,7 @@ class TestOpenTelemetry(unittest.TestCase): result = otel._get_span_name(kwargs) self.assertEqual(result, LITELLM_REQUEST_SPAN_NAME) + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) @patch("litellm.turn_off_message_logging", False) def test_maybe_log_raw_request_creates_span(self): """Test _maybe_log_raw_request creates span when logging enabled""" @@ -2194,6 +2292,19 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): See: https://github.com/BerriAI/litellm/issues/17794 """ + def setUp(self): + # Insulate from a shell-set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + # so these tests exercise the legacy default path (message_logging=True). + self._prev = os.environ.pop( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", None + ) + + def tearDown(self): + if self._prev is not None: + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + self._prev + ) + def test_input_messages_uses_parts_structure(self): """ Test that gen_ai.input.messages uses the OTEL 1.38 parts array structure. From 492118de2515a2e2b327013c7004080e3d36d87d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 May 2026 18:12:23 +0000 Subject: [PATCH 2/2] Fix OTEL false content capture mode --- litellm/integrations/opentelemetry.py | 3 +++ tests/test_litellm/integrations/test_opentelemetry.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index e49f0fabda..0af016feb2 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -329,6 +329,7 @@ class OpenTelemetry(CustomLogger): ``self.message_logging`` flag is consulted dynamically per request). ``"true"``/``"1"`` map to ``EVENT_ONLY`` per the contrib convention. + ``"false"``/``"0"`` map to ``NO_CONTENT``. Unknown values are ignored. """ explicit = self.config.capture_message_content or os.getenv( @@ -339,6 +340,8 @@ class OpenTelemetry(CustomLogger): normalized = explicit.upper() if normalized in ("TRUE", "1"): return CAPTURE_MODE_EVENT_ONLY + if normalized in ("FALSE", "0"): + return CAPTURE_MODE_NO_CONTENT if normalized in _VALID_CAPTURE_MODES: return normalized return None diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 97e57240a9..bea718dfea 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -491,6 +491,12 @@ class TestOpenTelemetryCaptureMessageContent(unittest.TestCase): _, mode = self._make(env="true") self.assertEqual(mode, "EVENT_ONLY") + def test_env_var_legacy_false_maps_to_no_content(self): + for env in ("false", "0"): + with self.subTest(env=env): + _, mode = self._make(env=env) + self.assertEqual(mode, "NO_CONTENT") + def test_env_var_unknown_value_falls_through_to_legacy(self): _, mode = self._make(env="garbage", message_logging=True) self.assertEqual(mode, "SPAN_AND_EVENT")