mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
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:
parent
be658d5d29
commit
83281b3b6e
2 changed files with 85 additions and 11 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
Loading…
Add table
Reference in a new issue