mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(otel): prune None from enable_events content logs so OTLP export doesn't drop the batch
This commit is contained in:
parent
3e9e52042a
commit
09a192c092
2 changed files with 138 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue