Source code for klea_utils.plogging

#!/usr/bin/env python3
"""
Logging related utils

File: klea_utils/plogging.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any

#: Klea logger namespaces that are turned up to DEBUG by
#: ``setup_root_logger``.  Everything else (third-party libraries) inherits
#: the root logger's INFO level, so their DEBUG output is filtered at the
#: source without having to enumerate them.
KLEA_LOG_NAMESPACES = (
    "klea_utils",
    "klea_rag",
    "klea_agent",
    "neuroml_mcp",
)


[docs] class LoggerNotInfoFilter(logging.Filter): """Allow only non INFO messages"""
[docs] def filter(self, record): return record.levelno != logging.INFO
[docs] class LoggerInfoFilter(logging.Filter): """Allow only INFO messages"""
[docs] def filter(self, record): return record.levelno == logging.INFO
logger_formatter_info = logging.Formatter( "%(asctime)s %(name)s (%(levelname)s) >>> %(message)s\n\n" ) logger_formatter_other = logging.Formatter( "%(asctime)s %(name)s (%(levelname)s) in '%(funcName)s' >>> %(message)s\n\n" ) logger_formatter_file = logging.Formatter( "%(asctime)s %(name)s (%(levelname)s) in '%(funcName)s' >>> %(message)s" )
[docs] def setup_root_logger( app_name: str, stderr_level: int = logging.DEBUG, log_dir: str | Path | None = None, ) -> logging.Logger: """Configure the root logger once per process. Idempotent: if the root logger already has handlers, this is a no-op and the existing configuration is returned unchanged. Adds, on the root logger: * a stdout handler for INFO messages (simple format) * a stderr handler for all other levels at ``stderr_level`` (format includes the function name) * an optional ``RotatingFileHandler`` at ``{log_dir}/{app_name}.log`` logging all levels at DEBUG when ``log_dir`` is provided The root logger is set to INFO. The Klea logger namespaces (see ``KLEA_LOG_NAMESPACES``) and the application logger (``app_name``) are raised to DEBUG so our own logs are captured in full. Because module loggers propagate to the root logger by default, a single call from each application entry point routes all Klea logs (library modules, graph nodes, API routers) through the same console and file handlers. Third-party libraries inherit the root's INFO level, so their DEBUG output is filtered at the source without enumerating them. :param app_name: Application name, used as the log file name to keep per-app logs separate (e.g. ``"klea-rag"``). :param stderr_level: Level for the stderr handler (default ``DEBUG``) :param log_dir: Directory for the log file. ``None`` disables file logging. :returns: The configured root logger """ root = logging.getLogger() if root.handlers: return root root.setLevel(logging.INFO) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(logging.INFO) stdout_handler.addFilter(LoggerInfoFilter()) stdout_handler.setFormatter(logger_formatter_info) root.addHandler(stdout_handler) stderr_handler = logging.StreamHandler(sys.stderr) stderr_handler.setLevel(stderr_level) stderr_handler.addFilter(LoggerNotInfoFilter()) stderr_handler.setFormatter(logger_formatter_other) root.addHandler(stderr_handler) if log_dir: log_path = Path(log_dir) log_path.mkdir(parents=True, exist_ok=True) file_handler = RotatingFileHandler( log_path / f"{app_name}.log", maxBytes=1_000_000, backupCount=5, ) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logger_formatter_file) root.addHandler(file_handler) # Turn up verbosity for our own namespaces only. Third-party libraries # inherit root's INFO level, so their DEBUG is filtered without listing # them. Only applied when the logger's level is still NOTSET so an # explicit configuration always wins. for name in (*KLEA_LOG_NAMESPACES, app_name): klea_logger = logging.getLogger(name) if klea_logger.level == logging.NOTSET: klea_logger.setLevel(logging.DEBUG) return root
[docs] def mask_sensitive( data: dict[str, Any], sensitive_keys: set[str] | None = None, ) -> dict[str, Any]: """Return a copy with sensitive values masked for logging. Shows only the last 4 characters of each value to prevent secrets (API keys, tokens) from appearing in plaintext in log output. :param data: The dict to sanitize. :param sensitive_keys: Keys whose values should be masked. Defaults to ``{"api_key"}``. :returns: New dict with masked values. """ safe = dict(data) for key in sensitive_keys or {"api_key"}: if safe.get(key): val = str(safe[key]) safe[key] = f"...{val[-4:]}" return safe