""" Logging utilities for FastAPI application. Provides category-based logging filtering and SSE log handler management. """ import logging import sys from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from fastapi_app.lib.sse.sse_log_handler import SSELogHandler from fastapi_app.lib.sse.sse_service import SSEService _sse_log_handler: Optional["SSELogHandler"] = None class _SuppressWindowsConnectionReset(logging.Filter): """Suppress benign WinError 10054 noise from asyncio on Windows. When Playwright (or any HTTP client) hard-closes a connection with a TCP RST, Python's ProactorEventLoop logs a spurious ERROR from _call_connection_lost. The error is harmless but pollutes the log file. This filter drops those records without affecting any other asyncio logs. """ def filter(self, record: logging.LogRecord) -> bool: return '_call_connection_lost' not in record.getMessage() class CategoryFilter(logging.Filter): """Filter log records by category prefix""" def __init__(self, categories: list[str]): super().__init__() self.categories = categories def filter(self, record: logging.LogRecord) -> bool: # If no categories specified, allow all if not self.categories: return True # Check if logger name starts with any allowed category return any(record.name.startswith(cat) for cat in self.categories) def setup_logging(log_level: str = "INFO", log_categories: Optional[list[str]] = None): """ Configure logging for the application. Args: log_level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) log_categories: List of category prefixes to log (empty = all) """ from pathlib import Path if log_categories is None: log_categories = [] # Configure root logger root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, log_level.upper())) # Remove existing handlers root_logger.handlers.clear() # Format: timestamp [level] name - message formatter = logging.Formatter( fmt='%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) # Create console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(getattr(logging, log_level.upper())) console_handler.setFormatter(formatter) # Add category filter if specified if log_categories: console_handler.addFilter(CategoryFilter(log_categories)) root_logger.addHandler(console_handler) # Create file handler for application logs from fastapi_app.config import get_settings settings = get_settings() log_file = settings.app_log_file log_file.parent.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler(log_file, encoding='utf-8') file_handler.setLevel(getattr(logging, log_level.upper())) file_handler.setFormatter(formatter) if log_categories: file_handler.addFilter(CategoryFilter(log_categories)) root_logger.addHandler(file_handler) # Suppress benign WinError 10054 noise from asyncio's ProactorEventLoop. if sys.platform == 'win32': logging.getLogger('asyncio').addFilter(_SuppressWindowsConnectionReset()) # Reconfigure uvicorn loggers to use the same format. # This ensures consistency even when uvicorn is started without --log-config # (e.g., in tests or programmatic usage). for uvicorn_logger_name in ['uvicorn', 'uvicorn.error', 'uvicorn.access']: uvicorn_logger = logging.getLogger(uvicorn_logger_name) for handler in uvicorn_logger.handlers: handler.setFormatter(formatter) # Suppress routine HTTP request logs from httpx/httpcore — these flood the # log during WebDAV lock polling and other high-frequency network operations. for noisy_logger in ['httpx', 'httpcore']: logging.getLogger(noisy_logger).setLevel(logging.WARNING) def get_logger(name: str) -> logging.Logger: """ Get a logger instance for a specific module/category. Args: name: Logger name (typically __name__ of the module) Returns: Logger instance """ return logging.getLogger(name) def install_sse_log_handler(sse_service: "SSEService") -> "SSELogHandler": """ Install SSE log handler on the root logger. Called once during application startup (lifespan). Args: sse_service: SSEService singleton instance Returns: The installed SSELogHandler instance """ global _sse_log_handler if _sse_log_handler is not None: return _sse_log_handler from fastapi_app.lib.sse.sse_log_handler import SSELogHandler handler = SSELogHandler(sse_service) logging.getLogger().addHandler(handler) _sse_log_handler = handler return handler def get_sse_log_handler() -> Optional["SSELogHandler"]: """Get the installed SSE log handler, or None if not installed.""" return _sse_log_handler