文章 / 前端

不装 SaaS,自己给前端搭一套错误监控

线上出问题最难受的不是修,是不知道。用户截图发过来,只有一个"白屏了",连报错都没有。Sentry 这类服务很好用,但小项目上 SaaS 有点重——而且报错数据其实挺敏感的。

自己搭一套的成本比想象中低。核心就三件事:捕获、上报、还原。

捕获:四个入口,一个都不能少

// monitor.ts
type Report = {
  type: 'error' | 'unhandledrejection' | 'resource' | 'blank';
  message: string;
  stack?: string;
  url: string;
  ua: string;
  time: number;
  extra?: Record<string, unknown>;
};

const queue: Report[] = [];
const seen = new Map<string, number>();
const SAMPLE_RATE = 0.3;

function push(report: Report) {
  const key = `${report.type}|${report.message}|${report.stack?.split('\n')[1] || ''}`;
  const count = (seen.get(key) || 0) + 1;
  seen.set(key, count);

  // 同一错误只上报前 3 次,避免死循环刷爆接口
  if (count > 3) return;
  if (Math.random() > SAMPLE_RATE) return;

  queue.push({ ...report, extra: { ...report.extra, count } });
  if (queue.length >= 10) flush();
}

去重和采样一定要在客户端做。不然一个在 requestAnimationFrame 里抛的错,能在十秒内给你刷出上百万条记录。

1. 全局错误

window.addEventListener('error', (event) => {
  const err = event.error;

  // 资源加载失败走的是同一事件,但没有 error 对象
  if (!err) {
    push({
      type: 'resource',
      message: `资源加载失败: ${(event.target as HTMLElement)?.tagName}`,
      url: (event.target as HTMLScriptElement)?.src || location.href,
      ua: navigator.userAgent,
      time: Date.now(),
    });
    return;
  }

  push({
    type: 'error',
    message: err.message || String(err),
    stack: err.stack,
    url: location.href,
    ua: navigator.userAgent,
    time: Date.now(),
  });
}, true);   // 必须开 capture,否则捕获不到资源错误

最后一个参数 capture: true 是最容易漏的地方。不开就是捕获阶段的监听,<img>、<script> 的加载失败根本进不来。

2. 未处理的 Promise

window.addEventListener('unhandledrejection', (event) => {
  const reason = event.reason;
  push({
    type: 'unhandledrejection',
    message: reason?.message || String(reason),
    stack: reason?.stack,
    url: location.href,
    ua: navigator.userAgent,
    time: Date.now(),
  });
});

现在的代码几乎全是 async/await,漏掉 catch 的地方比 try/catch 多得多。这个监听器的收益往往比上面那个还大。

3. 白屏检测

白屏检测没有银弹,实用做法是抽点采样:在首帧之后检查页面几个关键位置的元素是否存在。

function checkBlank() {
  const points: Array<[number, number]> = [
    [0.5, 0.3], [0.5, 0.5], [0.2, 0.5], [0.8, 0.5], [0.5, 0.8],
  ];

  const blank = points.every(([x, y]) => {
    const el = document.elementFromPoint(
      window.innerWidth * x,
      window.innerHeight * y
    );
    return !el || el === document.documentElement || el === document.body;
  });

  if (blank) {
    push({
      type: 'blank',
      message: '疑似白屏:首屏关键区域无有效元素',
      url: location.href,
      ua: navigator.userAgent,
      time: Date.now(),
      extra: { viewport: `${innerWidth}x${innerHeight}` },
    });
  }
}

window.addEventListener('load', () => {
  setTimeout(checkBlank, 2000);   // 给骨架屏和首屏数据留出时间
});

选五个点、取五个采样点,误报率已经能压到可以接受的水平。关键是 load 之后还要再等一会儿,不然 SPA 还在挂载阶段,必然误判。

上报:用 sendBeacon

普通 fetch 有个致命问题:用户关页面时请求会被直接掐断,最后一批错误永远丢。

