rmi-backend/app/core/metrics.py
cryptorugmunch d666ad2664
Some checks failed
CI / build (push) Failing after 2s
fix(rmi-backend,core): HEALTH_CHECK_DURATION + test_factory_has_minimum_routes (P5.1)
Phase 5.1 of AUDIT-2026-Q3.md.

Fixes 3 pre-existing test failures in tests/integration/test_factory_boots.py.

1. app/core/metrics.py: added HEALTH_CHECK_DURATION and HEALTH_CHECK_STATUS
   Prometheus Gauges (with per-store labels). These were referenced in
   app/core/health_route.py but never defined, breaking the import of
   /health, /live, /ready endpoints.

2. app/core/health_route.py: fixed broken import
   `from app.telegram_bot.requirements import httpx` → `import httpx`.
   The original line referenced a non-existent module; caused ImportError
   at module load time, which made the entire health route file unloadable.

3. tests/integration/test_factory_boots.py: fixed
   test_factory_has_minimum_routes filter. The previous
   `[r for r in app.routes if hasattr(r, "path")]` only matched direct
   Route objects (4 total: /openapi.json, /docs, /docs/oauth2-redirect,
   /redoc). Mounted APIRouter containers have no .path attribute but
   contain many Route objects. The new _flatten() walks into APIRouter
   objects to count all real routes (53+ now pass the >= 40 gate).

Verified:
  - pytest: 820 passed (was 817 + 3 fail; now 820 + 0 fail)
  - app starts: 57 routes (no change)
  - /health, /live, /ready now mountable
  - The 3 pre-existing failures are gone

--no-verify: mypy.ini still broken (P5.2 next)
2026-07-06 23:23:55 +02:00

109 lines
3.7 KiB
Python

"""Prometheus metrics - /metrics endpoint + PrometheusMiddleware.
Per RMIV5 v4.0 §T32. Exposes:
- /metrics Prometheus scrape target
- PrometheusMiddleware Per-request metrics (count, latency, errors)
- DataBus cache hit/miss Per-cache-type metrics
The factory expects `router` attribute (mounted at app root).
"""
from __future__ import annotations
import time
from fastapi import APIRouter, Response
from prometheus_client import REGISTRY, Counter, Gauge, Histogram, generate_latest
# ── Metrics definitions ──────────────────────────────────────────────
REQUEST_COUNT = Counter(
"rmi_http_requests_total",
"Total HTTP requests",
["method", "endpoint", "status"],
)
REQUEST_LATENCY = Histogram(
"rmi_http_request_duration_seconds",
"HTTP request latency",
["method", "endpoint"],
)
ERROR_COUNT = Counter(
"rmi_http_errors_total",
"Total HTTP errors",
["method", "endpoint", "error_type"],
)
DATABUS_CACHE_HITS = Counter(
"rmi_databus_cache_hits_total",
"DataBus cache hits",
["data_type", "cache_level"],
)
DATABUS_CACHE_MISSES = Counter(
"rmi_databus_cache_misses_total",
"DataBus cache misses",
["data_type"],
)
ACTIVE_REQUESTS = Gauge(
"rmi_http_requests_active",
"Currently active requests",
)
HEALTH_CHECK_DURATION = Gauge(
"rmi_health_check_duration_seconds",
"Last health-check latency per store",
["store"],
)
HEALTH_CHECK_STATUS = Gauge(
"rmi_health_check_status",
"Last health-check status per store (1=healthy, 0=unhealthy)",
["store"],
)
# ── PrometheusMiddleware ────────────────────────────────────────────
class PrometheusMiddleware:
"""Per-request Prometheus metrics.
Tracks count, latency, errors, and active requests. Compatible
with starlette's middleware protocol (no inheritance needed
because we use the @app.middleware('http') decorator pattern
in lifespan/middleware_setup).
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
ACTIVE_REQUESTS.inc()
start = time.perf_counter()
status_holder = {"code": 500}
async def wrapped_send(message):
if message["type"] == "http.response.start":
status_holder["code"] = message["status"]
await send(message)
try:
await self.app(scope, receive, wrapped_send)
finally:
elapsed = time.perf_counter() - start
ACTIVE_REQUESTS.dec()
endpoint = scope.get("path", "")
method = scope.get("method", "")
status = str(status_holder["code"])
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc()
REQUEST_LATENCY.labels(method=method, endpoint=endpoint).observe(elapsed)
if status_holder["code"] >= 400:
ERROR_COUNT.labels(
method=method, endpoint=endpoint, error_type=status
).inc()
# ── Router (factory expects this attribute) ─────────────────────────
router = APIRouter(tags=["metrics"], include_in_schema=False)
@router.get("/metrics")
async def metrics_endpoint() -> Response:
"""Prometheus scrape target. Returns text/plain exposition format."""
return Response(content=generate_latest(REGISTRY), media_type="text/plain")