diff --git a/app/core/logging.py b/app/core/logging.py index 0981021..56b856b 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,20 +1,91 @@ """统一日志配置。 -业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。 +业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。 + +- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。 +- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则); + 异常栈作为字段内嵌不换行 → 每条日志一行。 +- 环境变量: + - LOG_JSON_CONSOLE=1 控制台也输出 JSON + - LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log) + ⚠️ admin 子进程(app.admin.main, 独立进程)应设不同 LOG_FILE, 避免与主进程争抢同一轮转文件 + - LOG_SERVICE_NAME JSON 里的 service 字段(默认 app-server) """ from __future__ import annotations +import json import logging +import os import sys +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +class JsonFormatter(logging.Formatter): + """把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。""" + def __init__(self, service: str = "app-server"): + super().__init__() + self.service = service + + def format(self, record: logging.LogRecord) -> str: + dt = datetime.fromtimestamp(record.created) + data = { + "time": dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(record.msecs):03d}", + "level": record.levelname, + "service": self.service, + "logger": record.name, + "func": record.funcName, + "line": record.lineno, + "message": record.getMessage(), + } + if record.exc_info: + data["exception"] = self.formatException(record.exc_info) + if record.stack_info: + data["stack"] = self.formatStack(record.stack_info) + return json.dumps(data, ensure_ascii=False, default=str) + + +_CONFIGURED = False def setup_logging(debug: bool = False) -> None: + global _CONFIGURED + if _CONFIGURED: + return + level = logging.DEBUG if debug else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - stream=sys.stdout, + service = os.getenv("LOG_SERVICE_NAME", "app-server") + + root = logging.getLogger() + root.setLevel(level) + # 清掉 basicConfig/uvicorn 可能预置的 root handler, 避免重复输出 + for h in list(root.handlers): + root.removeHandler(h) + + # 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON + console = logging.StreamHandler(sys.stdout) + if os.getenv("LOG_JSON_CONSOLE") == "1": + console.setFormatter(JsonFormatter(service)) + else: + console.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + ) + root.addHandler(console) + + # 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个) + log_file = os.getenv("LOG_FILE") or str( + Path(os.getenv("LOG_DIR", "logs")) / "app-server.log" ) + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler( + log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", + ) + file_handler.setFormatter(JsonFormatter(service)) + root.addHandler(file_handler) + # 第三方库降噪 logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) + + _CONFIGURED = True