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 <class 'NoneType'>" while encoding and BatchLogRecordProcessor
drops the whole batch, silently losing the content events.

Recursively drop None from the attributes and body before emitting.
This commit is contained in:
abrekhov 2026-07-12 07:44:51 +00:00
parent 10d5804b3e
commit 468e33ace7
2 changed files with 74 additions and 4 deletions

View file

@ -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 <class 'NoneType'>`` 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)

View file

@ -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 <class 'NoneType'>`` 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))