"""Stdlib logging setup — one stream handler on stdout, human or JSON. Workers used to print() everything; that gives no levels, no timestamps, and nothing Loki can parse. Default is a compact human format for local runs; set LOG_JSON=1 in the container so Grafana Alloy -> Loki gets structured fields (ts, level, logger, msg) plus any `extra=` keys a call site attaches. """ import json import logging import sys # logging.LogRecord built-ins we don't want to echo into a JSON line as "extra" fields. _RESERVED = set( logging.makeLogRecord({}).__dict__ ) | {"message", "asctime", "taskName"} class _JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: payload = { "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), "level": record.levelname, "logger": record.name, "msg": record.getMessage(), } for key, value in record.__dict__.items(): if key not in _RESERVED and not key.startswith("_"): payload[key] = value if record.exc_info: payload["exc"] = self.formatException(record.exc_info) return json.dumps(payload, default=str) def configure(level: str = "INFO", json_logs: bool = False) -> None: """Install a single stdout handler on the root logger (idempotent).""" handler = logging.StreamHandler(sys.stdout) if json_logs: handler.setFormatter(_JsonFormatter()) else: handler.setFormatter( logging.Formatter("%(asctime)s %(levelname)-5s %(name)s | %(message)s", "%H:%M:%S") ) root = logging.getLogger() root.handlers.clear() root.addHandler(handler) root.setLevel(level)