diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8d588896b2f..869b6ddbd77 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2128,6 +2128,20 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): guardrail_span.end(end_time=self._to_ns(end_time_datetime)) + def _get_error_status_description(self, kwargs) -> str | None: + """Mirror the error.message attribute text for the ERROR status + description, so backends that render the span status show the actual + error instead of an empty message (issue #44184).""" + standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object") + if standard_logging_payload is None: + return None + error_information: Final = standard_logging_payload.get("error_information") + if error_information is None: + error_str: Final = standard_logging_payload.get("error_str") + return error_str if isinstance(error_str, str) and error_str else None + message: Final = error_information.get("error_message") + return message if isinstance(message, str) and message else None + def _handle_failure(self, kwargs, response_obj, start_time, end_time): from opentelemetry.trace import Status, StatusCode @@ -2157,6 +2171,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): parent_otel_span = None # Ignore parent spans from other providers _parent_context = None + # Mirror the error.message attribute text onto the ERROR status, so + # backends that render the span status don't show an empty error + # (issue #44184). + error_status_description: Final = self._get_error_status_description(kwargs) + # Decide whether to create a primary span # Always create if no parent span exists (backward compatibility) # OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled @@ -2174,7 +2193,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(Status(status_code=StatusCode.ERROR, description=error_status_description)) self.set_attributes(span, kwargs, response_obj) # Record exception information using OTEL standard method @@ -2187,7 +2206,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(Status(status_code=StatusCode.ERROR, description=error_status_description)) self.set_attributes(parent_otel_span, kwargs, response_obj) self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs) diff --git a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py index fcbd6dbc531..5c758758ca7 100644 --- a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py +++ b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py @@ -87,20 +87,16 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): detected_context, detected_span = otel_integration._get_span_context(kwargs) # Assert: Should detect the active span - assert ( - detected_span is not None - ), "Should detect active span from global context" - assert ( - detected_span is parent_span - ), "Detected span should be the active parent span" + assert detected_span is not None, "Should detect active span from global context" + assert detected_span is parent_span, "Detected span should be the active parent span" detected_span_context = detected_span.get_span_context() - assert ( - detected_span_context.trace_id == parent_span_context.trace_id - ), "Detected span should have same trace_id as parent" - assert ( - detected_span_context.span_id == parent_span_context.span_id - ), "Detected span should have same span_id as parent" + assert detected_span_context.trace_id == parent_span_context.trace_id, ( + "Detected span should have same trace_id as parent" + ) + assert detected_span_context.span_id == parent_span_context.span_id, ( + "Detected span should have same span_id as parent" + ) def test_record_exception_on_span(self): """ @@ -162,9 +158,9 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): actual_calls = [call.args for call in mock_span.set_attribute.call_args_list] for expected_call in expected_calls: - assert ( - expected_call in actual_calls - ), f"Expected set_attribute call {expected_call} not found in actual calls: {actual_calls}" + assert expected_call in actual_calls, ( + f"Expected set_attribute call {expected_call} not found in actual calls: {actual_calls}" + ) def test_record_exception_on_span_with_fallback(self): """ @@ -205,6 +201,42 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): mock_span.record_exception.assert_called_once_with(test_exception) # Assert: error.message should be set from error_str using ErrorAttributes constant - mock_span.set_attribute.assert_called_with( - ErrorAttributes.ERROR_MESSAGE, "Fallback error message" - ) + mock_span.set_attribute.assert_called_with(ErrorAttributes.ERROR_MESSAGE, "Fallback error message") + + def test_get_error_status_description_from_error_information(self): + """The ERROR status description mirrors the error_message attribute + text, so backends that render the span status show the actual error + (issue #44184).""" + from litellm.integrations.opentelemetry import OpenTelemetry + + otel_integration = OpenTelemetry() + kwargs = { + "standard_logging_object": { + "error_information": { + "error_code": "500", + "error_class": "ValueError", + "error_message": "Test error message", + }, + "error_str": "Test error message", + }, + } + assert otel_integration._get_error_status_description(kwargs) == "Test error message" + + def test_get_error_status_description_falls_back_to_error_str(self): + from litellm.integrations.opentelemetry import OpenTelemetry + + otel_integration = OpenTelemetry() + kwargs = { + "standard_logging_object": { + "error_information": None, + "error_str": "Fallback error message", + }, + } + assert otel_integration._get_error_status_description(kwargs) == "Fallback error message" + + def test_get_error_status_description_none_when_no_error(self): + from litellm.integrations.opentelemetry import OpenTelemetry + + otel_integration = OpenTelemetry() + assert otel_integration._get_error_status_description({"standard_logging_object": None}) is None + assert otel_integration._get_error_status_description({}) is None