fix(otel): prune None from enable_events content logs so OTLP export doesn't drop the batch

This commit is contained in:
Devin AI 2026-07-12 08:17:35 +00:00
parent 3e9e52042a
commit 09a192c092
2 changed files with 138 additions and 5 deletions

View file

@ -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)

View file

@ -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)."""