from __future__ import annotations import logging import sys from typing import Any, MutableMapping import structlog from app.core.config import settings _NOISY_LOGGERS: tuple[str, ...] = ( "uvicorn.access", "uvicorn.error", "multipart", "httpx", "httpcore", "redis", "PIL", "asyncio", ) _BASE_PRE_CHAIN: tuple = ( structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso"), ) def _scrub_value(value: Any) -> Any: """Return a size placeholder for binary values, otherwise pass through.""" if isinstance(value, (bytes, bytearray, memoryview)): return f"<{len(bytes(value))} bytes>" return value def _scrub_binary(_logger: Any, _method: str, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]: """Replace any bytes / bytearray values in event_dict with a size placeholder. structlog will happily serialise raw bytes to JSON (escaped) or to the console renderer, but a single misrouted PDF or image stream can balloon a log line into megabytes. This guard catches the leak at the boundary. """ for key, value in list(event_dict.items()): event_dict[key] = _scrub_value(value) return event_dict def _consume_positional_args(_logger: Any, _method: str, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]: """Format `logger.info('msg %s', arg)` style calls safely. structlog stores the trailing args under `positional_args` and leaves the format string in `event` until a later renderer (e.g. `PositionalArgumentsFormatter`) consumes it. We do that here, scrubbing bytes so a stray PDF stream can never end up rendered as `b'\\x89PNG...'`. """ pos_args = event_dict.pop("positional_args", None) if not pos_args: return event_dict event = event_dict.get("event") if not isinstance(event, str) or "%" not in event: return event_dict safe_args = tuple(_scrub_value(a) for a in pos_args) try: event_dict["event"] = event % safe_args except (TypeError, ValueError): # Fall back to the raw event if the format string is malformed. pass return event_dict def configure_logging() -> None: pre_chain = list(_BASE_PRE_CHAIN) is_prod = settings.ENV == "production" if is_prod: renderer = structlog.processors.JSONRenderer() else: renderer = structlog.dev.ConsoleRenderer(colors=True) pre_chain.insert(0, structlog.stdlib.add_logger_name) structlog.configure( processors=[ structlog.stdlib.filter_by_level, _consume_positional_args, _scrub_binary, *pre_chain, structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=pre_chain, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, _consume_positional_args, _scrub_binary, renderer, ], ) handler = logging.StreamHandler(sys.stdout) handler.setFormatter(formatter) root_logger = logging.getLogger() root_logger.handlers = [handler] root_logger.setLevel(settings.LOG_LEVEL.upper()) for lib in _NOISY_LOGGERS: logging.getLogger(lib).setLevel(logging.WARNING)