function flush(useBeacon = false) {
  if (!queue.length) return;
  const payload = JSON.stringify({ events: queue.splice(0) });

  // 页面正在卸载:交给浏览器,保证发出去
  if (useBeacon && navigator.sendBeacon) {
    navigator.sendBeacon('/api/monitor', new Blob([payload], { type: 'application/json' }));
    return;
  }

  fetch('/api/monitor', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: payload,
    keepalive: true,
    credentials: 'omit',      // 监控上报不需要带 Cookie
  }).catch(() => { /* 上报失败不重试,避免雪崩 */ });
}

// 定时批量发,减少请求量
setInterval(() => flush(), 5000);

// 离开页面时兜底
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush(true);
});

sendBeacon 是浏览器接管发送的,不受页面卸载影响,而且天然不阻塞。用 Blob 加类型声明可以避免部分服务端把请求当成表单解析。

另外注意 credentials: 'omit'。监控接口不应该带用户 Cookie,否则一旦有人伪造上报就是一条 CSRF 通道。

服务端:聚合与限流

上报接口必须限流,这是对外的开放端点:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 30,                  // 单 IP 每分钟 30 次
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/api/monitor', limiter, express.json({ limit: '64kb' }), async (req, res) => {
  const events = Array.isArray(req.body?.events) ? req.body.events.slice(0, 50) : [];
  if (!events.length) return res.status(204).end();

  await db.bulkInsertErrors(
    events.map((e) => ({
      type: String(e.type || 'unknown').slice(0, 32),
      message: String(e.message || '').slice(0, 1000),
      stack: String(e.stack || '').slice(0, 8000),
      url: String(e.url || '').slice(0, 500),
      user_agent: String(e.ua || '').slice(0, 300),
      occurred_at: new Date(Number(e.time) || Date.now()),
    }))
  );

  res.status(204).end();
});

对每个字段都做长度截断。否则一条超长 message 就能把库写爆,这类事故在真实项目里并不罕见。

还原:source map 必须存下来

生产代码压缩后,堆栈长这样:

at s.a (https://example.com/assets/index-9f3c.js:1:48211)

完全没法看。解决方式是在构建时生成 source map,但不发布到 CDN,只上传到监控服务端:

# 构建时生成,随后上传到内网,再删除本地文件
npx vite build --sourcemap
npx source-map-uploader dist --release $GIT_SHA
rm -f dist/**/*.map

服务端拿到堆栈后,用 source-map 包还原:

import { SourceMapConsumer } from 'source-map';

async function resolveStack(stack, release, assetsPath) {
  const mapFile = findMapFile(assetsPath, release);
  if (!mapFile) return stack;

  const consumer = await new SourceMapConsumer(JSON.parse(mapFile));
  const out = stack.replace(/\(?(https?:\/\/[^:]+):(\d+):(\d+)\)?/g, (whole, file, line, col) => {
    const pos = consumer.originalPositionFor({
      line: Number(line),
      column: Number(col),
      bias: SourceMapConsumer.GREATEST_LOWER_BOUND,
    });
    if (!pos.source) return whole;
    return `${pos.source}:${pos.line}:${pos.column}`;
  });

  consumer.destroy();   // 别忘,否则 WASM 内存会一直涨
  return out;
}

consumer.destroy() 这行必须写。我一开始漏了,服务跑了三天内存从 200MB 涨到 2.4GB。

还原后堆栈变成:

at handleSubmit (src/views/order/Form.vue:148:11)

定位到具体文件和行号,从"有报错但不知道在哪"变成"直接点开看"。

最后一点工程习惯

监控搭完,真正的收益来自看。我的做法是给告警加两条规则:

  1. 同一错误 10 分钟内出现超过 50 次 → 通知
  2. 白屏上报出现任意一次 → 立即通知

第二条之所以用零容忍,是因为白屏意味着用户彻底用不了,哪怕只有一例也值得看一眼。

整套监控加起来不到三百行代码,一个下午能跑通。比起装上 SDK 然后常年不看报表,自己搭的更可能被真正用起来。