前端监控最容易做成"接了 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.。两条必须同时满足:
<script src="..." crossorigin="anonymous">;- 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. 自测
- 资源加载失败为什么必须用捕获阶段监听?不加
true会怎样? - 跨域脚本报
Script error.需要同时满足哪两个条件? - 指纹归并时去掉行列号,会带来什么代价?
sendBeacon相比fetch(keepalive)的优势和限制分别是什么?PerformanceObserver注册时buffered: true解决什么问题?- 为什么告警更适合挂在"影响用户数"而不是"错误次数"上?