diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 64d4dd578b2..22dcf166358 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -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)") diff --git a/tests/test_litellm/integrations/test_prometheus_metrics_endpoint_mount.py b/tests/test_litellm/integrations/test_prometheus_metrics_endpoint_mount.py new file mode 100644 index 00000000000..eaf2135ccdd --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_metrics_endpoint_mount.py @@ -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")