前端监控别只怼个 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.onerroraddEventListener('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 很好用,但你如果希望异常发现速度从用户反馈变成自动告警,那一定得在告警规则和通知链路上花功夫。

工具只是基础,让对的人在正确的时间知道该修什么,才是监控系统真正的价值。

评论区

0 条评论

登录后可评论。

查看完整榜单
查看完整榜单
查看完整榜单