2808 字
约 9 分钟
19
S14-A09 · 前端错误监控:从上报到归因

用户说白屏,服务端日志干干净净,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) });
});

按频率排序的三个盲区:

  1. 跨域脚本。缺 crossorigin 与 Access-Control-Allow-Origin 时,浏览器只给出 Script error.,无堆栈无行号。上报里"全是 Script error.",看着像监控坏了,其实是配置问题。
  2. try/catch 吞掉。catch (e) { console.warn(e) } 或 catch { return def },全局钩子永远收不到。纪律:每个 catch 必须重抛、上报、或写明降级为何安全。
  3. 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. 自测

  1. 资源加载失败为什么用冒泡阶段监听收不到?
  2. 用 PV 做错误率分母,会在什么情况下得出错误结论?
  3. Source Map 上传了但没删 .map 文件,风险是什么?
  4. 为什么性能数据可以采样而错误数据不宜采样?
  5. 一个 catch 块静默返回默认值,会给监控造成什么后果?
  6. "受影响用户占比"与"会话错误率"分别用在什么决策上?
S14-A09 · 前端错误监控:从上报到归因
https://www.clxhxhhr.top/posts/6216/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。