2667 字
约 8 分钟
13
S14-X12 · 前端监控与错误上报:从采集到聚合

前端监控最容易做成"接了 SDK,看板上一堆错误"。真正有用的是一条错误能追到哪个版本、影响多少用户、是不是同一个原因。

按数据流走一遍:采什么 → 怎么采不漏 → 怎么去重 → 怎么上报不拖慢页面 → 怎么聚合有结论。


1. 采四类数据,各有各的通道

类别 数据源 关键字段 用途
JS 错误 window.onerror、unhandledrejection message、stack、filename、lineno 定位代码缺陷
资源失败 error 捕获阶段(img/script/css) 资源 URL、标签名 CDN、部署缺失
接口异常 fetch/XHR 拦截 URL、状态码、耗时、traceId 后端与服务端契约
性能指标 PerformanceObserver LCP、CLS、INP、TTFB 体验回归

四类共同字段是 release、sessionId、route、ua。没有版本号的上报等于没上报——判断不了错误是不是这次发布引入的。


2. 三条采集通道与各自的覆盖边界

// 1) 未捕获的同步错误
window.addEventListener('error', (e) => {
  if (e.target && e.target !== window) return;   // 资源错误走下面
  report({ type: 'js', message: e.message, stack: e.error?.stack,
           file: e.filename, line: e.lineno, col: e.colno });
});

// 2) 未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', (e) => {
  report({ type: 'promise', message: String(e.reason?.message ?? e.reason),
           stack: e.reason?.stack });
});

// 3) 资源加载失败:error 不冒泡,必须捕获阶段
window.addEventListener('error', (e) => {
  const t = e.target as HTMLElement | null;
  if (!t || t === (window as unknown as HTMLElement)) return;
  report({ type: 'resource', tag: t.tagName,
           url: (t as HTMLImageElement).src || (t as HTMLLinkElement).href });
}, true);   // ← capture: true 是关键
通道 覆盖 不覆盖 注意
window.onerror / error 冒泡 同步脚本错误 资源、Promise、跨域无细节 同一事件与 addEventListener 会各触发一次,别重复上报
unhandledrejection 未 catch 的 Promise 被 catch 吞掉的错误 需要 e.reason 判空
error 捕获阶段 资源失败、跨域脚本错误事件 资源内容错误 必须 true,否则收不到
console.error 包装 主动上报 报错路径 只做补充,不能当主通道

2.1 Script error. 与 crossorigin

跨域脚本抛错时,同源策略把 message、stack、行列号全抹成 Script error.。两条必须同时满足:

  1. <script src="..." crossorigin="anonymous">;
  2. CDN 返回 Access-Control-Allow-Origin。
<script src="https://cdn.example.com/app.3f2a.js" crossorigin="anonymous"></script>

只加 crossorigin 不配 CORS 头,脚本直接加载失败——比丢堆栈更糟。


3. 去重与聚合:避免发布即风暴

一次发布引入的循环报错,十秒内能产生上万条。指纹相同就归并:

function fingerprint(err: { message: string; stack?: string; route: string }) {
  const frame = (err.stack ?? '').split('\n')[1] ?? '';   // 首帧
  const norm = frame.replace(/:\d+:\d+/g, '');            // 去掉行列号
  return hash(`${err.message}|${norm}|${err.route}`);
}
归并键 粒度 适用
message 最粗 快速看总量
message + 首帧函数 常用 同一行代码的不同输入可合并
message + 首帧 + 行号 细 能区分同名函数的不同位置
再加 release 最实用 直接把"新引入"和"历史遗留"分开

去重要两级:客户端按指纹在窗口内压(同指纹 5 s 一条并带 count),服务端再按指纹聚合。只在客户端压会丢跨会话量级,只在服务端压会让流量本身成负担。


4. 采样与限流

数据 策略 理由
JS 错误 全采(但按指纹限流) 漏一条可能就是漏一个线上问题
资源失败 全采 量小,且往往成批出现
接口异常 按状态码分层:5xx 全采,4xx 采样 4xx 多为用户输入问题
性能指标 采样 5–20% 统计量足够,且能控流量
成功请求耗时 采样 1–5% 只用于分位数
const SAMPLE = 0.1;   // 性能指标采样率
const seen = new Map<string, number>();   // 指纹 → 上次上报时间
const PER_SESSION_CAP = 50;
let sent = 0;

function shouldSend(fp: string) {
  if (sent >= PER_SESSION_CAP) return false;
  const last = seen.get(fp) ?? 0;
  if (Date.now() - last < 5000) return false;   // 同指纹 5 s 内只报一次
  seen.set(fp, Date.now());
  sent += 1;
  return true;
}

每会话上限必须有。没有上限时一个死循环渲染能把上报接口打挂,监控自己变成故障源。


5. 上报通道:卸载时也要发得出去

通道 页面卸载时可靠 体积 备注
navigator.sendBeacon 是 ≤ 64 KB(各家实现不同) 首选,不阻塞卸载
fetch(url, { keepalive: true }) 是 ≤ 64 KB 总量 可带自定义头
new Image().src 一般 URL 长度限制(约 2 KB) 最老,只能 GET
XMLHttpRequest 同步 是 —— 阻塞卸载,不要用
const QUEUE: unknown[] = [];

