主题
Trace Query API
本课交付结果
你将交付 TraceQuery.listRuns/getRun:按 runId 聚合最新优先的摘要,计算 status、startedAt、steps、toolCalls、tokens 和 durationMs,并为非法 ID 与未找到运行提供稳定错误。
岗位问题
Viewer 不应每次自行扫描全部 JSONL 并重复业务规则。若不同客户端对“步骤数”或“运行耗时”理解不同,同一次运行会显示冲突指标。查询层必须成为聚合语义的单一来源。
前置检查
前置知识快照
先得到安全 Trace:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 26事件 sequence 决定运行内顺序,timestamp 决定运行间新旧。
第 26 课交付的是可信事件,不是现成报表。开始本课前要明确投影与原始事实的区别:Trace event 是不可变输入,summary 是可以重算的派生视图。投影规则升级时应保留原始事件,通过新版本重新生成摘要;若直接改写事件去迎合界面,审计与复现都会失去共同基线。
还要区分两种时间语义。run 内必须按 sequence 排序,因为并发写入、时钟回拨和测试 fixture 乱序都可能让 timestamp 不可靠;run 之间的“最新优先”使用 startedAt,时间相同再用 runId 稳定打破平局。任何没有明确次级排序的列表,在不同运行时或数据库实现上都可能抖动。
原理拆解
mermaid
flowchart TD
A[严格读取 Trace 事件] --> B[按 runId 分组]
B --> C[按 sequence 排序]
C --> D[归约状态与指标]
D --> E[生成 RunSummary]
E --> F[按 startedAt 降序]
F --> G[listRuns]
C --> H[复制事件详情]
H --> I[getRun]按 runId 分组并按 sequence 排序。steps = count(type=step),toolCalls = count(type=tool_call),tokens = Σ data.tokens,durationMs = last.timestamp - first.timestamp。终止事件决定 status;没有终止事件为 running。
例如 token 为 10 和 5,则总 token 为 10 + 5 = 15;开始 100、结束 150,则耗时 50ms。
代码实验
bash
pnpm --dir bootcamps/coding-agent/labs/27-trace-query-api/starter test
pnpm --dir bootcamps/coding-agent/labs/27-trace-query-api/solution testStarter 应声明 实现 Trace 运行聚合;Solution 应通过 5 项测试。
失败实现
ts
function summarizeUnsafe(events: TraceEvent[]) {
const last = events.at(-1)!;
return {
status: last.type,
steps: events.filter((event) => event.type === "step").length,
tokens: Number(last.data?.tokens ?? 0),
durationMs: Date.now() - events[0]!.timestamp,
};
}这个实现把输入数组顺序当作事实,把最后一次 token 当总量,并用当前时间计算历史耗时。同一批事件只要反转输入或隔天重查,摘要就会改变。status 也错误地等于事件名,而不是终止事件声明的领域状态。查询层若不确定,就会把不确定性传播给每一个客户端。
正确投影先复制排序,再一次归约所有事件:
ts
function summarize(id: string, source: TraceEvent[]): RunSummary {
const events = [...source].sort((a, b) => a.sequence - b.sequence);
const startedAt = events[0]?.timestamp ?? 0;
let status = "running", steps = 0, toolCalls = 0, tokens = 0;
for (const event of events) {
if (event.type === "step") steps += 1;
if (event.type === "tool_call") toolCalls += 1;
if (Number.isFinite(event.data?.tokens)) tokens += Number(event.data?.tokens);
if (event.type === "run_completed") status = "completed";
if (event.type === "run_failed") status = "failed";
}
return { id, status, startedAt, steps, toolCalls, tokens,
durationMs: Math.max(0, (events.at(-1)?.timestamp ?? startedAt) - startedAt) };
}关键实现讲解
listRuns 先聚合再按 startedAt 降序,时间相同用 ID 稳定排序。getRun 先用严格 ID 正则阻断路径式输入,再返回事件副本,避免 API 调用方修改查询源。
查询层的第一条规则是绝不信任输入容器的顺序。内存 fixture、对象存储列表、合并后的分片和数据库查询如果没有明确 order,都可能返回任意顺序。对每个 run 建立新数组并按 sequence 排序,既保护调用者的原数组,也让所有公式使用同一时间线。若出现重复或跳号,理想做法是在 Logger 读取阶段拒绝;Query 接受内存事件时也可增加防御性验证,避免无声生成错误摘要。
分组使用 Map 而不是普通对象,可以避免特殊键、原型链和隐式字符串转换。每读到一条事件,找到对应 runId 的数组并追加;分组完成后再独立排序和归约。不要在全局按 sequence 排序,因为每个 run 都从一开始编号,全局 sequence 没有可比意义。也不要只按 timestamp 分组附近事件,相邻时间可能属于两个并发运行。
status 是状态机投影,不是最后一行 type 的别名。没有终止事件时是 running;看到完成事件变为 completed;看到失败事件变为 failed。若存在多个终止事件,应定义最后一个有效 sequence 胜出还是把运行标记为 corrupt。教学 Lab 采用顺序归约、后出现者覆盖,生产系统通常应在写入时禁止第二个终止事件,并在查询时显式暴露异常。
总项目的事件词典与 Lab 略有不同:run_finished 的 payload 承载 completed、failed、blocked、budget_exceeded、cancelled 等领域状态;steps 和 toolCalls 取最终运行结果,token 则累计所有 model_completed usage。这不是矛盾,而是说明投影必须依赖版本化 contract。博客中的通用公式需要映射到实际 schema,不能把示例 type 生搬到另一套事件里。
steps 既可以数 step 事件,也可以读取终止事件汇总。计数事件更容易从日志重建,却要求每步都完整落盘;终止汇总能保存 Runtime 的最终定义,但运行中或崩溃运行没有该字段。总项目选择完成时优先最终汇总、运行中通过 tool_started 等事件估算。无论选择哪种,API 字段文档都要说明指标是精确值还是当前观测值。
token 必须按有限数字累加。字符串、NaN、Infinity、负数都不应直接进入成本指标。输入与输出 token 最好分开,便于价格模型和上下文优化;Lab 为聚焦聚合只暴露 tokens 总数。成本不宜直接写死在 Query,因为模型定价会变;摘要提供使用量,成本投影再接收带版本的 price table,历史报告才能说明按哪个价格计算。
duration 有两种可靠来源。若终止事件带 Runtime 使用单调时钟测得的 durationMs,应优先使用它;若只有 timestamp,则用最后事件减首事件并把负数视为数据质量问题。不能用当前时间计算已完成 run,否则每次查询都会增长。对 running run 可以额外提供 ageMs,但这个易变字段不应进入报告哈希或缓存键。
startedAt 最好取显式 run_started,而不是最小 timestamp。日志可能缺开头、从中间导入或包含预热事件;显式事件表达领域含义。若缺失,可回退首个 sequence 的 timestamp,同时附加 incomplete 标志。总项目的 summarize 正是先找 run_started,再回退首事件。一个空事件数组不应产生伪造 run,而应被 listRuns 跳过或 getRun 报不存在。
listRuns 的稳定排序包含两个键:startedAt 降序、runId 升序。稳定性不仅为了界面不跳,也是分页与快照测试的前提。若两个 run 同时开始,只按时间比较返回零,后续顺序可能依赖文件系统。加入唯一 ID 次序后,相同输入在不同平台得到相同数组,报告序列化和缓存 ETag 才能复现。
getRun 的 ID 校验必须发生在任何路径拼接和存储读取之前。允许字母、数字、短横线和下划线通常足够;明确拒绝点、斜线、反斜线、空字节与编码后的路径片段。不要先 join 再检查 startsWith,大小写、符号链接和平台分隔符会引入复杂边界。格式校验之后,存储层仍需固定 root 与 realpath 防护,形成纵深防御。
非法 ID 与不存在应是不同稳定错误码。前者是调用者输入问题,可返回四百;后者是合法资源未找到,可返回四百零四。错误消息用于人读,客户端逻辑依赖 code,不应解析中文字符串。内部存储权限、JSON 损坏或 schema 不兼容则属于服务器错误,不能伪装成 not found,否则运维会忽略真实数据故障。
返回事件副本是最小防御。若只复制顶层而 data 仍共享嵌套对象,调用者仍可修改 events[0].data.usage.inputTokens,下一次查询得到不同结果。Lab 的浅层 data 足以测试基础边界;生产可以深冻结、结构化克隆,或让 JsonlTraceSink 每次解析产生新对象。关键契约是 Query 的返回值不能赋予调用方修改事实源的能力。
ts
it("is independent from storage and input order", () => {
const query = new TraceQuery([...events].reverse());
const detail = query.getRun("run-a");
expect(detail.events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
expect(detail.summary).toMatchObject({ steps: 1, toolCalls: 1, tokens: 15 });
detail.events[0]!.type = "tampered";
expect(query.getRun("run-a").events[0]!.type).toBe("run_started");
});如果上述最后一个断言失败,说明复制深度或源存储设计不足。测试不要只验证输出数值,也要验证查询是纯投影:重复调用结果相同、输入反转结果相同、修改返回值不影响后续调用。三个性质比单个 fixture 快照更能约束未来重构。
文件型 Query 的 listRuns 先列目录,只接受安全文件名后缀,再逐个调用严格 JsonlTraceSink。不存在的根目录返回空列表合理,因为尚无运行不是服务异常;权限拒绝或读取损坏必须继续抛出。若所有错误都转成空数组,Viewer 会显示“暂无运行”,把数据丢失伪装成正常空状态。
目录扫描规模增长后,需要索引或 materialized view。每次打开所有 JSONL 适合几十到几百次本地运行,不适合百万事件。可以在 run 完成时原子写 summary 文件,listRuns 只读摘要,getRun 才读详情;摘要带最终 event hash,查询时验证与原始 Trace 对应。索引只是缓存,损坏后应能从原始事件重建。
缓存键至少包含 runId、最终 sequence、schemaVersion 和投影版本。只按 runId 缓存会让 running run 永远停在旧状态;按文件 mtime 在某些文件系统精度不足。完成 run 可长期缓存,进行中 run 设置短期失效或基于 sequence 增量更新。缓存命中与重建的输出必须通过规范序列化比较一致。
分页需要稳定游标而非页码偏移。游标可编码 startedAt 与 runId 复合键,请求下一页时严格小于上一条排序键。运行新增时,偏移分页会重复或遗漏;复合游标在固定排序下更可靠。游标内容要签名或重新校验,不能直接把用户输入拼进查询条件。
筛选 status、时间范围、task 关键字时,应先定义是在可信摘要上过滤还是扫描原始 data。避免把提示、源码等敏感字段建立全文索引。团队通常只给 listRuns 暴露低敏元数据,详细事件需要更高权限。服务端强制授权,Viewer 隐藏按钮不构成安全控制。
可观测查询自身也要有指标:扫描文件数、坏 Trace 数、投影耗时、缓存命中率和结果数量。不要把 runId 作为监控标签造成高基数;可以在结构化诊断事件中受控记录。若某个坏文件导致整个列表失败,可选择返回部分结果加 warnings,但 API 必须显式标出不完整,不能静默丢弃。
属性测试可以随机打乱多 run 事件,再验证每个摘要与基准投影一致;生成同 timestamp 检查 ID 次序;注入非有限 token 确认不污染总量;生成非法 ID 字符验证在存储 mock 调用前就失败。通过这些性质,Query 从几个手写例子升级为有边界证明的投影器。
安全评审还要注意错误回显。非法 ID 可能含控制字符或很长输入,直接插入日志会造成日志注入和资源浪费。入口先限制长度与字符集,用户错误响应使用受控摘要;内部审计可记录输入哈希。不存在错误只返回规范 runId,不暴露真实存储绝对路径。
最后,将 Query contract 看作多个消费者之间的公共语言。Viewer、CLI、评测报告和运维工具都读取相同 summary,而不是各自计算。新增指标时先在 Query 定义公式、缺失策略与测试,再让客户端展示。这样某次“成功率提高”或“token 降低”的说法能追溯到唯一投影规则,而不是前端临时逻辑。
进行中的 run 是最容易被忽略的分支。它没有 run_finished,steps 与 toolCalls 只能从已到达事件计算,duration 也没有最终值。API 可以返回 status 为 running、durationMs 为当前已知事件跨度,并增加 complete 为 false;不要填零伪装成“零耗时”,也不要每次用当前时间改变确定性字段。界面若需要显示“已运行多久”,由展示层根据 startedAt 计算临时值并明确它不属于持久报告。
中断、预算耗尽与人工取消不应统称 failed。它们对排障和产品判断含义不同:失败可能是代码或工具错误,blocked 需要授权,budget_exceeded 说明策略超限,cancelled 是用户意图。Query 只接受 contract 枚举中的状态,未知字符串映射为 unknown 并告警;擅自把未知值归为失败会让新版本事件在旧查询器中产生误导。
多次 run_started 也是结构异常。简单的 find 会取第一条,掩盖同一 runId 被复用;更严格实现验证恰好一条开始事件和至多一条终止事件。对于从中间截取的导入文件,可以启用 tolerant 模式并给 summary 加 warnings。严格与容忍必须由调用场景显式选择,不能在同一默认接口里随机吞错。
投影版本应出现在响应元数据和缓存键中。例如 projectionVersion: 2 表示 token 从总量拆成输入与输出,并更改了进行中 duration 的定义。评测报告记录该版本,比较两份报告时先检查是否一致。没有版本的公式变更会让趋势图出现跳点,却无法判断是 Agent 改进还是统计口径改变。
规范序列化对确定性很重要。JavaScript 对普通对象的属性顺序有规则,但跨语言服务、Map 转换和动态类别键仍可能不同。需要计算 ETag 或报告哈希时,递归排序对象键、固定数字精度、把缺失与 null 区分,并排除 age、查询时间等易变字段。显示对象可以保留丰富信息,哈希对象只包含稳定语义。
时间字符串应统一为带时区的 ISO 格式,数值 timestamp 应统一声明毫秒。混用秒与毫秒会让排序仍看似合理但 duration 放大千倍。入口 schema 对格式做验证,输出保持原时区规范化为 UTC;不要依赖本机 locale 解析模糊日期。测试固定时钟与明确字符串,避免在夏令时环境偶发失败。
数据量大时可做增量投影:缓存每个 run 已处理的 finalSequence 与累加器,新事件只从下一序号归约。发现 sequence 不连续、旧事件被改写或 schemaVersion 改变时,立即丢弃缓存并全量重建。增量优化必须能退回权威路径,否则一次缓存错误会永久污染摘要。
权限模型通常分两层:列表只能看自己有权访问的 run 元数据,详情还要按 run 重新授权。不能因为 runId 难猜就视为秘密,也不能先读取详情再过滤响应,因为读取本身可能触发敏感审计或缓存。授权条件最好进入存储查询,让未授权资源与不存在采用产品约定的不可枚举响应。
负载验证至少包含一万个短 run 与一个十万事件长 run。前者暴露目录扫描和排序成本,后者暴露单次解析内存峰值。测量时分别记录首字节、总耗时和最大内存,避免只看平均。若长 run 影响所有列表,请把摘要与详情路径分离,而不是简单提高服务器内存上限。
发布前做一次跨消费者契约测试:同一 fixture 由 Query 生成 summary,CLI 打印、Viewer 展示、评测导出分别只读取公开字段;删除一个旧字段或加入未知事件,确认客户端仍给出可理解降级。契约测试让 Query 真正成为单一语义来源,而不是只有名字上的共享模块。
课程验收可以用一段故意乱序的双 run Trace:两个 run 开始时间不同,一个完成、一个失败,完成 run 含两次 token 和一次工具调用。先证明 listRuns 新者在前,再证明 getRun 事件连续、指标求和正确、返回副本不可污染源,最后请求非法与缺失 ID。这个短场景覆盖顺序、归约、隔离和错误语义四条主线。
只有当相同事实始终得到相同摘要、异常事实不会被包装成正常结果时,查询层才配得上成为界面和评测共同依赖的证据入口。每一项聚合数字都应能沿事件序号反向解释,而不是停留在无法复核的展示值。
运行与验证
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 27
pnpm --filter @coding-agent/trace test输入顺序即使反转,getRun 仍应返回 sequence 1–4;../secret 在读取任何存储之前被拒绝。
真实运行输出
text
✓ tests/jsonl-trace.test.ts (5 tests)
✓ tests/query.test.ts (4 tests)
Test Files 2 passed (2)
Tests 9 passed (9)除了绿色测试,还应保存一次确定性对照:把同一事件数组反转后,summary 的规范 JSON 与原结果完全一致。再用存储 spy 证明非法 ID 调用次数为零,才能把“查询安全”从代码阅读变成运行证据。
常见失败与排查
故障案例 1
症状:同一份 Trace 在本地与持续集成显示不同步骤、状态或运行顺序。
根因:投影依赖输入数组、目录枚举或相同时间下的不确定顺序。
定位:反转事件与文件名输入,多次运行并比较规范摘要;检查所有 sort 是否同时包含主键和稳定次键。
修复:run 内复制后按 sequence,run 间按 startedAt 降序再按 ID 升序;任何缺失或重复 sequence 显式失败。
故障案例 2
症状:Viewer 显示的 token 明显偏低,历史 duration 会随刷新增长。
根因:只读取最后一次 usage,并用当前时间减开始时间计算已经结束的运行。
定位:构造两次模型调用各带十与五 token,并在相隔时间重复查询同一完成 run。
修复:累计所有有限 usage;完成耗时使用终止事件的单调时钟结果或首尾事件时间,易变 age 单独建字段。
故障案例 3
症状:合法不存在与非法路径输入都变成空数组,甚至能观察到工作区外文件读取尝试。
根因:错误被统一吞掉,runId 在路径拼接后才校验。
定位:给存储读取函数加 spy,依次请求 missing、点点斜线、反斜线和超长 ID,比较错误码与读取次数。
修复:入口先做长度和 allowlist 校验;不存在返回稳定 TRACE_NOT_FOUND,存储损坏继续上抛,绝不伪装为空。
课后作业
加入分页、状态筛选、P50/P95 工具耗时和缓存失效;为同 timestamp、多终止事件和缺 start 事件定义明确策略。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 分组 | runId 隔离 | 混合运行 |
| 聚合 | 五类指标公式稳定 | 客户端自行猜测 |
| 顺序 | sequence 与 startedAt 各司其职 | 依赖文件顺序 |
| 查询安全 | ID 校验、not-found 明确 | 拼接任意路径 |
总项目增量
总项目包:@coding-agent/trace
总项目路径:packages/trace/src/query.ts
总项目验证命令:pnpm --filter @coding-agent/trace test
查询层准备好后,下一课用独立 React 应用呈现运行与证据面板。
延伸阅读
- Event projection 与 materialized view。
- Percentile latency 和 cost attribution。
- API pagination 的稳定游标。
方案对比与工程取舍
让前端直接读取 JSONL 看似省掉一层 API,却把 schema、聚合、安全和错误处理复制到每个客户端,也迫使浏览器获得文件权限。独立 Query 层增加一个模块,但把领域公式集中起来,CLI 与 Web 可以共享。对本地单用户应用,它可以是进程内类;对团队服务,再包成经过鉴权的 HTTP API,核心投影无需重写。
每次现算保证永远基于原始事实,复杂度低;预计算摘要显著加快列表,却需要失效和一致性协议。教学阶段选择现算,规模出现后采用“完成时原子写摘要、可从事件重建”的缓存。不要只保存摘要后删除原始事件,否则新的投影规则无法回放,故障证据也会丢失。
浅复制便宜但只保护第一层,深冻结可及早暴露修改却有运行开销,结构化克隆提供更强隔离。文件型实现每次 JSON.parse 天然创建新对象;内存 Lab 应至少复制事件和 data。选择由事件深度与调用频率决定,但公开契约始终是只读事实,调用者不得依靠可变引用。
下一课衔接
Query 已把原始事件变成稳定摘要和按序详情,下一课可以让浏览器只依赖注入的 API,而不碰文件系统。Trace Viewer 将把 loading、empty、error、选择与 tool、Diff、测试面板做成可访问状态机,并用真实按钮和具名 region 让人、读屏软件与自动化测试读到同一种证据。