"""Audit logging middleware. Logs every API request with method, path, status code, response time, and the authenticated user identity (extracted from the JWT when present). Log lines are structured so they can be ingested by ELK / Loki. """ from __future__ import annotations import time from fastapi import Request, Response from loguru import logger from starlette.middleware.base import BaseHTTPMiddleware class AuditMiddleware(BaseHTTPMiddleware): """Log all API calls. Skips health/docs paths to reduce noise.""" # Paths that produce no audit log entry. _SKIP_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json") async def dispatch(self, request: Request, call_next) -> Response: """Intercept the request, call the handler, and log the outcome.""" path = request.url.path if path == "/" or any(path == p or path.startswith(p + "/") for p in self._SKIP_PREFIXES): return await call_next(request) start = time.perf_counter() response = await call_next(request) elapsed_ms = int((time.perf_counter() - start) * 1000) # Extract user identity from JWT header for structured audit records. # The token is not re-validated here — auth dependencies do that upstream. user_id = "anonymous" username = "anonymous" auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): try: from app.shared.bootstrap import get_jwt_handler claims = get_jwt_handler().decode_token(auth_header[7:]) user_id = claims.user_id username = claims.username except Exception: pass logger.info( "AUDIT method={} path={} status={} elapsed_ms={} user_id={} username={}", request.method, path, response.status_code, elapsed_ms, user_id, username, ) return response