"""loguru 日志配置(后端日志系统的唯一出口)。
提供全局日志配置、标准 logging 接管、上下文管理和便捷的日志记录函数。
所有日志(含 uvicorn / SQLAlchemy 等第三方库)统一汇入 loguru,
文件统一存放在项目根目录的 logs 文件夹下。
"""
import logging
import sys
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from loguru import logger
from app.core.config import settings
# ==================== 上下文变量 ====================
# 用于在异步请求中传递日志上下文
_request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
"""请求 ID 上下文变量,用于追踪单个请求的完整链路。"""
_user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
"""用户 ID 上下文变量,用于记录当前操作用户。"""
class InterceptHandler(logging.Handler):
"""标准 ``logging`` → loguru 转发器。
uvicorn / SQLAlchemy / httpx / langchain 等第三方库一律使用标准 ``logging`` 模块;
把本 handler 装到 root logger 后,它们的日志会统一汇入 loguru,与应用自有日志
走同一 sink、格式与落盘策略,实现整个后端日志系统的单一出口。
注:此处 ``import logging`` 与「避免使用 logging 模块」规范并不冲突——那条规范
针对的是「用 logging 记业务日志」,而本 handler 的职责恰是把 logging 的输出
导入 loguru,是统一日志出口的必要接管机制。
"""
def emit(self, record: logging.LogRecord) -> None:
# 将标准 logging 的级别名映射为 loguru 级别
try:
level: str | int = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# 沿调用栈回溯到真正的日志发出处,让 loguru 记录正确的文件名 / 行号
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging() -> None:
"""配置全局日志系统。
配置内容:
1. 创建日志目录
2. 移除 loguru 默认处理器
3. 添加控制台输出(彩色,级别跟随 debug 开关与 LOG_LEVEL)
4. 添加主日志文件(按大小轮转、压缩)
5. 添加错误日志单独文件(保留更久)
6. 添加访问日志(可选)
7. 接管标准 logging,使 uvicorn / SQLAlchemy 等第三方日志汇入 loguru
日志文件位置:
- {project_root}/logs/app.log # 主日志
- {project_root}/logs/errors.log # 错误日志
- {project_root}/logs/access.log # 访问日志(可选)
"""
# 获取并创建日志目录
log_dir: Path = settings.resolved_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
# 移除 loguru 默认的 stderr 处理器
logger.remove()
# 判断是否为开发环境。开发 reload 进程不使用异步队列,避免退出时残留 multiprocessing semaphore。
is_dev = settings.is_development
enqueue_logs = not is_dev
# 控制台输出格式(带颜色,便于阅读)
console_format = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
"{level: <8} | "
"{name}:{function}:{line}"
"{extra} - {message}\n{exception}"
)
# 文件输出格式(纯文本,便于解析)
file_format = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | "
"{name}:{function}:{line}{extra} | {message}\n{exception}"
)
# ----- 控制台输出 -----
# 控制台级别跟随 debug 开关与 LOG_LEVEL:debug=true → DEBUG,否则用 LOG_LEVEL。
console_level = "DEBUG" if settings.debug else settings.log_level
logger.add(
sys.stderr, # 输出到标准错误
level=console_level, # 日志级别
format=console_format, # 彩色格式
colorize=is_dev, # 开发环境启用颜色
enqueue=enqueue_logs, # 生产环境异步写入,提高性能
backtrace=is_dev, # 开发环境显示完整回溯
diagnose=is_dev, # 开发环境显示诊断信息
)
# ----- 主日志文件 -----
# 记录所有级别日志,按大小轮转,自动压缩
logger.add(
log_dir / "app.log", # 日志文件路径
level=settings.log_level, # 日志级别
format=file_format, # 纯文本格式
rotation=settings.log_rotation_size, # 轮转大小:10 MB
retention=f"{settings.log_retention_days} days", # 保留天数:14天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=False, # 不保存完整回溯
diagnose=False, # 不保存诊断信息
)
# ----- 错误日志单独文件 -----
# 只记录 ERROR 及以上级别,保留更久,便于故障排查
logger.add(
log_dir / "errors.log", # 错误日志文件路径
level="ERROR", # 只记录 ERROR 及以上
format=file_format, # 纯文本格式
rotation="50 MB", # 轮转大小:50 MB
retention="30 days", # 保留天数:30天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=True, # 错误时保留完整堆栈
diagnose=True, # 错误时保留诊断信息
)
# ----- 访问日志(可选) -----
# 通过 filter 只记录标记为 access 类型的日志
if settings.log_access:
logger.add(
log_dir / "access.log", # 访问日志文件路径
level="INFO", # INFO 及以上
format=file_format, # 纯文本格式
rotation="100 MB", # 轮转大小:100 MB
retention="7 days", # 保留天数:7天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
filter=lambda r: r["extra"].get("type") == "access", # 只记录访问日志
)
# ----- 接管标准 logging -----
# force=True 清掉第三方库自带的 handler(含 uvicorn 默认 dictConfig);
# level=0 让记录全量进入,最终级别过滤交由上方 loguru sink 完成。
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# 应用已有自研访问日志中间件(trace_middleware + log_access,含 trace_id /
# method / path / status / duration 等结构化字段),关闭 uvicorn.access 以
# 避免访问日志重复落盘。
logging.getLogger("uvicorn.access").disabled = True
# ==================== 日志上下文管理 ====================
def set_request_id(request_id: str) -> None:
"""设置当前请求的 ID。
Args:
request_id: 请求唯一标识,用于链路追踪
"""
_request_id_var.set(request_id)
def set_user_id(user_id: str) -> None:
"""设置当前操作用户的 ID。
Args:
user_id: 用户唯一标识
"""
_user_id_var.set(user_id)
def clear_log_context() -> None:
"""清除当前日志上下文。
在请求结束后调用,避免上下文污染。
"""
_request_id_var.set(None)
_user_id_var.set(None)
def get_log_context() -> dict[str, Any]:
"""获取当前日志上下文。
Returns:
包含 request_id 和 user_id 的字典(如果已设置)
"""
context = {}
if rid := _request_id_var.get():
context["request_id"] = rid
if uid := _user_id_var.get():
context["user_id"] = uid
return context
def log_with_context(message: str, level: str = "INFO", **kwargs: Any) -> None:
"""带上下文记录日志。
自动附加当前请求的 request_id 和 user_id。
Args:
message: 日志消息
level: 日志级别(DEBUG/INFO/WARNING/ERROR)
**kwargs: 额外的上下文字段
"""
context = get_log_context()
context.update(kwargs)
logger.bind(**context).log(level, message)
def log_access(message: str, **kwargs: Any) -> None:
"""记录访问日志。
自动添加 type="access" 标记,会被 access.log 过滤器捕获。
Args:
message: 日志消息
**kwargs: 额外的上下文字段(如 method, path, status, duration 等)
"""
logger.bind(type="access", **kwargs).info(message)