Files
office_translator/middleware/metrics.py
sepehr 26dfa08730
Some checks failed
Deploy to Homelab / Deploy Wordly to 192.168.1.151 (push) Has been cancelled
Deploy to Homelab / Deploy Monitoring (if configured) (push) Has been cancelled
feat: add Prometheus metrics + fix CI/CD health check port
- Add prometheus-client dependency
- Create middleware/metrics.py with PrometheusMiddleware
- Expose /metrics endpoint in Prometheus text format
- Track http_requests_total, translation_total, translation_duration_seconds,
  file_size_bytes
- Instrument translate routes with record_translation() and record_file_size()
- Fix deploy.yml health check: localhost:8000 -> localhost:8001 (Portainer conflict)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:33:10 +02:00

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_LSP
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_LSP)