""" Prometheus metrics middleware and metrics endpoint for the API. Exposes: - HTTP request duration histogram (by method, path, status) - HTTP requests total counter (by method, path, status) - Requests in progress gauge - Custom application metrics """ import re import time from typing import Callable from fastapi import Request, Response from prometheus_client import ( CONTENT_TYPE_LATEST, REGISTRY, Counter, Gauge, Histogram, generate_latest, ) from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import PlainTextResponse # Buckets for HTTP request duration (seconds) HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0) # --- Metrics Definitions --- HTTP_REQUESTS_TOTAL = Counter( "http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"], registry=REGISTRY, ) HTTP_REQUEST_DURATION_SECONDS = Histogram( "http_request_duration_seconds", "HTTP request duration in seconds", ["method", "endpoint", "status"], buckets=HTTP_DURATION_BUCKETS, registry=REGISTRY, ) HTTP_REQUESTS_IN_PROGRESS = Gauge( "http_requests_in_progress", "Number of HTTP requests currently being processed", ["method"], registry=REGISTRY, ) # --- Application Metrics --- APP_INFO = Gauge( "app_info", "Application information", ["version", "environment"], registry=REGISTRY, ) def _normalize_path(path: str) -> str: """ Normalize request path to reduce cardinality. Replace UUIDs and numeric IDs with placeholders. """ # Replace UUIDs path = re.sub( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", ":uuid", path, ) # Replace numeric IDs in path segments path = re.sub(r"/\d+(/|$)", "/:id\\1", path) return path class PrometheusMetricsMiddleware(BaseHTTPMiddleware): """Middleware that records Prometheus metrics for every HTTP request.""" def __init__(self, app, exclude_paths: list[str] | None = None): super().__init__(app) self.exclude_paths = set(exclude_paths or ["/metrics", "/health", "/ready", "/startup"]) async def dispatch(self, request: Request, call_next: Callable) -> Response: path = request.url.path if path in self.exclude_paths: return await call_next(request) method = request.method normalized_path = _normalize_path(path) HTTP_REQUESTS_IN_PROGRESS.labels(method=method).inc() start_time = time.perf_counter() try: response = await call_next(request) status = str(response.status_code) return response except Exception: status = "500" raise finally: duration = time.perf_counter() - start_time HTTP_REQUESTS_TOTAL.labels( method=method, endpoint=normalized_path, status=status, ).inc() HTTP_REQUEST_DURATION_SECONDS.labels( method=method, endpoint=normalized_path, status=status, ).observe(duration) HTTP_REQUESTS_IN_PROGRESS.labels(method=method).dec() async def metrics_endpoint(request: Request) -> PlainTextResponse: """FastAPI endpoint that returns Prometheus metrics in text format. 需要 Bearer Token 认证,Token 通过 METRICS_AUTH_TOKEN 环境变量配置。 """ import os # Bearer Token 认证 auth_token = os.getenv("METRICS_AUTH_TOKEN", "") if auth_token: auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer ") or auth_header[7:] != auth_token: return PlainTextResponse(content="Unauthorized", status_code=401) version = os.getenv("APP_VERSION", "unknown") environment = os.getenv("APP_ENV", "unknown") APP_INFO.labels(version=version, environment=environment).set(1) metrics_output = generate_latest(REGISTRY) return PlainTextResponse( content=metrics_output, media_type=CONTENT_TYPE_LATEST, )