用户说白屏,服务端日志干干净净,Nginx 200,后端无异常。这类工单的价值不在于"再查一遍服务器",而在于它证明了一件事:可观测体系在客户端是断的。后端日志只记录到达服务器的请求;脚本在浏览器里抛异常、资源 404、接口超时,全都不经过你的机器。前端错误监控要解决的不是"收集错误",而是把散落在千万台设备上的失败,收敛成能排期、能定责、能判断是否值得修的少数几个指标。本文只谈决策:采什么、分母取谁、何时聚合、哪些错误不该进列表。
1. 四类错误,四个不同的捕获点
| 类型 | 典型形态 | 捕获点 | 常见漏采原因 |
|---|---|---|---|
| 脚本异常 | TypeError、模块求值期报错 |
window.onerror / error 捕获阶段 |
跨域脚本无 CORS 头,message 变成 Script error. |
| 资源加载失败 | <script>/<img>/<link> 404 |
addEventListener('error', h, true) |
资源错误不冒泡,冒泡阶段收不到 |
| Promise 未捕获 | await 抛错没人 catch |
unhandledrejection |
旧内核无此事件;SSR 环境无 window |
| 接口失败 | 4xx/5xx、超时、预检失败 | 请求层包装(fetch/XHR 拦截) | 只在业务 catch 里处理,被静默降级吞掉 |
判据:资源错误必须用捕获阶段;接口失败由请求层统一上报。公共字段只有 session_id、release、url、时间戳。
2. 覆盖盲区:三种"报了也没有"的情形
window.addEventListener('error', (e) => {
const t = e.target;
if (t && t !== window && (t.src || t.href)) {
report('resource', { url: t.src || t.href, tag: t.tagName });
return;
}
report('script', { msg: e.message, file: e.filename, line: e.lineno });
}, true); // true 才能拿到资源错误
window.addEventListener('unhandledrejection', (e) => {
report('promise', { reason: String((e.reason && e.reason.stack) || e.reason) });
});
按频率排序的三个盲区:
- 跨域脚本。缺
crossorigin与Access-Control-Allow-Origin时,浏览器只给出Script error.,无堆栈无行号。上报里"全是 Script error.",看着像监控坏了,其实是配置问题。 try/catch吞掉。catch (e) { console.warn(e) }或catch { return def },全局钩子永远收不到。纪律:每个catch必须重抛、上报、或写明降级为何安全。iframe与 Worker。子 iframe 的错误在子窗口触发,父窗口收不到;跨域 iframe 完全隔离;Worker 不走window。要么子页面自装 SDK,要么列为盲区。
3. Source Map:价值与四项成本
| 策略 | 堆栈可读性 | 产物体积 | 源码暴露面 | 构建链路 |
|---|---|---|---|---|
.map 随产物上线 |
好 | 增加 1.5–3 倍量级 | 高,任何人可下载 | 无改动 |
| 上传平台后删除 | 好 | 不增加线上 | 低 | 需 release 绑定 |
| 私有部署还原 | 好 | 不增加线上 | 最低 | 自建服务 |
四项成本一起算:体积(同域可公开访问等于开源)、上传(必须与构建号绑定)、安全性(sourceMappingURL 注释本身就是线索)、私有部署(SaaS 平台意味着源码出境)。判据很硬:没有 release 版本号的 Source Map 等于没有。
# 先上传、再剥注释、最后删 map,顺序错了等于白做
npx @sentry/cli sourcemaps upload --release "$GIT_SHA" --url-prefix '~/static/js' dist/
find dist -name '*.js' -exec sed -i '/sourceMappingURL=/d' {} +
find dist -name '*.map' -delete
4. 聚合:千万条如何变成一个指标
同一个 TypeError 在十万用户上产生十万条上报。不聚合的系统会在首次发版后打满入口带宽,告警永远在响。聚合键只有三个:类型 + 归一化消息 + 首个业务栈帧。
const hash = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
function fingerprint(e) {
const frame = ((e.stack || '').split('\n')[1] || '')
.replace(/:\d+:\d+\)?$/, '') // 去掉行列号
.replace(/[0-9a-f]{8,}/g, 'H'); // 去掉构建哈希
return hash([e.type, e.msg.replace(/\d+/g, 'N'), frame].join('|'));
}
必须归一化的片段:资源 id、用户 id、时间戳、随机 nonce。少一项,"错误种类"就随流量膨胀。
| 聚合粒度 | 收益 | 代价 |
|---|---|---|
| 按消息 | 条数最少 | 同名报错的不同函数被合并 |
| 按栈帧 | 能定位到具体行 | 构建哈希一变指纹就变 |
| 按符号(函数名) | 跨版本可比较 | 压缩后函数名不稳定,误合并高 |
时间窗按小时分桶、跨小时不合并,否则发布时刻的拐点会被平均掉。
5. 错误率的分母:三种取法,三种结论
同一批错误,换分母会得出相反的行动结论。
| 分母 | 公式 | 回答的问题 | 失真场景 |
|---|---|---|---|
| PV | 错误数 / PV | 页面上报了多少次失败 | 无限滚动、轮询使 PV 虚高,错误率被稀释 |
| 会话 | 错误数 / 会话数 | 多少次访问被影响 | 一次会话内多次报错算一次还是多次需先定义 |
| 独立用户 | 受影响用户 / DAU | 影响面、是否回滚 | 重度用户拉高分母;小样本抖动大 |
| 请求数 | 接口错误 / 总请求 | 后端健康 | 前端脚本异常完全不进这个分母 |
判据:是否回滚看受影响用户占比;修不修看每千会话错误数;排不排期看绝对次数 × 预估影响;是否后端问题只有请求数分母能回答。
一句话:告警用会话率,复盘用用户占比,排序用绝对次数。 混用是误判主源——用 PV 做分母看白屏,会把全站白屏算成 0.3% 的"轻微波动"。
6. 噪声治理与采样
| 噪声源 | 识别特征 | 处置 |
|---|---|---|
| 浏览器扩展注入 | 栈帧指向 chrome-extension:// |
上报前按协议白名单过滤 |
| 爬虫/无头浏览器 | UA 含 bot、无交互事件、同秒批量 | 打 tag 但不告警 |
| 老版本缓存 | release 落后多个版本、集中少数 UA | 只对活跃版本窗口告警 |
| 用户网络问题 | NetworkError、ERR_INTERNET_DISCONNECTED |
归入可用性信号 |
性能数据可以采样,错误数据不行,稀有错误正是长尾价值。折中是首报全量、同类稀释、服务端乘回权重:
const seen = new Set();
function report(type, payload) {
const fp = fingerprint({ ...payload, type });
const rate = seen.has(fp) ? SAMPLE_N : 1; // 首次必报
seen.add(fp);
if (Math.random() < rate) send({ fp, payload, weight: 1 / rate });
}
7. 版本归因:不按发布版本切分就没有决策价值
没有 release 字段的上报只能回答"有没有错",不能回答"是不是这次发版引入的"。版本号在构建期注入(git sha),不要运行时手填。
SELECT release, date_trunc('hour', ts) AS h,
count(*) FILTER (WHERE kind = 'script')::float
/ nullif(count(DISTINCT session_id), 0) AS err_per_session
FROM fe_events
WHERE ts > now() - interval '7 days'
GROUP BY 1, 2
ORDER BY 2 DESC;
三类信号的关系要说清,否则归因互相打架:
| 信号 | 采集点 | 因果位置 | 联合判读 |
|---|---|---|---|
| 脚本错误 | error / unhandledrejection |
结果 | 错误率涨且 LCP 涨 → 多半是资源或依赖 |
| 性能指标 | PerformanceObserver |
因 | 性能劣化常先于脚本异常出现 |
| 接口失败 | 请求层 + 后端日志 | 因/果皆有 | 前端 5xx 突增先查后端 |
铁律:三类信号共享同一个 session_id 与 release,否则只能各自解释。
8. 上报通道的自保
| 通道 | 何时可用 | 失效场景 |
|---|---|---|
fetch(keepalive) |
页面存活 | 请求体有上限(约 64KB 量级,按浏览器版本核对) |
sendBeacon |
卸载时最可靠 | 读不到响应,部分头不可设 |
| 图片打点 | 兼容性最好 | 只能 GET,长度受限 |
| WebSocket | 高频上报 | 断线丢数据,运维成本高 |
四条纪律:不阻塞首屏、队列有上限(超限丢最旧)、SDK 不能触发自己的钩子(否则递归上报)、熔断(连续失败若干次暂停)。
function send(body) {
const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
fetch(ENDPOINT, { method: 'POST', body: blob, keepalive: true }).catch(() => {});
}
9. 可修复性排序:不是所有错误都值得修
| 类别 | 用户可感知 | 修复成本 | 优先级 |
|---|---|---|---|
| 主 chunk 404、首屏资源失败 | 是(白屏) | 低(回源/缓存头) | P0 |
首屏顶层 TypeError |
是 | 中 | P0 |
| 接口 5xx 导致空列表 | 是 | 视后端 | P1 |
| 接口 4xx(参数/权限) | 部分 | 低 | P2,多为前端兜底缺失 |
判据是三个因子的乘积:用户可感知 × 出现频率 × 修复成本。只按次数排,第三方脚本噪音长期占榜首,真正白屏那类沉在第二页。告警阈值别用绝对值,用相对基线(当前小时会话错误率 vs 前七天同小时中位数)。
10. 排错:上报了但没有用
| 现象 | 根因 | 处理 |
|---|---|---|
大量 Script error. |
跨域脚本缺 crossorigin 与 CORS 头 |
补 crossorigin="anonymous" 与 ACAO |
| 资源 404 一条都没收到 | 监听器写在冒泡阶段 | 改为 addEventListener('error', h, true) |
| 错误种类每天翻倍 | 指纹未归一化动态片段 | 归一化 id/时间戳/hash,按小时分桶 |
| 堆栈能看但行号对不上 | release 未与 Source Map 绑定 | 构建期注入 git sha,上传指定同一 release |
11. 边界:做到什么程度就够了
| 场景 | 合理范围 |
|---|---|
| 内部管理系统 | 只做脚本异常 + 接口失败,不做采样与版本切分 |
| 用户量极小(日活几百) | 全量上报、单看板、人工看,不建告警 |
| 纯静态展示页 | 只需资源加载监控与可用性探测 |
| 已有 RUM 平台 | 只补业务错误维度,别自建采集通道 |
最少可用版本只有三件:捕获阶段监听、release 字段、按会话错误率。三件齐了,白屏工单当天能定位到版本;缺一件,监控只是"有个看板"。
12. 自测
- 资源加载失败为什么用冒泡阶段监听收不到?
- 用 PV 做错误率分母,会在什么情况下得出错误结论?
- Source Map 上传了但没删
.map文件,风险是什么? - 为什么性能数据可以采样而错误数据不宜采样?
- 一个
catch块静默返回默认值,会给监控造成什么后果? - "受影响用户占比"与"会话错误率"分别用在什么决策上?