""" 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), ) # ---- Quality pipeline metrics (L0 + L1) ---- quality_l0_checks_total = Counter( "quality_l0_checks_total", "Total L0 (script+length+pattern) quality checks", ["result", "file_type"], # result: pass | fail | error ) quality_l1_judge_total = Counter( "quality_l1_judge_total", "Total L1 (LLM judge) verdicts", ["verdict", "model"], # verdict: pass | fail | skip | error ) quality_l1_judge_duration_seconds = Histogram( "quality_l1_judge_duration_seconds", "L1 LLM judge call duration in seconds", ["model"], buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 30), ) quality_l1_judge_cost_usd = Histogram( "quality_l1_judge_cost_usd", "L1 LLM judge estimated cost in USD", ["model"], buckets=(0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1), ) # ---- L2 Pro premium judge (Track A4) ---- quality_l2_judge_total = Counter( "quality_l2_judge_total", "Total L2 (Pro premium judge, 8-dim) verdicts", ["verdict", "model", "tier"], # verdict: pass | fail | skip | error ) quality_l2_judge_duration_seconds = Histogram( "quality_l2_judge_duration_seconds", "L2 Pro judge call duration in seconds", ["model"], buckets=(0.5, 1, 2, 5, 10, 20, 30, 60), ) quality_l2_judge_cost_usd = Histogram( "quality_l2_judge_cost_usd", "L2 Pro judge estimated cost in USD", ["model"], buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0), ) # ---- Retry metrics ---- translation_retry_total = Counter( "translation_retry_total", "Total translation retries triggered by quality issues", ["reason", "tier"], # reason: l0_fail | l1_fail | format_loss ) # ---- Format preservation metrics ---- format_elements_lost_total = Counter( "format_elements_lost_total", "Format elements (hyperlinks, footnotes, charts, ...) lost during translation", ["format", "element_type"], # format: docx | xlsx | pptx | pdf ) # 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) # ---- Quality helpers ---- def record_l0_result(passed: bool, file_type: str = "unknown"): """Record an L0 quality check outcome. Args: passed: True if the L0 check passed. file_type: docx | xlsx | pptx | pdf | unknown """ result = "pass" if passed else "fail" quality_l0_checks_total.labels(result=result, file_type=file_type).inc() def record_l0_error(file_type: str = "unknown"): """Record an L0 internal error (so we can spot broken checks).""" quality_l0_checks_total.labels(result="error", file_type=file_type).inc() def record_l1_verdict( verdict: str, model: str = "unknown", duration_seconds: float = None, cost_usd: float = None, ): """Record an L1 judge verdict. Args: verdict: pass | fail | skip | error model: model name (e.g. deepseek-chat) duration_seconds: optional, observed in histogram cost_usd: optional, observed in histogram """ quality_l1_judge_total.labels(verdict=verdict, model=model).inc() if duration_seconds is not None: quality_l1_judge_duration_seconds.labels(model=model).observe(duration_seconds) if cost_usd is not None: quality_l1_judge_cost_usd.labels(model=model).observe(cost_usd) def record_l2_verdict( verdict: str, model: str = "unknown", tier: str = "pro", duration_seconds: float = None, cost_usd: float = None, ): """Record an L2 Pro judge verdict. Args: verdict: pass | fail | skip | error model: model name (e.g. gpt-4o) tier: pro | business | enterprise duration_seconds: optional, observed in histogram cost_usd: optional, observed in histogram """ quality_l2_judge_total.labels(verdict=verdict, model=model, tier=tier).inc() if duration_seconds is not None: quality_l2_judge_duration_seconds.labels(model=model).observe(duration_seconds) if cost_usd is not None: quality_l2_judge_cost_usd.labels(model=model).observe(cost_usd) def record_translation_retry(reason: str, tier: str = "free"): """Record a translation retry triggered by a quality issue. Args: reason: l0_fail | l1_fail | format_loss | user_request tier: free | pro | enterprise """ translation_retry_total.labels(reason=reason, tier=tier).inc() def record_format_loss(format: str, element_type: str): """Record a format element that was lost during translation. Args: format: docx | xlsx | pptx | pdf element_type: hyperlink | footnote | chart | diagram | comment | image """ format_elements_lost_total.labels(format=format, element_type=element_type).inc() 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)