From b6aad9470732bcc0bfce21cc9cc9660a50150b21 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 2 Oct 2026 16:49:50 +0200 Subject: [PATCH] fix(otel): put the error message on the failed span status The legacy OTel logger (used by otel, langfuse_otel, arize and others) ended a failed call with Status(StatusCode.ERROR) and no description, so backends that read the span status, such as Langfuse's status message, showed an empty error. The text was only in the error.message attribute. Use the same message as that attribute, as the OTel v2 emitter already does. --- litellm/integrations/opentelemetry.py | 13 +++- tests/unit/integrations/test_opentelemetry.py | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8d588896b2f..a8ddb0a84be 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2162,6 +2162,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled should_create_primary_span: Final = parent_otel_span is None or get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN") + standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object") + error_information: Final = ( + standard_logging_payload.get("error_information") if standard_logging_payload else None + ) + error_message: Final = (error_information.get("error_message") if error_information else None) or ( + standard_logging_payload.get("error_str") if standard_logging_payload else None + ) + error_status: Final = Status(StatusCode.ERROR, error_message if isinstance(error_message, str) else None) + span = None if should_create_primary_span: # Span 1: Request sent to litellm SDK @@ -2174,7 +2183,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): if self._gen_ai_semconv_latest_experimental: span_kwargs["kind"] = self.span_kind.CLIENT span = otel_tracer.start_span(**span_kwargs) - span.set_status(Status(StatusCode.ERROR)) + span.set_status(error_status) self.set_attributes(span, kwargs, response_obj) # Record exception information using OTEL standard method @@ -2187,7 +2196,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # Only set attributes if the span is still recording (not closed) # Note: parent_otel_span is guaranteed to be not None here if parent_otel_span.is_recording(): - parent_otel_span.set_status(Status(StatusCode.ERROR)) + parent_otel_span.set_status(error_status) self.set_attributes(parent_otel_span, kwargs, response_obj) self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs) diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 175bd95c263..9b10f7ba650 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -3558,6 +3558,71 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): error_spans = [s for s in spans if s.status.status_code == StatusCode.ERROR] self.assertTrue(error_spans, "Expected at least one span with ERROR status") + @parameterized.expand( + [ + ( + "own_span_error_information", + False, + { + "error_str": "litellm.ServiceUnavailableError: upstream returned 503", + "error_information": { + "error_class": "ServiceUnavailableError", + "error_message": "upstream returned 503", + }, + }, + "upstream returned 503", + ), + ( + "own_span_error_str_only", + False, + {"error_str": "litellm.ServiceUnavailableError: upstream returned 503"}, + "litellm.ServiceUnavailableError: upstream returned 503", + ), + ( + "parent_span_error_information", + True, + { + "error_information": { + "error_class": "ServiceUnavailableError", + "error_message": "upstream returned 503", + } + }, + "upstream returned 503", + ), + ] + ) + def test_handle_failure_error_status_carries_the_error_message( + self, _name: str, has_parent_span: bool, error_fields: dict, expected_description: str + ): + span_exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + otel = OpenTelemetry(tracer_provider=tracer_provider) + otel.tracer = tracer_provider.get_tracer("litellm") + active_parent_span = ( + trace.use_span(tracer_provider.get_tracer("proxy").start_span("parent_span"), end_on_exit=True) + if has_parent_span + else contextlib.nullcontext() + ) + start = datetime.now(timezone.utc) + end = start + timedelta(seconds=1) + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": {"id": "test-id", "call_type": "completion", "metadata": {}, **error_fields}, + "exception": Exception("upstream returned 503"), + } + + with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}), active_parent_span: + otel._handle_failure(kwargs, None, start, end) + + error_spans = [s for s in span_exporter.get_finished_spans() if s.status.status_code == trace.StatusCode.ERROR] + self.assertEqual(len(error_spans), 1) + self.assertEqual(error_spans[0].name, "parent_span" if has_parent_span else "litellm_request") + self.assertEqual(error_spans[0].status.description, expected_description) + class TestRawSpanAttributeIsolation(unittest.TestCase): """Issue #3: raw_gen_ai_request span should only contain provider-specific