"""内存日志缓冲:供后台「系统日志」页查看应用运行日志。 把应用日志(stdout)同时捕获到进程内环形缓冲(deque),提供级别/关键字/条数 过滤查询。缓冲在进程重启后清零;需要持久化的审计请另行落库。 """ from __future__ import annotations import logging from collections import deque _BUFFER: deque[dict] = deque(maxlen=2000) _LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50} class MemoryLogHandler(logging.Handler): """把日志记录写入内存环形缓冲。""" def __init__(self) -> None: super().__init__() # format() 会在有 exc_info 时自动附带异常堆栈文本 self.setFormatter(logging.Formatter("%(message)s")) def emit(self, record: logging.LogRecord) -> None: try: entry = { "ts": record.created, "level": record.levelname, "logger": record.name, "message": self.format(record), } _BUFFER.append(entry) except Exception: # noqa: BLE001 日志采集绝不影响业务 self.handleError(record) class _SQLNoiseFilter(logging.Filter): """过滤 SQLAlchemy 的 DEBUG/INFO 回显(只留警告以上)。""" def filter(self, record: logging.LogRecord) -> bool: return not ( record.name.startswith("sqlalchemy.") and record.levelno < logging.WARNING ) def get_entries( min_level: str | None = None, keyword: str | None = None, limit: int = 200, ) -> list[dict]: """按条件查询缓冲日志,最新在前。""" min_no = _LEVEL_ORDER.get((min_level or "").upper(), 0) kw = (keyword or "").strip().lower() items = list(_BUFFER) items.reverse() out: list[dict] = [] for e in items: if _LEVEL_ORDER.get(e["level"], 0) < min_no: continue if kw and kw not in e["message"].lower() and kw not in e["logger"].lower(): continue out.append(e) if len(out) >= limit: break return out def setup_logging(level: str = "INFO", log_file: str = "") -> None: """配置应用日志:stdout(容器收集) + 内存环形缓冲(Admin 日志页) + 可选滚动文件(持久化)。 幂等:重复调用不会重复挂 handler(文件 handler 按 abspath 判重,相对/绝对 路径指向同一文件视为同一个)。文件写入基础设施失败只记 warning,绝不 影响启动与业务 —— 与 _safe_write_ingest_failure 同级约束:可观测性 基础设施不许拖垮主流程。 """ import os from logging.handlers import RotatingFileHandler root = logging.getLogger() if root.level == logging.NOTSET or root.level > logging.INFO: root.setLevel(getattr(logging, level.upper(), logging.INFO)) if not any(isinstance(h, MemoryLogHandler) for h in root.handlers): handler = MemoryLogHandler() handler.setLevel(logging.INFO) handler.addFilter(_SQLNoiseFilter()) root.addHandler(handler) if not log_file: return # 滚动上限:单文件 10MB × 当前+5 份 ≈ 60MB,足够回溯数周的关键事件, # 又不会吃满磁盘。格式含时间/级别/logger 名,便于事后 grep 排查。 target = os.path.abspath(log_file) if any( isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", None) == target for h in root.handlers ): return try: os.makedirs(os.path.dirname(target), exist_ok=True) file_handler = RotatingFileHandler( target, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter( logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") ) file_handler.setLevel(logging.INFO) file_handler.addFilter(_SQLNoiseFilter()) root.addHandler(file_handler) logging.getLogger(__name__).info("文件日志已启用: %s", log_file) except PermissionError: # 容器场景最常见:挂载卷目录属主是 root,进程是非 root 用户。 # 修复方向:镜像内预创建目录并 chown(Dockerfile),或重建空卷。 logging.getLogger(__name__).warning( "启用文件日志失败(%s):无写权限。容器部署请确认镜像已预创建该目录" "并 chown 给运行用户(compose 卷挂载点默认 root 属主);" "宿主机直跑请检查目录权限。本次仅保留 stdout/内存日志。", log_file, ) except Exception: logging.getLogger(__name__).warning( "启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True, )