"""Loguru-based structured logging configuration. All logs are written to server.log as JSON lines for full traceability. Stdlib logging is intercepted and funneled to loguru. Context vars (request_id, node_id, chat_id) from contextualize() are included at top level for easy grep/filter. """ import json import logging import re from pathlib import Path from loguru import logger _configured = False # Context keys we promote to top-level JSON for traceability _CONTEXT_KEYS = ("request_id", "node_id", "chat_id") _TELEGRAM_BOT_RE = re.compile( r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)", re.IGNORECASE, ) # Authorization: Bearer (HTTP client / proxy debug lines) _AUTH_BEARER_RE = re.compile( r"(\bAuthorization\s*:\s*Bearer\s+)([^\s'\"]+)", re.IGNORECASE, ) def _redact_sensitive_substrings(message: str) -> str: """Remove obvious API tokens and secrets before JSON log line emission.""" text = _TELEGRAM_BOT_RE.sub(r"\1bot\3", message) return _AUTH_BEARER_RE.sub(r"\1", text) def _serialize_with_context(record) -> str: """Format record as JSON with context vars at top level. Returns a format template; we inject _json into record for output. """ extra = record.get("extra", {}) out = { "time": str(record["time"]), "level": record["level"].name, "message": _redact_sensitive_substrings(str(record["message"])), "module": record["name"], "function": record["function"], "line": record["line"], } for key in _CONTEXT_KEYS: if key in extra and extra[key] is not None: out[key] = extra[key] record["_json"] = json.dumps(out, default=str) return "{_json}\n" class InterceptHandler(logging.Handler): """Redirect stdlib logging to loguru.""" def emit(self, record: logging.LogRecord) -> None: try: level = logger.level(record.levelname).name except ValueError: level = record.levelno frame, depth = logging.currentframe(), 2 while frame is not None and frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 logger.opt(depth=depth, exception=record.exc_info).log( level, record.getMessage() ) def configure_logging( log_file: str, *, force: bool = False, verbose_third_party: bool = False ) -> None: """Configure loguru with JSON output to log_file and intercept stdlib logging. Idempotent: skips if already configured (e.g. hot reload). Use force=True to reconfigure (e.g. in tests with a different log path). When ``verbose_third_party`` is false, noisy HTTP and Telegram loggers are capped at WARNING unless explicitly configured otherwise. """ global _configured if _configured and not force: return _configured = True # Remove default loguru handler (writes to stderr) logger.remove() # Truncate log file on fresh start for clean debugging Path(log_file).write_text("") # Add file sink: JSON lines, DEBUG level, context vars at top level logger.add( log_file, level="DEBUG", format=_serialize_with_context, encoding="utf-8", mode="a", rotation="50 MB", ) # Intercept stdlib logging: route all root logger output to loguru intercept = InterceptHandler() logging.root.handlers = [intercept] logging.root.setLevel(logging.DEBUG) third_party = ( "httpx", "httpcore", "httpcore.http11", "httpcore.connection", "telegram", "telegram.ext", ) for name in third_party: logging.getLogger(name).setLevel( logging.WARNING if not verbose_third_party else logging.NOTSET )