第 39 章
第 9 周:把 trace 画出来:一个单文件 trace 查看器怎么写?
一个能读任意输入的 trace 查看器:本周约定的 JSON、OTLP/JSON 导出、Codex app-server 的通知序列先适配成同一种 span 列表,宽进严出,纳秒时间戳用 JSON.parse 的 context.source 按 BigInt 读;Map 一次建树,瀑布图看总耗时,找慢步骤看自身耗时;token 只算「最深层」的 usage,避免根上的汇总被加两遍;两次运行按步骤签名加权对齐,第一个分叉就是第 6 周错误分析里「第一个上游失败」的候选;5 万个 span 用虚拟滚动只建 26 行;trace 内容是不可信输入,只用 textContent。一段讲解视频,互动演示本身就是查看器。
BugHunt 的测试 Agent 每跑一次,都留下一条 trace。第 2 周《Agent 做错了,你怎么知道它错在哪一步?》讲了 trace 里该记什么,也给过一个瀑布图演示;但那是写死四条 trace 的教具,数据就在代码里。真要拿来用,查看器面对的是别人产出的文件:字段可能缺,父 span 可能不在,时间戳是 19 位整数,内容里混着被测网页的原文,一条 trace 可能有几万个 span。
这一讲把查看器做成一个单文件 HTML 页面,不依赖任何服务:读三种输入,建树,画瀑布图,按错误和慢步骤过滤,汇总 token 和成本,再把两次运行对齐、找到第一个分叉。每一步都会碰到一个具体的工程问题:数据加载、重复计数、错位、大 trace 的性能、XSS。下一讲给它接上后端存储和查询。
全部数据是合成的 BugHunt trace,模型名是 mock-large / mock-small,价格一律是示例价格(合成),不是任何厂商的真实价格。
讲解视频
互动演示
演示本身就是查看器。上面一排按钮加载 6 条合成 trace(run-07 找到 Bug、run-08 导航超时后重试、run-09 漏报、run-10 OTLP 导出、Codex 通知序列、run-11 带 XSS 探针的恶意页面),也可以选本地文件或直接粘贴 JSON。左边是 span 树,右边是瀑布图,点一行看属性;可以只看错误、按总耗时或自身耗时筛慢步骤、搜索文本。下面几块分别是:按模型汇总 token 和成本(价格可改),两条 trace 的加权对齐和按下标对齐,生成 1,000 到 50,000 个 span 的大 trace 比较虚拟滚动和全建,以及同一个不可信字符串用 innerHTML 和 textContent 会得到什么。页面底部有 5 道自动判分的练习。
查看器要做的六件事
| 要做的事 | 本讲的做法 | 对应的工程问题 |
|---|---|---|
| 读数据 | 三种输入先转成同一种 span 列表 | 格式适配、宽进严出、纳秒精度 |
| 建树、画瀑布图 | Map 建索引,迭代 DFS | 孤儿、环、多个根;大 trace 不爆栈 |
| 过滤 | 错误、慢步骤(按自身耗时)、文本搜索,命中项连同祖先一起显示 | “慢"该怎么定义 |
| 汇总 token 和成本 | 按模型汇总,只算"最深层"的 usage | 重复计算、缓存口径 |
| 对比两条 trace | 按步骤签名加权对齐,标出第一个分叉 | 插入一步后整体错位 |
| 显示内容 | 只用 textContent | trace 内容是不可信输入(XSS) |
数据模型:一个 span 有哪些字段
本周三讲共用一份 JSON 约定,写在 week09_全栈/code/trace_schema.md:一条 trace 有 trace_id、name 和 spans,字段名照搬 OTLP 的 protobuf 字段名(snake_case),值做了简化。和 OTLP/JSON 的区别:
| 本周约定 | OTLP/JSON | 说明 |
|---|---|---|
trace_id / span_id / parent_span_id | traceId / spanId / parentSpanId | 十六进制字符串,16 字节 / 8 字节,全 0 无效 |
kind:"CLIENT" 等字符串 | kind:整数(3 = CLIENT) | OTLP/JSON 规定枚举必须编码成整数 |
start_time_unix_nano:JSON 整数 | startTimeUnixNano:十进制字符串 | 64 位整数写成字符串,读的时候数字、字符串都要接受 |
status.code:UNSET / OK / ERROR | status.code:0 / 1 / 2 | message 只在 ERROR 时有意义 |
attributes:扁平对象,值只能是标量 | [{key, value: {stringValue | intValue | ...}}] | 结构化内容在本约定里存成 JSON 字符串 |
OTLP/JSON 的几条规则出自 opentelemetry-proto v1.11.1 的
docs/specification.md
:“Values of enum fields MUST be encoded as integer values”;对象的键是转成 lowerCamelCase 的字段名;trace / span id 是十六进制字符串;64 位整数(包括 startTimeUnixNano 这类 fixed64 时间戳)“are encoded as decimal strings, and either numbers or strings are accepted when decoding”。
属性名用 OpenTelemetry GenAI 语义约定:gen_ai.operation.name(chat、execute_tool、invoke_agent)、gen_ai.request.model、gen_ai.tool.name、gen_ai.tool.call.arguments / result、gen_ai.usage.input_tokens / output_tokens / cache_read.input_tokens / cache_write.input_tokens。错误用 error.type,HTTP 子 span 用 http.request.method、http.response.status_code、url.full。
版本:GenAI 约定已经搬到单独的仓库 open-telemetry/semantic-conventions-genai,属性表里的状态都是 Development。这个仓库还没有发过版本,本文按 2026-09-30 的提交
b31e9e8
核对。error.type、http.*、url.full 在通用语义约定
v1.44.0
里是 Stable。Development 意味着属性名以后可能改,所以查看器读数据时要容忍不认识的属性。
另一种数据模型:Codex app-server 的 thread / turn / item
第 3 周《一个生产级 Agent 长什么样?Codex 的仓库地图》讲过 app-server 对外的三层模型:thread 是一次会话,turn 是一次用户请求,item 是 turn 里的一条消息、一次命令执行、一次 MCP 调用。它本身就是一棵树,所以查看器也能读 Codex 的通知序列。下面的映射是笔者自己定的,不是 Codex 的约定;源码引用都基于 openai/codex 的提交 799324821d36。
| Codex | 转成 | 要注意的地方 |
|---|---|---|
Turn.startedAt / completedAt | turn span 的起止(只在 turn 里没有 item 时兜底) | 注释写的是 Unix 秒 |
item/started 的 startedAtMs、item/completed 的 completedAtMs | item span 的起止 | 毫秒;时间在通知上,不在 ThreadItem 里 |
thread/tokenUsage/updated 的 tokenUsage.last,只在 total 变化时按 turnId 相加 | turn span 上的 gen_ai.usage.* | 同一个 last 可能被推多次,见下文 |
commandExecution 的 status: failed / declined | ERROR + error.type | TurnStatus 只有 completed / interrupted / failed / inProgress |
Turn 的时间字段见
thread_data.rs:386-406
:
/// Unix timestamp (in seconds) when the turn started.
#[ts(type = "number | null")]
pub started_at: Option<i64>,
/// Unix timestamp (in seconds) when the turn completed.
#[ts(type = "number | null")]
pub completed_at: Option<i64>,
/// Duration between turn start and completion in milliseconds, if known.
#[ts(type = "number | null")]
pub duration_ms: Option<i64>,
item 的毫秒时间在通知上,见 item.rs:1337-1344 和 item.rs:1415-1422 :
pub struct ItemStartedNotification {
pub item: ThreadItem,
pub thread_id: String,
pub turn_id: String,
/// Unix timestamp (in milliseconds) when this item lifecycle started.
#[ts(type = "number")]
pub started_at_ms: i64,
}
秒级精度画不了瀑布图。所以 turn 里有 item 时,turn 的起止只用 item 的毫秒时间;秒级的 startedAt / completedAt 只在没有 item 时兜底。如果把秒级时间也取进包络,秒的取整会让 turn 凭空多出一段"自身耗时”(样例里 turn 1 的 startedAt 比第一个 item 早 120 ms)。查看器会在警告里说明这一点。
token 用量走 thread/tokenUsage/updated 通知,载荷是 ThreadTokenUsage { total, last }(
thread.rs:1894-1898
、
thread.rs:1934-1940
)。两个字段的含义要读 core 才知道:
protocol.rs:2311-2314
里,total 是一路累加的,last 被替换成最近一次的值:
pub fn append_last_usage(&mut self, last: &TokenUsage) {
self.total_token_usage.add_assign(last);
self.last_token_usage = last.clone();
}
而这个更新发生在每次模型响应完成时(
core/src/session/turn.rs:2975-2988
先 record_observed_response_completed,再 record_token_usage_info)。所以 last 是最近一次响应的用量。
但不能把每条通知的 last 逐条相加。app-server 每收到一次 core 发出的 TokenCount 事件,就推一条 thread/tokenUsage/updated(
bespoke_event_handling.rs:1026-1028
、
1579-1593
),而 TokenCount 事件带的是 session 里当前的 total / last(
session/mod.rs:4881-4888
)。下面几种情况都会在没有新响应时再发一次:
- 收到限流信息:
update_rate_limits先记下限流信息,再发一次TokenCount( session/mod.rs:4847-4854 ),压缩时( compact.rs:811-812 )和用量超限出错时( turn.rs:1705-1708 )都会调用它; - 一次采样请求里收到过限流信息、但流出错了:循环带着错误退出,退出前照样发一次( turn.rs:2939-2943 、 3173-3179 ),之后上层重试;
- 客户端恢复或重新连接一个线程时,app-server 把当前用量重放给这个连接( token_usage_replay.rs:35-55 )。
这些重推里的 last 还是上一次响应的值,turnId 却可能已经是新 turn 的。Codex 自己算单个 turn 的用量,用的是 turn 首尾 total 的差值(
tasks/mod.rs:720-735
)。查看器只在 total 比上一条通知变化时才累加 last,没变就跳过并给出警告。total 是按线程累计的,所以比较也按 threadId 分开;一份录制里如果混进了别的线程的通知,查看器只读 thread/started 那个线程,其他线程的通知跳过并计入警告,否则别的线程的 total 会打断去重。
另外,Codex 的 inputTokens 含缓存读的部分:non_cached_input() 是 input_tokens - cached_input()(
protocol.rs:2426-2432
),cached_input() 只取 cached_input_tokens。只就缓存读这一部分而言,它和 GenAI 约定"cache_read.input_tokens 应计入 input_tokens“一致;缓存写(cache_write_input_tokens)算不算在 input_tokens 里,本文没有核实,不下结论。
演示里的 Codex 样例在这个合成设定下:2 个 turn,turn 1 的一次命令执行失败。每个 turn 有两次模型响应,按 turn 相加后,turn 1 是输入 11,300(缓存 8,100)、输出 380,turn 2 是输入 14,800(缓存 13,200)、输出 130;合计输入 26,100、缓存 21,300、输出 510。样例在 turn 2 开头还插了一条重推(total 不变,last 还是 turn 1 最后一次响应的 6,100),如果逐条相加,turn 2 的输入会变成 20,900;查看器跳过这一条,并在警告里说明。
数据加载:宽进严出,纳秒不能丢
适配层。 三种输入各写一个适配函数,输出同一种内部结构;后面的建树、过滤、汇总、对比只认这一种。新增一种来源,只加一个适配函数。
宽进。 下一讲的后端校验不过就返回 400;查看器反过来,尽量显示,把问题列成警告。原因是查看器还要读直接导出的 OTLP 文件和还在运行的 trace:父 span 可能还没写进来,结束时间可能还没有。
| 情况 | 查看器的处理 |
|---|---|
| 父 span 不在这条 trace 里(孤儿) | 挂到一个虚拟根下 |
| 多个根 / 没有根 | 加一个虚拟根 |
| 父子关系成环 | 从根和孤儿出发走一遍,走不到的不是在环上就是环的后代;每个环只断开一条边,断开处的节点挂到虚拟根,环上其他节点和环下的后代仍挂在原父节点下;环和孤儿分开计数 |
span_id 重复 | 保留第一个 |
end < start / 缺结束时间 | 按 0 时长画 / 画到 trace 的最后时刻,标"未结束” |
后端严、前端宽,是笔者的取舍:后端是写入的关口,脏数据进了库就到处扩散;查看器拒绝整条不如显示出来、把问题指给人看。
纳秒精度。 1759300000000000123 用 JSON.parse 读成 Number,会变成 1759300000000000000。这个量级在 \(2^{60}\) 和 \(2^{61}\) 之间,double 有 52 位尾数,相邻两个可表示的数相差
查看器给 JSON.parse 传一个 reviver,用它的第三个参数 context.source 拿到这个值在原文里的源码,键名以 _unix_nano / UnixNano 结尾的值按 BigInt 读;相减求出相对时间之后才转成毫秒:
const isNanoKey = k => /(_unix_nano|UnixNano)$/.test(k);
function parsePrecise(text) {
let lossy = false;
const obj = JSON.parse(text, function (key, value, ctx) {
if (!isNanoKey(key)) return value;
if (typeof value === 'string' && /^\d+$/.test(value)) return BigInt(value); // OTLP/JSON:十进制字符串
if (typeof value === 'number') {
if (ctx && typeof ctx.source === 'string' && /^\d+$/.test(ctx.source)) return BigInt(ctx.source);
if (!Number.isSafeInteger(value)) lossy = true;
return BigInt(Math.round(value));
}
return value;
});
return { obj, lossy };
}
context.source 是 TC39 的 JSON.parse source text access 提案带来的;按
MDN 的 JSON.parse 页面
的兼容表,Chrome 114、Firefox 135、Safari 18.4、Node.js 21 起支持。不支持时退回 Number,页面会提示精度可能丢失。导出时 BigInt 原样写回,测试里确认了导出再读回逐位一致。
建树和瀑布图:自身耗时才是"慢在哪"
建树用一个 Map(span_id → 节点),一遍扫完挂好父子关系,\(O(n)\);对每个 span 用数组 find 找父亲是 \(O(n^2)\),几万个 span 时就能感觉到。遍历一律用显式栈,不用递归,深度很大的 trace 也不会爆栈。
瀑布图的横条长度是总耗时 \(e - s\)。但"哪一步慢"要看自身耗时:总耗时减去子 span 覆盖的时间。子 span 可能重叠(并行调用),也可能超出父 span(时钟偏差、异步收尾),所以先裁剪到父 span 的区间、再求并集:
$$t_{\text{self}} = (e - s) - \Bigl|\,\bigcup_i \bigl[\max(s_i, s),\ \min(e_i, e)\bigr]\Bigr|$$function selfTime(n) {
const iv = n.children.map(c => [Math.max(c.s, n.s), Math.min(c.e, n.e)]).filter(([a, b]) => b > a).sort((x, y) => x[0] - y[0]);
let cov = 0, cs = 0, ce = -Infinity;
for (const [a, b] of iv) {
if (a > ce) { if (ce > cs) cov += ce - cs; cs = a; ce = b; } else ce = Math.max(ce, b);
}
if (ce > cs) cov += ce - cs;
return Math.max(0, (n.e - n.s) - cov);
}
按总耗时排序,根 span 永远排第一,这个信息没用。在这个合成设定下:
- run-07(15 个 span、3 层、总耗时 11.84 s)阈值设 1 秒,按总耗时命中 6 个 span(根 + 5 次模型调用),按自身耗时命中 5 个;根的自身耗时只有 590 ms。
- run-08 按自身耗时最慢的是那次超时的 HTTP 请求
GET(4.90 s),它的父 spanexecute_tool browser_navigate总耗时 5.00 s,自身只有 100 ms。
过滤:错误、慢步骤,以及"过滤不出来"的失败
过滤命中的 span 要连同祖先一起显示(祖先变淡),否则一个孤零零的 GET 看不出是哪一步的。run-08 勾上"只看错误"剩 3 行:根(上下文)、超时的导航、它下面的 HTTP 请求。
错误还要再分一层。语义约定 v1.44.0 的 Recording errors 有两条:
- “Span Status Code MUST be left unset if the instrumented operation has ended without any errors.”
- “Errors that were retried or handled (allowing an operation to complete gracefully) SHOULD NOT be recorded on spans or metrics that describe this operation.”
第二条是 SHOULD NOT,不是 MUST NOT;而且说的是"描述这个操作"的 span。每一次尝试自己是一个 span,超时的那次尝试照样是 ERROR。所以查看器多了一条规则(笔者定的):一个 ERROR span 后面如果有一个签名相同、没出错的兄弟 span,就标成"已补上"。实现上每个父节点只建一次索引(签名 → 没出错的兄弟里最晚的开始时间),每次判断查一下表,整棵树是 \(O(n)\)。run-08 的超时导航就是这样;它下面的 HTTP 子 span 没有同签名的兄弟,不算补上,所以 run-08 是 2 个 ERROR、1 个已补上。
run-09 是一次漏报:登录态丢了,Agent 打开购物车被重定向到登录页,截了一张图,又调了一次模型,就结束了,没提交报告。整条 trace 一个 ERROR span 都没有,“只看错误"过滤出 0 行。这类失败要靠和成功运行对比,见后面"对比两条 trace”。
token 与成本:别把同一批 token 加两遍
GenAI 约定里
client
/
internal
两种 invoke_agent span 的属性表里,gen_ai.usage.input_tokens / output_tokens 都是 Recommended。BugHunt 是进程内的 Agent,对应 internal span。如果插桩在根上记了汇总值、每次模型调用的 span 也记了自己的值,把所有 span 的 usage 相加,同一批 token 就算了两遍。
在这个合成设定下,run-10 是一份 OTLP 导出,根 span 带了汇总:所有 span 直接相加,输入是 38,800;去重后是 19,400,正好两倍。
查看器的规则(笔者定的):一个 span 的 usage 只有在它的所有后代都没有 usage 时才计入,即"最深层"规则。它不依赖 gen_ai.operation.name 有没有写对,Codex 那种 usage 记在 turn 上的数据也能用。实现上先收集一遍节点,逆序处理,保证子节点先于父节点:
for (let i = order.length - 1; i >= 0; i--) { // 逆序 = 子节点先于父节点
const n = order[i];
const hasBelow = n.children.some(c => below.get(c));
const u = usageOf(n);
below.set(n, hasBelow || !!u);
if (!u) continue;
for (const k of ['in', 'cr', 'cw', 'out']) naive[k] += u[k];
naive.calls += 1;
if (hasBelow) { skipped.push(n); continue; }
// ... 按模型累加到 byModel 和 total
}
下一讲的后端用的是另一种规则:只累加 gen_ai.operation.name 是推理操作(chat 等)的 span。对本课的样例,两种规则结果相同。
缓存口径。 GenAI 属性注册表在
gen_ai.usage.input_tokens 的说明
里写:“This value SHOULD include all types of input tokens, including cached tokens.";cache_read.input_tokens 和 cache_write.input_tokens 也都"SHOULD be included in gen_ai.usage.input_tokens"。所以算成本时,未缓存输入要把两种缓存减掉:
这是 SHOULD,不是 MUST。如果某个插桩没把缓存算进 input,减出来会是负数;查看器把它截到 0,并单独计数提示。
成本按模型分开算。在这个合成设定下,run-07 有 6 次模型调用,输入 19,400(缓存读 9,000、缓存写 1,800)、输出 910。按示例价格(合成)mock-large 输入 4、缓存读 0.4、缓存写 5、输出 20(美元 / 百万 token):
页面上可以改价格,成本现场重算。
对比两条 trace:先对齐,再找第一个分叉
按下标比较,一次重试就会让后面全部错位。run-08 比 run-07 多了两步:一次超时的导航,以及模型看到超时后又想了一次。按下标比较,13 个位置里有 7 个"不一致”,看起来像两次运行大不相同。
查看器先给每一步算一个签名(操作类型 + 工具名 + 目标,例如 tool browser_navigate http://shop.bughunt.test/cart;模型调用是 chat + 模型名),再算一个结果(状态、error.type、子 span 的 HTTP 状态码、退出码)。对齐用动态规划:签名相同且结果相同得 2 分,签名相同结果不同得 1 分,签名不同不能配对,求总分最大的配法。打分规则是笔者定的;这和最长公共子序列是一类问题,git diff 默认用的 Myers 算法也是在求这类对齐。
function align(A, B) {
const n = A.length, m = B.length;
if (n * m > 4e6) throw new Error(`步骤太多(${n} × ${m}),对齐表要 ${fmtInt(n * m)} 个格子,超过上限`);
const sa = A.map(sig), sb = B.map(sig), oa = A.map(outcome), ob = B.map(outcome);
const W = m + 1, dp = new Int32Array((n + 1) * W);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
let best = Math.max(dp[(i + 1) * W + j], dp[i * W + j + 1]);
if (sa[i] === sb[j]) best = Math.max(best, (oa[i] === ob[j] ? 2 : 1) + dp[(i + 1) * W + j + 1]);
dp[i * W + j] = best;
}
}
// ... 从 (0, 0) 回溯,输出 same / changed / onlyA / onlyB
}
| 对比(在这个合成设定下) | 按下标 | 加权对齐 | 第一个分叉 |
|---|---|---|---|
| run-07 vs run-08 | 13 个位置 7 个不一致 | 相同 11,只有 B 有 2 | 第 2 步:B 多了一次超时的导航(后面被补上) |
| run-07 vs run-09 | 11 个位置 8 个不一致 | 相同 4,结果不同 1,只有 A 有 6 | 第 2 步:同样打开 /cart,A 是 200,B 是 302 → 200(被重定向到登录页) |
run-07 vs run-08 对齐之后,结论从"整条运行都不一样"变成"一次重试"。
和第 6 周接上。 《测试 Agent 为什么漏掉 Bug?从读 trace 到失败分类》里,开放编码时只记 trace 里第一个失败,因为上游的错会引出下游的错。和一条成功运行对齐后的第一个分叉,就是这个"第一个上游失败"的候选。run-09 的分叉落在导航的 HTTP 302 上,对应那一章分类表里的"登录态丢失";后面没点优惠券、没提交报告都是下游症状,不该各记一笔。分叉点只是候选,归不归这一类还要人读。
大 trace:只建看得见的那几十行
一行 span 大约 8 个 DOM 节点。2 万个 span 全部建出来,查看器里有 160,141 个节点,滚动、过滤、折叠都要重建。虚拟滚动:外层容器的高度按"行数 × 24 px"撑开,滚动时只建可见范围加上下各 8 行缓冲。
测试脚本生成 2,000 到 50,000 个 span 的合成 trace,记录建出来的行数和 DOM 节点数(这两个数是确定的;耗时随机器变化,下面只是笔者机器上一次运行的量级):
perf N=2000 virtual=true: 0.64 MB parse 11 ms build 5 ms render 5 ms rows 26 nodes 218
perf N=10000 virtual=true: 3.22 MB parse 59 ms build 18 ms render 9 ms rows 26 nodes 218
perf N=50000 virtual=true: 16.11 MB parse 301 ms build 94 ms render 26 ms rows 26 nodes 218
perf N=2000 virtual=false: 0.64 MB parse 9 ms build 2 ms render 54 ms rows 2000 nodes 16029
perf N=10000 virtual=false: 3.22 MB parse 48 ms build 7 ms render 306 ms rows 10000 nodes 80082
perf N=20000 virtual=false: 6.44 MB parse 118 ms build 25 ms render 682 ms rows 20000 nodes 160141
5 万个 span 时虚拟滚动也只建 26 行、218 个节点;滚到第 25,000 行附近,还是只有几十行。
其他几处限制:对齐表是 \(n \times m\) 的 Int32Array,超过 400 万格就拒绝对齐并提示;文件超过 50 MB 直接拒绝。5 万个 span 的 JSON 约 16 MB,主线程解析要零点几秒(本机实测,随机器变化),再大就该把解析挪到 Web Worker,本讲没做。
trace 内容是不可信输入:只用 textContent
BugHunt 的测试 Agent 每天读别人写的网页,页面文字会原样进到工具结果、模型输出、Bug 报告标题里,最后进 trace。第 8 周《被测页面里藏了一句「指令」,测试 Agent 会照做吗?》讲的是这些内容骗模型;查看器面对的是同一批内容,换了一个受害者:打开 trace 的人。如果查看器用 innerHTML 显示工具结果,页面评论里的一段 <img src=x onerror=...> 就会在查看器里执行,这就是存储型 XSS。对应第 35 讲《找 Bug 的测试 Agent,会踩到 OWASP LLM Top 10 的哪几条?》里的 LLM10:2026 Improper Output Handling(2025 版是 LLM05):模型和工具的输出要当成不可信用户输入。
// 错:插入的 <script> 不执行,但 onerror 会执行
td.innerHTML = result;
// 错:只转义了尖括号,放进属性里,一个双引号就能闭合属性
row.innerHTML = `<td title="${escLtGt(result)}">...`;
// 对:纯文本
td.textContent = result;
MDN 的 innerHTML 页面
专门讲了第一种:innerHTML 会阻止插入的 <script> 执行,但攻击者还有很多别的写法能执行 JavaScript,页面上的例子就是 <img src='x' onerror='alert(1)'>。
页面里的做法:
写入一律
textContent/createElement。 OWASP 的 XSS 防护清单 把textContent列为安全的写入点(Safe Sink),并建议把innerHTML重构掉。页面的所有 DOM 都由一个小函数h()生成,文字走textContent或文本节点:function h(tag, props, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props || {})) { if (v == null || v === false) continue; if (k === 'className') el.className = v; else if (k === 'text') el.textContent = v; else if (k === 'style') Object.assign(el.style, v); // 只放程序算出来的数值,不放 trace 里的字符串 else if (k === 'dataset') Object.assign(el.dataset, v); else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else el.setAttribute(k, String(v)); // 只用于 title / type / value 这类无害属性 } for (const kid of kids.flat()) { if (kid == null || kid === false) continue; el.appendChild(typeof kid === 'string' || typeof kid === 'number' ? document.createTextNode(String(kid)) : kid); } return el; }脚本里没有一处
innerHTML、outerHTML、insertAdjacentHTML、document.write、eval,测试脚本第一项就检查这一点。属性也小心。
setAttribute只用于title这类无害属性;不把 trace 里的字符串放进href、src、style、on*。比如url.full只显示,不做成链接。拼 URL 前先校验。 从后端列表拿到的
trace_id先检查是 32 位十六进制,再拼进请求路径。纵深防御。 部署时再加内容安全策略(CSP),以及 MDN 提到的 Trusted Types(
require-trusted-types-for)。它们是第二道防线,不能代替第一条。
演示里的 run-11 在 trace 名和三个属性里放了合成的探针:一个 <b>、一个带 onload 的 <svg>、两个带 onerror 的 <img>,触发后只会设置一个全局标志 window.__laqPwned,不做任何别的事。“不可信内容"那块用 DOMParser 在一个不执行脚本的文档里解析这些字符串,列出"如果用 innerHTML 会建出什么元素”;测试脚本点开 run-11 的每一行,确认查看器里没有外来元素、标志没有被设置、原文按文本显示。
测试
week09_全栈/code/trace_viewer/test_viewer.mjs 用 Playwright 打开页面,直接调用页面里真实的解析、建树、汇总、对齐函数,点界面、做练习、量嵌入高度;本文和视频里的数字都来自它写出的 viewer_numbers.json。设了 TRACE_BASE 时,它还会把 samples/ 里的 4 条样例 POST 到下一讲的 Go 后端,再 GET 回来逐字段比对。节选:
PASS 脚本里没有 innerHTML / outerHTML / insertAdjacentHTML / document.write / eval
PASS context.source 读出精确的纳秒 — 精确 1759300000000000123,Number 1759300000000000000
PASS 坏数据不崩,给出警告并加虚拟根:1 个孤儿、2 个在环上
PASS 环:A↔B 成环 → 2 个在环上、0 个孤儿,C 仍挂在 A 下 — {"stats":{"orphans":0,"onCycle":2,"cycles":1},"cParent":"A","warnings":["2 个 span 的父子关系成环(1 个环),每个环断开一条边,挂到虚拟根下"],"top":["R","A"]}
PASS 15 万个孤儿 span:正常建树(不爆栈) — {"ok":true,"nodes":150000,"top":150000,"orphans":150000,"steps":150000,"rootE":149.9995}
PASS run-08:2 个 ERROR(导航 + 它的 HTTP 子 span),导航被后面的重试补上 — errors=2 recovered=1(HTTP 子 span 没有同签名的兄弟,不算补上)
PASS run-09:没有任何 ERROR span
PASS run-10:OTLP 根 span 带汇总,朴素相加正好翻倍 — naive=38800 dedup=19400
PASS Codex:重推的 last 被识别并跳过(total 没变) — 通知 5 条;逐条相加 turn 2 输入 20900,去重后 14800
PASS 样例 run07_found_bug.json 满足第 40 讲的校验规则
PASS 导出为本周约定:纳秒时间戳逐位一致
PASS XSS:点开 run-11 每一行,查看器里没有外来元素,探针没执行,原文按文本显示 — {"foreign":[],"pwned":"undefined","imgs":0,"shown":true}
PASS 虚拟滚动:滚到第 25,000 行附近,仍只建几十行 — {"n":34,"firstTop":"599808px"}
...
全部通过
下一讲:Go 后端,接收 trace、存进 SQLite、提供查询 API。查看器里已经留了"从后端加载"的入口:填上后端地址,列出最近的 trace,点一条加载。第 40 讲的后端不返回 CORS 头,所以从博客页面或本地文件打开查看器时,“从后端加载"会被浏览器拦下,页面给出提示。要用这个入口,按第 41 讲用 nginx 把查看器和 /api/ 放在同一个源(打开 http://127.0.0.1:8080/)。查看器以 http(s) 打开时,默认地址取页面自己的源(location.origin),用 localhost 打开或改了端口也同源;以 file:// 打开时默认 http://127.0.0.1:8080。测试脚本里给响应加 CORS 头,只是为了在测试里走通这条路径。
面试怎么讲
一句话版本:trace 查看器先把各种来源(自己的 JSON、OTLP、Codex 通知)适配成同一种 span 列表,宽进严出,纳秒时间戳按 BigInt 读;用 Map 一次建树,瀑布图看总耗时,找慢步骤看自身耗时;token 只算最深层的 usage,避免根上的汇总被加两遍;对比两次运行时按步骤签名加权对齐,第一个分叉就是错误分析里"第一个上游失败"的候选;大 trace 用虚拟滚动;trace 内容是不可信输入,只用 textContent。
可能的追问:
- 为什么后端严格、前端宽松? 后端是写入的关口,脏数据进了库就到处扩散;查看器要读运行中的 trace 和外部导出的文件,拒绝整条不如显示出来、把问题列成警告。
- 按自身耗时还是总耗时找慢步骤? 总耗时会把根和所有容器 span 排在前面;自身耗时才是"时间花在这一层自己身上”。子 span 并行、越界时要先裁剪再求并集。
- 两次运行步数不一样,怎么比? 按下标比,一次重试就让后面全部错位。按签名做序列对齐,配对后再比结果;第一个不相同的位置是分叉点。
- 漏报的 trace 里没有任何错误,怎么发现? 错误过滤找不到它。要么和成功运行对比找分叉,要么看结局(比如没有 submit_report),再人工读。
- 查看器有什么安全风险? trace 里有被测网页和模型输出的原文,用 innerHTML 显示就是存储型 XSS,受害者是看 trace 的工程师。只用 textContent,属性和 URL 也不拼不可信字符串,部署时加 CSP。
常见错误说法
- “innerHTML 插进去的 script 不会执行,所以用 innerHTML 显示也安全”:
<img onerror>这类事件属性照样执行。 - “把所有 span 的 gen_ai.usage. 加起来就是总 token”*:根 span 也可能带汇总值,会算两遍;在这个合成设定下 run-10 正好翻倍。
- “瀑布图里最长的条就是最慢的步骤”:最长的永远是根,要看自身耗时。
- “只看错误过滤一遍就能找到所有失败”:漏报那条 trace 一个 ERROR 都没有。
- “被重试兜住的错误,OTel 规定不能标 ERROR”:规范写的是 SHOULD NOT 记在描述整个操作的 span 上;每次尝试自己的 span 失败了照样是 ERROR。
- “把 Codex 每条
thread/tokenUsage/updated的last加起来就是用量”:限流更新、流出错重试、恢复线程时同一个last会被再推一次;要看total有没有变,或者像 Codex 自己那样用 turn 首尾total的差值。 - “两次运行按步骤下标对比就行”:插入一次重试,后面全部错位。
- “纳秒时间戳用 JSON.parse 读就行”:\(1.76\times10^{18}\) 附近 double 的间隔是 256 ns,原值回不去。
- “GenAI 语义约定是稳定的标准”:属性表里的状态是 Development,仓库还没有发版,属性名可能改。