fix(otel): stamp http.response.status_code on all error responses#28405
Conversation
httpx.HTTPStatusError exposes status under .response.status_code, not as a
top-level attr, so unified-endpoint 5xx failures left the SERVER span without
a status. The admin hooks only wrote a child span and never stamped or ended
the parent at all, so admin 4xx/5xx (and success) responses were invisible
to dashboards. Adds a fallback to .response.status_code in get_error_information,
and ends the parent SERVER span in async_management_endpoint_{success,failure}_hook
with the same _record_exception_on_span helper the unified path uses.
Resolves LIT-3193
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR closes two gaps that prevented the OTEL SERVER span from receiving
Confidence Score: 5/5Safe to merge; the core instrumentation fixes are correct and well-tested, with only non-blocking quality gaps remaining. The failure-path fixes (httpx fallback, management wrapper refactor, new exception handlers) are logically sound and backed by a thorough mock-only test matrix. The success-path closure for management endpoints is incomplete for endpoints that omit http_request: Request from their signatures, and the unhandled-exception response format silently changes — but neither causes wrong data, crashes, or auth bypasses on the critical request path. litellm/proxy/management_helpers/utils.py (success-hook gating) and litellm/proxy/proxy_server.py (new unhandled-exception response format)
|
| Filename | Overview |
|---|---|
| litellm/integrations/opentelemetry.py | Adds parent span stamping and end() to both management success and failure hooks; failure hook correctly reuses _record_exception_on_span and sets StatusCode.ERROR |
| litellm/litellm_core_utils/litellm_logging.py | Adds .response.status_code fallback in get_error_information to handle httpx.HTTPStatusError; logic is correct and doesn't affect non-httpx exceptions |
| litellm/proxy/auth/user_api_key_auth.py | Stores parent_otel_span on request.state so exception handlers can close dangling spans; only done when open_telemetry_logger is not None, so safe when OTEL is disabled |
| litellm/proxy/management_helpers/utils.py | Failure hook correctly moved outside the http_request guard via else fallback; success hook remains gated on http_request, so most management endpoints (without Request param) still don't close the parent span on success |
| litellm/proxy/proxy_server.py | Adds _close_dangling_otel_server_span helper plus two new exception handlers; otel_unhandled_exception_handler changes the error response format for truly unhandled exceptions |
| litellm/proxy/management_endpoints/key_management_endpoints.py | Adds @management_endpoint_wrapper to info_key_fn; endpoint lacks http_request: Request param so success-path hook won't be called from the wrapper |
| tests/test_litellm/integrations/open_telemetry/test_otel_admin_endpoints.py | New test file; failure matrix is thorough including httpx.HTTPStatusError cases; success test validates the hook in isolation (not the full management wrapper path) |
| tests/test_litellm/integrations/open_telemetry/test_otel_unified_endpoints.py | New test file; well-structured matrix for unified inference endpoint failures including all httpx.HTTPStatusError variants; mocks only, no real network calls |
| tests/test_litellm/integrations/open_telemetry/test_otel_passthrough_endpoints.py | New test file; covers passthrough failure paths for Vertex and 6 provider smoke endpoints; uses mock-only approach, correct |
| tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py | New test file; validates _close_dangling_otel_server_span directly and tests both new exception handler functions; comprehensive edge case coverage |
| tests/test_litellm/integrations/open_telemetry/_helpers.py | New shared helpers; assert_server_span_attrs checks all four required OTEL attributes including span status code; make_httpx_status_error correctly builds real httpx objects |
| tests/test_litellm/integrations/open_telemetry/conftest.py | New shared fixtures; uses InMemorySpanExporter with real TracerProvider and SimpleSpanProcessor; server_span_factory correctly mirrors the user_api_key_auth setup |
Reviews (3): Last reviewed commit: "fix(otel): close SERVER span on body-val..." | Re-trigger Greptile
Pins the contract that get_error_information's response.status_code fallback is reachable from any entry point — without this, a future refactor that bypasses _record_exception_on_span in the admin hooks could regress for httpx-wrapped exceptions while the unified suite still passes.
Tighten docstrings and remove redundant section dividers/inline narration. Behavior is unchanged.
|
@greptileai re review |
Mirror the unified failure path: stamp StatusCode.ERROR on the parent SERVER span before recording the exception, and StatusCode.OK before ending it on success. Without this, OTEL backends filtering on span status (the idiomatic primitive) miss admin-endpoint failures even though the http.response.status_code attribute is correct. Extend assert_server_span_attrs to assert span.status.status_code matches the expected outcome so the gap can't regress.
Stash the SERVER span on request.state in auth so FastAPI exception handlers can finish it for failures that occur after auth but before the route handler (e.g. /model/new TypeError, /key/generate RequestValidationError). Without this, those requests left dangling spans missing http.response.status_code. Resolves LIT-3193
|
@greptileai re review |
PR overviewOpenTelemetry error response status stampingThis PR adds and closes proxy SERVER spans across error paths, management endpoints, request validation failures, and unhandled exceptions so response status codes are reflected in OTEL. I checked the changed auth initialization, exception handlers, management wrapper behavior, and error metadata extraction and did not find a security regression in the diff. Security review
Risk: 2/10 |
Don't leak str(exc) and type(exc).__name__ to clients on uncaught exceptions. The full traceback is logged via verbose_proxy_logger and the SERVER span still gets http.response.status_code=500. Resolves LIT-3193
Closes three remaining gaps where the proxy SERVER span ended without the http.response.status_code attribute: 1. ProxyException raised from _read_request_body (e.g. invalid JSON body) bubbled out of user_api_key_auth before the SERVER span was created, so the FastAPI handler had nothing to close and the trace never reached the backend. Hoist the span creation to a new idempotent _ensure_parent_otel_span_on_request_state helper called at the top of user_api_key_auth; wire openai_exception_handler to close the dangling span. Covers /v1/chat/completions, /v1/messages, /v1/responses (shared handler). 2. /v1/responses success — _handle_success ends the proxy span before async_post_call_success_hook fires on this path, so the hook's set_response_status_code_attribute(200) silently no-op'd against an ended span. Stamp 200 + set OK status at the close site in _handle_success / _end_proxy_span_from_kwargs via a shared _close_proxy_span_ok helper, so the attribute lands regardless of which success hook runs first. 3. Failure path for exceptions without code/status_code (e.g. a bare TypeError surfacing through _handle_llm_api_exception) — empty error_information.error_code → _record_exception_on_span skips the stamp → the hook ends the span. Default to 500 in async_post_call_failure_hook so the attribute is always set. Resolves LIT-3193
886e91b
into
litellm_internal_staging
…rriAI#28405) * fix(otel): stamp http.response.status_code on all error responses httpx.HTTPStatusError exposes status under .response.status_code, not as a top-level attr, so unified-endpoint 5xx failures left the SERVER span without a status. The admin hooks only wrote a child span and never stamped or ended the parent at all, so admin 4xx/5xx (and success) responses were invisible to dashboards. Adds a fallback to .response.status_code in get_error_information, and ends the parent SERVER span in async_management_endpoint_{success,failure}_hook with the same _record_exception_on_span helper the unified path uses. Resolves LIT-3193 * test(otel): exercise httpx.HTTPStatusError through admin path Pins the contract that get_error_information's response.status_code fallback is reachable from any entry point — without this, a future refactor that bypasses _record_exception_on_span in the admin hooks could regress for httpx-wrapped exceptions while the unified suite still passes. * chore(otel): trim verbose comments in LIT-3193 changes Tighten docstrings and remove redundant section dividers/inline narration. Behavior is unchanged. * fix(otel): set span.status on management hook parent SERVER span Mirror the unified failure path: stamp StatusCode.ERROR on the parent SERVER span before recording the exception, and StatusCode.OK before ending it on success. Without this, OTEL backends filtering on span status (the idiomatic primitive) miss admin-endpoint failures even though the http.response.status_code attribute is correct. Extend assert_server_span_attrs to assert span.status.status_code matches the expected outcome so the gap can't regress. * fix(otel): close SERVER span on body-validation and unhandled errors Stash the SERVER span on request.state in auth so FastAPI exception handlers can finish it for failures that occur after auth but before the route handler (e.g. /model/new TypeError, /key/generate RequestValidationError). Without this, those requests left dangling spans missing http.response.status_code. Resolves LIT-3193 * fix(otel): generic 500 body, log exception details server-side Don't leak str(exc) and type(exc).__name__ to clients on uncaught exceptions. The full traceback is logged via verbose_proxy_logger and the SERVER span still gets http.response.status_code=500. Resolves LIT-3193 * fix(otel): stamp http.response.status_code on every SERVER span path Closes three remaining gaps where the proxy SERVER span ended without the http.response.status_code attribute: 1. ProxyException raised from _read_request_body (e.g. invalid JSON body) bubbled out of user_api_key_auth before the SERVER span was created, so the FastAPI handler had nothing to close and the trace never reached the backend. Hoist the span creation to a new idempotent _ensure_parent_otel_span_on_request_state helper called at the top of user_api_key_auth; wire openai_exception_handler to close the dangling span. Covers /v1/chat/completions, /v1/messages, /v1/responses (shared handler). 2. /v1/responses success — _handle_success ends the proxy span before async_post_call_success_hook fires on this path, so the hook's set_response_status_code_attribute(200) silently no-op'd against an ended span. Stamp 200 + set OK status at the close site in _handle_success / _end_proxy_span_from_kwargs via a shared _close_proxy_span_ok helper, so the attribute lands regardless of which success hook runs first. 3. Failure path for exceptions without code/status_code (e.g. a bare TypeError surfacing through _handle_llm_api_exception) — empty error_information.error_code → _record_exception_on_span skips the stamp → the hook ends the span. Default to 500 in async_post_call_failure_hook so the attribute is always set. Resolves LIT-3193
Summary
Closes the gaps that left the OTEL SERVER span without
http.response.status_codeso dashboards couldn't break errors down by status. This PR covers four distinct paths:Unified 5xx —
httpx.HTTPStatusErrorexposes the upstream status ase.response.status_code, not as a top-level.code/.status_code.get_error_informationonly checked the top-level attrs, so the SERVER span saw an emptyerror_code. Added.response.status_codeas a third fallback in the same priority chain.Admin endpoints —
async_management_endpoint_{success,failure}_hookonly wrote a child span and never stamped or ended the parent SERVER span. The parent stayed open and the exporter never emitted it. Both hooks now stamp the status andend()the parent — the failure hook reuses the same_record_exception_on_spanhelper the unified path uses, so admin and unified now produce identical error attributes.Body-parse failures (invalid JSON) on
/v1/chat/completions,/v1/messages,/v1/responses—ProxyExceptionraised from_read_request_bodybubbled out ofuser_api_key_authbefore the SERVER span was created. The FastAPI exception handler had nothing to close, and the trace never reached the backend at all. Hoist the span creation to a new idempotent_ensure_parent_otel_span_on_request_statehelper called at the top ofuser_api_key_auth, and wireopenai_exception_handlerto close the dangling span onProxyException./v1/responsessuccess + raw-exception failures — on/v1/responsesthe SERVER span is ended by_handle_successbeforeasync_post_call_success_hookfires, so the hook'sset_response_status_code_attribute(200)silently no-op'd against an ended span. Stamp 200 + status OK at the close site via a shared_close_proxy_span_okhelper in_handle_success/_end_proxy_span_from_kwargs. On the failure side, raw exceptions withoutcode/status_code(e.g. a bareTypeError) ended up with emptyerror_information.error_code—_record_exception_on_spanskipped the stamp and the hook ended the span. Default to 500 inasync_post_call_failure_hook.Test plan
Unit tests
tests/test_litellm/integrations/open_telemetry/test_otel_unified_endpoints.py— 29-cell deep matrix on/v1/chat/completions4xx/5xx (400, 401, 403, 404, 422, 429, 500, 502, 503, 504) + 9-endpoint smoke (including everyhttpx.HTTPStatusError5xx case that previously failed) + success path; newtest_end_proxy_span_from_kwargs_stamps_200parametrized over chat/messages/responses; newtest_async_post_call_failure_hook_defaults_to_500for the bare-TypeErrorpathtests/test_litellm/integrations/open_telemetry/test_otel_passthrough_endpoints.py— 21-cell vertex deep matrix (upstream 4xx/5xx + LiteLLM auth-fail 401) + 6-provider smoketests/test_litellm/integrations/open_telemetry/test_otel_admin_endpoints.py— 42-cell/key/generate4xx/5xx + success + 18-resource smoke (key/team/user/model/customer/org/budget/credentials/mcp/tag)tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py— closes-the-dangling-span tests, newtest_openai_exception_handler_closes_spanparametrized over(400, /v1/chat/completions),(400, /v1/messages),(400, /v1/responses),(429, ...),(503, ...)uv run pytest tests/test_litellm/integrations/open_telemetry/— 109 passed); no regressionshttp.response.status_code(int),url.path,http.route, and a non-zero durationEnd-to-end verification against Jaeger
Stood up
jaeger v2.18.0(all-in-one) on localhost, pointed the proxy at it viaOTEL_EXPORTER=otlp_http OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318, and exercised a 36-cell live matrix against the running proxy. Used deterministictraceparentheaders per scenario so each trace ID could be looked up directly via the Jaeger query API. Each assertion checks: exactly one SERVER span in the trace,http.response.status_codematches the HTTP response code (and is anint), andotel.status_codeisOKfor 2xx /ERRORfor 4xx/5xx.Result: 36/36 traces correct — every triggered status code lands on the SERVER span with the right value and span status.
Live status-code matrix
/v1/chat/completions/v1/messages/v1/responsesdefinitely-not-a-real-model)mock_response: "litellm.ContextWindowExceededError"api_baseroute mismatch (httpbin)mock_response: "litellm.RateLimitError"mock_response: "litellm.InternalServerError"TypeError)/key/generate(models="string")/key/generatewith invalidduration(handler exception)*
/v1/responsesdoesn't honor themock_responseexception-string conventions used by chat/completions and messages — it returns the string as plain content (HTTP 200). Independent of this PR; the responses-specific 500 case is exercised by the missing-inputTypeErrorpath.502/503/504 not triggered live — the httpbin approach didn't work because the OpenAI SDK appends
/chat/completionstoapi_base, turninghttps://httpbin.org/status/502into a 404. These codes ARE covered by unit tests viamake_httpx_status_error(...)intest_otel_unified_endpoints.py(chat 502/503/504),test_otel_passthrough_endpoints.py(vertex 502 upstream + 502 wrapped), and the smoke matrix (502 across 9 endpoints).Resolves LIT-3193