log_setup.py 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. """日志装配。
  2. 两个格式档位:
  3. - ``text``(默认):人类可读,本地开发用 ``tail`` 看
  4. - ``json``:一行一条 JSON,便于采集到 ELK/Loki
  5. trace_id 通过 ``obs.tracing.get_trace_id`` 动态注入——用 filter 而非
  6. formatter 参数,是为了让每条日志自动带上当前上下文的 trace_id,调用方
  7. 不需要在每条 log 里手动传。
  8. """
  9. from __future__ import annotations
  10. import json
  11. import logging
  12. import sys
  13. from datetime import datetime
  14. from obs.tracing import get_trace_id
  15. _TEXT_FMT = "%(asctime)s %(levelname)-5s [%(trace_id)s] %(name)s | %(message)s"
  16. _DATE_FMT = "%Y-%m-%d %H:%M:%S"
  17. # 这些第三方 logger 在 INFO 级别过于聒噪,统一压到 WARNING
  18. _NOISY_LOGGERS = (
  19. "httpx",
  20. "httpcore",
  21. "urllib3",
  22. "apscheduler.executors.default",
  23. "apscheduler.scheduler",
  24. "openai",
  25. )
  26. class _TraceIdFilter(logging.Filter):
  27. """把当前上下文的 trace_id 注入 LogRecord,缺省为 '-'。"""
  28. def filter(self, record: logging.LogRecord) -> bool:
  29. record.trace_id = get_trace_id() or "-"
  30. return True
  31. class _JsonFormatter(logging.Formatter):
  32. """一行一条 JSON 的格式化器。"""
  33. def format(self, record: logging.LogRecord) -> str:
  34. payload = {
  35. "ts": datetime.fromtimestamp(record.created).isoformat(timespec="milliseconds"),
  36. "level": record.levelname,
  37. "logger": record.name,
  38. "trace_id": getattr(record, "trace_id", "-"),
  39. "message": record.getMessage(),
  40. }
  41. if record.exc_info:
  42. payload["exc"] = self.formatException(record.exc_info)
  43. # 允许调用方通过 logger.info("...", extra={"kv": {...}}) 附加结构化字段
  44. kv = getattr(record, "kv", None)
  45. if isinstance(kv, dict):
  46. payload.update(kv)
  47. return json.dumps(payload, ensure_ascii=False)
  48. def setup_logging(level: str = "INFO", fmt: str = "text") -> None:
  49. """初始化根 logger。重复调用安全(先清空已有 handler)。"""
  50. root = logging.getLogger()
  51. root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
  52. for handler in list(root.handlers):
  53. root.removeHandler(handler)
  54. handler = logging.StreamHandler(sys.stdout)
  55. handler.addFilter(_TraceIdFilter())
  56. if str(fmt).lower() == "json":
  57. handler.setFormatter(_JsonFormatter())
  58. else:
  59. handler.setFormatter(logging.Formatter(_TEXT_FMT, datefmt=_DATE_FMT))
  60. root.addHandler(handler)
  61. for name in _NOISY_LOGGERS:
  62. logging.getLogger(name).setLevel(logging.WARNING)
  63. def get_logger(name: str) -> logging.Logger:
  64. """取 logger 的统一入口(留一层薄封装,将来换实现只改这里)。"""
  65. return logging.getLogger(name)