前端监控别只怼个 Sentry,我把自建 SDK 到告警通知的全链路拆给你看
前端监控告警方案,我把自建 SDK 到告警通知的全链路拆开了
说实话,大多数前端团队对”监控”的理解,就是怼个 Sentry SDK,然后等着它报错。
但线上的真实情况常常是:Sentry 里堆了几千条 issue,没人看也没人管。真正出问题的时候,第一个发现的是用户,不是监控系统。
我在团队里折腾了大半年,从零搭了一套前端监控告警全链路方案。今天把整个链路从采集到通知串起来聊聊,顺带分享一些关键代码。
监控到底要管什么?
很多人一提监控就只想到 JS 报错,但实际上前端要管的东西至少分三层:
错误层:JS 运行时错误 | Promise 未处理 | 资源加载失败 | 接口异常
性能层:LCP / INP / CLS | FCP / TTFB | 首屏耗时 | 接口耗时
体验层:页面白屏 | 卡顿帧率 | 用户操作重放
我们团队的目标是:线上出任何异常,3 分钟内开发收到告警,10 分钟内定位根因。
要达到这个目标,光靠 Sentry 是不够的。我们要自建一套采集 + 计算 + 告警的闭环。
第一步:自建采集 SDK
很多人觉得自建 SDK 没必要,Sentry 不香吗?
Sentry 真香,但它有几个先天不足:
- 数据不出墙:Sentry 是 SaaS 服务,一些大厂或金融项目根本不让用
- 告警太粗糙:Sentry 的告警规则相对固定,没法做复杂的聚合逻辑
- 采样不可控:大流量下全量上报扛不住,但又要保证关键错误不漏
我们自己写了一个轻量 SDK,核心就三个模块。
错误采集
// jsError.js - 采集运行时错误
export function setupJSError(callback) {
// 捕获 JS 运行时错误
window.onerror = (msg, url, line, col, error) => {
callback({
type: 'js_error',
message: msg,
url,
line,
col,
stack: error?.stack,
time: Date.now()
});
};
// 捕获 Promise 未处理
window.addEventListener('unhandledrejection', (event) => {
callback({
type: 'promise_error',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
time: Date.now()
});
});
// 捕获资源加载失败(img/script/link/font)
window.addEventListener('error', (event) => {
const target = event.target;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT'
|| target.tagName === 'LINK' || target.tagName === 'VIDEO')) {
callback({
type: 'resource_error',
tagName: target.tagName,
src: target.src || target.href,
time: Date.now()
});
}
}, true); // 捕获阶段
}
有个小细节:window.onerror 和 addEventListener('error') 是两码事。前者只能捕获 JS 执行错误,后者用捕获阶段才能抓到资源加载失败。两个都得有。
性能指标采集
// perf.js - 采集 Web Vitals 和性能指标
export function setupPerf(callback) {
// 用 PerformanceObserver 监听核心指标
if ('PerformanceObserver' in window) {
// LCP
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
callback({ metric: 'LCP', value: lastEntry.startTime, time: Date.now() });
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// FCP
const fcpObserver = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
callback({ metric: 'FCP', value: entry.startTime, time: Date.now() });
});
fcpObserver.observe({ type: 'paint', buffered: true });
// CLS
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) clsValue += entry.value;
}
callback({ metric: 'CLS', value: clsValue, time: Date.now() });
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// INP(2024年取代 FID 成为核心指标)
const inpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
callback({ metric: 'INP', value: entry.processingStart - entry.startTime, time: Date.now() });
});
});
inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 0 });
}
// Navigation Timing:TTFB、DOMReady、首屏
window.addEventListener('load', () => {
setTimeout(() => {
const perf = performance.getEntriesByType('navigation')[0];
if (perf) {
callback({ metric: 'TTFB', value: perf.responseStart - perf.requestStart });
callback({ metric: 'DOMReady', value: perf.domContentLoadedEventEnd - perf.fetchStart });
callback({ metric: 'LoadComplete', value: perf.loadEventEnd - perf.fetchStart });
}
// 资源加载明细
const resources = performance.getEntriesByType('resource');
const slowResources = resources
.filter(r => r.duration > 2000)
.map(r => ({ name: r.name, duration: r.duration }));
if (slowResources.length) {
callback({ metric: 'SLOW_RESOURCES', value: slowResources });
}
}, 0);
});
}
这里踩过一个坑:PerformanceObserver 得设 buffered: true,否则如果 SDK 加载晚了,前面的 entry 就收不到了。
数据上报
上报方式上,我对比了三种方案:
| 方式 | 优点 | 缺点 | 选型 |
|---|---|---|---|
new Image() GET |
无跨域、不阻塞 | 只能 GET、长度受限 | 轻量场景 |
XMLHttpRequest POST |
传大量数据 | 页面关闭会丢请求 | 不推荐 |
navigator.sendBeacon() POST |
页面关闭也不丢、异步不阻塞 | 兼容性一般(现代浏览器都行) | ✅ 主选 |
实际代码如下:
// reporter.js - 数据上报
export class Reporter {
constructor(options) {
this.reportUrl = options.reportUrl;
this.appId = options.appId;
this.buffer = [];
this.flushTimer = null;
this.bufferSize = options.bufferSize || 10; // 攒够10条批量发
this.flushInterval = options.flushInterval || 5000; // 或每5秒自动发
}
push(event) {
this.buffer.push({ ...event, appId: this.appId, time: Date.now() });
if (this.buffer.length >= this.bufferSize) {
this.flush();
} else if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), this.flushInterval);
}
}
flush() {
if (!this.buffer.length) return;
const data = this.buffer.splice(0, this.bufferSize);
const payload = JSON.stringify({ events: data });
if (navigator.sendBeacon) {
navigator.sendBeacon(this.reportUrl, payload);
} else {
// fallback: 用 Image + 编码
new Image().src = `${this.reportUrl}?data=${encodeURIComponent(payload)}`;
}
this.flushTimer = null;
}
// 页面关闭前强制刷缓存
setupBeforeUnload() {
window.addEventListener('beforeunload', () => this.flush());
}
}
关键选择:sendBeacon 是首选。它最大的好处是不管用户是关闭标签页、刷新页面还是跳转,数据都不会丢。用 XHR 同步上报会导致页面卡顿+被浏览器警告。
第二步:告警规则怎么设?
采集上来了,数据怎么变成 actionable 的通知?我们设了三个级别的告警规则:
一级告警(立即通知)
触发条件:
- 白屏率 > 5% (连续2个采样周期)
- JS Error 率 > 3% (同版本、同页面)
- 接口整体成功率 < 90%
通知方式:企业微信 @所有人 + 电话
二级告警(5分钟窗口)
触发条件:
- LCP > 4s 的用户占比 > 20%
- INP > 200ms 的用户占比 > 30%
- CLS > 0.25 的用户占比 > 15%
通知方式:企业微信群通知
三级告警(日报级别)
触发条件:
- 慢资源(加载 > 3s)数量 > 50
- 新版本发布后错误率上升 > 50%
通知方式:每日邮件报告
告警规则最怕的就是「狼来了」。设置太多低级别告警,开发会直接静默通知群。
经验是:宁可漏报,不要误报。 告警阈值设在明显异常的边界,而不是刚刚超过 P95。
第三步:接入层实战
我们后端用了 Node.js + Koa 收数据,直接写一个简单的上报接口:
// server.js - 上报服务端
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
router.post('/api/report', async (ctx) => {
const { events } = ctx.request.body;
// 1. 实时检测:高优错误立刻进告警队列
const criticalErrors = events.filter(e =>
e.type === 'js_error' || e.type === 'promise_error'
);
if (criticalErrors.length > 0) {
// 推到 Redis 流供告警引擎消费
await redis.xadd('monitor:alert:queue', '*', 'events',
JSON.stringify(criticalErrors));
}
// 2. 批量数据入 Kafka/Redis 做离线聚合
await redis.xadd('monitor:raw:events', '*', 'data',
JSON.stringify(events));
ctx.body = { code: 0 };
});
// 3. 定时任务:每分钟算错误率
setInterval(async () => {
const window = 60 * 1000; // 1分钟窗口
const now = Date.now();
const errors = await redis.zcount('monitor:js_errors', now - window, now);
const pv = await redis.zcount('monitor:pv', now - window, now);
const rate = pv > 0 ? (errors / pv) * 100 : 0;
if (rate > 3) {
await sendAlert(`JS Error 率异常: ${rate.toFixed(2)}%(阈值 3%)`);
}
}, 60 * 1000);
Redis 的 Sorted Set + Stream 组合做简单的实时计算,门槛极低。小体量项目完全够用。
第四步:告警通知
我们走了两种通知渠道:
企业微信机器人 Webhook
async function sendWecomAlert(message, level = 'info') {
const webhook = level === 'critical'
? WECOM_CRITICAL_WEBHOOK
: WECOM_NORMAL_WEBHOOK;
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: {
content: `## 🚨 前端监控告警n` +
`> **级别**: ${level === 'critical' ? '🔥 紧急' : '⚠️ 一般'}n` +
`> **时间**: ${new Date().toLocaleString()}n` +
`> **详情**: ${message}n` +
`> [查看监控面板](https://monitor.xxx.com)`
}
})
});
}
飞书/钉钉
基本同理,换个 webhook URL 和 payload 格式就行。
我们内部还做了一件事:告警认领机制。谁先点的「认领」,告警消息就变色,其他人不用重复响应。
实际效果数据
这套方案上线后,我们统计了三个月的效果:
| 指标 | 之前 | 之后 |
|---|---|---|
| 异常发现时间(中位数) | ~30 分钟(用户反馈) | ~2 分钟(自动告警) |
| 白屏问题响应时间 | ~1 小时 | ~3 分钟 |
| 误告警率(产生无意义通知) | — | ~8%(接受范围内) |
| 开发投诉「监控太吵」 | 每周 ~3 次 | 每月 ~1 次 |
最典型的一个 case:有次我们上线了一个新版本,某个老旧浏览器因为不支持 CSS :has() 导致大面积白屏。Sentry 虽然报了错,但团队没人看。而我们的告警系统在白屏率触发 5% 阈值后 3 分钟内就发出了企业微信告警,直接 @ 前端负责人。从收到告警到回滚,一共用了 11 分钟。
一些经验和教训
1. 采样率要分级。普通页面数据采样 10%,关键页面(登录、支付)全量。不能一把梭。
2. SourceMap 脱敏上传。生产环境记得把 SourceMap 传到内部系统而非 CDN,否则等于给别人送源码。Sentry 和自建方案都可以配置上传地址。
3. 告警要收敛。一个接口挂了可能导致几十个页面报错,不要每一条都发通知。按 error fingerprint 聚合,同类型错误只告警一次。
4. 不要只做错误监控。只说「今天报了 500 个错」没意义。要算「每 PV 错误率」「按版本对比错误率」,这些才能驱动改进。
5. 告警要有闭环。告警发出 → 认领 → 定位 → 修复 → 恢复 → 复盘。每个环节都要有记录,不然告警就只是噪音。
写在最后
前端监控这件事,说难不难,说简单也不简单。Sentry 很好用,但你如果希望异常发现速度从用户反馈变成自动告警,那一定得在告警规则和通知链路上花功夫。
工具只是基础,让对的人在正确的时间知道该修什么,才是监控系统真正的价值。
评论区
登录后可评论。












