生产实测: applogs 命名卷挂到 /app/logs 时挂载点由 Docker 以 root:root 创建,而非 root 进程(profeto)写入 app.log 时 PermissionError(Errno 13), best-effort 降级生效但文件日志实际未启用。 - Dockerfile: 镜像内预创建 /app/logs 并 chown(chown -R 已有,覆盖新目录); 命名卷为空且首次挂载时 Docker 会复制镜像目录属主,重建镜像即可修复 - log_buffer: PermissionError 单独捕获,warning 给出可操作的排查方向 (镜像预建目录/chown/重建空卷),其余异常仍保持原通用降级
128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
"""内存日志缓冲:供后台「系统日志」页查看应用运行日志。
|
|
|
|
把应用日志(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,
|
|
)
|