"""日志装配。 两个格式档位: - ``text``(默认):人类可读,本地开发用 ``tail`` 看 - ``json``:一行一条 JSON,便于采集到 ELK/Loki trace_id 通过 ``obs.tracing.get_trace_id`` 动态注入——用 filter 而非 formatter 参数,是为了让每条日志自动带上当前上下文的 trace_id,调用方 不需要在每条 log 里手动传。 """ from __future__ import annotations import json import logging import sys from datetime import datetime from obs.tracing import get_trace_id _TEXT_FMT = "%(asctime)s %(levelname)-5s [%(trace_id)s] %(name)s | %(message)s" _DATE_FMT = "%Y-%m-%d %H:%M:%S" # 这些第三方 logger 在 INFO 级别过于聒噪,统一压到 WARNING _NOISY_LOGGERS = ( "httpx", "httpcore", "urllib3", "apscheduler.executors.default", "apscheduler.scheduler", "openai", ) class _TraceIdFilter(logging.Filter): """把当前上下文的 trace_id 注入 LogRecord,缺省为 '-'。""" def filter(self, record: logging.LogRecord) -> bool: record.trace_id = get_trace_id() or "-" return True class _JsonFormatter(logging.Formatter): """一行一条 JSON 的格式化器。""" def format(self, record: logging.LogRecord) -> str: payload = { "ts": datetime.fromtimestamp(record.created).isoformat(timespec="milliseconds"), "level": record.levelname, "logger": record.name, "trace_id": getattr(record, "trace_id", "-"), "message": record.getMessage(), } if record.exc_info: payload["exc"] = self.formatException(record.exc_info) # 允许调用方通过 logger.info("...", extra={"kv": {...}}) 附加结构化字段 kv = getattr(record, "kv", None) if isinstance(kv, dict): payload.update(kv) return json.dumps(payload, ensure_ascii=False) def setup_logging(level: str = "INFO", fmt: str = "text") -> None: """初始化根 logger。重复调用安全(先清空已有 handler)。""" root = logging.getLogger() root.setLevel(getattr(logging, str(level).upper(), logging.INFO)) for handler in list(root.handlers): root.removeHandler(handler) handler = logging.StreamHandler(sys.stdout) handler.addFilter(_TraceIdFilter()) if str(fmt).lower() == "json": handler.setFormatter(_JsonFormatter()) else: handler.setFormatter(logging.Formatter(_TEXT_FMT, datefmt=_DATE_FMT)) root.addHandler(handler) for name in _NOISY_LOGGERS: logging.getLogger(name).setLevel(logging.WARNING) def get_logger(name: str) -> logging.Logger: """取 logger 的统一入口(留一层薄封装,将来换实现只改这里)。""" return logging.getLogger(name)