fix(otel): stamp http.response.status_code on all error responses (#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
This commit is contained in:
parent
14c0a2b3e2
commit
886e91b85e
@ -702,6 +702,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||
},
|
||||
)
|
||||
|
||||
# _record_exception_on_span only stamps when error_code is set;
|
||||
# bare TypeError etc. has none, and the span is about to be ended.
|
||||
error_code = (
|
||||
error_information.get("error_code") if error_information else None
|
||||
)
|
||||
if not error_code:
|
||||
self.set_response_status_code_attribute(parent_otel_span, 500)
|
||||
|
||||
# Pre-request latency (request_data carries the propagated
|
||||
# metadata on the failure path; omitted if it failed before handoff).
|
||||
self.set_preprocessing_duration_attribute(parent_otel_span, request_data)
|
||||
@ -798,11 +806,6 @@ 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)
|
||||
|
||||
@ -985,7 +988,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||
and hasattr(proxy_span, "is_recording")
|
||||
and proxy_span.is_recording()
|
||||
):
|
||||
proxy_span.end(end_time=self._to_ns(end_time))
|
||||
self._close_proxy_span_ok(proxy_span, end_time)
|
||||
|
||||
def _close_proxy_span_ok(self, span: Span, end_time) -> None:
|
||||
"""Stamp http.response.status_code=200 + status=OK, then end the span."""
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
self.set_response_status_code_attribute(span, 200)
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
def _handle_success(self, kwargs, response_obj, start_time, end_time):
|
||||
"""Create the litellm_request span then close the proxy span."""
|
||||
@ -1071,8 +1082,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||
parent_span is not None
|
||||
and hasattr(parent_span, "name")
|
||||
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
and hasattr(parent_span, "is_recording")
|
||||
and parent_span.is_recording()
|
||||
):
|
||||
parent_span.end(end_time=self._to_ns(end_time))
|
||||
self._close_proxy_span_ok(parent_span, end_time)
|
||||
|
||||
# Stamp team attributes onto the SERVER (root) span before it is
|
||||
# closed, so the trace root carries them like every child span.
|
||||
@ -3041,6 +3054,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||
management_endpoint_span.set_status(Status(StatusCode.OK))
|
||||
management_endpoint_span.end(end_time=_end_time_ns)
|
||||
|
||||
# The management wrapper has no other hook that closes the SERVER span.
|
||||
self.set_response_status_code_attribute(parent_otel_span, 200)
|
||||
parent_otel_span.set_status(Status(StatusCode.OK))
|
||||
parent_otel_span.end(end_time=_end_time_ns)
|
||||
|
||||
async def async_management_endpoint_failure_hook(
|
||||
self,
|
||||
logging_payload: ManagementEndpointLoggingPayload,
|
||||
@ -3091,6 +3109,24 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||
management_endpoint_span.set_status(Status(StatusCode.ERROR))
|
||||
management_endpoint_span.end(end_time=_end_time_ns)
|
||||
|
||||
# The management wrapper has no other hook that closes the SERVER span.
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
StandardLoggingPayloadSetup,
|
||||
)
|
||||
|
||||
error_information = StandardLoggingPayloadSetup.get_error_information(
|
||||
original_exception=_exception,
|
||||
)
|
||||
parent_otel_span.set_status(Status(StatusCode.ERROR))
|
||||
self._record_exception_on_span(
|
||||
span=parent_otel_span,
|
||||
kwargs={
|
||||
"exception": _exception,
|
||||
"standard_logging_object": {"error_information": error_information},
|
||||
},
|
||||
)
|
||||
parent_otel_span.end(end_time=_end_time_ns)
|
||||
|
||||
def create_litellm_proxy_request_started_span(
|
||||
self,
|
||||
start_time: datetime,
|
||||
|
||||
@ -5140,13 +5140,17 @@ class StandardLoggingPayloadSetup:
|
||||
) -> StandardLoggingPayloadErrorInformation:
|
||||
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
|
||||
|
||||
# Check for 'code' first (used by ProxyException), then fall back to 'status_code' (used by LiteLLM exceptions)
|
||||
# Ensure error_code is always a string for Prisma Python JSON field compatibility
|
||||
# ProxyException uses .code, LiteLLM exceptions use .status_code,
|
||||
# httpx.HTTPStatusError exposes status only as .response.status_code.
|
||||
# Stringified for Prisma JSON compatibility.
|
||||
error_code_attr = getattr(original_exception, "code", None)
|
||||
if error_code_attr is not None and str(error_code_attr) not in ("", "None"):
|
||||
error_status: str = str(error_code_attr)
|
||||
else:
|
||||
status_code_attr = getattr(original_exception, "status_code", None)
|
||||
if status_code_attr is None:
|
||||
response_attr = getattr(original_exception, "response", None)
|
||||
status_code_attr = getattr(response_attr, "status_code", None)
|
||||
error_status = str(status_code_attr) if status_code_attr is not None else ""
|
||||
error_class: str = (
|
||||
str(original_exception.__class__.__name__) if original_exception else ""
|
||||
|
||||
@ -671,6 +671,37 @@ async def _resolve_jwt_to_virtual_key(
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_parent_otel_span_on_request_state(request: Request) -> None:
|
||||
"""Idempotently create the OTEL SERVER span and stash it on
|
||||
``request.state.parent_otel_span``. Safe to call multiple times.
|
||||
|
||||
Called both at the top of ``user_api_key_auth`` (so body-parse failures
|
||||
have a span to close) and inside ``_user_api_key_auth_builder`` (for
|
||||
callers that bypass ``user_api_key_auth``, e.g. MCP).
|
||||
"""
|
||||
from litellm.proxy.proxy_server import open_telemetry_logger
|
||||
|
||||
if open_telemetry_logger is None:
|
||||
return
|
||||
if getattr(request.state, "parent_otel_span", None) is not None:
|
||||
return
|
||||
start_time = datetime.now()
|
||||
try:
|
||||
request.state.litellm_received_at = start_time
|
||||
except Exception:
|
||||
pass
|
||||
parent_otel_span = open_telemetry_logger.create_litellm_proxy_request_started_span(
|
||||
start_time=start_time,
|
||||
headers=_safe_get_request_headers(request),
|
||||
)
|
||||
open_telemetry_logger.set_proxy_request_route_attributes(
|
||||
parent_otel_span,
|
||||
url_path=get_request_route(request=request),
|
||||
http_route=get_request_route_template(request),
|
||||
)
|
||||
request.state.parent_otel_span = parent_otel_span
|
||||
|
||||
|
||||
async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
request: Request,
|
||||
api_key: str,
|
||||
@ -697,9 +728,10 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
)
|
||||
|
||||
parent_otel_span: Optional[Span] = None
|
||||
start_time = datetime.now()
|
||||
# Stash the proxy-receive instant for the pre-request latency calc —
|
||||
# the OTel Span API exposes no start-time getter, so propagate it.
|
||||
# Prefer the receive-instant stamped by the early helper in
|
||||
# user_api_key_auth (before body parse) — overwriting it would shorten
|
||||
# the preprocessing-duration measurement by the body-parse window.
|
||||
start_time = getattr(request.state, "litellm_received_at", None) or datetime.now()
|
||||
try:
|
||||
request.state.litellm_received_at = start_time
|
||||
except Exception:
|
||||
@ -739,18 +771,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
)
|
||||
|
||||
if open_telemetry_logger is not None:
|
||||
parent_otel_span = (
|
||||
open_telemetry_logger.create_litellm_proxy_request_started_span(
|
||||
start_time=start_time,
|
||||
headers=_safe_get_request_headers(request),
|
||||
)
|
||||
)
|
||||
# `route` is the literal path; template from the matched route.
|
||||
open_telemetry_logger.set_proxy_request_route_attributes(
|
||||
parent_otel_span,
|
||||
url_path=route,
|
||||
http_route=get_request_route_template(request),
|
||||
)
|
||||
# Reuse the span created by user_api_key_auth (before body parse)
|
||||
# so it survives _read_request_body failures. For callers that
|
||||
# bypass user_api_key_auth (e.g. MCP), create it lazily.
|
||||
_ensure_parent_otel_span_on_request_state(request)
|
||||
parent_otel_span = getattr(request.state, "parent_otel_span", None)
|
||||
|
||||
### USER-DEFINED AUTH FUNCTION ###
|
||||
if enterprise_custom_auth is not None:
|
||||
@ -2149,6 +2174,12 @@ async def user_api_key_auth(
|
||||
Parent function to authenticate user api key / jwt token.
|
||||
"""
|
||||
|
||||
# Create the SERVER span and stash it on request.state BEFORE reading the
|
||||
# body. _read_request_body can raise ProxyException for malformed JSON;
|
||||
# without this, that path leaves no span for the exception handler to
|
||||
# close, and the trace never reaches the backend.
|
||||
_ensure_parent_otel_span_on_request_state(request)
|
||||
|
||||
request_data = await _read_request_body(request=request)
|
||||
request_data = populate_request_with_path_params(
|
||||
request_data=request_data, request=request
|
||||
|
||||
@ -3226,6 +3226,7 @@ async def info_key_fn_v2(
|
||||
@router.get(
|
||||
"/key/info", tags=["key management"], dependencies=[Depends(user_api_key_auth)]
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def info_key_fn(
|
||||
key: Optional[str] = fastapi.Query(
|
||||
default=None, description="Key in the request parameters"
|
||||
|
||||
@ -518,19 +518,23 @@ def management_endpoint_wrapper(func):
|
||||
_request_body: dict = await _read_request_body(
|
||||
request=_http_request
|
||||
)
|
||||
logging_payload = ManagementEndpointLoggingPayload(
|
||||
route=_route,
|
||||
request_data=_request_body,
|
||||
response=None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exception=e,
|
||||
)
|
||||
else:
|
||||
_route = func.__name__
|
||||
_request_body = {}
|
||||
|
||||
await open_telemetry_logger.async_management_endpoint_failure_hook( # type: ignore
|
||||
logging_payload=logging_payload,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
logging_payload = ManagementEndpointLoggingPayload(
|
||||
route=_route,
|
||||
request_data=_request_body,
|
||||
response=None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exception=e,
|
||||
)
|
||||
|
||||
await open_telemetry_logger.async_management_endpoint_failure_hook( # type: ignore
|
||||
logging_payload=logging_payload,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
|
||||
raise e
|
||||
|
||||
|
||||
@ -550,6 +550,7 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
@ -1209,15 +1210,69 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
|
||||
# NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions
|
||||
headers = exc.headers
|
||||
error_dict = exc.to_dict()
|
||||
status_code = int(exc.code) if exc.code else status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
_close_dangling_otel_server_span(request, status_code)
|
||||
return JSONResponse(
|
||||
status_code=(
|
||||
int(exc.code) if exc.code else status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
),
|
||||
status_code=status_code,
|
||||
content={"error": error_dict},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _close_dangling_otel_server_span(request: Request, status_code: int) -> None:
|
||||
parent_otel_span = getattr(request.state, "parent_otel_span", None)
|
||||
if parent_otel_span is None:
|
||||
return
|
||||
if open_telemetry_logger is None:
|
||||
return
|
||||
try:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
open_telemetry_logger.set_response_status_code_attribute(
|
||||
parent_otel_span, status_code
|
||||
)
|
||||
parent_otel_span.set_status(
|
||||
Status(StatusCode.ERROR if status_code >= 400 else StatusCode.OK)
|
||||
)
|
||||
parent_otel_span.end()
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
"Error closing dangling OTEL SERVER span: %s", str(e)
|
||||
)
|
||||
finally:
|
||||
request.state.parent_otel_span = None
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def otel_request_validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
_close_dangling_otel_server_span(request, 422)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": jsonable_encoder(exc.errors())},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def otel_unhandled_exception_handler(request: Request, exc: Exception):
|
||||
if isinstance(exc, (ProxyException, HTTPException, RequestValidationError)):
|
||||
raise exc
|
||||
verbose_proxy_logger.exception(
|
||||
"Unhandled exception in request: %s", type(exc).__name__
|
||||
)
|
||||
_close_dangling_otel_server_span(request, 500)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"message": "Internal server error",
|
||||
"type": "internal_server_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
109
tests/test_litellm/integrations/open_telemetry/_helpers.py
Normal file
109
tests/test_litellm/integrations/open_telemetry/_helpers.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""
|
||||
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)
|
||||
93
tests/test_litellm/integrations/open_telemetry/conftest.py
Normal file
93
tests/test_litellm/integrations/open_telemetry/conftest.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""
|
||||
Shared fixtures for the LIT-3193 OTEL HTTP-attribute matrix.
|
||||
|
||||
The matrix needs every error response — across unified inference, passthrough,
|
||||
and admin endpoints — to carry ``http.response.status_code``, ``url.path``,
|
||||
``http.route``, and a non-zero duration on the SERVER (root) span. These
|
||||
fixtures hook a real ``OpenTelemetry`` callback into ``litellm.callbacks`` so
|
||||
the tests drive the actual handler / wrapper code under test, not the OTEL
|
||||
emitter in isolation.
|
||||
|
||||
See ``LIT-3193_test_matrix.md`` (same directory) for the cell list.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
||||
InMemorySpanExporter,
|
||||
)
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OTEL + exporter
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def otel_with_exporter() -> Tuple[OpenTelemetry, InMemorySpanExporter]:
|
||||
"""Real OpenTelemetry callback with every span captured in-memory."""
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
|
||||
otel = OpenTelemetry()
|
||||
otel.tracer = provider.get_tracer("lit-3193-tests")
|
||||
otel.message_logging = True
|
||||
return otel, exporter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_span_factory(otel_with_exporter):
|
||||
"""Factory mirroring user_api_key_auth: SERVER span + url.path + http.route."""
|
||||
otel, _exporter = otel_with_exporter
|
||||
|
||||
def _make(url_path: str, http_route: Optional[str] = None):
|
||||
span = otel.create_litellm_proxy_request_started_span(
|
||||
start_time=datetime.now(), headers={}
|
||||
)
|
||||
otel.set_proxy_request_route_attributes(
|
||||
span, url_path=url_path, http_route=http_route or url_path
|
||||
)
|
||||
return span
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_api_key_dict_factory():
|
||||
"""UserAPIKeyAuth-shaped mock; the only attr the failure hooks read is
|
||||
parent_otel_span (plus team_id/team_alias for stamping)."""
|
||||
|
||||
def _make(parent_span):
|
||||
d = MagicMock()
|
||||
d.parent_otel_span = parent_span
|
||||
d.team_id = "team-lit-3193"
|
||||
d.team_alias = "lit-3193-team"
|
||||
d.request_route = None
|
||||
return d
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def register_otel_callback(otel_with_exporter, monkeypatch):
|
||||
"""Make ProxyLogging.post_call_failure_hook iterate our OTEL instance."""
|
||||
otel, _ = otel_with_exporter
|
||||
saved = list(litellm.callbacks)
|
||||
monkeypatch.setattr(litellm, "callbacks", [otel])
|
||||
yield otel
|
||||
litellm.callbacks = saved
|
||||
|
||||
|
||||
# Helpers (assertions, exception factories) live in ``_helpers.py`` — pytest
|
||||
# auto-discovers fixtures here but forbids ``from .conftest import …``.
|
||||
@ -0,0 +1,182 @@
|
||||
"""LIT-3193 — admin / management endpoints. Drives the
|
||||
async_management_endpoint_{success,failure}_hook integration points."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import (
|
||||
ManagementEndpointLoggingPayload,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
|
||||
from ._helpers import (
|
||||
HttpStatusException,
|
||||
assert_server_span_attrs,
|
||||
make_fastapi_http_exception,
|
||||
make_httpx_status_error,
|
||||
)
|
||||
|
||||
|
||||
def _real_user_api_key_dict(parent_span):
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-test-admin",
|
||||
team_id="team-lit-3193",
|
||||
team_alias="lit-3193-team",
|
||||
parent_otel_span=parent_span,
|
||||
)
|
||||
|
||||
|
||||
async def _drive_admin_failure(*, otel, exception, parent_span, route):
|
||||
payload = ManagementEndpointLoggingPayload(
|
||||
route=route,
|
||||
request_data={},
|
||||
response=None,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
exception=exception,
|
||||
)
|
||||
await otel.async_management_endpoint_failure_hook(
|
||||
logging_payload=payload,
|
||||
parent_otel_span=parent_span,
|
||||
)
|
||||
|
||||
|
||||
async def _drive_admin_success(*, otel, parent_span, route, response):
|
||||
payload = ManagementEndpointLoggingPayload(
|
||||
route=route,
|
||||
request_data={},
|
||||
response=response,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
await otel.async_management_endpoint_success_hook(
|
||||
logging_payload=payload,
|
||||
parent_otel_span=parent_span,
|
||||
)
|
||||
|
||||
|
||||
KEY_GENERATE_PATH = "/key/generate"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(400, "negative max_budget"), 400),
|
||||
(make_fastapi_http_exception(401, "missing master key"), 401),
|
||||
(make_fastapi_http_exception(403, "non-admin"), 403),
|
||||
(make_fastapi_http_exception(422, "validation"), 422),
|
||||
(HttpStatusException(500, "DB unreachable"), 500),
|
||||
# Pins .response.status_code fallback through the admin path.
|
||||
(make_httpx_status_error(500, "upstream blew up"), 500),
|
||||
],
|
||||
ids=["400", "401", "403", "422", "500", "500-httpx"],
|
||||
)
|
||||
def test_key_generate_failure_stamps_server_span(
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
otel_with_exporter,
|
||||
):
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(KEY_GENERATE_PATH)
|
||||
|
||||
asyncio.run(
|
||||
_drive_admin_failure(
|
||||
otel=otel,
|
||||
exception=exception,
|
||||
parent_span=server_span,
|
||||
route=KEY_GENERATE_PATH,
|
||||
)
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=KEY_GENERATE_PATH,
|
||||
where=f"key/generate {expected_status}",
|
||||
)
|
||||
|
||||
|
||||
def test_key_generate_success_stamps_server_span(
|
||||
server_span_factory, otel_with_exporter
|
||||
):
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(KEY_GENERATE_PATH)
|
||||
|
||||
asyncio.run(
|
||||
_drive_admin_success(
|
||||
otel=otel,
|
||||
parent_span=server_span,
|
||||
route=KEY_GENERATE_PATH,
|
||||
response={"key": "sk-1", "key_name": "k"},
|
||||
)
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=200,
|
||||
expected_url_path=KEY_GENERATE_PATH,
|
||||
where="key/generate 200",
|
||||
)
|
||||
|
||||
|
||||
SMOKE_ADMIN_ENDPOINTS = [
|
||||
"/key/info",
|
||||
"/key/update",
|
||||
"/key/delete",
|
||||
"/team/new",
|
||||
"/team/member_add",
|
||||
"/user/new",
|
||||
"/user/info",
|
||||
"/model/new",
|
||||
"/model/delete",
|
||||
"/customer/new",
|
||||
"/customer/info",
|
||||
"/organization/new",
|
||||
"/organization/member_add",
|
||||
"/budget/new",
|
||||
"/budget/info",
|
||||
"/credentials/new",
|
||||
"/mcp/server/add",
|
||||
"/tag/new",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", SMOKE_ADMIN_ENDPOINTS)
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(404, "not found"), 404),
|
||||
(HttpStatusException(500, "DB unreachable"), 500),
|
||||
],
|
||||
ids=["404", "500"],
|
||||
)
|
||||
def test_admin_endpoint_failure_stamps_server_span(
|
||||
path,
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
otel_with_exporter,
|
||||
):
|
||||
"""Confirm SERVER-span stamping works for every admin resource family —
|
||||
same wrapper, just different routes."""
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(path)
|
||||
|
||||
asyncio.run(
|
||||
_drive_admin_failure(
|
||||
otel=otel,
|
||||
exception=exception,
|
||||
parent_span=server_span,
|
||||
route=path,
|
||||
)
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=path,
|
||||
where=f"{path} {expected_status}",
|
||||
)
|
||||
@ -0,0 +1,136 @@
|
||||
"""LIT-3193 — exception-handler path. Closes SERVER spans for requests
|
||||
that fail after auth but before the route handler runs (e.g. /model/new
|
||||
TypeError or RequestValidationError)."""
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
import litellm.proxy.proxy_server as proxy_server_module
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.proxy_server import (
|
||||
_close_dangling_otel_server_span,
|
||||
openai_exception_handler,
|
||||
otel_request_validation_exception_handler,
|
||||
otel_unhandled_exception_handler,
|
||||
)
|
||||
|
||||
from ._helpers import assert_server_span_attrs
|
||||
|
||||
|
||||
def _fake_request(parent_otel_span=None):
|
||||
state = types.SimpleNamespace()
|
||||
if parent_otel_span is not None:
|
||||
state.parent_otel_span = parent_otel_span
|
||||
return types.SimpleNamespace(state=state)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wired_otel(otel_with_exporter, monkeypatch):
|
||||
otel, exporter = otel_with_exporter
|
||||
monkeypatch.setattr(proxy_server_module, "open_telemetry_logger", otel)
|
||||
return exporter
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,path", [(500, "/model/new"), (422, "/key/generate")])
|
||||
def test_close_dangling_span_stamps_status(
|
||||
wired_otel, server_span_factory, status, path
|
||||
):
|
||||
request = _fake_request(parent_otel_span=server_span_factory(path))
|
||||
_close_dangling_otel_server_span(request, status)
|
||||
assert_server_span_attrs(
|
||||
wired_otel,
|
||||
expected_status=status,
|
||||
expected_url_path=path,
|
||||
where=f"{path} {status}",
|
||||
)
|
||||
assert request.state.parent_otel_span is None
|
||||
|
||||
|
||||
def test_close_dangling_span_noop_when_no_span(wired_otel):
|
||||
_close_dangling_otel_server_span(_fake_request(), 500)
|
||||
assert wired_otel.get_finished_spans() == ()
|
||||
|
||||
|
||||
def test_close_dangling_span_noop_when_otel_absent(server_span_factory, monkeypatch):
|
||||
monkeypatch.setattr(proxy_server_module, "open_telemetry_logger", None)
|
||||
request = _fake_request(parent_otel_span=server_span_factory("/key/generate"))
|
||||
_close_dangling_otel_server_span(request, 500)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"handler,exc,status,path",
|
||||
[
|
||||
(
|
||||
otel_request_validation_exception_handler,
|
||||
RequestValidationError(errors=[]),
|
||||
422,
|
||||
"/key/generate",
|
||||
),
|
||||
(
|
||||
otel_unhandled_exception_handler,
|
||||
TypeError("Deployment.__init__() missing required positional arg"),
|
||||
500,
|
||||
"/model/new",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_exception_handler_closes_span(
|
||||
wired_otel, server_span_factory, handler, exc, status, path
|
||||
):
|
||||
request = _fake_request(parent_otel_span=server_span_factory(path))
|
||||
response = asyncio.run(handler(request, exc))
|
||||
assert response.status_code == status
|
||||
assert_server_span_attrs(
|
||||
wired_otel,
|
||||
expected_status=status,
|
||||
expected_url_path=path,
|
||||
where=f"{handler.__name__} ({type(exc).__name__})",
|
||||
)
|
||||
|
||||
|
||||
def test_unhandled_handler_reraises_known_exceptions(wired_otel, server_span_factory):
|
||||
"""ProxyException / HTTPException / RequestValidationError have dedicated handlers."""
|
||||
request = _fake_request(parent_otel_span=server_span_factory("/key/generate"))
|
||||
with pytest.raises(HTTPException):
|
||||
asyncio.run(
|
||||
otel_unhandled_exception_handler(
|
||||
request, HTTPException(status_code=403, detail="forbidden")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Covers ProxyException raised after auth stashed the span (e.g., invalid-JSON
|
||||
# body via _read_request_body) — handler must close the dangling SERVER span.
|
||||
@pytest.mark.parametrize(
|
||||
"code,path",
|
||||
[
|
||||
(400, "/v1/chat/completions"),
|
||||
(400, "/v1/messages"),
|
||||
(400, "/v1/responses"),
|
||||
(429, "/v1/chat/completions"),
|
||||
(503, "/v1/chat/completions"),
|
||||
],
|
||||
)
|
||||
def test_openai_exception_handler_closes_span(
|
||||
wired_otel, server_span_factory, code, path
|
||||
):
|
||||
request = _fake_request(parent_otel_span=server_span_factory(path))
|
||||
exc = ProxyException(
|
||||
message="boom",
|
||||
type="invalid_request_error",
|
||||
param="request_body",
|
||||
code=code,
|
||||
)
|
||||
response = asyncio.run(openai_exception_handler(request, exc))
|
||||
assert response.status_code == code
|
||||
assert_server_span_attrs(
|
||||
wired_otel,
|
||||
expected_status=code,
|
||||
expected_url_path=path,
|
||||
where=f"openai_exception_handler ({path} code={code})",
|
||||
)
|
||||
assert request.state.parent_otel_span is None
|
||||
@ -0,0 +1,136 @@
|
||||
"""LIT-3193 — passthrough endpoints. Drives proxy_logging.post_call_failure_hook
|
||||
(the integration point pass_through_endpoint reaches on upstream >=300)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
from ._helpers import (
|
||||
assert_server_span_attrs,
|
||||
make_fastapi_http_exception,
|
||||
make_httpx_status_error,
|
||||
)
|
||||
|
||||
|
||||
def _real_user_api_key_dict(parent_span):
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-test",
|
||||
team_id="team-lit-3193",
|
||||
team_alias="lit-3193-team",
|
||||
parent_otel_span=parent_span,
|
||||
)
|
||||
|
||||
|
||||
def _proxy_logging():
|
||||
return ProxyLogging(user_api_key_cache=UserApiKeyCache(DualCache()))
|
||||
|
||||
|
||||
def _drive_passthrough_failure(*, exception, user_api_key_dict):
|
||||
asyncio.run(
|
||||
_proxy_logging().post_call_failure_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
original_exception=exception,
|
||||
request_data={},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
VERTEX_PATH = "/vertex_ai/v1/projects/p/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(401, "no proxy key"), 401),
|
||||
(make_fastapi_http_exception(400, "bad request"), 400),
|
||||
(make_fastapi_http_exception(403, "upstream forbidden"), 403),
|
||||
(make_fastapi_http_exception(404, "upstream not found"), 404),
|
||||
(make_fastapi_http_exception(429, "upstream rate limit"), 429),
|
||||
(make_httpx_status_error(500, "upstream blew up"), 500),
|
||||
(make_httpx_status_error(502, "bad gateway"), 502),
|
||||
(make_httpx_status_error(503, "service unavailable"), 503),
|
||||
(make_fastapi_http_exception(502, "wrapped 502"), 502),
|
||||
],
|
||||
ids=[
|
||||
"401-litellm-auth",
|
||||
"400-upstream",
|
||||
"403-upstream",
|
||||
"404-upstream",
|
||||
"429-upstream",
|
||||
"500-upstream-httpx",
|
||||
"502-upstream-httpx",
|
||||
"503-upstream-httpx",
|
||||
"502-wrapped",
|
||||
],
|
||||
)
|
||||
def test_vertex_passthrough_failure_stamps_server_span(
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
otel_with_exporter,
|
||||
register_otel_callback,
|
||||
):
|
||||
_otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(
|
||||
VERTEX_PATH, http_route="/vertex_ai/{endpoint:path}"
|
||||
)
|
||||
uakd = _real_user_api_key_dict(server_span)
|
||||
|
||||
_drive_passthrough_failure(exception=exception, user_api_key_dict=uakd)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=VERTEX_PATH,
|
||||
expected_http_route="/vertex_ai/{endpoint:path}",
|
||||
where=f"vertex passthrough {expected_status}",
|
||||
)
|
||||
|
||||
|
||||
SMOKE_PASSTHROUGHS = [
|
||||
("/bedrock/model/anthropic.claude-v2/invoke", "/bedrock/{endpoint:path}"),
|
||||
("/anthropic/v1/messages", "/anthropic/{endpoint:path}"),
|
||||
("/openai/v1/chat/completions", "/openai/{endpoint:path}"),
|
||||
("/gemini/v1beta/models/gemini-pro:generateContent", "/gemini/{endpoint:path}"),
|
||||
("/cohere/v1/chat", "/cohere/{endpoint:path}"),
|
||||
("/azure/openai/deployments/gpt4/chat/completions", "/azure/{endpoint:path}"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,http_route", SMOKE_PASSTHROUGHS)
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(400, "upstream bad request"), 400),
|
||||
(make_httpx_status_error(502, "upstream"), 502),
|
||||
],
|
||||
ids=["400", "502"],
|
||||
)
|
||||
def test_passthrough_failure_stamps_server_span(
|
||||
path,
|
||||
http_route,
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
otel_with_exporter,
|
||||
register_otel_callback,
|
||||
):
|
||||
_otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(path, http_route=http_route)
|
||||
uakd = _real_user_api_key_dict(server_span)
|
||||
|
||||
_drive_passthrough_failure(exception=exception, user_api_key_dict=uakd)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=path,
|
||||
expected_http_route=http_route,
|
||||
where=f"{path} {expected_status}",
|
||||
)
|
||||
@ -0,0 +1,223 @@
|
||||
"""LIT-3193 — unified inference endpoints. Drives _handle_llm_api_exception
|
||||
to assert SERVER-span attrs (status, url.path, http.route, duration)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
from ._helpers import (
|
||||
HttpStatusException,
|
||||
assert_server_span_attrs,
|
||||
make_fastapi_http_exception,
|
||||
make_httpx_status_error,
|
||||
)
|
||||
|
||||
|
||||
def _real_user_api_key_dict(parent_span):
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-test",
|
||||
team_id="team-lit-3193",
|
||||
team_alias="lit-3193-team",
|
||||
parent_otel_span=parent_span,
|
||||
)
|
||||
|
||||
|
||||
def _proxy_logging():
|
||||
return ProxyLogging(user_api_key_cache=UserApiKeyCache(DualCache()))
|
||||
|
||||
|
||||
def _drive_unified_failure(
|
||||
*,
|
||||
exception,
|
||||
server_span,
|
||||
user_api_key_dict,
|
||||
):
|
||||
proc = ProxyBaseLLMRequestProcessing(data={})
|
||||
try:
|
||||
asyncio.run(
|
||||
proc._handle_llm_api_exception(
|
||||
e=exception,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_logging_obj=_proxy_logging(),
|
||||
)
|
||||
)
|
||||
except (ProxyException, HTTPException):
|
||||
pass
|
||||
|
||||
|
||||
CHAT_PATH = "/v1/chat/completions"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(400, "bad request"), 400),
|
||||
(make_fastapi_http_exception(401, "no key"), 401),
|
||||
(make_fastapi_http_exception(403, "no model access"), 403),
|
||||
(make_fastapi_http_exception(404, "model not in router"), 404),
|
||||
(make_fastapi_http_exception(422, "validation"), 422),
|
||||
(make_fastapi_http_exception(429, "rate limit"), 429),
|
||||
(HttpStatusException(500, "uncaught"), 500),
|
||||
(make_httpx_status_error(502, "upstream blew up"), 502),
|
||||
(make_httpx_status_error(503, "upstream down"), 503),
|
||||
(make_httpx_status_error(504, "upstream timeout"), 504),
|
||||
],
|
||||
ids=[
|
||||
"400-bad-request",
|
||||
"401-no-key",
|
||||
"403-no-model-access",
|
||||
"404-model-not-found",
|
||||
"422-validation",
|
||||
"429-rate-limit",
|
||||
"500-uncaught",
|
||||
"502-upstream",
|
||||
"503-upstream",
|
||||
"504-upstream-timeout",
|
||||
],
|
||||
)
|
||||
def test_chat_completions_failure_stamps_server_span(
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
user_api_key_dict_factory,
|
||||
otel_with_exporter,
|
||||
register_otel_callback,
|
||||
):
|
||||
_otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(CHAT_PATH)
|
||||
uakd = _real_user_api_key_dict(server_span)
|
||||
|
||||
_drive_unified_failure(
|
||||
exception=exception, server_span=server_span, user_api_key_dict=uakd
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=CHAT_PATH,
|
||||
where=f"chat/completions {expected_status}",
|
||||
)
|
||||
|
||||
|
||||
def test_chat_completions_success_path_stamps_200(
|
||||
otel_with_exporter, server_span_factory
|
||||
):
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(CHAT_PATH)
|
||||
_real_user_api_key_dict(server_span)
|
||||
|
||||
otel.set_response_status_code_attribute(server_span, 200)
|
||||
otel.set_preprocessing_duration_attribute(server_span, {})
|
||||
server_span.set_status(Status(StatusCode.OK))
|
||||
server_span.end()
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=200,
|
||||
expected_url_path=CHAT_PATH,
|
||||
where="chat/completions 200",
|
||||
)
|
||||
|
||||
|
||||
# /v1/responses ends the proxy span before async_post_call_success_hook fires,
|
||||
# so the 200 stamp must happen at span close (here), not in the hook.
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/v1/chat/completions", "/v1/messages", "/v1/responses"]
|
||||
)
|
||||
def test_end_proxy_span_from_kwargs_stamps_200(
|
||||
path, otel_with_exporter, server_span_factory
|
||||
):
|
||||
from datetime import datetime
|
||||
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(path)
|
||||
kwargs = {"litellm_params": {"metadata": {"litellm_parent_otel_span": server_span}}}
|
||||
otel._end_proxy_span_from_kwargs(kwargs, datetime.now())
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=200,
|
||||
expected_url_path=path,
|
||||
where=f"{path} _end_proxy_span_from_kwargs",
|
||||
)
|
||||
|
||||
|
||||
# Bare TypeError has no .code/.status_code, so error_information.error_code is
|
||||
# empty and _record_exception_on_span skips the stamp — must default to 500.
|
||||
def test_async_post_call_failure_hook_defaults_to_500(
|
||||
otel_with_exporter, server_span_factory
|
||||
):
|
||||
otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory("/v1/responses")
|
||||
uakd = _real_user_api_key_dict(server_span)
|
||||
|
||||
asyncio.run(
|
||||
otel.async_post_call_failure_hook(
|
||||
request_data={},
|
||||
original_exception=TypeError("missing required argument"),
|
||||
user_api_key_dict=uakd,
|
||||
)
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=500,
|
||||
expected_url_path="/v1/responses",
|
||||
where="async_post_call_failure_hook (TypeError) defaults to 500",
|
||||
)
|
||||
|
||||
|
||||
SMOKE_ENDPOINTS = [
|
||||
"/v1/embeddings",
|
||||
"/v1/completions",
|
||||
"/v1/images/generations",
|
||||
"/v1/audio/speech",
|
||||
"/v1/audio/transcriptions",
|
||||
"/v1/moderations",
|
||||
"/v1/rerank",
|
||||
"/v1/responses",
|
||||
"/v1/messages",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", SMOKE_ENDPOINTS)
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_status",
|
||||
[
|
||||
(make_fastapi_http_exception(401, "no key"), 401),
|
||||
(make_httpx_status_error(502, "upstream"), 502),
|
||||
],
|
||||
ids=["401", "502"],
|
||||
)
|
||||
def test_unified_endpoint_failure_stamps_server_span(
|
||||
path,
|
||||
exception,
|
||||
expected_status,
|
||||
server_span_factory,
|
||||
otel_with_exporter,
|
||||
register_otel_callback,
|
||||
):
|
||||
_otel, exporter = otel_with_exporter
|
||||
server_span = server_span_factory(path)
|
||||
uakd = _real_user_api_key_dict(server_span)
|
||||
|
||||
_drive_unified_failure(
|
||||
exception=exception, server_span=server_span, user_api_key_dict=uakd
|
||||
)
|
||||
|
||||
assert_server_span_attrs(
|
||||
exporter,
|
||||
expected_status=expected_status,
|
||||
expected_url_path=path,
|
||||
where=f"{path} {expected_status}",
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user