logging_setup.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. """auto_put_ad_mini 生产任务共用的日志配置。
  2. 这些任务也会作为独立脚本执行,因此本模块不依赖 ``agent.utils.logging``。
  3. 日志始终输出到控制台;配置阿里云 SLS 后,普通日志与错误日志分别写入对应
  4. Logstore,不再额外写本地日志文件。
  5. """
  6. from __future__ import annotations
  7. import atexit
  8. import logging
  9. import os
  10. import random
  11. import string
  12. import sys
  13. import threading
  14. from datetime import datetime
  15. from typing import Iterable, Optional
  16. _FORMAT = "%Y-%m-%d %H:%M:%S"
  17. _LOCAL_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
  18. _SLS_LOG_FORMAT = "%(message)s"
  19. _SLS_HANDLERS: list[logging.Handler] = []
  20. _CAPTURE_INSTALLED = False
  21. _CONFIGURED = False
  22. _EXCEPTION_HOOKS_INSTALLED = False
  23. _ATEXIT_REGISTERED = False
  24. _TRACE_ID: str | None = None
  25. _TRACE_ID_ENV = "AUTO_PUT_TRACE_ID"
  26. def get_trace_id() -> str | None:
  27. """返回当前进程的 trace_id;日志尚未初始化时返回 None。"""
  28. return _TRACE_ID
  29. class _LevelFilter(logging.Filter):
  30. def __init__(self, minimum: int, maximum: Optional[int] = None):
  31. super().__init__()
  32. self.minimum = minimum
  33. self.maximum = maximum
  34. def filter(self, record: logging.LogRecord) -> bool:
  35. return record.levelno >= self.minimum and (
  36. self.maximum is None or record.levelno <= self.maximum
  37. )
  38. class _ExcludeCapturedOutput(logging.Filter):
  39. def filter(self, record: logging.LogRecord) -> bool:
  40. return not getattr(record, "captured_output", False)
  41. class _TraceIdFilter(logging.Filter):
  42. """向每条日志注入 ``trace_id``,供 SLS 按一次任务执行链路检索。
  43. 过滤器从模块级 ``_TRACE_ID`` 读取值,因此先挂载过滤器、后执行
  44. ``setup_logging()`` 的调用顺序也能正常工作。
  45. """
  46. def filter(self, record: logging.LogRecord) -> bool:
  47. record.trace_id = _TRACE_ID or "-"
  48. return True
  49. class _SafeQueuedLogHandler:
  50. """创建不会因 SLS 发送失败而中断业务的 SDK handler。
  51. 同时移除 SLS protobuf 中的 ``Time_ns`` 可选字段,避免控制台时间戳显示
  52. 无意义的九位纳秒尾数,只保留秒级时间。
  53. """
  54. @staticmethod
  55. def create(handler_cls, **kwargs) -> logging.Handler:
  56. handler = handler_cls(**kwargs)
  57. # 包装 emit,SLS 网络异常只进入 logging 自身错误处理,不影响业务线程。
  58. original_emit = handler.emit
  59. def safe_emit(record):
  60. try:
  61. original_emit(record)
  62. except Exception:
  63. logging.Handler.handleError(handler, record)
  64. handler.emit = safe_emit
  65. # SDK 使用 ``time.time()`` 填充 LogItem.time_nano_part,而不是使用
  66. # ``record.created``,会生成随机的九位尾数。即使强制设为 0,该可选字段
  67. # 仍会被序列化并显示 ``.000000000``。这里在每批日志序列化前临时清除
  68. # ``Time_ns``,避免复制 SDK 内部约 40 行的发送实现。
  69. import aliyun.log.log_logs_pb2 as _sls_pb
  70. _orig_serialize = _sls_pb.LogGroup.SerializeToString
  71. original_send = handler.send
  72. def patched_send(req):
  73. def _no_nano_serialize(self):
  74. for log in self.Logs:
  75. if log.HasField("Time_ns"):
  76. log.ClearField("Time_ns")
  77. return _orig_serialize(self)
  78. _sls_pb.LogGroup.SerializeToString = _no_nano_serialize
  79. try:
  80. return original_send(req)
  81. finally:
  82. _sls_pb.LogGroup.SerializeToString = _orig_serialize
  83. handler.send = patched_send
  84. return handler
  85. class _CapturedStream:
  86. """保留终端原始输出,同时把 stdout/stderr 镜像到统一日志。"""
  87. def __init__(self, original, level: int, logger_name: str):
  88. self._original = original
  89. self._level = level
  90. self._logger = logging.getLogger(logger_name)
  91. self._buffer = ""
  92. def write(self, value: str) -> int:
  93. if not value:
  94. return 0
  95. self._original.write(value)
  96. self._original.flush()
  97. self._buffer += value
  98. while "\n" in self._buffer:
  99. line, self._buffer = self._buffer.split("\n", 1)
  100. line = line.rstrip("\r")
  101. if line.strip():
  102. self._logger.log(
  103. self._level,
  104. "%s",
  105. line,
  106. extra={"captured_output": True},
  107. )
  108. return len(value)
  109. def flush(self) -> None:
  110. self._original.flush()
  111. if self._buffer.strip():
  112. self._logger.log(
  113. self._level,
  114. "%s",
  115. self._buffer.strip(),
  116. extra={"captured_output": True},
  117. )
  118. self._buffer = ""
  119. def isatty(self) -> bool:
  120. return self._original.isatty()
  121. def fileno(self) -> int:
  122. return self._original.fileno()
  123. @property
  124. def encoding(self):
  125. return getattr(self._original, "encoding", "utf-8")
  126. def _configured_sls_handlers(log_level: int) -> Iterable[logging.Handler]:
  127. try:
  128. from aliyun.log import QueuedLogHandler
  129. except ImportError:
  130. logging.getLogger(__name__).warning(
  131. "[sls] aliyun-log-python-sdk 未安装,跳过 SLS 上报"
  132. )
  133. return []
  134. sls_endpoint = os.getenv("SLS_ENDPOINT", "").strip()
  135. access_key_id = os.getenv("SLS_ACCESS_KEY_ID", "").strip()
  136. access_key_secret = os.getenv("SLS_ACCESS_KEY_SECRET", "").strip()
  137. project = os.getenv("SLS_PROJECT", "auto-put-tecent").strip()
  138. info_logstore = os.getenv(
  139. "SLS_INFO_LOGSTORE", os.getenv("SLS_LOGSTORE", "info-log")
  140. ).strip()
  141. error_logstore = os.getenv("SLS_ERROR_LOGSTORE", "error-log").strip()
  142. batch_size = int(os.getenv("SLS_BATCH_SIZE_MAX", "1024"))
  143. put_wait_ms = int(os.getenv("SLS_PUT_WAIT_MS", "2000"))
  144. sls_level = getattr(
  145. logging, os.getenv("SLS_LOG_LEVEL", "INFO").upper(), logging.INFO
  146. )
  147. if not all(
  148. (sls_endpoint, access_key_id, access_key_secret,
  149. project, info_logstore, error_logstore)
  150. ):
  151. logging.getLogger(__name__).warning(
  152. "[sls] SLS 凭证或 Logstore 未完整配置,跳过上报"
  153. )
  154. return []
  155. # SLS 自带 __time__ 字段,级别、模块、文件、行号和线程也已通过 fields
  156. # 单独上报,因此正文只保留 message,避免控制台出现重复字段。
  157. sls_formatter = logging.Formatter(_SLS_LOG_FORMAT)
  158. common = dict(
  159. end_point=sls_endpoint,
  160. access_key_id=access_key_id,
  161. access_key=access_key_secret,
  162. project=project,
  163. fields=[
  164. "record_name", "level", "func_name", "module", "file_path", "line_no",
  165. "process_id", "process_name", "thread_id", "thread_name",
  166. ],
  167. extract_kv=True,
  168. batch_size=batch_size,
  169. put_wait=max(float(put_wait_ms) / 1000, 0.1),
  170. )
  171. handlers = []
  172. for store, level_filter in (
  173. (info_logstore, _LevelFilter(sls_level, logging.WARNING)),
  174. (error_logstore, _LevelFilter(logging.ERROR)),
  175. ):
  176. handler = _SafeQueuedLogHandler.create(
  177. QueuedLogHandler, log_store=store, **common
  178. )
  179. handler.setLevel(sls_level if store == info_logstore else logging.ERROR)
  180. handler.addFilter(level_filter)
  181. handler.setFormatter(sls_formatter)
  182. handlers.append(handler)
  183. return handlers
  184. def attach_sls_handler(root_logger: Optional[logging.Logger] = None) -> bool:
  185. """幂等挂载本项目的 SLS handlers,并返回是否已启用 SLS。"""
  186. global _SLS_HANDLERS
  187. root_logger = root_logger or logging.getLogger()
  188. if _SLS_HANDLERS and all(handler in root_logger.handlers for handler in _SLS_HANDLERS):
  189. return True
  190. # 清理残留的旧 SLS handler(如 setup_logging 被多次调用导致部分 handler 残留)
  191. for old_handler in list(_SLS_HANDLERS):
  192. if old_handler in root_logger.handlers:
  193. root_logger.removeHandler(old_handler)
  194. try:
  195. old_handler.close()
  196. except Exception:
  197. pass
  198. _SLS_HANDLERS = []
  199. try:
  200. handlers = list(_configured_sls_handlers(root_logger.level or logging.INFO))
  201. for handler in handlers:
  202. root_logger.addHandler(handler)
  203. _SLS_HANDLERS = handlers
  204. if handlers:
  205. logging.getLogger(__name__).info(
  206. "[sls] 已挂载双 Logstore: project=%s info-log/error-log",
  207. os.getenv("SLS_PROJECT", "auto-put-tecent"),
  208. )
  209. return bool(handlers)
  210. except Exception:
  211. logging.getLogger(__name__).exception("[sls] handler 挂载失败,降级为本地日志")
  212. return False
  213. def _close_sls_handlers() -> None:
  214. for handler in _SLS_HANDLERS:
  215. try:
  216. handler.close()
  217. except Exception:
  218. pass
  219. def _install_exception_hooks() -> None:
  220. global _EXCEPTION_HOOKS_INSTALLED
  221. if _EXCEPTION_HOOKS_INSTALLED:
  222. return
  223. original_sys_hook = sys.excepthook
  224. def log_uncaught_exception(exc_type, exc_value, exc_traceback) -> None:
  225. if issubclass(exc_type, KeyboardInterrupt):
  226. original_sys_hook(exc_type, exc_value, exc_traceback)
  227. return
  228. logging.getLogger("auto_put_ad_mini.uncaught").critical(
  229. "event=uncaught_exception thread=main result=failed",
  230. exc_info=(exc_type, exc_value, exc_traceback),
  231. )
  232. def log_thread_exception(args: threading.ExceptHookArgs) -> None:
  233. logging.getLogger("auto_put_ad_mini.uncaught").critical(
  234. "event=uncaught_exception thread=%s result=failed",
  235. args.thread.name if args.thread else "unknown",
  236. exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
  237. )
  238. sys.excepthook = log_uncaught_exception
  239. threading.excepthook = log_thread_exception
  240. _EXCEPTION_HOOKS_INSTALLED = True
  241. def setup_logging(
  242. level: str = "INFO",
  243. capture_output: bool = True,
  244. ) -> None:
  245. """配置根日志:控制台加可选 SLS,不写本地文件。"""
  246. global _CAPTURE_INSTALLED, _CONFIGURED, _TRACE_ID
  247. # 调度器启动的子进程继承任务专属 trace_id;独立运行的脚本自行生成。
  248. if _TRACE_ID is None:
  249. _TRACE_ID = _inherited_trace_id() or _generate_trace_id()
  250. log_level = getattr(logging, str(level).upper(), logging.INFO)
  251. stdout = sys.__stdout__
  252. stderr = sys.__stderr__
  253. trace_filter = _TraceIdFilter()
  254. console = logging.StreamHandler(stdout)
  255. console.setLevel(log_level)
  256. console.addFilter(_ExcludeCapturedOutput())
  257. console.addFilter(trace_filter)
  258. formatter = logging.Formatter(_LOCAL_LOG_FORMAT, datefmt=_FORMAT)
  259. console.setFormatter(formatter)
  260. root_logger = logging.getLogger()
  261. # handler 失败(例如 SLS 网络中断)不能递归向 stderr 打印异常,更不能中断业务。
  262. logging.raiseExceptions = False
  263. if not _CONFIGURED:
  264. logging.basicConfig(
  265. level=log_level,
  266. handlers=[console],
  267. force=True,
  268. )
  269. _CONFIGURED = True
  270. else:
  271. root_logger.setLevel(log_level)
  272. for noisy in ("httpx", "httpcore", "urllib3", "apscheduler", "aliyun.log"):
  273. logging.getLogger(noisy).setLevel(logging.WARNING)
  274. attach_sls_handler(root_logger)
  275. _install_exception_hooks()
  276. if capture_output and not _CAPTURE_INSTALLED:
  277. sys.stdout = _CapturedStream(stdout, logging.INFO, "auto_put_ad_mini.stdout")
  278. sys.stderr = _CapturedStream(stderr, logging.ERROR, "auto_put_ad_mini.stderr")
  279. _CAPTURE_INSTALLED = True
  280. global _ATEXIT_REGISTERED
  281. if not _ATEXIT_REGISTERED:
  282. atexit.register(_close_sls_handlers)
  283. _ATEXIT_REGISTERED = True
  284. logging.getLogger(__name__).info("[logging] 日志初始化完成 trace_id=%s", _TRACE_ID)
  285. def _generate_trace_id() -> str:
  286. """为一次进程执行生成紧凑、可排序且近似唯一的 trace_id。
  287. 格式为 ``YYYYMMDD-HHMMSS-{8位随机字符}``,足以区分定时任务的并发执行。
  288. """
  289. now = datetime.now()
  290. ts = now.strftime("%Y%m%d-%H%M%S")
  291. suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
  292. return f"{ts}-{suffix}"
  293. def _inherited_trace_id() -> str | None:
  294. """读取并校验调度器传入的 trace_id;不存在或不安全时返回 None。"""
  295. raw = os.getenv(_TRACE_ID_ENV, "").strip()
  296. if not raw or len(raw) > 160:
  297. return None
  298. allowed_punctuation = frozenset("-_.:")
  299. if not all(
  300. character.isalnum() or character in allowed_punctuation
  301. for character in raw
  302. ):
  303. return None
  304. return raw