主题
Trace Schema 与 Logger
本课交付结果
你将交付 redactTraceValue 与 JsonlTraceSink.append/read:对象、数组和 Bearer token 被递归脱敏;并发追加保持 sequence 连续;只容忍最后一行撕裂,并拒绝混入其他 runId。
岗位问题
没有 Trace,Agent 的“为什么这样改”无法重建;Trace 若原样记录 Provider 请求、工具参数和环境错误,又会把 API key、密码和 token 固化到磁盘。可观测性必须先经过隐私边界。
前置检查
前置知识快照
先验证第 22 课 JSONL 恢复:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 22Trace 比会话日志多 runId、timestamp、type 和任意 data,并要求写前脱敏。
第 22 课解决的是“状态事件怎样连续落盘并在尾部撕裂时恢复”,本课把这个能力提升成可观测性边界。两者都采用 JSONL 和单写者队列,却服务于不同问题:状态日志用于恢复执行真相,Trace 用于解释一次运行发生了什么。不能因为格式相同就让两种记录共享 schema、保留周期或访问权限。
开始前应能解释四个基础概念。第一,sequence 是运行内的逻辑顺序,timestamp 只是墙上时间;系统时钟可能回拨,事件顺序不能由时间戳猜。第二,脱敏必须发生在序列化和持久化之前,文件写完再清理不构成保护。第三,JSONL 的恢复容忍度只针对最后一条未写完记录,中间损坏意味着证据链不可信。第四,Logger 不是随手打印对象的工具,而是运行时与磁盘之间的一道数据最小化协议。
本课沿用 run-安全slug 形式的运行标识。runId 不只用于筛选,它也是防止选错文件、拼接两个运行或读取错误租户数据的完整性断言。若团队将 Trace 发送到远端,还需在服务端再次校验身份与授权;本地正则只能限制格式,不能证明调用者有权查看该运行。
原理拆解
mermaid
flowchart LR
A[Runtime 事件] --> B[验证 runId 与 type]
B --> C[递归复制并脱敏]
C --> D[单写者队列分配 sequence]
D --> E[序列化为单行 JSON]
E --> F[追加换行并落盘]
F --> G[严格读取与完整性校验]敏感 key 匹配 apiKey、authorization、password、secret、token、cookie;对应值整体替换为 [REDACTED]。普通字符串中的 Bearer abc 也替换 token 部分。随后按 Promise queue 分配 sequence 并追加 JSONL。
json
{"runId":"run-1","sequence":2,"type":"tool_call","timestamp":100,"data":{"apiKey":"[REDACTED]","tool":"read_file"}}代码实验
bash
pnpm --dir bootcamps/coding-agent/labs/26-trace-logger/starter test
pnpm --dir bootcamps/coding-agent/labs/26-trace-logger/solution testStarter 应声明 实现递归 Trace 脱敏;Solution 应通过 5 项测试。
失败实现
下面的写法看起来简短,却同时制造顶层之外的泄密和并发序号竞争:
ts
export async function appendUnsafe(path: string, data: Record<string, unknown>) {
if ("apiKey" in data) data.apiKey = "[REDACTED]";
const sequence = (await readAll(path)).length + 1;
await appendFile(path, JSON.stringify({ sequence, data }) + "\n");
}它原地修改输入,只处理一个固定键,并让每个并发调用都在写前读到相同长度。三个调用可能全部分配 sequence 1;即使操作系统保证单次 append 字节不交错,逻辑序号仍重复。更严重的是 nested.authorization 与错误文本里的 Bearer token 会原样留在文件中。
一个可审计的实现把脱敏与写入分成两个小而明确的单元:
ts
const sensitive = /api[-_]?key|authorization|password|passwd|secret|token|cookie/i;
export function redactTraceValue(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
}
if (Array.isArray(value)) return value.map(redactTraceValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
sensitive.test(key) ? "[REDACTED]" : redactTraceValue(item),
]),
);
}
return value;
}注意这里不把敏感键的值继续递归,而是整体替换。authorization 若是复杂对象,也不应保留其中任何局部字段。正则是模块级常量且没有全局 g 标志,避免 RegExp.test 的 lastIndex 状态在多次调用之间产生交替结果。
关键实现讲解
脱敏必须生成新对象,不能修改 Runtime 正在使用的工具参数。写盘前完成脱敏,避免“稍后清理”期间秘密已经落盘。read 验证 sequence 与 runId,使文件拼接或选错路径立即可见。
这条流水线的顺序不能任意交换。若先进入队列再处理一个可能很深的对象,慢脱敏会阻塞后续事件;若先序列化再脱敏,秘密已经进入字符串副本并可能被异常日志捕获。实践中先验证小型元数据,再对受限深度的数据做纯函数脱敏,最后把已经安全、可序列化的值交给写队列。对大型二进制、循环引用和超深对象应在入口拒绝或摘要化,而不是让 JSON 序列化在关键路径意外失败。
敏感信息有两种形态。结构化秘密依附在 apiKey、authorization、password、secret、token、cookie 等键上,此时整个值必须替换,不能保留前后几位。非结构化秘密可能出现在普通错误文本,例如请求失败消息里的 Bearer token,因此所有普通字符串还要扫描 Bearer 模式。键匹配应大小写不敏感并覆盖连字符与下划线,但不要用包含任意 key 的过宽规则,否则 keyboardLayout 也会被误删,Trace 失去诊断价值。
数组必须逐项递归,对象必须创建新对象。原地改写会污染 Runtime 的参数:Provider 仍要使用真实 authorization,而 Logger 若把它换成占位符,请求就会失败。纯函数还有额外好处:同一输入可在属性测试中验证“原对象未变”“输出不含种子秘密”和“再次脱敏结果相同”。这种幂等性让事件经过多个日志边界时不会不断改变。
Promise queue 的核心不是异步,而是把多个调用者提交的 append 线性化。每次调用捕获当前队尾,在其后读取或分配序号、写入记录,再把成功或失败都转换为可继续的队尾。若失败后的 queue 保持 rejected,后续所有 append 会被永久短路;所以内部队尾要吞掉上一项的拒绝,而当前调用返回的 operation 仍保留错误,让调用者知道本次没有成功。
首次 append 不能假设文件为空。进程重启后同一个 run 继续写,需要先严格读取已有记录,以最后一个 sequence 加一。读取本身会验证从一开始连续、runId 一致、schema 基础字段有效。只取文件最后一行虽然更快,却可能绕过中间污染;正确性版本应先完整校验,规模扩大后再通过校验过的索引或分段文件优化。
写入器可以用如下队列骨架表达。真正的设计重点是 sequence 的所有权只属于队列内部,调用者无法指定或覆盖:
ts
class JsonlTraceSink {
private queue: Promise<void> = Promise.resolve();
private nextSequence: number | undefined;
append(type: string, data: unknown): Promise<TraceRecord> {
const operation = this.queue.then(async () => {
if (this.nextSequence === undefined) {
const existing = await this.read();
this.nextSequence = (existing.at(-1)?.sequence ?? 0) + 1;
}
const record = {
runId: this.runId,
sequence: this.nextSequence++,
type,
timestamp: Date.now(),
data: redactTraceValue(data),
};
await appendFile(this.path, `${JSON.stringify(record)}\n`, "utf8");
return record;
});
this.queue = operation.then(() => undefined, () => undefined);
return operation;
}
}这里还有一个需要主动讨论的故障窗口:sequence 在 appendFile 前自增,写入失败后下一次会留下缺口。本课测试关注成功并发和已有文件恢复;生产实现若要求失败后仍连续,应仅在写成功后提交 nextSequence,或在失败时把缓存失效并从磁盘重新读取。更稳妥的模式是计算候选序号、完成 append、再更新内存序号,同时保持单队列防止候选被并发复用。
每条事件的 type 应来自受控枚举或带命名空间的字符串,例如 run_started、model_response、tool_call、tool_result、run_completed。接受任意非空 type 适合教学扩展,但生产查询层需要版本化词典,否则 tool-call 与 tool_call 会被统计成两类。data 保持扩展性,关键聚合字段如 tokens、duration、toolName 则要定义明确类型和单位。
timestamp 应由可注入 clock 生成,单元测试才不依赖真实时间。它用于展示和持续时间估算,不替代 sequence。跨机器合并 Trace 时,墙上时间存在偏差;可以同时记录单调时钟耗时、hostId 和父级 span,但 run 内先由 sequence 建立确定顺序。任何负 duration 都应在投影层归零或报告异常,而不是悄悄改写原始事件。
JSONL 的优势是追加简单、尾部损坏局部化、可用普通工具逐行检查;代价是缺少事务索引与随机查询。一个事件必须压成单行,data 内换行由 JSON 转义。读取时若文件以换行结束,任何解析失败都是硬错误;若不以换行结束,只允许最后一行解析失败并忽略它。若最后一行是完整但 schema 错误,同样必须拒绝,不能借尾裂规则吞掉主动篡改。
runId 混入是高价值故障信号。可能是路径计算错误、日志轮转拼错、用户选择了别的运行,也可能是跨租户数据泄漏。读取器发现后应立即停止并返回不包含敏感原文的错误。不要过滤掉异 run 记录后继续,因为那会把一个已经失去完整性的文件伪装成正常结果。
脱敏不是安全的全部。Trace 文件仍可能包含源码、文件路径、用户提示和业务数据,因此需要权限最小化、传输加密、保留期限、删除流程与访问审计。占位符只能证明已知模式被处理,不能证明所有个人信息都消失。高风险部署应优先使用字段白名单:只记录查询和评测真正需要的内容,其余生成摘要或哈希。
循环引用会让朴素递归无限运行或让 JSON 序列化抛错。可以用 WeakSet 跟踪已访问对象,遇到循环替换为固定标记;同时设置最大深度、最大数组项数和单事件字节上限。截断必须显式记录原始字节数和截断状态,否则调查者会误以为看到完整输入。截断前仍要脱敏,因为被截掉的边界附近可能进入预览。
错误处理要区分调用错误与存储错误。空 type、非法 runId 属于同步可预防问题;权限不足、磁盘满和文件系统只读属于 append 失败。Logger 是否让 Agent 主流程失败取决于模式:审计强制模式下无法落盘应停止副作用;开发观测模式可告警后继续。这个选择必须配置并写入运行摘要,不能在 catch 中无声吞掉。
性能优化应以秘密不落盘和顺序不破坏为硬约束。可以批量刷新、多文件分段、异步上传或压缩,但批次内仍要有连续 sequence,崩溃时只能丢失明确标注的缓冲窗口。若为了吞吐让多个进程直接 append 同一文件,进程内 Promise queue 不再提供互斥;应改为单独 collector、文件锁或每进程分片后做可验证合并。
哈希链能增强防篡改证据:每条记录加入 previousHash 和当前规范记录哈希。它无法阻止有写权限的人删除整个尾部,也不等于数字签名;若要证明外部不可抵赖,需要定期把锚点提交到独立可信存储。教学项目先保证 schema、连续序号和 runId,再把哈希链作为扩展,复杂度顺序才合理。
测试策略分三层。纯函数测试覆盖嵌套对象、数组、大小写键、Bearer 文本、原对象不变和幂等;存储测试覆盖并发顺序、重启续写、尾裂与中间损坏;安全回归直接读取原始文件,搜索注入的唯一秘密种子。这最后一层非常重要,因为只断言解析后的对象被脱敏,无法证明序列化前没有把秘密写进旁路或错误日志。
ts
it("never persists seeded secrets", async () => {
await sink.append("tool_call", {
nested: { password: "seeded-secret" },
message: "Bearer seeded-token",
});
const raw = await readFile(tracePath, "utf8");
expect(raw).not.toMatch(/seeded-secret|seeded-token/);
expect((await sink.read()).map((event) => event.sequence)).toEqual([1]);
});这类测试使用专门的假秘密,不要把真实 key 放进 fixture。持续集成失败日志也可能打印断言的接收值,所以断言原始文本“不包含种子”比把完整文件做快照更安全。若必须诊断失败,只输出事件序号、字段路径和哈希。
部署时还应明确目录布局,例如每个 run 一个独立文件、文件名只由受信 runId 映射,不接受用户提供绝对路径。创建父目录时使用固定根,解析 realpath 后确认没有越界;符号链接与硬链接策略要显式。Logger 内部验证 runId 格式,可以降低路径穿越风险,但真正的路径边界仍由存储层负责。
日志轮转不能把一个 run 任意截成无法验证的片段。若按大小分段,每个分段要记录起止 sequence、前一段哈希和完成标记;读取器先按数字段号排序再验证连续性。按日期字符串排序或依赖 readdir 顺序都会产生隐性错误。完成运行后可写 manifest,列出总事件数、最终哈希和 schema 版本。
schema 演进遵循“读者先行”。新增可选 data 字段通常兼容,重命名 type 或改变 tokens 单位则需要新 schemaVersion 与迁移投影。原始事件最好保持不可变;查询层根据版本解释,不要后台批量重写所有历史文件。重写会破坏哈希、审计时间和故障复现,除非有独立迁移证据。
最终验收不是“文件里有日志”,而是能回答四个问题:某事件在运行中的唯一顺序是什么;任何已知秘密是否在落盘前消失;文件遭到中间损坏或身份混入时是否停止;进程并发调用时是否仍得到确定记录。四项都需要自动化测试和原始文件证据,才形成可用于下一层投影的可信输入。
运维面板至少要暴露追加失败次数、队列深度、单事件字节数、脱敏命中数和尾裂恢复次数,但标签中绝不能放 runId、文件路径或提示内容等高基数字段。队列深度持续增长说明磁盘或远端 collector 跟不上;脱敏命中突然归零不一定是好消息,也可能是字段名变更导致规则失效。指标只负责提示异常,最终诊断仍回到受控的 Trace 证据。
磁盘配额耗尽是必须演练的故障。可以用受限临时文件系统或注入失败的 appendFile,验证当前调用收到明确错误、队列不会永久卡死、下一次写入在容量恢复后重新校验文件并继续。若审计模式选择停止 Agent,停止事件本身可能也无法写入,因此外层控制器还要通过独立通道记录“因 Trace 不可用而中止”。
脱敏规则需要版本号。规则新增后,同一原始事件在不同版本下可能得到不同输出;把 redactionVersion 写入文件 manifest,查询和导出时即可说明保护基线。不要为了应用新规则读取并重写全部旧 Trace,因为读取过程本身扩大秘密暴露面;更安全的做法是缩短旧数据保留期,并对确有必要保留的历史执行隔离迁移。
事件 data 还应避免把整份源码重复写入每一步。文件读取可记录规范路径、行范围、内容哈希与受限预览;Diff 记录补丁或补丁哈希;命令记录可执行文件、参数摘要、退出码和截断标记。这样既降低泄密面,也控制文件增长。需要完整产物时放进权限更严格的 artifact store,Trace 只保存引用和完整性哈希。
排查顺序应从结构到内容:先确认路径对应正确 run,再验证每行 JSON 与 sequence,再检查事件词典,最后才查看 data。若先盯着某条工具输入,很容易忽略整份文件已经混入别的运行。自动化检查也应沿同一顺序执行,一旦结构失败就停止内容投影,避免基于不可信输入生成看似合理的摘要。
团队评审 Logger 代码时,可以要求作者展示一次对抗性演示:并发提交三条事件;其中一条包含三层嵌套 token,另一条包含 Bearer 文本;写完后注入一个异 run 记录和撕裂尾行。合格结果应先证明原文件没有种子秘密,正常文件读取顺序连续,尾裂单独可恢复,身份混入则明确拒绝。演示把抽象安全声明变成可重复证据。
最后要把责任边界写进接口文档:Runtime 负责提供有意义的事件类型,Logger 负责脱敏、顺序和持久化完整性,Query 负责聚合,Viewer 负责呈现。Logger 不判断任务成功,不计算 token 总量,也不根据 UI 需求改写历史事件。边界清晰后,每层都能独立测试,后续替换存储或界面时不会破坏安全核心。
运行与验证
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 26
pnpm --filter @coding-agent/trace test测试向 Trace 注入 seeded-secret 和 Bearer seeded-token,随后直接读取原文件并证明两个字符串均不存在。
真实运行输出
text
✓ tests/jsonl-trace.test.ts (5 tests)
✓ tests/query.test.ts (4 tests)
Test Files 2 passed (2)
Tests 9 passed (9)运行结果要与证据对应:通过数证明自动化契约成立,原文件秘密扫描证明持久化边界成立。不要只截取绿色终端图;持续集成应保留命令、提交版本、Node 版本和报告哈希,使后来的人能在同一版本复现。
常见失败与排查
故障案例 1
症状:测试读取对象时看见脱敏占位符,但安全扫描仍在 trace.jsonl 找到真实 token。
根因:实现先写原始对象,之后才读取文件并清洗,或错误处理旁路打印了序列化前的数据。
定位:使用唯一种子扫描整个临时目录,检查 append 前后的调用顺序,并搜索所有 console、debug 与异常包装代码。
修复:让 Logger 只接收脱敏后的新对象进入持久化函数,删除写后清洗路径;错误只携带字段名与事件元数据。
故障案例 2
症状:单次 append 全通过,并发压测偶发出现重复 sequence 或事件顺序变化。
根因:每个调用独立读取文件长度,或者 queue 在失败后保持 rejected,重试绕过了统一分配器。
定位:把 appendFile 替换为可控屏障,让三个调用同时完成读取再放行写入;记录候选序号而不输出 data。
修复:由一个单写者队列拥有 nextSequence,失败后恢复队尾;多进程场景迁移到 collector 或真正的跨进程锁。
故障案例 3
症状:读取器在文件尾损坏时可恢复,却也悄悄接受了中间非法 JSON 或另一个 runId 的记录。
根因:实现对所有解析错误都继续,或先按 runId 过滤再验证完整文件。
定位:分别注入中间坏行、无换行尾坏行、混合 runId 与 sequence 跳号,观察哪一类被错误吞掉。
修复:仅忽略未以换行结束且最后一行无法解析的唯一情况;其余 schema、身份和连续性错误全部硬失败。
课后作业
加入循环引用检测、字段 allowlist 和每条记录哈希链;用属性测试生成深层随机对象,证明任何敏感 key 都不会出现在序列化结果。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 脱敏 | key、数组、嵌套和 Bearer 全覆盖 | 只处理顶层 |
| 时机 | 写盘前完成 | 事后清理 |
| 顺序 | 并发 sequence 连续 | 竞争编号 |
| 完整性 | 尾裂可恢复、runId 严格 | 接受混合日志 |
总项目增量
总项目包:@coding-agent/trace
总项目路径:packages/trace/src/jsonl-trace-sink.ts、packages/trace/src/redact.ts
总项目验证命令:pnpm --filter @coding-agent/trace test
下一课把原始事件聚合为可供 API 与 Viewer 使用的运行摘要。
延伸阅读
- Structured logging 与 data minimization。
- Secret scanning 和日志保留策略。
- Tamper-evident hash chain。
方案对比与工程取舍
直接输出控制台成本最低,适合本地一次性调试,但缺少 schema、顺序、脱敏证明和恢复语义,不能作为 Agent 的产品证据。结构化 JSONL 保持依赖少、容易检查,最适合单机教学项目;当查询量和并发增长后,可把同一 Trace contract 发送到 OpenTelemetry collector 或数据库。迁移的关键不是换存储,而是保持事件词典、脱敏边界与 sequence 语义不变。
黑名单脱敏保留更多诊断数据,但永远面临新字段漏网;白名单最安全,却会让早期调试信息不足。合理路线是开发期用严格黑名单加安全扫描,稳定后为外发和长期保留事件建立白名单投影。原始短期 Trace 受更严格权限与较短保留期,长期指标只保存聚合数据。
单文件 queue 简单且保证进程内顺序,但吞吐受一个写点限制;批量写入提高吞吐,却扩大崩溃时尚未持久化窗口。对 Coding Agent 而言,工具调用频率通常远低于日志系统极限,优先选择可证明的线性化和立即写入。只有监控显示 Logger 成为瓶颈时,再带着明确服务目标引入批处理。
下一课衔接
本课得到的是安全、连续、可拒绝污染的原始事件流。原始 Trace 仍不适合让 Viewer 自己计算运行是否完成、走了几步、调用多少工具、消耗多少 token。下一课会实现 Trace Query API,把相同事件按 runId 和 sequence 投影成唯一、确定、可复用的运行摘要,并在查询入口阻断非法 ID。