"""Production-grade logging configuration for Notification Microservice.""" import json import logging import logging.handlers import os import sys import traceback from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict SERVICE_NAME = "notification-ms" LOG_DIR = Path(os.getenv("LOG_DIR", "logs")) LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", 50 * 1024 * 1024)) LOG_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", "10")) class JSONFormatter(logging.Formatter): """Emit log records as single-line JSON objects.""" RESERVED = frozenset( { "args", "created", "exc_info", "exc_text", "filename", "funcName", "levelname", "levelno", "lineno", "message", "module", "msecs", "msg", "name", "pathname", "process", "processName", "relativeCreated", "stack_info", "thread", "threadName", } ) def format(self, record: logging.LogRecord) -> str: payload: Dict[str, Any] = { "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "service": SERVICE_NAME, "pid": record.process, } if record.levelno >= logging.WARNING: payload["caller"] = f"{record.pathname}:{record.lineno}" for key, val in record.__dict__.items(): if key not in self.RESERVED and not key.startswith("_"): payload[key] = val if record.exc_info and record.exc_info[0] is not None: exc_type, exc_value, exc_tb = record.exc_info payload["exception"] = { "type": exc_type.__name__, "message": str(exc_value), "stacktrace": traceback.format_exception(exc_type, exc_value, exc_tb), } return json.dumps(payload, default=str) class ConsoleFormatter(logging.Formatter): """Readable formatter for local development.""" FMT = "%(asctime)s %(levelname)-8s %(name)s — %(message)s" def format(self, record: logging.LogRecord) -> str: formatter = logging.Formatter(self.FMT, datefmt="%Y-%m-%d %H:%M:%S") result = formatter.format(record) extras = { k: v for k, v in record.__dict__.items() if k not in JSONFormatter.RESERVED and not k.startswith("_") and k != "color_message" } if extras: result += f" {extras}" return result def setup_logging(log_level: str = "INFO") -> None: """Configure root logger with JSON/console and rotating file handlers.""" numeric_level = getattr(logging, log_level.upper(), logging.INFO) log_format = os.getenv("LOG_FORMAT", "json").lower() LOG_DIR.mkdir(parents=True, exist_ok=True) root = logging.getLogger() root.setLevel(numeric_level) root.handlers.clear() stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(numeric_level) stdout_handler.setFormatter(ConsoleFormatter() if log_format == "console" else JSONFormatter()) root.addHandler(stdout_handler) app_log_path = LOG_DIR / "app.log" file_handler = logging.handlers.RotatingFileHandler( filename=app_log_path, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, encoding="utf-8", ) file_handler.setLevel(numeric_level) file_handler.setFormatter(JSONFormatter()) root.addHandler(file_handler) error_log_path = LOG_DIR / "error.log" error_handler = logging.handlers.RotatingFileHandler( filename=error_log_path, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, encoding="utf-8", ) error_handler.setLevel(logging.ERROR) error_handler.setFormatter(JSONFormatter()) root.addHandler(error_handler) for noisy in ("motor", "pymongo", "httpx", "uvicorn.access", "aioredis"): logging.getLogger(noisy).setLevel(logging.WARNING) startup_logger = logging.getLogger(SERVICE_NAME) startup_logger.info( "Logging initialised", extra={ "event": "logging_init", "log_level": log_level.upper(), "log_format": log_format, "log_dir": str(LOG_DIR.resolve()), "app_log": str(app_log_path.resolve()), "error_log": str(error_log_path.resolve()), }, ) def get_logger(name: str) -> logging.Logger: """Return a standard logger for the given module name.""" return logging.getLogger(name)