53fb25efcf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 26h14m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 26h24m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 26h19m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 26h23m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 26h23m44s
CI/CD Pipeline / Deploy Production (push) Failing after 26h13m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 26h13m26s
CI/CD Pipeline / CI Gate (push) Failing after 26h13m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 26h13m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 26h13m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 26h19m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 26h23m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 26h23m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 26h19m46s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
146 lines
4.1 KiB
Python
146 lines
4.1 KiB
Python
"""
|
|
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 collections.abc 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,
|
|
)
|