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. 清理作为独立结果
主任务失败 + 清理成功
主任务成功 + 清理失败
主任务失败 + 清理失败
三种都应能区分。清理错误不能覆盖主错误,也不能完全沉默。
练习题
- 为 ZIP 转换设计一条成功日志和三条阶段指标。
- 从日志字段中删除可能暴露样本身份的信息。
- 解释日志、指标和审计记录的不同目的。
- 模拟主任务和清理同时失败,保留两个错误上下文。