diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 64d4dd578b2..8b780283a1c 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -3932,30 +3932,48 @@ class PrometheusLogger(CustomLogger): @staticmethod def _mount_metrics_endpoint(): """ - Mount the Prometheus metrics endpoint with optional authentication. + Expose the Prometheus metrics endpoint with optional authentication. - Args: - require_auth (bool, optional): Whether to require authentication for the metrics endpoint. - Defaults to False. + Registers explicit GET routes for ``/metrics`` and ``/metrics/`` + instead of using ``app.mount(...)``. Starlette mounts emit a 307 + redirect when the request path equals the mount prefix without a + trailing slash; Prometheus-compatible scrapers do not follow + redirects, so bare-path scrapes return an empty body and silently + record no metrics (#30079). """ - from prometheus_client import make_asgi_app + from fastapi import Response + from prometheus_client import ( + CONTENT_TYPE_LATEST, + generate_latest, + ) from litellm._logging import verbose_proxy_logger from litellm.proxy.proxy_server import app - # Create metrics ASGI app if "PROMETHEUS_MULTIPROC_DIR" in os.environ: from prometheus_client import CollectorRegistry, multiprocess registry = CollectorRegistry() multiprocess.MultiProcessCollector(registry) - metrics_app = make_asgi_app(registry) else: - metrics_app = make_asgi_app() + from prometheus_client import REGISTRY as registry - # Mount the metrics app to the app - app.mount("/metrics", metrics_app) - verbose_proxy_logger.debug("Starting Prometheus Metrics on /metrics (no authentication)") + async def _metrics_endpoint() -> Response: + return Response( + content=generate_latest(registry), + media_type=CONTENT_TYPE_LATEST, + ) + + for path in ("/metrics", "/metrics/"): + app.add_api_route( + path, + _metrics_endpoint, + methods=["GET"], + include_in_schema=False, + ) + verbose_proxy_logger.debug( + "Starting Prometheus Metrics on /metrics (no authentication)" + ) def _prometheus_labels_from_context( diff --git a/tests/test_litellm/integrations/test_prometheus_metrics_endpoint.py b/tests/test_litellm/integrations/test_prometheus_metrics_endpoint.py new file mode 100644 index 00000000000..594250b5f4d --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_metrics_endpoint.py @@ -0,0 +1,56 @@ +""" +Regression for #30079. + +After 1.88.0 the Prometheus ``/metrics`` endpoint was mounted via +``app.mount("/metrics", make_asgi_app())``. Starlette mounts emit a 307 +``Location: /metrics/`` when the request path equals the mount prefix +without a trailing slash, and Prometheus / Grafana Alloy scrapers do not +follow redirects -- so ``GET /metrics`` returns an empty body while the +``up`` metric stays ``1``, hiding the regression. + +These tests assert that BOTH ``/metrics`` and ``/metrics/`` return 200 +directly with the Prometheus text-format content type, no 307 in between. +""" + +import os + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def test_metrics_endpoint_no_trailing_slash_no_redirect(monkeypatch): + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + + fresh_app = FastAPI() + monkeypatch.setattr("litellm.proxy.proxy_server.app", fresh_app) + + from litellm.integrations.prometheus import PrometheusLogger + + PrometheusLogger._mount_metrics_endpoint() + + client = TestClient(fresh_app, follow_redirects=False) + + response = client.get("/metrics") + + assert ( + response.status_code == 200 + ), f"bare /metrics must not 307 (got {response.status_code}); see #30079" + assert "text/plain" in response.headers.get("content-type", "") + + +def test_metrics_endpoint_with_trailing_slash_works(monkeypatch): + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + + fresh_app = FastAPI() + monkeypatch.setattr("litellm.proxy.proxy_server.app", fresh_app) + + from litellm.integrations.prometheus import PrometheusLogger + + PrometheusLogger._mount_metrics_endpoint() + + client = TestClient(fresh_app, follow_redirects=False) + + response = client.get("/metrics/") + + assert response.status_code == 200 + assert "text/plain" in response.headers.get("content-type", "")