CONTENT_TYPE_LSP doesn't exist in prometheus_client, causing ImportError. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
86 lines
2.5 KiB
Python
86 lines
2.5 KiB
Python
"""
|
|
Prometheus metrics middleware for FastAPI.
|
|
|
|
Exposes /metrics endpoint in Prometheus text format.
|
|
Tracks HTTP requests, translations, and file uploads.
|
|
"""
|
|
|
|
import time
|
|
import logging
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---- Metrics definitions ----
|
|
|
|
http_requests_total = Counter(
|
|
"http_requests_total",
|
|
"Total HTTP requests",
|
|
["method", "path", "status"],
|
|
)
|
|
|
|
translation_total = Counter(
|
|
"translation_total",
|
|
"Total translations processed",
|
|
["provider", "file_type", "status"],
|
|
)
|
|
|
|
translation_duration_seconds = Histogram(
|
|
"translation_duration_seconds",
|
|
"Translation processing duration in seconds",
|
|
["provider", "file_type"],
|
|
buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
|
|
)
|
|
|
|
file_size_bytes = Histogram(
|
|
"file_size_bytes",
|
|
"Uploaded file size in bytes",
|
|
["file_type"],
|
|
buckets=(100_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000),
|
|
)
|
|
|
|
# Paths to skip from metrics (noisy health checks)
|
|
_SKIP_PATHS = {"/health", "/ready", "/metrics", "/favicon.ico"}
|
|
|
|
|
|
def record_translation(provider: str, file_type: str, duration: float, status: str = "success"):
|
|
translation_total.labels(provider=provider, file_type=file_type, status=status).inc()
|
|
translation_duration_seconds.labels(provider=provider, file_type=file_type).observe(duration)
|
|
|
|
|
|
def record_file_size(file_type: str, size_bytes: int):
|
|
file_size_bytes.labels(file_type=file_type).observe(size_bytes)
|
|
|
|
|
|
class PrometheusMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next):
|
|
if request.url.path in _SKIP_PATHS:
|
|
return await call_next(request)
|
|
|
|
start = time.time()
|
|
response: Response = await call_next(request)
|
|
duration = time.time() - start
|
|
|
|
path = request.url.path
|
|
# Group dynamic paths to avoid label explosion
|
|
if path.startswith("/api/v1/translations/"):
|
|
path = "/api/v1/translations/{id}"
|
|
elif path.startswith("/api/v1/download/"):
|
|
path = "/api/v1/download/{id}"
|
|
|
|
http_requests_total.labels(
|
|
method=request.method,
|
|
path=path,
|
|
status=str(response.status_code),
|
|
).inc()
|
|
|
|
return response
|
|
|
|
|
|
def get_metrics() -> Response:
|
|
body = generate_latest()
|
|
return Response(content=body, media_type=CONTENT_TYPE_LATEST)
|