fix(otel): put the error message on the ERROR status description

_handle_failure() ended error spans with Status(StatusCode.ERROR) and
no description — the message only landed in the error.message
attribute. Backends that render the span status showed an empty error
(e.g. a langfuse_otel failed observation with level ERROR and an empty
status message) while the text was buried in attributes (issue
#44184). The v2 emitter already puts the message on the status.

Mirror the error.message text (error_information.error_message, with
the same error_str fallback the attribute path uses) as the status
description on both the primary and the parent span.
This commit is contained in:
JingHao-Leon 2026-10-03 04:11:15 +08:00
parent 70cb219dfa
commit d76c279c95
2 changed files with 71 additions and 20 deletions

View file

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

View file

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