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 os
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any

import colorlog

#: 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",
)

logger = logging.getLogger(__name__)


[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
#: Environment variable that selects the console logging level across all #: Klea CLIs (see :func:`resolve_log_level`). Read from the process #: environment only -- the app env files (e.g. ``klea_agent.env``) are #: loaded after logging is configured, so this var cannot be set there. KLEA_LOG_LEVEL_ENV = "KLEA_LOG_LEVEL"
[docs] def resolve_log_level(debug: bool = False) -> int: """Return the console logging level for the current process. Precedence, highest first: #. ``--debug`` (the *debug* flag) -- always ``DEBUG`` #. the :data:`KLEA_LOG_LEVEL_ENV` environment variable -- accepted as a case-insensitive level name (``debug``/``info``/``warning``/``error``/ ``critical``) or a numeric level #. ``INFO`` An unknown :data:`KLEA_LOG_LEVEL_ENV` value is logged as a warning and falls back to ``INFO``. Intended to be shared by every Klea CLI so the ``--debug`` flag and env var behave consistently across them. :param debug: ``True`` when the ``--debug`` flag was given :returns: A :mod:`logging` level constant """ if debug: return logging.DEBUG raw = os.environ.get(KLEA_LOG_LEVEL_ENV) if not raw: return logging.INFO if raw.strip().lstrip("-").isdigit(): level = int(raw) else: try: level = logging.getLevelName(raw.upper()) except TypeError: level = 0 if isinstance(level, int) and logging.DEBUG <= level <= logging.CRITICAL: return level logger.warning(f"Unknown {KLEA_LOG_LEVEL_ENV}={raw!r}; using INFO") return logging.INFO
[docs] def enable_debug_logging() -> None: """Set :data:`KLEA_LOG_LEVEL_ENV` to ``debug`` in this process. Called by a ``--debug`` flag on a CLI that spawns child processes (a client starting a server, a serve command) so the child resolves ``DEBUG`` via :func:`resolve_log_level` -- environment variables are inherited across the subprocess boundary. """ os.environ[KLEA_LOG_LEVEL_ENV] = "debug"
#: Console formatters colorize each line by level (whole-line color via the #: ``%(log_color)s`` prefix). The file formatter stays plain so ANSI escape #: codes never end up in log files. logger_formatter_info = colorlog.ColoredFormatter( "%(log_color)s%(asctime)s %(name)s (%(levelname)s) >>> %(message)s\n\n" ) logger_formatter_other = colorlog.ColoredFormatter( "%(log_color)s%(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.INFO, 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. The console default is ``INFO`` (progress on stdout, warnings and errors on stderr) -- end users see no debug noise. Pass ``stderr_level=logging.DEBUG`` (e.g. from :func:`resolve_log_level` when ``--debug`` / ``KLEA_LOG_LEVEL`` request it) to surface full detail on the console. The rotating log file always captures ``DEBUG`` regardless, so verbose logs remain available for diagnostics. :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 ``INFO``) :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 matching value to prevent secrets (API keys, tokens) from appearing in plaintext in log output. Recurses into nested dicts, so e.g. ``{"model_overrides": {"chat": {"api_key": ...}}}`` is sanitized too. :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. """ keys = sensitive_keys or {"api_key"} safe: dict[str, Any] = {} for key, val in data.items(): if isinstance(val, dict): safe[key] = mask_sensitive(val, sensitive_keys=keys) elif key in keys and val: safe[key] = f"...{str(val)[-4:]}" else: safe[key] = val return safe