"""Structured JSON logging configuration.""" import json import logging import sys import time import traceback from collections.abc import Callable from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware from src.services.correlation import get_correlation_id logger = logging.getLogger(__name__) class CorrelationIdFilter(logging.Filter): """Inject correlation_id into every log record from context var.""" def filter(self, record: logging.LogRecord) -> bool: record.correlation_id = get_correlation_id() # type: ignore[attr-defined] return True class JSONFormatter(logging.Formatter): """Emit log records as single-line JSON.""" def format(self, record: logging.LogRecord) -> str: log_obj: dict = { "timestamp": self.formatTime(record), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "correlation_id": getattr(record, "correlation_id", None), } # Optional extra fields for key in ("instance_id", "event_type"): value = getattr(record, key, None) if value is not None: log_obj[key] = value if record.exc_info: log_obj["exception"] = self.formatException(record.exc_info) return json.dumps(log_obj, default=str) def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) class RequestLoggingMiddleware(BaseHTTPMiddleware): """Log all HTTP requests with timing and status codes.""" async def dispatch(self, request: Request, call_next: Callable) -> Response: start_time = time.time() client_host = request.client.host if request.client else "unknown" logger.info( "→ Request: %s %s (client: %s)", request.method, request.url.path, client_host, ) try: response = await call_next(request) duration = time.time() - start_time logger.info( "← Response: %s %s → %d (%dms)", request.method, request.url.path, response.status_code, int(duration * 1000), ) return response except Exception as exc: duration = time.time() - start_time logger.error( "✗ Error: %s %s → %s (%dms)\n%s", request.method, request.url.path, type(exc).__name__, int(duration * 1000), traceback.format_exc(), ) raise class ExceptionLoggingMiddleware(BaseHTTPMiddleware): """Catch and log all unhandled exceptions.""" async def dispatch(self, request: Request, call_next: Callable) -> Response: try: return await call_next(request) except Exception: logger.critical( "Unhandled exception in %s %s:\n%s", request.method, request.url.path, traceback.format_exc(), ) raise def configure_logging(level: int = logging.INFO) -> None: """Configure structured JSON logging for the application.""" formatter = JSONFormatter() # Console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(formatter) console_handler.addFilter(CorrelationIdFilter()) # Configure root logger root_logger = logging.getLogger() root_logger.setLevel(level) root_logger.handlers = [console_handler] # Set levels for specific loggers logging.getLogger("uvicorn").setLevel(logging.WARNING) logging.getLogger("uvicorn.access").setLevel(logging.WARNING) logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) logger.info("Logging configured at level %s", logging.getLevelName(level))