feat(logging): 日志持久化落盘(LOG_FILE 滚动文件)
- LOG_FILE 配置: 空(默认)保持 stdout + Admin 内存日志页(重启清零); 填路径后额外写 RotatingFileHandler(单文件 10MB × 5 份,utf-8) - setup_logging 幂等挂载(按 abspath 判重,相对/绝对同文件算一个); 目录自动创建;文件基础设施失败只 warning 绝不拖垮启动 - docker-compose: api 挂 applogs 卷,默认 LOG_FILE=/app/logs/app.log, 容器重建日志不丢;.env.example/.gitignore/docs 同步 - tests/test_log_persistence.py: 落盘/幂等/空值禁用/自动建目录/失败降级 - test_p0_standings_cutoff: 匿名约束 name=None 使 any() 子串匹配 TypeError (集合顺序不定 → flaky),先判真值再匹配(存量缺陷顺手加固)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""日志持久化(LOG_FILE)测试:setup_logging 启用滚动文件后日志必须落盘。
|
||||
|
||||
背景: 此前应用日志只进 stdout(docker json-file 收集,不可控) + Admin 内存
|
||||
日志页(环形缓冲 2000 条,进程重启清零),没有任何应用层持久化 —— 排查
|
||||
「昨晚采集为什么失败」这类问题时无据可查。本测试守护:
|
||||
1. 传 log_file → root logger 挂 RotatingFileHandler,日志写入文件
|
||||
2. 幂等:重复调用不重复挂 handler
|
||||
3. log_file 为空 → 不挂文件 handler(保持旧行为)
|
||||
4. 目录不存在 → 自动创建
|
||||
5. 文件打开失败(如路径是已存在的目录) → 只降级不炸,stdout/内存日志仍在
|
||||
|
||||
范式: 直接操作 root logger + tmp_path;fixture 保存/恢复 root 状态,
|
||||
并关闭新增 handler 的文件句柄(Windows 上句柄不关会锁住 tmp 目录)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.log_buffer import MemoryLogHandler, setup_logging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_root_logger():
|
||||
"""保存/恢复 root logger;关闭测试期间新挂 handler 的句柄。"""
|
||||
root = logging.getLogger()
|
||||
saved_handlers = list(root.handlers)
|
||||
saved_level = root.level
|
||||
yield
|
||||
for h in root.handlers:
|
||||
if h not in saved_handlers and hasattr(h, "close"):
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
root.handlers[:] = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
|
||||
|
||||
def _file_handlers():
|
||||
return [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)]
|
||||
|
||||
|
||||
def _memory_handlers():
|
||||
return [h for h in logging.getLogger().handlers if isinstance(h, MemoryLogHandler)]
|
||||
|
||||
|
||||
class TestFileHandlerAttached:
|
||||
def test_attaches_rotating_file_handler(self, tmp_path):
|
||||
log_file = tmp_path / "app.log"
|
||||
|
||||
setup_logging("INFO", str(log_file))
|
||||
|
||||
fhs = _file_handlers()
|
||||
assert len(fhs) == 1
|
||||
assert fhs[0].baseFilename == str(log_file)
|
||||
# 内存日志页照常工作,两者并存
|
||||
assert len(_memory_handlers()) == 1
|
||||
# 滚动参数与文档口径一致: 单文件 10MB × 5 份
|
||||
assert fhs[0].maxBytes == 10 * 1024 * 1024
|
||||
assert fhs[0].backupCount == 5
|
||||
|
||||
def test_log_written_to_file(self, tmp_path):
|
||||
log_file = tmp_path / "app.log"
|
||||
setup_logging("INFO", str(log_file))
|
||||
|
||||
logging.getLogger("persist-test").info("hello-persist-12345")
|
||||
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "hello-persist-12345" in content
|
||||
assert "persist-test" in content # logger 名可追溯
|
||||
assert "INFO" in content # 级别在行首可过滤
|
||||
|
||||
|
||||
class TestIdempotent:
|
||||
def test_repeated_call_does_not_duplicate_handlers(self, tmp_path):
|
||||
log_file = tmp_path / "app.log"
|
||||
|
||||
setup_logging("INFO", str(log_file))
|
||||
setup_logging("INFO", str(log_file))
|
||||
setup_logging("INFO", str(log_file))
|
||||
|
||||
assert len(_file_handlers()) == 1
|
||||
assert len(_memory_handlers()) == 1
|
||||
|
||||
def test_same_file_via_relative_and_absolute_path_counts_as_one(self, tmp_path, monkeypatch):
|
||||
"""相对/绝对路径指向同一文件时不得重复挂(幂等按 abspath 判重)。"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
setup_logging("INFO", "app.log")
|
||||
setup_logging("INFO", str(tmp_path / "app.log"))
|
||||
|
||||
assert len(_file_handlers()) == 1
|
||||
|
||||
|
||||
class TestDisabled:
|
||||
def test_empty_log_file_keeps_old_behavior(self):
|
||||
setup_logging("INFO", "")
|
||||
|
||||
assert _file_handlers() == []
|
||||
assert len(_memory_handlers()) == 1
|
||||
|
||||
|
||||
class TestAutoMkdir:
|
||||
def test_creates_missing_directories(self, tmp_path):
|
||||
log_file = tmp_path / "deep" / "nested" / "app.log"
|
||||
|
||||
setup_logging("INFO", str(log_file))
|
||||
|
||||
assert len(_file_handlers()) == 1
|
||||
assert log_file.parent.is_dir()
|
||||
|
||||
|
||||
class TestGracefulDegradation:
|
||||
def test_unopenable_path_degrades_without_raising(self, tmp_path):
|
||||
"""路径是已存在的目录 → 打开必然失败;只降级,不炸启动。"""
|
||||
dir_as_file = tmp_path / "occupied"
|
||||
dir_as_file.mkdir()
|
||||
|
||||
# 不应抛异常(文件日志是可观测性基础设施,失败只 warning)
|
||||
setup_logging("INFO", str(dir_as_file))
|
||||
|
||||
assert _file_handlers() == []
|
||||
# 降级后 stdout/内存日志路径仍在
|
||||
assert len(_memory_handlers()) == 1
|
||||
Reference in New Issue
Block a user