diff --git a/litellm/integrations/otel/plumbing/events.py b/litellm/integrations/otel/plumbing/events.py index e7b8e22ddcd..bc89650d40d 100644 --- a/litellm/integrations/otel/plumbing/events.py +++ b/litellm/integrations/otel/plumbing/events.py @@ -37,6 +37,18 @@ class GenAIEventRecorder: self.event_logger.emit( Event( name=GenAIEvent.OPERATION_EXCEPTION, + # The body MUST be set. ``Event.body`` defaults to ``None``, and + # OTLP's ``AnyValue`` has no representation for ``None``: the + # exporter's ``_encode_value`` raises ``Invalid type ``, and ``BatchLogRecordProcessor._export_batch`` + # swallows that and discards the WHOLE batch — so one such event + # silently destroys every log record batched with it. The + # encoder grew a ``None`` branch in opentelemetry-exporter-otlp- + # proto-common 1.43.0, but litellm pins 1.28.0, so this event is + # unexportable as shipped. The message is the natural body for a + # WARN record and adds no data the attributes don't already + # carry. + body=message, timestamp=timestamp_ns, trace_id=span_context.trace_id, span_id=span_context.span_id, diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index d856d6871a3..5b1defd87fc 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -940,6 +940,57 @@ def test_operation_exception_log_event_always_carries_required_pair(): assert ExceptionEvent.STACKTRACE not in attributes +def test_operation_exception_log_event_is_otlp_encodable(): + """The event must survive the real OTLP encoder, not just an in-memory exporter. + + ``Event.body`` defaults to ``None`` and OTLP's ``AnyValue`` cannot represent + it, so an event emitted without a body raises ``Invalid type `` inside ``encode_logs``. That happens on the exporter's batch + thread, where ``BatchLogRecordProcessor._export_batch`` catches it, logs it + and drops the WHOLE batch — every record batched alongside is lost silently. + Every other test here uses ``InMemoryLogExporter``, which never encodes, so + only an explicit encode step can catch this. + """ + encode_logs = pytest.importorskip( + "opentelemetry.exporter.otlp.proto.common._internal._log_encoder" + ).encode_logs + + engine, _, log_exporter = _engine_with_event_recorder() + engine.emit( + SpanRole.LLM_CALL, + _llm_call_data(SpanError(error_type="RateLimitError", message="rate limited")), + ) + logs = log_exporter.get_finished_logs() + + # Raises "Invalid type of value None" when body is unset. + request = encode_logs(logs) + assert request.SerializeToString() + + (resource_logs,) = request.resource_logs + (scope_logs,) = resource_logs.scope_logs + (log_record,) = scope_logs.log_records + assert log_record.body.string_value == "rate limited" + + +def test_operation_exception_log_event_body_is_never_none(): + """A batch is dropped whole on an unencodable body, so this is asserted for + both event shapes: with and without a stacktrace.""" + engine, _, log_exporter = _engine_with_event_recorder() + engine.emit( + SpanRole.LLM_CALL, + _llm_call_data(SpanError(error_type="APIError", message="boom")), + ) + engine.emit( + SpanRole.LLM_CALL, + _llm_call_data( + SpanError(error_type="APIError", message="boom", stack_trace="Traceback ...") + ), + ) + + bodies = [log.log_record.body for log in log_exporter.get_finished_logs()] + assert bodies == ["boom", "boom"] + + def test_operation_exception_log_event_not_emitted_on_success(): engine, span_exporter, log_exporter = _engine_with_event_recorder() engine.emit(SpanRole.LLM_CALL, _llm_call_data(None))