| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361 |
- """Logging setup owned by the auto_put_ad_mini example.
- The example is also run as standalone scripts, so this module deliberately
- does not depend on ``agent.utils.logging``. Logs go to the console and,
- when configured, to separate Alibaba Cloud SLS Logstores (INFO/WARNING and
- ERROR/CRITICAL). No local file logging is performed.
- """
- from __future__ import annotations
- import atexit
- import logging
- import os
- import random
- import string
- import sys
- import threading
- from datetime import datetime
- from typing import Iterable, Optional
- _FORMAT = "%Y-%m-%d %H:%M:%S"
- _LOCAL_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
- _SLS_LOG_FORMAT = "%(message)s"
- _SLS_HANDLERS: list[logging.Handler] = []
- _CAPTURE_INSTALLED = False
- _CONFIGURED = False
- _EXCEPTION_HOOKS_INSTALLED = False
- _ATEXIT_REGISTERED = False
- _TRACE_ID: str | None = None
- def get_trace_id() -> str | None:
- """Return the trace_id of the current session, or None if not yet initialised."""
- return _TRACE_ID
- class _LevelFilter(logging.Filter):
- def __init__(self, minimum: int, maximum: Optional[int] = None):
- super().__init__()
- self.minimum = minimum
- self.maximum = maximum
- def filter(self, record: logging.LogRecord) -> bool:
- return record.levelno >= self.minimum and (
- self.maximum is None or record.levelno <= self.maximum
- )
- class _ExcludeCapturedOutput(logging.Filter):
- def filter(self, record: logging.LogRecord) -> bool:
- return not getattr(record, "captured_output", False)
- class _TraceIdFilter(logging.Filter):
- """Inject ``trace_id`` into every LogRecord for structured SLS queries.
- The filter reads from the module-level ``_TRACE_ID`` so that it works even
- when ``setup_logging()`` is called after the filter is added.
- """
- def filter(self, record: logging.LogRecord) -> bool:
- record.trace_id = _TRACE_ID or "-"
- return True
- class _SafeQueuedLogHandler:
- """Create an SDK handler whose emit failures cannot break the app logger,
- and whose timestamps are forced to second-level precision.
- The SLS protobuf schema includes ``optional fixed32 Time_ns`` — when set
- (even to 0) the console renders ``.000000000``. We strip it so only
- integer-second timestamps appear.
- """
- @staticmethod
- def create(handler_cls, **kwargs) -> logging.Handler:
- handler = handler_cls(**kwargs)
- # -- safe emit ----------------------------------------------------
- original_emit = handler.emit
- def safe_emit(record):
- try:
- original_emit(record)
- except Exception:
- logging.Handler.handleError(handler, record)
- handler.emit = safe_emit
- # -- strip Time_ns from protobuf before serialization -------------
- # The SDK populates LogItem.time_nano_part from ``time.time()``
- # rather than ``record.created``, injecting a random 9-digit value.
- # Even when forced to 0 the optional protobuf field *is* serialised
- # (fixed32 0 ≠ absent) and the console renders ``.000000000``.
- # Monkey-patching ``LogGroup.SerializeToString`` (called once per
- # batch inside ``LogClient.put_logs``) clears the field right before
- # encoding without duplicating the ~40-line SDK method.
- import aliyun.log.log_logs_pb2 as _sls_pb
- _orig_serialize = _sls_pb.LogGroup.SerializeToString
- original_send = handler.send
- def patched_send(req):
- def _no_nano_serialize(self):
- for log in self.Logs:
- if log.HasField("Time_ns"):
- log.ClearField("Time_ns")
- return _orig_serialize(self)
- _sls_pb.LogGroup.SerializeToString = _no_nano_serialize
- try:
- return original_send(req)
- finally:
- _sls_pb.LogGroup.SerializeToString = _orig_serialize
- handler.send = patched_send
- return handler
- class _CapturedStream:
- """Mirror a process stream into logging while preserving interactive output."""
- def __init__(self, original, level: int, logger_name: str):
- self._original = original
- self._level = level
- self._logger = logging.getLogger(logger_name)
- self._buffer = ""
- def write(self, value: str) -> int:
- if not value:
- return 0
- self._original.write(value)
- self._original.flush()
- self._buffer += value
- while "\n" in self._buffer:
- line, self._buffer = self._buffer.split("\n", 1)
- line = line.rstrip("\r")
- if line.strip():
- self._logger.log(
- self._level,
- "%s",
- line,
- extra={"captured_output": True},
- )
- return len(value)
- def flush(self) -> None:
- self._original.flush()
- if self._buffer.strip():
- self._logger.log(
- self._level,
- "%s",
- self._buffer.strip(),
- extra={"captured_output": True},
- )
- self._buffer = ""
- def isatty(self) -> bool:
- return self._original.isatty()
- def fileno(self) -> int:
- return self._original.fileno()
- @property
- def encoding(self):
- return getattr(self._original, "encoding", "utf-8")
- def _configured_sls_handlers(log_level: int) -> Iterable[logging.Handler]:
- try:
- from aliyun.log import QueuedLogHandler
- except ImportError:
- logging.getLogger(__name__).warning(
- "[sls] aliyun-log-python-sdk 未安装,跳过 SLS 上报"
- )
- return []
- sls_endpoint = os.getenv("SLS_ENDPOINT", "").strip()
- access_key_id = os.getenv("SLS_ACCESS_KEY_ID", "").strip()
- access_key_secret = os.getenv("SLS_ACCESS_KEY_SECRET", "").strip()
- project = os.getenv("SLS_PROJECT", "auto-put-tecent").strip()
- info_logstore = os.getenv(
- "SLS_INFO_LOGSTORE", os.getenv("SLS_LOGSTORE", "info-log")
- ).strip()
- error_logstore = os.getenv("SLS_ERROR_LOGSTORE", "error-log").strip()
- batch_size = int(os.getenv("SLS_BATCH_SIZE_MAX", "1024"))
- put_wait_ms = int(os.getenv("SLS_PUT_WAIT_MS", "2000"))
- sls_level = getattr(
- logging, os.getenv("SLS_LOG_LEVEL", "INFO").upper(), logging.INFO
- )
- if not all(
- (sls_endpoint, access_key_id, access_key_secret,
- project, info_logstore, error_logstore)
- ):
- logging.getLogger(__name__).warning(
- "[sls] SLS 凭证或 Logstore 未完整配置,跳过上报"
- )
- return []
- # SLS uses its own __time__ field, and level/name/func/file/line/thread are
- # already sent as separate fields via the ``fields`` list. Keep only the
- # message body to avoid redundant data cluttering the SLS console.
- sls_formatter = logging.Formatter(_SLS_LOG_FORMAT)
- common = dict(
- end_point=sls_endpoint,
- access_key_id=access_key_id,
- access_key=access_key_secret,
- project=project,
- fields=[
- "record_name", "level", "func_name", "module", "file_path", "line_no",
- "process_id", "process_name", "thread_id", "thread_name",
- ],
- extract_kv=True,
- batch_size=batch_size,
- put_wait=max(float(put_wait_ms) / 1000, 0.1),
- )
- handlers = []
- for store, level_filter in (
- (info_logstore, _LevelFilter(sls_level, logging.WARNING)),
- (error_logstore, _LevelFilter(logging.ERROR)),
- ):
- handler = _SafeQueuedLogHandler.create(
- QueuedLogHandler, log_store=store, **common
- )
- handler.setLevel(sls_level if store == info_logstore else logging.ERROR)
- handler.addFilter(level_filter)
- handler.setFormatter(sls_formatter)
- handlers.append(handler)
- return handlers
- def attach_sls_handler(root_logger: Optional[logging.Logger] = None) -> bool:
- """Attach the example-owned SLS handlers once, returning whether enabled."""
- global _SLS_HANDLERS
- root_logger = root_logger or logging.getLogger()
- if _SLS_HANDLERS and all(handler in root_logger.handlers for handler in _SLS_HANDLERS):
- return True
- # 清理残留的旧 SLS handler(如 setup_logging 被多次调用导致部分 handler 残留)
- for old_handler in list(_SLS_HANDLERS):
- if old_handler in root_logger.handlers:
- root_logger.removeHandler(old_handler)
- try:
- old_handler.close()
- except Exception:
- pass
- _SLS_HANDLERS = []
- try:
- handlers = list(_configured_sls_handlers(root_logger.level or logging.INFO))
- for handler in handlers:
- root_logger.addHandler(handler)
- _SLS_HANDLERS = handlers
- if handlers:
- logging.getLogger(__name__).info(
- "[sls] 已挂载双 Logstore: project=%s info-log/error-log",
- os.getenv("SLS_PROJECT", "auto-put-tecent"),
- )
- return bool(handlers)
- except Exception:
- logging.getLogger(__name__).exception("[sls] handler 挂载失败,降级为本地日志")
- return False
- def _close_sls_handlers() -> None:
- for handler in _SLS_HANDLERS:
- try:
- handler.close()
- except Exception:
- pass
- def _install_exception_hooks() -> None:
- global _EXCEPTION_HOOKS_INSTALLED
- if _EXCEPTION_HOOKS_INSTALLED:
- return
- original_sys_hook = sys.excepthook
- def log_uncaught_exception(exc_type, exc_value, exc_traceback) -> None:
- if issubclass(exc_type, KeyboardInterrupt):
- original_sys_hook(exc_type, exc_value, exc_traceback)
- return
- logging.getLogger("auto_put_ad_mini.uncaught").critical(
- "event=uncaught_exception thread=main result=failed",
- exc_info=(exc_type, exc_value, exc_traceback),
- )
- def log_thread_exception(args: threading.ExceptHookArgs) -> None:
- logging.getLogger("auto_put_ad_mini.uncaught").critical(
- "event=uncaught_exception thread=%s result=failed",
- args.thread.name if args.thread else "unknown",
- exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
- )
- sys.excepthook = log_uncaught_exception
- threading.excepthook = log_thread_exception
- _EXCEPTION_HOOKS_INSTALLED = True
- def setup_logging(
- level: str = "INFO",
- capture_output: bool = True,
- ) -> None:
- """Configure root logging: console + optional SLS, no local file."""
- global _CAPTURE_INSTALLED, _CONFIGURED, _TRACE_ID
- # Generate a per-session trace_id once per process lifetime.
- if _TRACE_ID is None:
- _TRACE_ID = _generate_trace_id()
- log_level = getattr(logging, str(level).upper(), logging.INFO)
- stdout = sys.__stdout__
- stderr = sys.__stderr__
- trace_filter = _TraceIdFilter()
- console = logging.StreamHandler(stdout)
- console.setLevel(log_level)
- console.addFilter(_ExcludeCapturedOutput())
- console.addFilter(trace_filter)
- formatter = logging.Formatter(_LOCAL_LOG_FORMAT, datefmt=_FORMAT)
- console.setFormatter(formatter)
- root_logger = logging.getLogger()
- # Handler failures (for example an SLS network outage) must never write
- # recursive logging tracebacks to stderr or interrupt the business flow.
- logging.raiseExceptions = False
- if not _CONFIGURED:
- logging.basicConfig(
- level=log_level,
- handlers=[console],
- force=True,
- )
- _CONFIGURED = True
- else:
- root_logger.setLevel(log_level)
- for noisy in ("httpx", "httpcore", "urllib3", "apscheduler", "aliyun.log"):
- logging.getLogger(noisy).setLevel(logging.WARNING)
- attach_sls_handler(root_logger)
- _install_exception_hooks()
- if capture_output and not _CAPTURE_INSTALLED:
- sys.stdout = _CapturedStream(stdout, logging.INFO, "auto_put_ad_mini.stdout")
- sys.stderr = _CapturedStream(stderr, logging.ERROR, "auto_put_ad_mini.stderr")
- _CAPTURE_INSTALLED = True
- global _ATEXIT_REGISTERED
- if not _ATEXIT_REGISTERED:
- atexit.register(_close_sls_handlers)
- _ATEXIT_REGISTERED = True
- logging.getLogger(__name__).info("[logging] 日志初始化完成 trace_id=%s", _TRACE_ID)
- def _generate_trace_id() -> str:
- """Generate a compact, sortable, unique trace_id for one process invocation.
- Format: ``YYYYMMDD-HHMMSS-{8 random hex chars}``.
- ~16M combinations per second; sufficient for cron-triggered processes.
- """
- now = datetime.now()
- ts = now.strftime("%Y%m%d-%H%M%S")
- suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
- return f"{ts}-{suffix}"
|