From 468e33ace73cea0a031bf2dc8187626358e7149b Mon Sep 17 00:00:00 2001 From: abrekhov Date: Sun, 12 Jul 2026 07:44:51 +0000 Subject: [PATCH] fix(otel): drop None from semantic log events to prevent OTLP export drops _emit_semantic_logs emits gen_ai.content.prompt/completion log records whose attributes and body can contain None (finish_reason, gen_ai.system for an unresolved provider, and raw message fields carried by msg.copy() such as content/tool_calls). OTLP AnyValue cannot encode None, so the exporter raises "Invalid type " while encoding and BatchLogRecordProcessor drops the whole batch, silently losing the content events. Recursively drop None from the attributes and body before emitting. --- litellm/integrations/opentelemetry.py | 34 ++++++++++++-- .../integrations/test_opentelemetry.py | 44 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index de543fa042b..797159aa604 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1618,6 +1618,32 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ) return LogRecord, SeverityNumber + @staticmethod + def _drop_none_values(value): + """Recursively remove ``None`` from a log record's attributes/body. + + OTLP ``AnyValue`` has no representation for ``None``. A single ``None`` + anywhere in an emitted log record makes the OTLP exporter raise + ``Invalid type `` while encoding and drop the whole + batch, so the content events are silently lost. Sources of ``None`` + here include ``choice.finish_reason``, ``gen_ai.system`` (unresolved + provider) and raw message fields carried by ``msg.copy()`` such as + ``content`` or ``tool_calls``. + """ + if isinstance(value, dict): + return { + key: OpenTelemetry._drop_none_values(val) + for key, val in value.items() + if val is not None + } + if isinstance(value, (list, tuple)): + return [ + OpenTelemetry._drop_none_values(item) + for item in value + if item is not None + ] + return value + def _emit_semantic_logs(self, kwargs, response_obj, span: Span): if not self.config.enable_events: return @@ -1675,8 +1701,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=body, - attributes=attrs, + body=self._drop_none_values(body), + attributes=self._drop_none_values(attrs), ) otel_logger.emit(log_record) @@ -1707,8 +1733,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=body, - attributes=attrs, + body=self._drop_none_values(body), + attributes=self._drop_none_values(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..0bbd982c150 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -5852,3 +5852,47 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase): exporter="console", attributes=attributes ) ) + + +class TestOpenTelemetrySemanticLogsNoneSafety(unittest.TestCase): + def test_drop_none_values_recursively_removes_none(self): + payload = { + "gen_ai.system": None, + "index": 0, + "finish_reason": None, + "message": {"role": "assistant", "content": None}, + "tool_calls": [None, {"id": "a", "name": None}], + } + self.assertEqual( + OpenTelemetry._drop_none_values(payload), + { + "index": 0, + "message": {"role": "assistant"}, + "tool_calls": [{"id": "a"}], + }, + ) + + def test_sanitized_payload_encodes_without_nonetype_error(self): + """A ``None`` anywhere in a semantic-log record makes the OTLP exporter + raise ``Invalid type `` and drop the whole batch. + ``_drop_none_values`` removes them so the same payload encodes cleanly. + """ + from opentelemetry.exporter.otlp.proto.common._internal import ( + _encode_value, + ) + + attrs = { + "event_name": "gen_ai.content.completion", + "gen_ai.system": None, + "index": 0, + "finish_reason": None, + } + body = {"role": "assistant", "content": None, "tool_calls": None} + + with self.assertRaises(Exception): + _encode_value(attrs) + with self.assertRaises(Exception): + _encode_value(body) + + _encode_value(OpenTelemetry._drop_none_values(attrs)) + _encode_value(OpenTelemetry._drop_none_values(body))