diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index de543fa042b..1f5b9dfa2fe 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: from opentelemetry.trace import Context as _Context from opentelemetry.trace import Span as _Span from opentelemetry.trace import Tracer as _Tracer + from opentelemetry.util.types import AnyValue from litellm.proxy._types import ( ManagementEndpointLoggingPayload as _ManagementEndpointLoggingPayload, @@ -111,6 +112,23 @@ METRIC_METADATA_KEYS: Tuple[str, ...] = ( "vector_store_request_metadata", ) + +def _prune_none(value: "AnyValue") -> "AnyValue": + """Recursively drop ``None`` entries from dicts and lists. + + The OTLP exporter encodes a log record's body as ``AnyValue``, which has no + representation for ``None`` and raises ``Invalid type NoneType`` on it. That + aborts the export and makes ``BatchLogRecordProcessor`` drop the whole batch, + so a content event carrying a ``None`` (e.g. a ``content=None`` assistant + message) is silently lost. Pruning ``None`` first keeps it (Issue #32996). + """ + if isinstance(value, dict): + return {key: _prune_none(val) for key, val in value.items() if val is not None} + if isinstance(value, list): + return [_prune_none(item) for item in value if item is not None] + return value + + TOKEN_TYPE_ATTRIBUTE: str = "gen_ai.token.type" VALID_METRIC_ATTRIBUTE_NAMES: FrozenSet[str] = frozenset( @@ -1639,7 +1657,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): otel_logger = self._logger_provider.get_logger(LITELLM_LOGGER_NAME) parent_ctx = span.get_span_context() - provider = (kwargs.get("litellm_params") or {}).get("custom_llm_provider", "Unknown") + provider = (kwargs.get("litellm_params") or {}).get("custom_llm_provider") or "Unknown" if self._gen_ai_semconv_latest_experimental: self._emit_inference_details_event( @@ -1675,7 +1693,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=body, + body=_prune_none(body), attributes=attrs, ) otel_logger.emit(log_record) @@ -1686,17 +1704,20 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): "event_name": "gen_ai.content.completion", "gen_ai.system": provider, "index": idx, - "finish_reason": choice.get("finish_reason"), } + finish_reason = choice.get("finish_reason") + if finish_reason is not None: + attrs["finish_reason"] = finish_reason body_msg = choice.get("message", {}) 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 finish_reason is not None: + body["finish_reason"] = finish_reason if capture_event_content and body_msg.get("content"): body["message"]["content"] = body_msg["content"] @@ -1707,7 +1728,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=body, + body=_prune_none(body), attributes=attrs, ) otel_logger.emit(log_record) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 7ffd09b931f..fd9bf103967 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -755,6 +755,118 @@ class TestOpenTelemetryCaptureMessageContent(unittest.TestCase): self.assertTrue(kept._capture_in_event()) +def _contains_none(value): + """True if ``value`` is ``None`` or nests a ``None`` anywhere.""" + if value is None: + return True + if isinstance(value, dict): + return any(_contains_none(v) for v in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_none(v) for v in value) + return False + + +class TestOpenTelemetryContentEventsNonePruning(unittest.TestCase): + """Regression for #32996: enable_events content logs carrying a ``None`` + (unresolved provider/finish_reason, or a ``content=None`` message) must not + reach the OTLP exporter with a ``None``, which raises ``Invalid type + NoneType`` and makes BatchLogRecordProcessor drop the whole batch.""" + + @staticmethod + def _emit(kwargs, response_obj): + from opentelemetry import _logs + from opentelemetry._logs._internal import ProxyLoggerProvider + + log_exporter = InMemoryLogExporter() + with ( + patch.object( + _logs, "get_logger_provider", return_value=ProxyLoggerProvider() + ), + patch.object(_logs, "set_logger_provider"), + patch.object( + OpenTelemetry, "_get_log_exporter", return_value=log_exporter + ), + ): + h = OpenTelemetry( + config=OpenTelemetryConfig(exporter="console", enable_events=True) + ) + h.message_logging = True + span = h.tracer.start_span("test") + h._emit_semantic_logs(kwargs, response_obj, span) + span.end() + h._logger_provider.force_flush(2000) + return log_exporter.get_finished_logs() + + def test_none_laden_events_encode_and_carry_no_none(self): + from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs + + kwargs = { + "model": "gpt-4", + "call_type": "acompletion", + "messages": [ + {"role": "assistant", "content": None, "tool_calls": None}, + {"role": "user", "content": "hi"}, + ], + "litellm_params": {"custom_llm_provider": None}, + } + response_obj = { + "choices": [ + { + "message": {"role": "assistant", "content": None}, + "finish_reason": None, + } + ] + } + + logs = self._emit(kwargs, response_obj) + self.assertEqual(len(logs), 3) + + encode_logs(logs) + + for data in logs: + record = data.log_record + for key, value in dict(record.attributes or {}).items(): + self.assertIsNotNone(value, msg=f"attr {key} is None") + self.assertFalse( + _contains_none(record.body), msg=f"body has None: {record.body}" + ) + + def test_unresolved_provider_falls_back_to_unknown(self): + kwargs = { + "messages": [{"role": "user", "content": "hi"}], + "litellm_params": {"custom_llm_provider": None}, + } + response_obj = {"choices": []} + logs = self._emit(kwargs, response_obj) + self.assertEqual(len(logs), 1) + attrs = dict(logs[0].log_record.attributes or {}) + self.assertEqual(attrs["gen_ai.system"], "Unknown") + + def test_present_finish_reason_survives_pruning(self): + kwargs = { + "messages": [{"role": "user", "content": "hi"}], + "litellm_params": {"custom_llm_provider": "openai"}, + } + response_obj = { + "choices": [ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + } + ] + } + logs = self._emit(kwargs, response_obj) + completion = next( + data.log_record + for data in logs + if dict(data.log_record.attributes or {}).get("event_name") + == "gen_ai.content.completion" + ) + attrs = dict(completion.attributes or {}) + self.assertEqual(attrs["finish_reason"], "stop") + self.assertEqual(completion.body["finish_reason"], "stop") + + class TestOpenTelemetrySemconvStability(unittest.TestCase): """OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental opts into semconv-conformant span shape (name, kind, no raw_gen_ai_request child)."""