diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 29f9efba6d..2de8e8bf7f 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -736,6 +736,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # Pre-request latency on the SERVER span (success path). self.set_preprocessing_duration_attribute(parent_span, kwargs) + # http.response.status_code on the SERVER span (success path). + # A successful proxy response is HTTP 200; the failure path sets + # this from the error code in _record_exception_on_span. + self.set_response_status_code_attribute(parent_span, 200) + # 3. Guardrail span self._create_guardrail_span(kwargs=kwargs, context=ctx) @@ -2957,6 +2962,25 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): span=span, key=HTTP_ROUTE_ATTRIBUTE, value=http_route ) + def set_response_status_code_attribute( + self, span: Optional[Span], status_code: Optional[int] + ) -> None: + """ + Set OTel-standard ``http.response.status_code`` (int) on the proxy + SERVER span. The failure path sets this from the error code in + ``_record_exception_on_span``; this is the success-path counterpart + so the attribute is present on every SERVER span regardless of + outcome (required by the HTTP semconv, and needed for error-ratio / + status-breakdown dashboards). No-op if span/value missing. + """ + if span is None or status_code is None: + return + self.safe_set_attribute( + span=span, + key=HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE, + value=int(status_code), + ) + def set_preprocessing_duration_attribute( self, span: Optional[Span], container: Any ) -> None: diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index a98ad2c95a..87298b5a7a 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -4808,6 +4808,41 @@ class TestOpenTelemetrySetProxyRequestRouteAttributes(unittest.TestCase): otel.set_proxy_request_route_attributes(None, url_path="/x", http_route="/x") +class TestOpenTelemetrySetResponseStatusCodeAttribute(unittest.TestCase): + """http.response.status_code must land on the SERVER span on the + success path too (failure path sets it in _record_exception_on_span). + Without this the attribute is failure-only, so error-ratio / + status-breakdown dashboards have no 2xx bucket. + """ + + def _set(self, status_code): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer(__name__) + + otel = OpenTelemetry() + span = tracer.start_span("Received Proxy Server Request") + otel.set_response_status_code_attribute(span, status_code) + span.end() + return exporter.get_finished_spans()[0] + + def test_success_sets_int_200(self): + span = self._set(200) + # Exact OTel-standard name, stored as int (regression guard). + assert span.attributes["http.response.status_code"] == 200 + assert isinstance(span.attributes["http.response.status_code"], int) + + def test_none_status_code_omits_attribute(self): + span = self._set(None) + assert "http.response.status_code" not in span.attributes + + def test_none_span_is_noop(self): + otel = OpenTelemetry() + # Mirrors the Langfuse-override path (create span returns None). + otel.set_response_status_code_attribute(None, 200) + + class TestOpenTelemetryPreprocessingDuration(unittest.TestCase): """litellm.preprocessing.duration_ms (proxy-receive -> first provider handoff) on the SERVER span. Read from container metadata so the