"""Centralized logging configuration.""" import logging import logging.handlers import secrets from contextvars import ContextVar # Transaction ID context (thread-safe per-request) _txn_id = ContextVar("txn_id", default="-") # Syslog facility mapping SYSLOG_FACILITIES = { "daemon": logging.handlers.SysLogHandler.LOG_DAEMON, "user": logging.handlers.SysLogHandler.LOG_USER, "local0": logging.handlers.SysLogHandler.LOG_LOCAL0, "local1": logging.handlers.SysLogHandler.LOG_LOCAL1, "local2": logging.handlers.SysLogHandler.LOG_LOCAL2, "local3": logging.handlers.SysLogHandler.LOG_LOCAL3, "local4": logging.handlers.SysLogHandler.LOG_LOCAL4, "local5": logging.handlers.SysLogHandler.LOG_LOCAL5, "local6": logging.handlers.SysLogHandler.LOG_LOCAL6, "local7": logging.handlers.SysLogHandler.LOG_LOCAL7, } def get_txn_id(): """Get current transaction ID.""" return _txn_id.get() def set_txn_id(txn_id=None): """Set transaction ID, generate if not provided.""" if txn_id is None: txn_id = secrets.token_hex(4) _txn_id.set(txn_id) return txn_id def clear_txn_id(): """Reset transaction ID to default.""" _txn_id.set("-") class TxnIdFilter(logging.Filter): """Inject txn_id into log records.""" def filter(self, record): record.txn_id = get_txn_id() return True class TxnIdFormatter(logging.Formatter): """Formatter that conditionally includes transaction ID.""" def __init__(self, fmt_with_txn, fmt_without_txn, datefmt=None): super().__init__(fmt_with_txn, datefmt) self.fmt_with_txn = fmt_with_txn self.fmt_without_txn = fmt_without_txn def format(self, record): if hasattr(record, "txn_id") and record.txn_id != "-": self._style._fmt = self.fmt_with_txn else: self._style._fmt = self.fmt_without_txn return super().format(record) def setup_logging( level="INFO", target="stdout", syslog_socket="/dev/log", syslog_facility="daemon", log_file="/var/log/ddns-service/ddns-service.log", log_file_size=52428800, log_versions=5, ): """ Configure global logging. Args: level: Log level name (DEBUG, INFO, WARNING, ERROR) target: "stdout", "syslog", or "file" syslog_socket: Path to syslog socket syslog_facility: Syslog facility name log_file: Path to log file (for target="file") log_file_size: Max log file size in bytes before rotation log_versions: Number of backup files to keep """ root = logging.getLogger() root.setLevel(getattr(logging, level.upper(), logging.INFO)) # Clear existing handlers root.handlers.clear() txn_filter = TxnIdFilter() if target == "syslog": facility = SYSLOG_FACILITIES.get( syslog_facility.lower(), logging.handlers.SysLogHandler.LOG_DAEMON, ) handler = logging.handlers.SysLogHandler( address=syslog_socket, facility=facility, ) formatter = TxnIdFormatter( "ddns-service[%(process)d]: [%(levelname)s] [%(txn_id)s] %(message)s", "ddns-service[%(process)d]: [%(levelname)s] %(message)s", ) elif target == "file": handler = logging.handlers.RotatingFileHandler( log_file, maxBytes=log_file_size, backupCount=log_versions, ) formatter = TxnIdFormatter( "%(asctime)s [%(levelname)s] [%(txn_id)s] %(message)s", "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) else: handler = logging.StreamHandler() formatter = TxnIdFormatter( "%(asctime)s [%(levelname)s] [%(txn_id)s] %(message)s", "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) handler.addFilter(txn_filter) handler.setFormatter(formatter) root.addHandler(handler) def disable_logging(): """Disable all logging (for CLI quiet mode).""" logging.disable(logging.CRITICAL) def enable_logging(): """Re-enable logging.""" logging.disable(logging.NOTSET)