有一段时间,线上偶尔出现 Cannot read property 'map' of undefined。堆栈指向图表转换函数,本地却怎么也复现不了。错误平台里有浏览器、URL 和构建版本,但没有用户在出错前切了哪个筛选、哪个接口先返回。我们只能在群里问用户“刚才做了什么”,大多数人已经记不清。
我开始给错误加 Breadcrumb,也就是异常前的一小段有界事件。它不是录屏,更不是把所有日志上传,而是保留能够解释状态转移的最低信息。
图 1:错误堆栈回答代码停在哪里,Breadcrumb 回答程序怎样走到这里。
先定义哪些事件值得留下
我们只记录四类:路由变化;用户触发的业务动作;关键请求开始/结束;应用状态从一个可命名阶段转到另一个阶段。
type Breadcrumb = {
at: number;
category: "navigation" | "action" | "http" | "state";
name: string;
data?: Record<string, string | number | boolean | null>;
};
const breadcrumbs: Breadcrumb[] = [];
function addBreadcrumb(event: Breadcrumb) {
breadcrumbs.push(redact(event));
if (breadcrumbs.length > 40) breadcrumbs.shift();
}只保留最近 40 条,是为了让成本和含义都有限。无限日志会增加内存,真正发生错误时也难以阅读。
记录业务摘要,不记录用户输入
搜索关键词、手机号、token 和表单正文都不应该进入 Breadcrumb。我们记录“筛选条件数量从 2 变成 3”,而不是三个条件的原值;记录资源类型与匿名 ID,而不是客户名称。
| 原始信息 | 实际记录 |
|---|---|
keyword=张三 138... |
action=search, keywordLength=9 |
| 完整请求 URL 与 token | 路由模板、method、status、duration |
| 表单所有字段 | dirtyFields=3, validation=failed |
| 客户 ID | 服务端生成的不可逆诊断 ID |
脱敏应该发生在写入缓冲区之前,而不是上传前。否则第三方插件或其他错误路径仍可能读到敏感数据。
用 requestId 把前端和服务端串起来
那次问题最终与两个筛选请求竞态有关。前端给每次请求生成 clientRequestId,服务端响应自己的 requestId,错误事件同时保存两者:
addBreadcrumb({
at: Date.now(),
category: "http",
name: "chart.query.completed",
data: {
clientRequestId,
requestId: response.headers.get("x-request-id"),
status: response.status,
durationMs: performance.now() - startedAt,
},
});回看时间线时能看到:请求 A 先发出,请求 B 后发出并先返回,页面切到新筛选;随后请求 A 返回,用旧结构覆盖新状态,转换函数拿到 undefined。堆栈没有告诉我们的时间关系,在 Breadcrumb 里非常清楚。
错误聚合不能只看 message
同一句 Cannot read property 可能来自不同模块,带动态 ID 的错误也可能被拆成几千组。我们用错误类型、规范化堆栈顶、路由模板和构建版本生成指纹,再观察影响用户数和首次出现版本。
fingerprint = error.name
+ normalizedTopFrames(3)
+ routeTemplate
+ releaseId版本进入指纹不是为了永久拆分,而是帮助判断回归。平台同时提供跨版本合并视图,避免同一根因每次发布都变成新问题。
监控 SDK 自己不能拖垮页面
采集代码运行在用户页面里,也会失败。序列化遇到循环引用、事件过大、上报接口超时,都不能影响主流程。我们的约束是:同步写入小于 1 ms;异常吞掉但计数;上报使用批量和 sendBeacon;单事件与会话总量都有上限。
这套 Breadcrumb 没有让所有线上问题自动可解,但把“用户说点了几下就坏了”变成了一条可复盘时间线。对我影响最大的不是某个监控 SDK,而是一个原则:可观测性要记录决定系统行为的状态变化,同时克制地不记录与诊断无关的人。