function flush() {
  if (!QUEUE.length) return;
  const body = new Blob([JSON.stringify(QUEUE.splice(0))], { type: 'application/json' });
  const ok = navigator.sendBeacon('/api/rrweb', body);   // 返回是否入队成功
  if (!ok) void fetch('/api/rrweb', { method: 'POST', body, keepalive: true });
}

window.addEventListener('pagehide', flush);       // 用 pagehide,不用 unload
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

unload 在移动端与 bfcache 下经常不触发,pagehide + visibilitychange 才可靠。重试要有上限(如 2 次)且只在会话内,别把失败数据存 localStorage 下次发——越积越多。


6. 体积、隐私与 Source Map

要处理的 做法 为什么
堆栈过长 只留前 10 帧,单帧截断 200 字符 一条上报几百 KB,流量与存储都吃不消
URL 含敏感参数 上报前删除 query 中 token/code 等键 URL 会进日志与第三方
表单内容 从不采集 value 隐私红线,采了就是事故
用户标识 用不可逆 hash 的 sessionId 能串会话即可
请求体 默认不采,需要时白名单字段 避免把密码写进监控

Source Map 原则只有一条:上传到监控平台,别跟产物一起部署。

// vite.config.ts —— 产物带 sourcemap,但部署前删除 .map
export default defineConfig({
  build: { sourcemap: true },
});
# 上传后删除,产物目录里不留 .map
npx @sentry/cli sourcemaps upload --release "$RELEASE" ./dist
find ./dist -name '*.map' -delete

.map 公开可访问,等于把源码、注释、内部接口名一起公开。有 .map 时压缩混淆等于零。


7. 性能指标:PerformanceObserver 采核心指标

指标 观测方式 采集时机 注意
LCP largest-contentful-paint visibilitychange → hidden 时定稿 只取最后一次候选值
CLS layout-shift(排除用户输入后) 同上累计 用 hadRecentInput 过滤
INP event 的 durationThreshold 会话结束或定期上报 取高百分位而非均值
TTFB navigation timing load 后 反映后端与网络
长任务 longtask 实时 单条 > 50 ms
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if ((entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) continue;
    cls += (entry as PerformanceEntry & { value: number }).value;
  }
}).observe({ type: 'layout-shift', buffered: true });

new PerformanceObserver((list) => {
  const e = list.getEntries().at(-1);
  if (e) lcp = e.startTime;   // 取最后一个候选
}).observe({ type: 'largest-contentful-paint', buffered: true });

buffered: true 不能省:注册晚于指标发生时靠它补历史条目,否则 SDK 加载慢一点就永远收不到 LCP。


8. 告警与看板:次数不等于影响面

指标 定义 适合告警 陷阱
错误次数 上报条数 突发流量 一个死循环能顶到几万
错误率 错误会话 / 总会话 是 需要会话口径统一
影响用户数 去重 sessionId 是(首选) 采样时需按比例放大
新增错误 本版本首次出现的指纹 是 依赖 release 字段
错误分布 按 route / 浏览器 / 地区 定位而非告警 维度太多会稀释信号

告警挂在错误率 + 影响用户数 + 新增指纹三条上。只看次数会被单个用户的循环报错误导;只看错误率,小流量时段会疯狂误报。


9. 排错:六种"上报了但没用"

现象 原因 处理
全是 Script error. 跨域脚本缺 crossorigin 或 CORS 头 两件一起补
堆栈指向压缩后的 a.js:1:23456 没上传 Source Map 或 release 不匹配 上传并核对 release
同一错误占满看板 没做指纹归并 客户端限流 + 服务端聚合
页面变慢 上报在关键路径上同步发送 队列 + sendBeacon + 采样
error 里再抛错导致死循环 上报函数自身异常进入了采集通道 上报函数整体 try/catch
卸载时的事件全丢 用了 unload 或 XHR pagehide + sendBeacon

补充:error 里再报错会再次触发 error,形成递归。采集入口第一行就该 try/catch,且上报函数内部不调可能抛错的 API(如对循环引用 JSON.stringify)。


10. 边界:什么不该自己做

场景 更合适 理由
需要会话回放 成熟 RUM 产品或自建录像(成本高) DOM 快照与增量序列化不是小工程
需要精确到单请求链路 后端 APM + traceId 透传 前端只能看到出口
合规要求严格(金融/医疗) 私有化部署的监控平台 第三方 SDK 的数据流向要审
只想看有没有挂 合成监控(定时探测) 比真实用户监控更早发现全站故障

一句话:自建监控的价值在"能改",不在"能采"。指标定义、告警阈值、脱敏规则必须自己掌握;采集与存储能外包就外包。


11. 自测

  1. 资源加载失败为什么必须用捕获阶段监听?不加 true 会怎样?
  2. 跨域脚本报 Script error. 需要同时满足哪两个条件?
  3. 指纹归并时去掉行列号,会带来什么代价?
  4. sendBeacon 相比 fetch(keepalive) 的优势和限制分别是什么?
  5. PerformanceObserver 注册时 buffered: true 解决什么问题?
  6. 为什么告警更适合挂在"影响用户数"而不是"错误次数"上?
S14-X12 · 前端监控与错误上报:从采集到聚合
https://www.clxhxhhr.top/posts/5818/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。