"""Centralized structlog configuration. Configures structlog with ProcessorFormatter so that both structlog.get_logger(__name__) and logging.getLogger() calls produce identically formatted output. Usage:: from app.logging_config import setup_logging file_formatter, console_formatter, telegram_notifier = setup_logging() # Apply formatters to handlers... # Later: telegram_notifier.set_bot(bot) """ from __future__ import annotations import logging from typing import Any import structlog from app.config import settings def _create_timezone_timestamper() -> structlog.types.Processor: """Create a timestamper processor that uses the configured timezone.""" from zoneinfo import ZoneInfo try: tz = ZoneInfo(settings.TIMEZONE) except Exception: tz = ZoneInfo('UTC') from datetime import datetime def timestamper(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: dt = datetime.now(tz=tz) event_dict['timestamp'] = dt.strftime('%Y-%m-%d %H:%M:%S') return event_dict return timestamper def _clean_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: """Strip __main__ logger name — it's redundant noise in startup logs.""" if event_dict.get('logger') == '__main__': del event_dict['logger'] return event_dict def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: """Move logger name before event text: [module.name] event text.""" logger_name = event_dict.pop('logger', None) if logger_name: event_dict['event'] = f'[{logger_name}] {event_dict.get("event", "")}' return event_dict def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: """Configure structlog and return formatters + notifier. Returns: (file_formatter, console_formatter, telegram_notifier) - file_formatter: ProcessorFormatter without ANSI colors (for file handlers) - console_formatter: ProcessorFormatter with auto-detected colors (for console) - telegram_notifier: TelegramNotifierProcessor (call .set_bot(bot) later) """ from app.logging_handler import TelegramNotifierProcessor telegram_notifier = TelegramNotifierProcessor() timestamper = _create_timezone_timestamper() # Shared processors applied to both structlog and stdlib log entries. # Order matters: each processor enriches event_dict for the next one. shared_processors: list[structlog.types.Processor] = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, _clean_logger_name, structlog.stdlib.ExtraAdder(), structlog.stdlib.PositionalArgumentsFormatter(), timestamper, structlog.processors.StackInfoRenderer(), # TelegramNotifierProcessor MUST run while exc_info is still a raw # tuple so it can extract the traceback for Telegram notifications. # ConsoleRenderer handles exc_info formatting downstream (with Rich # tracebacks on console, plain text in files). telegram_notifier, ] # Configure structlog for structlog-originated logs. # wrap_for_formatter packages event_dict into the stdlib LogRecord # so ProcessorFormatter can extract and render it. structlog.configure( processors=shared_processors + [ structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], wrapper_class=structlog.make_filtering_bound_logger( getattr(logging, settings.LOG_LEVEL, logging.INFO), ), logger_factory=structlog.stdlib.LoggerFactory(), # NOTE: cache is safe because LOG_LEVEL is set once at startup. # If dynamic level changes are ever added, switch to False. cache_logger_on_first_use=True, ) # File formatter: no ANSI colors, plain tracebacks (safe for log files) file_formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, _prefix_logger_name, structlog.dev.ConsoleRenderer( colors=False, pad_event_to=0, pad_level=False, exception_formatter=structlog.dev.plain_traceback, ), ], ) # Console formatter: colors controlled by LOG_COLORS env var (default: true). # Rich tracebacks with conservative limits to avoid 5000-line dumps. use_colors = settings.LOG_COLORS console_renderer_kwargs: dict[str, Any] = { 'colors': use_colors, 'pad_event_to': 0, 'pad_level': False, } if use_colors: console_renderer_kwargs['exception_formatter'] = structlog.dev.RichTracebackFormatter( show_locals=False, max_frames=20, extra_lines=1, width=120, suppress=['aiogram', 'aiohttp'], ) else: console_renderer_kwargs['exception_formatter'] = structlog.dev.plain_traceback console_formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, _prefix_logger_name, structlog.dev.ConsoleRenderer(**console_renderer_kwargs), ], ) _configure_noisy_loggers() return file_formatter, console_formatter, telegram_notifier def _configure_noisy_loggers() -> None: """Suppress noisy third-party loggers.""" for name, level in { 'aiohttp.access': logging.ERROR, 'aiohttp.client': logging.WARNING, 'aiohttp.internal': logging.WARNING, 'app.external.remnawave_api': logging.WARNING, 'aiogram': logging.WARNING, 'uvicorn.access': logging.ERROR, 'uvicorn.error': logging.WARNING, 'uvicorn.protocols.websockets.websockets_impl': logging.WARNING, 'websockets.server': logging.WARNING, 'websockets': logging.WARNING, }.items(): logging.getLogger(name).setLevel(level)