"""Production-grade logging configuration. Installs one ``dictConfig`` layout with: * A ``LogRecordFactory`` that pulls request-scoped identifiers from ``notify_bridge_core.log_context`` onto every record, so logs can be filtered/correlated by ``request_id``, ``command``, ``chat_id``, ``bot_id``, ``dispatch_id`` without each call site passing them. * A ``SecretMaskingFilter`` that redacts Telegram bot tokens and common ``Authorization`` / ``x-api-key`` headers so an accidental ``repr`` or dumped request doesn't leak credentials into the log aggregator. * A text formatter (default) or a JSON formatter (one line per record) selectable via ``NOTIFY_BRIDGE_LOG_FORMAT`` / app setting. Levels are configurable three ways (later wins): 1. ``NOTIFY_BRIDGE_LOG_LEVEL`` env var (root) plus ``NOTIFY_BRIDGE_LOG_LEVELS`` (``mod=LEVEL,mod2=LEVEL``). 2. DB ``AppSetting`` rows ``log_level`` / ``log_levels`` / ``log_format``, applied after migrations during startup. 3. Live edits via the settings API — ``apply_log_levels()`` updates existing loggers in place without a server restart. """ from __future__ import annotations import json import logging import logging.config import re import sys from typing import Any from notify_bridge_core.log_context import ( bot_id_var, chat_id_var, command_var, dispatch_id_var, request_id_var, ) # --------------------------------------------------------------------------- # Secret masking # --------------------------------------------------------------------------- # Telegram bot tokens: /bot: _TELEGRAM_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]{20,}") # Header-style secrets: Authorization: Bearer xxx, x-api-key=xxx, etc. # Only matches reasonably long tokens so short legitimate values don't trip. _HEADER_SECRET_RE = re.compile( r"(?i)(authorization|x-api-key|api[_-]?key|password|secret|access[_-]?token|refresh[_-]?token)" r"([\"']?\s*[:=]\s*[\"']?)" r"([A-Za-z0-9._+/=\-]{12,})" ) def _mask(text: str) -> str: redacted = _TELEGRAM_TOKEN_RE.sub("/bot***", text) redacted = _HEADER_SECRET_RE.sub(r"\1\2***", redacted) return redacted class SecretMaskingFilter(logging.Filter): """Redact likely secrets from every log message before it's emitted. Covers three surfaces where a leaked token can end up in the log: the formatted message, a cached exception traceback (``exc_text``), and a cached stack frame dump (``stack_info``). The formatter still expands ``exc_info`` for us when ``exc_text`` is None, so we also pre-render + mask on first emission. """ def filter(self, record: logging.LogRecord) -> bool: try: msg = record.getMessage() except Exception: return True redacted = _mask(msg) if redacted != msg: # Replace the formatted message and drop args so the handler # doesn't re-format with the original values. record.msg = redacted record.args = () if record.exc_info and not record.exc_text: # Pre-render so we can mask before the formatter caches it. fmt = logging.Formatter() record.exc_text = fmt.formatException(record.exc_info) if record.exc_text: record.exc_text = _mask(record.exc_text) if record.stack_info: record.stack_info = _mask(record.stack_info) return True # --------------------------------------------------------------------------- # Record factory — injects context identifiers onto every record # --------------------------------------------------------------------------- _CONTEXT_FIELDS = ("request_id", "command", "chat_id", "bot_id", "dispatch_id") _PLACEHOLDER = "-" _original_factory = logging.getLogRecordFactory() def _context_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: record = _original_factory(*args, **kwargs) record.request_id = request_id_var.get() or _PLACEHOLDER record.command = command_var.get() or _PLACEHOLDER record.chat_id = chat_id_var.get() or _PLACEHOLDER bid = bot_id_var.get() record.bot_id = str(bid) if bid is not None else _PLACEHOLDER record.dispatch_id = dispatch_id_var.get() or _PLACEHOLDER return record # --------------------------------------------------------------------------- # JSON formatter # --------------------------------------------------------------------------- class JsonFormatter(logging.Formatter): """Emit one JSON object per log record.""" def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}", "level": record.levelname, "logger": record.name, "module": record.module, "line": record.lineno, "msg": record.getMessage(), } for field in _CONTEXT_FIELDS: val = getattr(record, field, None) if val and val != _PLACEHOLDER: payload[field] = val # Prefer the pre-masked exc_text cached by SecretMaskingFilter over # re-formatting from exc_info, which would bypass the mask. if record.exc_text: payload["exc"] = record.exc_text elif record.exc_info: payload["exc"] = self.formatException(record.exc_info) if record.stack_info: payload["stack"] = record.stack_info return json.dumps(payload, ensure_ascii=False, default=str) # --------------------------------------------------------------------------- # Text formatter # --------------------------------------------------------------------------- # Keeps all context fields on one line so grep-by-field works. Empty values # are rendered as "-" by the record factory to avoid KeyError if a record # arrives without the filter. _TEXT_FORMAT = ( "%(asctime)s %(levelname)-7s %(name)s:%(lineno)d " "[req=%(request_id)s cmd=%(command)s bot=%(bot_id)s chat=%(chat_id)s disp=%(dispatch_id)s] " "%(message)s" ) # --------------------------------------------------------------------------- # Level override parsing # --------------------------------------------------------------------------- _VALID_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NOTSET"} def parse_level_overrides(raw: str) -> dict[str, str]: """Parse ``module=LEVEL,module2=LEVEL`` into a mapping of validated levels. Invalid entries (bad format, unknown level) are silently dropped — a malformed env var or DB setting must not crash boot. """ result: dict[str, str] = {} for chunk in (raw or "").split(","): chunk = chunk.strip() if not chunk or "=" not in chunk: continue mod, _, lvl = chunk.partition("=") mod = mod.strip() lvl = lvl.strip().upper() if not mod or lvl not in _VALID_LEVELS: continue result[mod] = lvl return result def _normalize_level(level: str | None, default: str = "INFO") -> str: if not level: return default up = level.strip().upper() return up if up in _VALID_LEVELS else default # --------------------------------------------------------------------------- # Setup + live apply # --------------------------------------------------------------------------- # Libraries we quiet by default — noisy at DEBUG and almost always irrelevant # to a service issue. Override via LOG_LEVELS=sqlalchemy.engine=DEBUG when # actually debugging. _NOISY_LIBRARY_DEFAULTS: dict[str, str] = { "sqlalchemy": "WARNING", "sqlalchemy.engine": "WARNING", "sqlalchemy.pool": "WARNING", "aiohttp": "WARNING", "aiohttp.access": "WARNING", "aiohttp.client": "WARNING", "aiohttp.server": "WARNING", "apscheduler": "WARNING", "apscheduler.scheduler": "WARNING", "apscheduler.executors.default": "WARNING", "urllib3": "WARNING", "asyncio": "WARNING", "httpx": "WARNING", "httpcore": "WARNING", "PIL": "WARNING", "uvicorn.access": "WARNING", } def setup_logging( *, level: str = "INFO", fmt: str = "text", per_module_levels: str = "", ) -> None: """Install the logging configuration. Safe to call more than once. Args: level: Root log level (applied to ``notify_bridge_*`` loggers). fmt: ``"text"`` (default) or ``"json"``. per_module_levels: ``mod=LEVEL,mod2=LEVEL`` overrides. Wins over the root level for the listed loggers. """ root_level = _normalize_level(level, "INFO") overrides = parse_level_overrides(per_module_levels) # Install the context-aware record factory (idempotent — setting the same # factory twice is fine because ``_original_factory`` is captured at # import time). logging.setLogRecordFactory(_context_record_factory) if fmt == "json": formatters = {"default": {"()": f"{__name__}.JsonFormatter"}} else: formatters = { "default": { "format": _TEXT_FORMAT, "datefmt": "%Y-%m-%d %H:%M:%S", } } # Start with noisy-library defaults, then layer user overrides on top so # the user can raise them to DEBUG when actually debugging. loggers: dict[str, dict[str, Any]] = {} for mod, lvl in _NOISY_LIBRARY_DEFAULTS.items(): loggers[mod] = {"level": lvl, "propagate": True} # App loggers follow the root level unless overridden. loggers["notify_bridge_server"] = {"level": root_level, "propagate": True} loggers["notify_bridge_core"] = {"level": root_level, "propagate": True} # User overrides win. for mod, lvl in overrides.items(): loggers[mod] = {"level": lvl, "propagate": True} config: dict[str, Any] = { "version": 1, "disable_existing_loggers": False, "filters": { "mask_secrets": {"()": f"{__name__}.SecretMaskingFilter"}, }, "formatters": formatters, "handlers": { "stderr": { "class": "logging.StreamHandler", "stream": sys.stderr, "formatter": "default", "filters": ["mask_secrets"], }, }, "root": { "level": root_level, "handlers": ["stderr"], }, "loggers": loggers, } logging.config.dictConfig(config) def apply_log_levels( *, level: str | None, per_module_levels: str | None, ) -> None: """Update existing logger levels in-place without rebuilding handlers. Called when an admin changes the log settings at runtime. Setting ``level`` to None leaves the root untouched; setting it to a valid level applies to ``notify_bridge_server`` / ``notify_bridge_core``. ``per_module_levels`` is treated as an exclusive set — loggers that previously had an override but aren't in the new string are reset *toward* the root level so a removed override actually takes effect. """ if level: lvl = _normalize_level(level, "INFO") logging.getLogger("notify_bridge_server").setLevel(lvl) logging.getLogger("notify_bridge_core").setLevel(lvl) # NOTSET on root is almost never what you want — keep root where it is # unless the caller explicitly set something. logging.getLogger().setLevel(lvl) if per_module_levels is not None: overrides = parse_level_overrides(per_module_levels) # Apply new overrides for mod, lvl in overrides.items(): logging.getLogger(mod).setLevel(lvl) # Reset noisy libs that aren't in the new overrides back to defaults for mod, default_lvl in _NOISY_LIBRARY_DEFAULTS.items(): if mod not in overrides: logging.getLogger(mod).setLevel(default_lvl)