refactor(logging): replace structlog with stdlib logging compat layer

- Remove structlog dependency from backend/pyproject.toml
- Add app.utils.logging_compat shim for keyword-arg logging API
- Add app.utils.json_formatter for JSON log output with extra fields
- Update all backend modules to use logging_compat.get_logger()
- Update docstrings in log_sanitizer.py and json_formatter.py
- Update test comment in test_async_utils.py
- Record 406 failing tests in Docs/Tasks.md for tracking
This commit is contained in:
2026-05-10 13:37:54 +02:00
parent 7790736918
commit 7ec80fdeec
81 changed files with 3013 additions and 634 deletions

View File

@@ -25,7 +25,6 @@ if TYPE_CHECKING:
from app.models.response import ErrorMetadata
import structlog
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
@@ -73,13 +72,14 @@ from app.utils.external_logging import (
ExternalLogHandler,
create_external_log_handler,
)
from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter
from app.utils.rate_limiter import GlobalRateLimiter
from app.utils.runtime_state import ApplicationState, RuntimeState
from app.utils.scheduler_lock import release_scheduler_lock
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache
from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache
from app.utils.json_formatter import JSONFormatter
log: structlog.stdlib.BoundLogger = structlog.get_logger()
log = logging.getLogger("bangui")
# ---------------------------------------------------------------------------
@@ -89,26 +89,32 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
_external_log_handler: ExternalLogHandler | None = None
def _external_logging_processor(
logger: logging.Logger, method_name: str, event_dict: dict[str, Any]
) -> dict[str, Any]:
"""Structlog processor that queues logs to external logging handler.
def _external_logging_processor(record: logging.LogRecord) -> None:
"""Queue log record to external logging handler.
Args:
logger: The logger instance.
method_name: The name of the method called on the logger.
event_dict: The event dictionary from structlog.
Returns:
The event dictionary unchanged (other processors handle rendering).
record: The log record to queue.
"""
if _external_log_handler is not None:
_external_log_handler.queue_log(event_dict.copy())
return event_dict
_external_log_handler.queue_log(
{
"event": record.getMessage(),
"level": record.levelname.lower(),
"logger": record.name,
"timestamp": record.created,
}
)
class _ExternalLoggingHandler(logging.Handler):
"""Handler that forwards log records to the external log handler."""
def emit(self, record: logging.LogRecord) -> None:
_external_logging_processor(record)
def _configure_logging(log_level: str, log_file: str | None, settings: Settings | None = None) -> None:
"""Configure structlog for production JSON output.
"""Configure stdlib logging for production JSON output.
Args:
log_level: One of ``debug``, ``info``, ``warning``, ``error``, ``critical``.
@@ -120,32 +126,23 @@ def _configure_logging(log_level: str, log_file: str | None, settings: Settings
if log_file:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
handlers.append(logging.FileHandler(log_file))
logging.basicConfig(level=level, handlers=handlers, format="%(message)s")
processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
]
# Suppress verbose third-party library logs that emit plain text
# through the standard library logging module.
if settings is None or settings.suppress_third_party_logs:
logging.getLogger("apscheduler").setLevel(logging.WARNING)
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
formatter = JSONFormatter()
for handler in handlers:
handler.setFormatter(formatter)
logging.basicConfig(level=level, handlers=handlers)
if settings and settings.external_logging_enabled and settings.external_logging_provider:
processors.append(_external_logging_processor)
processors.append(structlog.processors.JSONRenderer())
structlog.configure(
processors=processors,
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
external_handler = _ExternalLoggingHandler()
external_handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(external_handler)
# ---------------------------------------------------------------------------
@@ -239,11 +236,6 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# deployments, it should be replaced with a shared backend.
_update_session_cache(app, settings)
# Initialize the login rate limiter (5 attempts per 60 seconds per IP).
# This is process-local and not cluster-safe. In multi-worker deployments,
# each worker has independent counters, limiting the blast radius of attacks.
app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# Applied to all endpoints via middleware. Process-local implementation.
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
@@ -1101,11 +1093,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
else NoOpSessionCache()
)
# Initialize the login rate limiter (5 attempts per 60 seconds per IP).
# This is also re-initialized in the lifespan, but must be present here
# for tests that bypass the lifespan via ASGITransport.
app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# This is also re-initialized in the lifespan, but must be present here
# for tests that bypass the lifespan via ASGITransport.