"""Shared logging for James components. Usage: from james.lib.log import get_logger log = get_logger("watcher") log.info("started", interval=30) log.error("redis connect failed", exc_info=True) Output is JSON-lines to stderr (captured by systemd journal). Set JAMES_LOG_LEVEL to DEBUG/INFO/WARNING/ERROR (default: INFO). """ import json import logging import os import sys import time from typing import Any class JsonFormatter(logging.Formatter): """Formats log records as single-line JSON for easy parsing.""" def format(self, record: logging.LogRecord) -> str: entry: dict[str, Any] = { "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)), "level": record.levelname.lower(), "component": record.name, "msg": record.getMessage(), } # Merge extra fields passed via log.info("msg", extra={"k": "v"}) # or via the convenience wrapper below. extras = getattr(record, "_extra_fields", None) if extras: entry.update(extras) if record.exc_info and record.exc_info[1] is not None: entry["error"] = str(record.exc_info[1]) entry["error_type"] = type(record.exc_info[1]).__name__ return json.dumps(entry, default=str) class JamesLogger(logging.Logger): """Logger subclass that accepts keyword args as structured fields.""" def _log(self, level, msg, args, exc_info=None, extra=None, stack_info=False, stacklevel=1, **kwargs): if extra is None: extra = {} extra["_extra_fields"] = kwargs super()._log(level, msg, args, exc_info=exc_info, extra=extra, stack_info=stack_info, stacklevel=stacklevel + 1) def get_logger(component: str) -> JamesLogger: """Return a named JSON logger for a James component.""" logging.setLoggerClass(JamesLogger) logger = logging.getLogger(f"james.{component}") if not logger.handlers: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(JsonFormatter()) logger.addHandler(handler) level = os.environ.get("JAMES_LOG_LEVEL", "INFO").upper() logger.setLevel(getattr(logging, level, logging.INFO)) return logger # type: ignore[return-value]