fix(prometheus): serve /metrics directly so prometheus scrapers don't hit the 307 redirect (#30079)

app.mount("/metrics", ...) introduced in v1.88.0 (e23d06dda4) made
starlette emit a 307 'Location: /metrics/' for the bare /metrics path.
prometheus-compatible scrapers do not follow redirects, so they got an
empty body while the 'up' metric stayed 1 -- silent metric loss.

replace the mount with two explicit GET routes (/metrics and
/metrics/) that both call generate_latest(registry) directly. matches
prometheus_client.make_asgi_app's behaviour without the starlette
mount-redirect.

Fixes #30079
This commit is contained in:
Chenglun Hu 2026-06-10 14:31:01 +08:00
parent be658d5d29
commit 83281b3b6e
2 changed files with 85 additions and 11 deletions

View file

@ -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(

View file

@ -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", "")