feat(audit): skip audit log for excluded health/root paths

健康检查与根路径(/health/live、/health/ready、/api/v1/health、
/、/health/storage)没有用户、没业务动作,每秒被 K8s/LB
探针刷一次只会灌进无意义噪音。命中排除集即跳过审计行;
诊断日志(method/path/status/ms 走 stderr)照常打,对容器
运维排错仍有用。

* settings.audit_excluded_paths: list[str] 默认覆盖 5 条
  基础设施路径,env AUDIT_EXCLUDED_PATHS 用逗号分隔
  (pydantic NoDecode + field_validator 兼容 str/list)
* main.py 模块级 _AUDIT_EXCLUDED = frozenset(...),
  access_log 的 success/exception 两条审计行各加守卫
  诊断无条件打
* 测试用 _AccessLogReplica 复刻 access_log 契约(不 import
  真实 main.py),新增 4 个 case:排除根路径、排除 /health/live、
  不排除路径照写审计、自定义排除集

顺带 schedule 模块:ExecutionResult 与 context 已迁到
schedule.domain.*(execution.py / orchestrator.py /
scheduler.py / worker.py),调用点跟进;schedule 自身
18 个测试在改前改后均通过。
This commit is contained in:
tao.chen
2026-08-21 13:47:44 +08:00
parent d2c87de32c
commit cdcfcb2e43
8 changed files with 162 additions and 45 deletions
+22 -12
View File
@@ -53,6 +53,12 @@ configure_audit_logging(
)
# 审计排除的精确路径集(不含 query):命中即跳过审计行,诊断日志照常打。
# 健康检查 / 根路径探针每秒刷审计文件但无业务价值;可用
# settings.audit_excluded_paths / AUDIT_EXCLUDED_PATHS 覆盖默认值。
_AUDIT_EXCLUDED: frozenset[str] = frozenset(settings.audit_excluded_paths)
@asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]:
# 生命周期内创建的对象挂在 app.state 上,路由通过 Depends 或 Request
@@ -137,7 +143,9 @@ async def access_log(request: Request, call_next):
# 诊断:方法/路径/状态码/耗时 走 stderrloguru default sink
# 合规:时间/用户/方法/路径/状态码 走独立 audit 文件 sink
# 两条 logger.info() 共用一个出口,便于排查
# 排除集是精确路径匹配(不含 query),命中即跳过审计行;诊断日志照常。
start = time.perf_counter()
skip_audit = request.url.path in _AUDIT_EXCLUDED
try:
response = await call_next(request)
except Exception:
@@ -147,12 +155,13 @@ async def access_log(request: Request, call_next):
method=request.method, path=request.url.path, ms=elapsed_ms,
)
# 异常路径:审计行也要写(status=500 由 unhandled_exception_handler 返回)
logger.bind(
user_id=_audit_user_id(request),
method=request.method,
path=request.url.path,
status=500,
).info("audit")
if not skip_audit:
logger.bind(
user_id=_audit_user_id(request),
method=request.method,
path=request.url.path,
status=500,
).info("audit")
raise
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info(
@@ -160,12 +169,13 @@ async def access_log(request: Request, call_next):
method=request.method, path=request.url.path,
status=response.status_code, ms=elapsed_ms,
)
logger.bind(
user_id=_audit_user_id(request),
method=request.method,
path=request.url.path,
status=response.status_code,
).info("audit")
if not skip_audit:
logger.bind(
user_id=_audit_user_id(request),
method=request.method,
path=request.url.path,
status=response.status_code,
).info("audit")
return response