mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-13 09:06:18 +00:00
886e91b85e
* 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
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""
|
|
Helpers for the LIT-3193 OTEL HTTP-attribute matrix.
|
|
|
|
Module split from ``conftest.py`` because pytest auto-discovers fixtures but
|
|
forbids ``from .conftest import …`` (no parent package). Fixtures stay in
|
|
``conftest.py``; pure helpers (assertions, exception factories, attribute
|
|
constants) live here so test modules can ``from ._helpers import …``.
|
|
"""
|
|
|
|
from typing import Any, Optional
|
|
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
|
InMemorySpanExporter,
|
|
)
|
|
from opentelemetry.trace import StatusCode
|
|
|
|
from litellm.integrations.opentelemetry import (
|
|
HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE,
|
|
HTTP_ROUTE_ATTRIBUTE,
|
|
LITELLM_PROXY_REQUEST_SPAN_NAME,
|
|
URL_PATH_ATTRIBUTE,
|
|
)
|
|
|
|
|
|
def get_server_span(exporter: InMemorySpanExporter):
|
|
"""Return the (single) finished SERVER span, or None if it never ended."""
|
|
for s in exporter.get_finished_spans():
|
|
if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME:
|
|
return s
|
|
return None
|
|
|
|
|
|
def assert_server_span_attrs(
|
|
exporter: InMemorySpanExporter,
|
|
*,
|
|
expected_status: int,
|
|
expected_url_path: str,
|
|
expected_http_route: Optional[str] = None,
|
|
where: str = "",
|
|
) -> None:
|
|
"""The four required attributes on the SERVER span must all be set."""
|
|
span = get_server_span(exporter)
|
|
assert span is not None, (
|
|
f"{where}: SERVER span never finished — exporter saw "
|
|
f"{[s.name for s in exporter.get_finished_spans()]}"
|
|
)
|
|
|
|
actual_status = span.attributes.get(HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE)
|
|
assert actual_status == expected_status, (
|
|
f"{where}: {HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE} = "
|
|
f"{actual_status!r}, expected {expected_status}"
|
|
)
|
|
assert isinstance(
|
|
actual_status, int
|
|
), f"{where}: status code must be int (semconv), got {type(actual_status)}"
|
|
|
|
actual_url = span.attributes.get(URL_PATH_ATTRIBUTE)
|
|
assert actual_url == expected_url_path, (
|
|
f"{where}: {URL_PATH_ATTRIBUTE} = {actual_url!r}, "
|
|
f"expected {expected_url_path!r}"
|
|
)
|
|
|
|
expected_route = expected_http_route or expected_url_path
|
|
actual_route = span.attributes.get(HTTP_ROUTE_ATTRIBUTE)
|
|
assert actual_route == expected_route, (
|
|
f"{where}: {HTTP_ROUTE_ATTRIBUTE} = {actual_route!r}, "
|
|
f"expected {expected_route!r}"
|
|
)
|
|
|
|
duration_ns = (span.end_time or 0) - (span.start_time or 0)
|
|
assert duration_ns > 0, f"{where}: duration must be > 0, got {duration_ns}ns"
|
|
|
|
expected_span_status = StatusCode.ERROR if expected_status >= 400 else StatusCode.OK
|
|
actual_span_status = span.status.status_code
|
|
assert actual_span_status == expected_span_status, (
|
|
f"{where}: span.status = {actual_span_status!r}, "
|
|
f"expected {expected_span_status!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic exceptions covering the matrix triggers
|
|
# ---------------------------------------------------------------------------
|
|
class HttpStatusException(Exception):
|
|
"""Generic exception with .status_code; mirrors what proxy code reads."""
|
|
|
|
def __init__(self, status_code: int, message: str = "boom"):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.code = status_code
|
|
|
|
|
|
def make_httpx_status_error(status_code: int, body: str = "upstream error"):
|
|
"""Real httpx.HTTPStatusError — what providers emit on 4xx/5xx upstream."""
|
|
import httpx
|
|
|
|
request = httpx.Request("POST", "https://upstream.example/v1/x")
|
|
response = httpx.Response(
|
|
status_code=status_code, content=body.encode("utf-8"), request=request
|
|
)
|
|
return httpx.HTTPStatusError(
|
|
f"HTTP {status_code}", request=request, response=response
|
|
)
|
|
|
|
|
|
def make_fastapi_http_exception(status_code: int, detail: Any = "boom"):
|
|
from fastapi import HTTPException
|
|
|
|
return HTTPException(status_code=status_code, detail=detail)
|