logging_setup.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """Logging setup owned by the auto_put_ad_mini example.
  2. The example is also run as standalone scripts, so this module deliberately
  3. does not depend on ``agent.utils.logging``. Logs go to the console and,
  4. when configured, to separate Alibaba Cloud SLS Logstores (INFO/WARNING and
  5. ERROR/CRITICAL). No local file logging is performed.
  6. """
  7. from __future__ import annotations
  8. import atexit
  9. import logging
  10. import os
  11. import random
  12. import string
  13. import sys
  14. import threading
  15. from datetime import datetime
  16. from typing import Iterable, Optional
  17. _FORMAT = "%Y-%m-%d %H:%M:%S"
  18. _LOCAL_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
  19. _SLS_LOG_FORMAT = "%(message)s"
  20. _SLS_HANDLERS: list[logging.Handler] = []
  21. _CAPTURE_INSTALLED = False
  22. _CONFIGURED = False
  23. _EXCEPTION_HOOKS_INSTALLED = False
  24. _ATEXIT_REGISTERED = False
  25. _TRACE_ID: str | None = None
  26. def get_trace_id() -> str | None:
  27. """Return the trace_id of the current session, or None if not yet initialised."""
  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. """Inject ``trace_id`` into every LogRecord for structured SLS queries.
  43. The filter reads from the module-level ``_TRACE_ID`` so that it works even
  44. when ``setup_logging()`` is called after the filter is added.
  45. """
  46. def filter(self, record: logging.LogRecord) -> bool:
  47. record.trace_id = _TRACE_ID or "-"
  48. return True
  49. class _SafeQueuedLogHandler:
  50. """Create an SDK handler whose emit failures cannot break the app logger,
  51. and whose timestamps are forced to second-level precision.
  52. The SLS protobuf schema includes ``optional fixed32 Time_ns`` — when set
  53. (even to 0) the console renders ``.000000000``. We strip it so only
  54. integer-second timestamps appear.
  55. """
  56. @staticmethod
  57. def create(handler_cls, **kwargs) -> logging.Handler:
  58. handler = handler_cls(**kwargs)
  59. # -- safe emit ----------------------------------------------------
  60. original_emit = handler.emit
  61. def safe_emit(record):
  62. try:
  63. original_emit(record)
  64. except Exception:
  65. logging.Handler.handleError(handler, record)
  66. handler.emit = safe_emit
  67. # -- strip Time_ns from protobuf before serialization -------------
  68. # The SDK populates LogItem.time_nano_part from ``time.time()``
  69. # rather than ``record.created``, injecting a random 9-digit value.
  70. # Even when forced to 0 the optional protobuf field *is* serialised
  71. # (fixed32 0 ≠ absent) and the console renders ``.000000000``.
  72. # Monkey-patching ``LogGroup.SerializeToString`` (called once per
  73. # batch inside ``LogClient.put_logs``) clears the field right before
  74. # encoding without duplicating the ~40-line SDK method.
  75. import aliyun.log.log_logs_pb2 as _sls_pb
  76. _orig_serialize = _sls_pb.LogGroup.SerializeToString
  77. original_send = handler.send
  78. def patched_send(req):
  79. def _no_nano_serialize(self):
  80. for log in self.Logs:
  81. if log.HasField("Time_ns"):
  82. log.ClearField("Time_ns")
  83. return _orig_serialize(self)
  84. _sls_pb.LogGroup.SerializeToString = _no_nano_serialize
  85. try:
  86. return original_send(req)
  87. finally:
  88. _sls_pb.LogGroup.SerializeToString = _orig_serialize
  89. handler.send = patched_send
  90. return handler
  91. class _CapturedStream:
  92. """Mirror a process stream into logging while preserving interactive output."""
  93. def __init__(self, original, level: int, logger_name: str):
  94. self._original = original
  95. self._level = level
  96. self._logger = logging.getLogger(logger_name)
  97. self._buffer = ""
  98. def write(self, value: str) -> int:
  99. if not value:
  100. return 0
  101. self._original.write(value)
  102. self._original.flush()
  103. self._buffer += value
  104. while "\n" in self._buffer:
  105. line, self._buffer = self._buffer.split("\n", 1)
  106. line = line.rstrip("\r")
  107. if line.strip():
  108. self._logger.log(
  109. self._level,
  110. "%s",
  111. line,
  112. extra={"captured_output": True},
  113. )
  114. return len(value)
  115. def flush(self) -> None:
  116. self._original.flush()
  117. if self._buffer.strip():
  118. self._logger.log(
  119. self._level,
  120. "%s",
  121. self._buffer.strip(),
  122. extra={"captured_output": True},
  123. )
  124. self._buffer = ""
  125. def isatty(self) -> bool:
  126. return self._original.isatty()
  127. def fileno(self) -> int:
  128. return self._original.fileno()
  129. @property
  130. def encoding(self):
  131. return getattr(self._original, "encoding", "utf-8")
  132. def _configured_sls_handlers(log_level: int) -> Iterable[logging.Handler]:
  133. try:
  134. from aliyun.log import QueuedLogHandler
  135. except ImportError:
  136. logging.getLogger(__name__).warning(
  137. "[sls] aliyun-log-python-sdk 未安装,跳过 SLS 上报"
  138. )
  139. return []
  140. sls_endpoint = os.getenv("SLS_ENDPOINT", "").strip()
  141. access_key_id = os.getenv("SLS_ACCESS_KEY_ID", "").strip()
  142. access_key_secret = os.getenv("SLS_ACCESS_KEY_SECRET", "").strip()
  143. project = os.getenv("SLS_PROJECT", "auto-put-tecent").strip()
  144. info_logstore = os.getenv(
  145. "SLS_INFO_LOGSTORE", os.getenv("SLS_LOGSTORE", "info-log")
  146. ).strip()
  147. error_logstore = os.getenv("SLS_ERROR_LOGSTORE", "error-log").strip()
  148. batch_size = int(os.getenv("SLS_BATCH_SIZE_MAX", "1024"))
  149. put_wait_ms = int(os.getenv("SLS_PUT_WAIT_MS", "2000"))
  150. sls_level = getattr(
  151. logging, os.getenv("SLS_LOG_LEVEL", "INFO").upper(), logging.INFO
  152. )
  153. if not all(
  154. (sls_endpoint, access_key_id, access_key_secret,
  155. project, info_logstore, error_logstore)
  156. ):
  157. logging.getLogger(__name__).warning(
  158. "[sls] SLS 凭证或 Logstore 未完整配置,跳过上报"
  159. )
  160. return []
  161. # SLS uses its own __time__ field, and level/name/func/file/line/thread are
  162. # already sent as separate fields via the ``fields`` list. Keep only the
  163. # message body to avoid redundant data cluttering the SLS console.
  164. sls_formatter = logging.Formatter(_SLS_LOG_FORMAT)
  165. common = dict(
  166. end_point=sls_endpoint,
  167. access_key_id=access_key_id,
  168. access_key=access_key_secret,
  169. project=project,
  170. fields=[
  171. "record_name", "level", "func_name", "module", "file_path", "line_no",
  172. "process_id", "process_name", "thread_id", "thread_name",
  173. ],
  174. extract_kv=True,
  175. batch_size=batch_size,
  176. put_wait=max(float(put_wait_ms) / 1000, 0.1),
  177. )
  178. handlers = []
  179. for store, level_filter in (
  180. (info_logstore, _LevelFilter(sls_level, logging.WARNING)),
  181. (error_logstore, _LevelFilter(logging.ERROR)),
  182. ):
  183. handler = _SafeQueuedLogHandler.create(
  184. QueuedLogHandler, log_store=store, **common
  185. )
  186. handler.setLevel(sls_level if store == info_logstore else logging.ERROR)
  187. handler.addFilter(level_filter)
  188. handler.setFormatter(sls_formatter)
  189. handlers.append(handler)
  190. return handlers
  191. def attach_sls_handler(root_logger: Optional[logging.Logger] = None) -> bool:
  192. """Attach the example-owned SLS handlers once, returning whether enabled."""
  193. global _SLS_HANDLERS
  194. root_logger = root_logger or logging.getLogger()
  195. if _SLS_HANDLERS and all(handler in root_logger.handlers for handler in _SLS_HANDLERS):
  196. return True
  197. # 清理残留的旧 SLS handler(如 setup_logging 被多次调用导致部分 handler 残留)
  198. for old_handler in list(_SLS_HANDLERS):
  199. if old_handler in root_logger.handlers:
  200. root_logger.removeHandler(old_handler)
  201. try:
  202. old_handler.close()
  203. except Exception:
  204. pass
  205. _SLS_HANDLERS = []
  206. try:
  207. handlers = list(_configured_sls_handlers(root_logger.level or logging.INFO))
  208. for handler in handlers:
  209. root_logger.addHandler(handler)
  210. _SLS_HANDLERS = handlers
  211. if handlers:
  212. logging.getLogger(__name__).info(
  213. "[sls] 已挂载双 Logstore: project=%s info-log/error-log",
  214. os.getenv("SLS_PROJECT", "auto-put-tecent"),
  215. )
  216. return bool(handlers)
  217. except Exception:
  218. logging.getLogger(__name__).exception("[sls] handler 挂载失败,降级为本地日志")
  219. return False
  220. def _close_sls_handlers() -> None:
  221. for handler in _SLS_HANDLERS:
  222. try:
  223. handler.close()
  224. except Exception:
  225. pass
  226. def _install_exception_hooks() -> None:
  227. global _EXCEPTION_HOOKS_INSTALLED
  228. if _EXCEPTION_HOOKS_INSTALLED:
  229. return
  230. original_sys_hook = sys.excepthook
  231. def log_uncaught_exception(exc_type, exc_value, exc_traceback) -> None:
  232. if issubclass(exc_type, KeyboardInterrupt):
  233. original_sys_hook(exc_type, exc_value, exc_traceback)
  234. return
  235. logging.getLogger("auto_put_ad_mini.uncaught").critical(
  236. "event=uncaught_exception thread=main result=failed",
  237. exc_info=(exc_type, exc_value, exc_traceback),
  238. )
  239. def log_thread_exception(args: threading.ExceptHookArgs) -> None:
  240. logging.getLogger("auto_put_ad_mini.uncaught").critical(
  241. "event=uncaught_exception thread=%s result=failed",
  242. args.thread.name if args.thread else "unknown",
  243. exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
  244. )
  245. sys.excepthook = log_uncaught_exception
  246. threading.excepthook = log_thread_exception
  247. _EXCEPTION_HOOKS_INSTALLED = True
  248. def setup_logging(
  249. level: str = "INFO",
  250. capture_output: bool = True,
  251. ) -> None:
  252. """Configure root logging: console + optional SLS, no local file."""
  253. global _CAPTURE_INSTALLED, _CONFIGURED, _TRACE_ID
  254. # Generate a per-session trace_id once per process lifetime.
  255. if _TRACE_ID is None:
  256. _TRACE_ID = _generate_trace_id()
  257. log_level = getattr(logging, str(level).upper(), logging.INFO)
  258. stdout = sys.__stdout__
  259. stderr = sys.__stderr__
  260. trace_filter = _TraceIdFilter()
  261. console = logging.StreamHandler(stdout)
  262. console.setLevel(log_level)
  263. console.addFilter(_ExcludeCapturedOutput())
  264. console.addFilter(trace_filter)
  265. formatter = logging.Formatter(_LOCAL_LOG_FORMAT, datefmt=_FORMAT)
  266. console.setFormatter(formatter)
  267. root_logger = logging.getLogger()
  268. # Handler failures (for example an SLS network outage) must never write
  269. # recursive logging tracebacks to stderr or interrupt the business flow.
  270. logging.raiseExceptions = False
  271. if not _CONFIGURED:
  272. logging.basicConfig(
  273. level=log_level,
  274. handlers=[console],
  275. force=True,
  276. )
  277. _CONFIGURED = True
  278. else:
  279. root_logger.setLevel(log_level)
  280. for noisy in ("httpx", "httpcore", "urllib3", "apscheduler", "aliyun.log"):
  281. logging.getLogger(noisy).setLevel(logging.WARNING)
  282. attach_sls_handler(root_logger)
  283. _install_exception_hooks()
  284. if capture_output and not _CAPTURE_INSTALLED:
  285. sys.stdout = _CapturedStream(stdout, logging.INFO, "auto_put_ad_mini.stdout")
  286. sys.stderr = _CapturedStream(stderr, logging.ERROR, "auto_put_ad_mini.stderr")
  287. _CAPTURE_INSTALLED = True
  288. global _ATEXIT_REGISTERED
  289. if not _ATEXIT_REGISTERED:
  290. atexit.register(_close_sls_handlers)
  291. _ATEXIT_REGISTERED = True
  292. logging.getLogger(__name__).info("[logging] 日志初始化完成 trace_id=%s", _TRACE_ID)
  293. def _generate_trace_id() -> str:
  294. """Generate a compact, sortable, unique trace_id for one process invocation.
  295. Format: ``YYYYMMDD-HHMMSS-{8 random hex chars}``.
  296. ~16M combinations per second; sufficient for cron-triggered processes.
  297. """
  298. now = datetime.now()
  299. ts = now.strftime("%Y%m%d-%H%M%S")
  300. suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
  301. return f"{ts}-{suffix}"