mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(prometheus): serve /metrics without a trailing-slash redirect
This commit is contained in:
parent
4d33964898
commit
f99972c2dc
2 changed files with 77 additions and 7 deletions
|
|
@ -7,6 +7,7 @@ import asyncio
|
|||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -63,12 +64,29 @@ from litellm.types.utils import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
else:
|
||||
AsyncIOScheduler = Any
|
||||
|
||||
_DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MetricsASGIApp:
|
||||
"""Adapts a raw ASGI app so Starlette's ``Route`` treats it as ASGI.
|
||||
|
||||
``Route`` wraps plain functions as ``func(request) -> response`` endpoints;
|
||||
an instance sidesteps that so the Prometheus ASGI app is served directly at
|
||||
the exact ``/metrics`` path without a trailing-slash redirect.
|
||||
"""
|
||||
|
||||
app: ASGIApp
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _get_budget_metrics_per_request_timeout() -> float:
|
||||
raw = os.getenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT")
|
||||
if raw is None:
|
||||
|
|
@ -3930,18 +3948,24 @@ class PrometheusLogger(CustomLogger):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _mount_metrics_endpoint():
|
||||
def _mount_metrics_endpoint(app: FastAPI | None = None) -> None:
|
||||
"""
|
||||
Mount the Prometheus metrics endpoint with optional authentication.
|
||||
Register the Prometheus metrics endpoint on the proxy app.
|
||||
|
||||
Args:
|
||||
require_auth (bool, optional): Whether to require authentication for the metrics endpoint.
|
||||
Defaults to False.
|
||||
The exact ``/metrics`` path is served directly so a scrape of the
|
||||
canonical endpoint returns 200 instead of a 307 redirect to
|
||||
``/metrics/``; the Mount continues to serve ``/metrics/`` for scrapers
|
||||
configured with the trailing slash.
|
||||
"""
|
||||
from prometheus_client import make_asgi_app
|
||||
from starlette.routing import Route
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
if app is None:
|
||||
from litellm.proxy.proxy_server import app as proxy_app
|
||||
|
||||
app = proxy_app
|
||||
|
||||
# Create metrics ASGI app
|
||||
if "PROMETHEUS_MULTIPROC_DIR" in os.environ:
|
||||
|
|
@ -3953,7 +3977,14 @@ class PrometheusLogger(CustomLogger):
|
|||
else:
|
||||
metrics_app = make_asgi_app()
|
||||
|
||||
# Mount the metrics app to the app
|
||||
app.router.routes.insert(
|
||||
0,
|
||||
Route(
|
||||
"/metrics",
|
||||
endpoint=_MetricsASGIApp(metrics_app),
|
||||
include_in_schema=False,
|
||||
),
|
||||
)
|
||||
app.mount("/metrics", metrics_app)
|
||||
verbose_proxy_logger.debug("Starting Prometheus Metrics on /metrics (no authentication)")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
"""Regression tests for PrometheusLogger._mount_metrics_endpoint route wiring.
|
||||
|
||||
https://github.com/BerriAI/litellm/issues/33676: scraping the canonical
|
||||
``/metrics`` path returned a 307 redirect to ``/metrics/`` because the endpoint
|
||||
was only registered as a Starlette Mount. The mount matches ``/metrics/`` and
|
||||
relies on ``redirect_slashes`` to bounce ``/metrics`` there, doubling scrape
|
||||
traffic and logs. Both paths must now return 200 directly.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = FastAPI()
|
||||
PrometheusLogger._mount_metrics_endpoint(app)
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
def test_metrics_without_trailing_slash_returns_200_not_redirect():
|
||||
response = _client().get("/metrics")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert not response.is_redirect
|
||||
|
||||
|
||||
def test_metrics_with_trailing_slash_returns_200():
|
||||
response = _client().get("/metrics/")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def test_metrics_serves_prometheus_exposition_payload():
|
||||
response = _client().get("/metrics")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"].startswith("text/plain")
|
||||
Loading…
Add table
Reference in a new issue