mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
70cb219dfa
commit
d76c279c95
2 changed files with 71 additions and 20 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue