21 文件操作日志与可观测性

1. 只记录“失败”不够

一次 ZIP 转换应能回答:

哪个任务
由谁发起
在哪个阶段
处理多少文件和字节
用了多久
为什么失败
是否完成清理

2. 结构化日志

logger.info({
  event: "markdown_export_completed",
  requestId,
  taskId,
  inputBytes,
  extractedBytes,
  outputBytes,
  entryCount,
  durationMs,
});

错误:

logger.error({
  event: "markdown_export_failed",
  requestId,
  taskId,
  stage: "extract",
  businessCode: "INVALID_ZIP_ENTRY",
  errno: isErrnoException(error) ? error.code : undefined,
  err: error,
});

Pino 对 err 有专门序列化处理。不要把 Error 只转换成 String(error) 后丢失堆栈和 cause。

3. 阶段耗时

const startedAt = performance.now();

try {
  await extractZip();
} finally {
  logger.info({
    event: "stage_finished",
    taskId,
    stage: "extract",
    durationMs: performance.now() - startedAt,
  });
}

可分为:

upload
validate
extract
render
archive
download
cleanup

这样才能判断慢在磁盘、Markdown 渲染、压缩还是网络。

4. 路径和隐私

优先记录:

taskId
storageKey
相对 Entry 路径(必要时脱敏)
文件类型
字节数

避免记录:

JWT
患者姓名
完整文件内容
邮箱/手机号
数据库密码
无必要的服务器绝对路径

生物数据文件名可能带样本或患者标识,不能默认无敏感信息。

5. 指标

日志适合追踪单次事件,指标适合观察趋势:

任务成功率
各阶段耗时分位数
队列长度
当前运行任务数
解压拒绝次数
客户端中断次数
清理失败次数
磁盘可用空间
进程 RSS/external memory
打开句柄趋势

当前项目业务错误始终返回 HTTP 200,因此必须统计 businessCode,不能只依赖 HTTP 5xx。

6. 不重复记录

底层函数可以添加上下文后抛出:

throw new Error("解压 Entry 失败", {
  cause: error,
});

在清楚的边界(任务 Worker 或全局错误处理器)记录一次完整错误。每层都 logger.error() 会产生同一失败的多条噪音,除非每层记录的是不同补偿结果。

7. 清理作为独立结果

主任务失败 + 清理成功
主任务成功 + 清理失败
主任务失败 + 清理失败

三种都应能区分。清理错误不能覆盖主错误,也不能完全沉默。

练习题

  1. 为 ZIP 转换设计一条成功日志和三条阶段指标。
  2. 从日志字段中删除可能暴露样本身份的信息。
  3. 解释日志、指标和审计记录的不同目的。
  4. 模拟主任务和清理同时失败,保留两个错误上下文。