前端错误监控:捕获、降噪、定位与恢复
本文目录
错误监控的目标不是"收集所有报错",而是在用户大规模受损之前发现、定位、修掉。生产环境里真正难的不是捕获——捕获是十行代码的事——难的是捕获之后:上百条堆栈怎么聚成一类问题、Script error. 怎么破、压缩后的堆栈怎么还原成源码、告警怎么发才不会被值班同学静音。这篇文章按"捕获 → 降噪 → 定位 → 恢复"的顺序把整条链路过一遍。
先分清要捕获的错误类型
前端错误监控覆盖五类,每一类的捕获入口都不同,混用入口是新手最常犯的错:
| 错误类型 | 典型场景 | 捕获入口 |
|---|---|---|
| JS 运行时异常 | 读取 undefined 属性、类型断言失败 | window.onerror / addEventListener('error') |
| 未处理的 Promise rejection | fetch 后忘写 .catch、await 抛错无人接 | unhandledrejection 事件 |
| 资源加载失败 | JS/CSS/图片 404、CDN 故障 | addEventListener('error', ..., true) |
| 接口业务错误 | HTTP 4xx/5xx、响应结构不符合预期 | 请求层统一拦截 |
| 跨域脚本错误 | 第三方/独立域名脚本抛错只给一句 Script error. | crossorigin + 响应头配合 |
只装 window.onerror 的监控系统,会漏掉表里除第一行外的全部错误。实际项目里,资源加载失败和接口错误往往比 JS 异常更早暴露问题——静态资源 404 通常是发布事故的第一信号。
window.onerror 与 error 事件的分工
window.onerror 和 addEventListener('error') 都能拿到 JS 异常,但覆盖范围不同,生产环境应该两个都挂:
interface ErrorPayload {
kind: 'js' | 'resource' | 'rejection' | 'http';
message: string;
stack?: string;
filename?: string;
lineno?: number;
colno?: number;
target?: string; // 资源错误的标签与 URL
}
// 入口一:window.onerror 只收 JS 运行时异常
window.onerror = function (message, source, lineno, colno, error) {
report({
kind: 'js',
message: String(message),
stack: error?.stack,
filename: source,
lineno,
colno,
});
};
// 入口二:error 事件既收 JS 异常,也收资源错误(必须开捕获)
// 资源错误不会冒泡,冒泡阶段监听 window 收不到,必须传第三参 true
window.addEventListener(
'error',
(event) => {
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
report({
kind: 'resource',
message: `load failed: ${target.tagName}`,
target: (target as HTMLImageElement).src || (target as HTMLLinkElement).href,
});
return;
}
// 走到这里的才是 JS 异常,ErrorEvent 上有完整 error 对象
if (event instanceof ErrorEvent) {
report({ kind: 'js', message: event.message, stack: event.error?.stack, filename: event.filename });
}
},
true,
);
// 入口三:未处理的 rejection
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
report({
kind: 'rejection',
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
});
});
几个容易踩的细节:
window.onerror能直接拿到行列号,方便做粗定位;addEventListener('error')的ErrorEvent上error属性可能为 null(跨域脚本、栈被改写)。- 资源错误里
event.target才是出错的元素,event.message是空的,别按 JS 异常的思路取字段。 unhandledrejection里reason不一定是 Error,可能是任意被 reject 的值,直接.message会再抛一次异常——监控代码里所有字段访问都要防二次报错。- 两个入口会收到同一次 JS 异常(
onerror先于error事件),上报前要去重,指纹相同的在窗口期内只报一次。
接口错误:在请求层统一拦截
接口错误不该靠各个页面自己上报,散在业务代码里必然漏。正确位置是统一的请求封装层:
async function request<T>(url: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(url, init);
} catch (networkError) {
// 网络层失败:断网、DNS、CORS 拦截
report({ kind: 'http', message: `network error: ${url}` });
throw networkError;
}
if (!response.ok) {
// HTTP 层失败:4xx/5xx
report({
kind: 'http',
message: `http ${response.status}: ${url}`,
});
}
return response.json() as Promise<T>; // 结构层失败交给上层 catch 统一处理
}
注意分寸:不是所有非 2xx 都值得报警。401 跳登录、429 限流重试,这些是业务已处理的路径,上报时打上 handled: true 标记进统计但不进告警,否则登录过期高峰期会把告警刷爆。
SourceMap:把生产堆栈还原成源码
生产代码经过压缩混淆后,堆栈长这样:
TypeError: Cannot read properties of undefined (reading 'id')
at Pe (https://cdn.example.com/assets/app.a3f9c2.js:1:48213)
at HTMLButtonElement.o (https://cdn.example.com/assets/app.a3f9c2.js:1:51190)
app.a3f9c2.js 的 1:48213 在源码里对应哪个函数哪一行,靠 SourceMap 还原。流程分三步:
- 构建时生成 .map 文件。Vite 里
build.sourcemap = 'hidden'——生成 map 但不在产物里引用(JS 尾部不追加//# sourceMappingURL注释),线上用户和爬虫都拿不到。 - 发布时把 .map 上传到监控平台(或私有存储),与版本号绑定。
- 查询时用 map 还原。还原可以发生在服务端(统一处理)或本地脚本(排查时用)。
// 本地排查脚本:node restore.js app.a3f9c2.js.map 1 48213
const fs = require('node:fs');
const { SourceMapConsumer } = require('source-map');
const [mapPath, line, column] = process.argv.slice(2);
const rawMap = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
SourceMapConsumer.with(rawMap, null, (consumer) => {
const pos = consumer.originalPositionFor({
line: Number(line),
column: Number(column),
});
console.log(`${pos.source}:${pos.line}:${pos.column} -> ${pos.name ?? '(匿名)'}`);
// sourcesContent 里通常包含源码片段,可直接打印上下文
});
| 检查项 | 没做会怎样 |
|---|---|
sourcesContent 保留在 map 里 | 还原出文件名和行列号但没有源码上下文,定位效率大打折扣 |
| map 与版本绑定 | 发版后拿旧 map 还原新堆栈,位置全错且不易察觉 |
| map 不部署到线上 | 内部源码被公开抓取,等于源码泄露 |
| 第三方依赖也带 map | node_modules 里的堆栈同样可还原,定位范围扩大到依赖 |
一个容易被忽略的坑:热修/回滚后线上同时存在多个版本,用户设备上跑的可能是上一个版本的资源。所以还原堆栈时必须按上报里带的版本号取对应 map,不能只看"当前最新版本"。
跨域脚本的 "Script error."
独立域名部署 JS(静态资源走 CDN)时,onerror 收到的只有一句 Script error.,没有 message 也没有堆栈。这是浏览器同源策略的保护:脚本抛错的详情只暴露给同源页面。
修复需要两端配合:
<!-- 页面侧:声明跨源脚本 -->
<script src="https://cdn.example.com/assets/app.a3f9c2.js" crossorigin="anonymous"></script>
# CDN 侧:脚本响应必须带回以下响应头
Access-Control-Allow-Origin: *
crossorigin 让脚本以 CORS 模式请求,CDN 允许跨源后,浏览器就放行错误详情。注意三个边界:
crossorigin不带值等价于anonymous,不发送 cookie;如果脚本需要凭证访问则用crossorigin="use-credentials"且Access-Control-Allow-Origin不能是*。- 用了
crossorigin但 CDN 没配响应头,脚本会直接加载失败——比原来的"只丢错误详情"更严重,上线前必须在预发环境验证。 - 用户自己装的去广告/隐私插件可能拦掉监控域名,这部分错误天然丢失,属于可接受损耗。
采样与降噪:从错误洪水到可行动的告警
一个版本发布引入了渲染 bug,一次雪崩就是几万条重复错误。不降噪的监控系统会在十分钟内让值班同学把它静音,然后真正的 P0 也被一起静音。
指纹聚合:同类错误合并成一个问题
指纹 = 规范化后的错误特征哈希。规范化要做两件事:去掉堆栈里的动态部分(数字 ID、UUID),截取前几帧而不是全栈:
function fingerprint(payload: ErrorPayload): string {
const normalized = payload.message
.replace(/\d+/g, '#') // "user 1234 not found" -> "user # not found"
.replace(/[0-9a-f]{8,}/gi, '~') // 长十六进制串(UUID/hash)
.slice(0, 200);
const topFrames = (payload.stack ?? '')
.split('\n')
.filter((line) => line.includes('/assets/')) // 只留应用自己的帧
.slice(0, 3)
.join('|');
return `${payload.kind}:${normalized}:${hash(topFrames)}`;
}
function hash(input: string): string {
let h = 5381;
for (let i = 0; i < input.length; i++) h = ((h << 5) + h + input.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
}
聚合维度建议:指纹 + 版本。同一指纹在新版本再次出现说明修复无效或引入回归,这是排期的重要信号。
频率限制与熔断
客户端侧两级保险:
- 会话内去重:同一指纹在一次会话内只报首次,后续次数累加进计数,页面卸载时补报一次计数。这样既保留"影响量"信息又不刷屏。
- 全局熔断:SDK 内置计数器,当短时间内上报超过阈值(比如会话内超过 20 条),非首次错误全部丢弃并打上
truncated标记——错误洪水时先保住监控通道本身。
服务端侧按指纹做时间窗口聚合(如一分钟一桶),看板展示的是"问题数 × 影响用户数 × 出现次数",而不是原始日志流。
采样
低价值错误可以采样上报,规则与埋点采样一致:
| 级别 | 例子 | 采样建议 |
|---|---|---|
| 致命错误 | 白屏、全局渲染异常 | 100%,且不可被熔断丢弃 |
| 业务错误 | 接口 5xx、资源 404 | 100% 或高采样 |
| 噪音类错误 | 第三方脚本抛错、旧浏览器兼容报错 | 低采样 + 指纹黑名单 |
黑名单要在服务端配置下发而不是写死在 SDK 里,因为"哪些是噪音"只有看到真实数据后才知道。
错误分级与告警
告警的价值密度取决于分级,一视同仁等于没有告警:
| 级别 | 定义 | 通道 | 响应要求 |
|---|---|---|---|
| P0 | 错误率突增(超过基线若干倍)或白屏类致命错误 | 电话/即时消息强提醒 | 立即响应,含回滚评估 |
| P1 | 新版本引入的新指纹错误 | 群消息 | 当天定位 |
| P2 | 存量低频错误 | 每周汇总 | 排期处理 |
上图是典型的"发版即事故"曲线(数据为示意):10:30 错误数从几十跳到六百。告警规则按"环比突增"而不是绝对值配置——绝对值基线每个页面不一样,突增检测才能适配所有页面。
一条合格的告警消息要能让值班同学不看看板就做判断:版本号、指纹、错误率与基线倍数、影响用户数、首例堆栈链接。
版本关联:把错误定位到引入版本
错误数据没有版本号,等于只有案发现场没有时间线。版本号在前端构建时注入,随每条错误一起上报:
// 构建时通过 define 注入,Vite 中使用 define 配置
declare const __APP_VERSION__: string;
function report(payload: ErrorPayload): void {
queue.push({
...payload,
release: __APP_VERSION__, // 建议直接用 commit hash
environment: 'production', // production / staging / dev 严格区分
fingerprint: fingerprint(payload),
url: location.pathname, // 不带 query,防 token 泄露也便于聚合
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
}
版本关联带来两个直接能力:
- 首发定位:按版本分组看每个指纹的首次出现版本,再对比相邻两次发布之间的代码 diff,引入错误的提交范围通常能缩到个位数。
- 回归验证:修复版本上线后,盯这个指纹在"新版本流量"里的计数是否归零——错误数下降不一定是修复生效,也可能是流量下跌,按版本分组才可靠。
恢复与验证
修完不是终点:
- 观察窗口至少一个完整流量周期(对多数业务是一天),覆盖不同时段的用户行为。
- 对比错误率与影响用户数两个口径:错误率降了但影响用户数还在,可能只是高频用户的问题消失了、长尾用户还在。
- 依赖灰度发布验证的,灰度组错误率应与对照组持平后再全量。
- 事后把这类错误的指纹加进 CI 回归(能用单测复现的写单测,不能复现的至少留一条 e2e 冒烟路径),防止同一错误换个版本再回来。
注意事项与边界
- 错误堆栈和 URL 里可能带 token、邮箱等敏感信息,
url字段去掉 query,message 进入存储前做模式脱敏。 - 监控 SDK 自身必须隔离:所有回调包 try/catch,上报失败静默放弃,绝不能因为"监控报错"给页面制造新错误。
- Safari 旧版本对
unhandledrejection支持不完整,rejection 覆盖率要按浏览器分桶看。 - 扩展程序注入的脚本报错会被计入你的
onerror,堆栈里带不属于自己域名的 URL 时,先判为环境噪音再归因。 - 错误率要有分母(PV 或会话数),只看分子会被流量波动误导。
- CSP 配置里要放行上报域名,否则严格策略下监控请求会被自己拦掉。
总结
错误监控的可靠性来自四个环节的闭环:捕获全(三入口 + 请求层拦截,覆盖 JS/rejection/资源/接口)、降噪狠(指纹聚合 + 会话去重 + 熔断 + 黑名单)、定位准(SourceMap 还原 + 版本关联 + 首发版本 diff)、恢复闭环(窗口观察 + 影响面对比 + 回归沉淀)。四个环节任何一环缺失,监控系统都会退化成一个昂贵的日志堆。
参考
- MDN: Window: error 事件
- MDN: Window: unhandledrejection 事件
- MDN: crossorigin 属性
- MDN: 使用 Source Map
- source-map 库
- 站内参考:前端埋点与数据监控体系(采样、批量上报与放量节奏同源)
- 站内参考:前端统一请求边界与错误反馈(React 管理端示例)(接口错误与网络层故障定位)
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。