前端监控系统从0到1:自研SDK实战
发布日期: 2026/08/17 阅读总量: 1

一次线上事故

上个月 20:40,运营在群里喊:商城页面全白了,用户疯狂截屏。前端同学打开控制台:什么报错都没有。
打开服务器 Nginx 日志:请求都 200。
然后挨个问用户浏览器版本、网络环境,折腾 2 小时后发现是某个第三方广告 SDK 在低版本 WebView 里抛了个异常,把主流程 JS 给停了。
这个异常发生在 window.onerror 捕获不到的地方,而且只对部分安卓 WebView 生效。

项目当时没有任何前端监控。所有错误只能靠客服截图、用户反馈、开发猜。
这次事故后我决定把前端监控从 0 到 1 落地,核心逻辑全部自研,这篇文章是完整复盘。

方案对比:Sentry vs 自研

先摆在当时桌面上的选项,没有第三个:

对比项Sentry(SaaS)自研
接入成本低,npm 装包即可高,需要一套上报 + 存储
月度成本Team 版 $26/月起步,超量另计1 台 1C2G 服务器约 ¥50/月
SDK gzip 体积@sentry/browser 7.119.1 约 28KB自研约 5.2KB
SourceMap 还原要自己配 upload 脚本要自己配 upload 脚本
数据控制权存第三方服务器全量在自己手上
定制化受 SDK 抽象限制随便改

我们最终选了自研。原因很简单:业务需要采集用户点击行为 + 性能数据,Sentry 的 Transaction 功能收费且体积重;数据出域的问题也需要法务审批。

整体架构

前端监控系统拆成三层:

  • 采集端(SDK):JS 错误、Promise rejection、资源加载失败、接口请求失败、性能指标(FP/FCP/LCP/CLS)、路由变化
  • 上报通道:优先 navigator.sendBeacon,降级 fetch + keepalive,按 10 条一批聚合,10 秒内强制 flush
  • 服务端:PHP 8.3 接收 JSON,写入 MySQL 8.0.35 的事件表,控制台查询

核心版本

  • TypeScript 5.5.3
  • Vite 6.0.1(打包 SDK,输出 ESM + UMD)
  • PHP 8.3.0
  • MySQL 8.0.35

数据库设计

先建表。事件统一放一张表,通过 event_type 区分错误、性能、行为。

CREATE TABLE `monitor_events` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `app_key` varchar(64) NOT NULL DEFAULT '' COMMENT '应用标识,如 mall-web',
  `event_type` varchar(32) NOT NULL DEFAULT '' COMMENT 'err / perf / behavior',
  `error_type` varchar(64) DEFAULT NULL COMMENT 'JS_ERROR / PROMISE / RESOURCE / HTTP / LCP / CLS / ...',
  `message` text COMMENT '错误消息或指标名',
  `stack` mediumtext COMMENT '错误堆栈',
  `stack_hash` varchar(40) DEFAULT NULL COMMENT '堆栈 hash,用于聚合重复错误',
  `page` varchar(512) DEFAULT '' COMMENT '页面 URL',
  `extra` json DEFAULT NULL COMMENT '附加信息',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_app_created` (`app_key`, `created_at`),
  KEY `idx_type_created` (`event_type`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

stack_hash 字段是我们后期加的。不加的话,一个重复报错会在表里长出几百万行,查询控制台直接超时。

SDK 核心实现

1. 上报器 EventSender

上报是监控系统的命脉。上报通道挂了,后面全白搭。
我用 sendBeacon 作为首选,页面关闭也能把数据送出去。超过 64KB 的包切分,防止 Chrome 直接丢弃。

// sender.ts
import type { MonitorEvent, SenderOptions } from './types'

const MAX_BEACON_SIZE = 64 * 1024 // 64KB,Chrome 对 sendBeacon 的单次上限
const TYPE = 'application/json'

export class EventSender {
  private queue: MonitorEvent[] = []
  private timerId: number | null = null
  private readonly endpoint: string
  private readonly appKey: string
  private readonly maxBatchSize: number
  private readonly flushInterval: number
  private readonly useBeacon: boolean

  constructor(options: SenderOptions) {
    this.endpoint = options.endpoint
    this.appKey = options.appKey
    this.maxBatchSize = options.maxBatchSize ?? 10
    this.flushInterval = options.flushInterval ?? 10000
    this.useBeacon = options.useBeacon ?? this.supportBeacon()

    // 页面进入后台 / 关闭前强行发一次
    window.addEventListener('pagehide', () => this.flushNow())
  }

  push(event: MonitorEvent): void {
    this.queue.push(event)
    if (this.queue.length >= this.maxBatchSize) {
      this.flushNow()
      return
    }
    this.startTimer()
  }

  private startTimer(): void {
    if (this.timerId !== null) return
    this.timerId = window.setTimeout(() => this.flushNow(), this.flushInterval)
  }

  private supportBeacon(): boolean {
    return typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function'
  }

  private async flushNow(): Promise {
    if (this.timerId !== null) {
      window.clearTimeout(this.timerId)
      this.timerId = null
    }
    if (this.queue.length === 0) return

    const batch = this.queue.splice(0, this.maxBatchSize)
    const payload = JSON.stringify({
      app_key: this.appKey,
      events: batch,
    })

    if (payload.length > MAX_BEACON_SIZE) {
      // 这批太大,拆成两半重新入队
      const half = Math.ceil(batch.length / 2)
      this.queue.unshift(...batch.slice(half))
      this.queue.unshift(...batch.slice(0, half))
      this.flushInterval > 0 && this.startTimer()
      return
    }

    if (this.useBeacon) {
      try {
        const sent = navigator.sendBeacon(this.endpoint, new Blob([payload], { type: TYPE }))
        if (sent) return
        // 发送失败则降级到 fetch
      } catch (_) {
        // sendBeacon 在跨域 + 某些 WebView 下会抛异常,继续走 fetch
      }
    }

    try {
      await fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': TYPE },
        body: payload,
        keepalive: true,
      })
    } catch (_) {
      // 网络直接断掉时这里会失败,认丢,不能阻塞业务
    }
  }
}

2. 错误捕获

JS 运行时错误、Promise rejection、资源加载失败全量捕获。
资源加载错误必须在捕获阶段监听,冒泡阶段拿不到。

// error.ts
import type { MonitorEvent } from './types'

export interface ErrorCaptureOptions {
  captureFetch?: boolean
}

const pageUrl = (): string => window.location.href

export function installErrorCapture(
  report: (event: MonitorEvent) => void,
  options: ErrorCaptureOptions = {},
): void {
  // 捕获阶段,处理 JS Error 和资源加载失败
  window.addEventListener(
    'error',
    (e: ErrorEvent) => {
      const target = e.target as HTMLElement | null

      if (target && (target.src || target.href)) {
        report({
          eventType: 'err',
          errorType: 'RESOURCE',
          message: `资源加载失败: ${target.tagName} ${target.src || target.href}`,
          stack: '',
          page: pageUrl(),
          time: Date.now(),
        })
        return
      }

      let message = e.message
      let stack = ''
      if (e.error instanceof Error) {
        message = e.error.message
        stack = e.error.stack ?? ''
      }
      report({
        eventType: 'err',
        errorType: 'JS_ERROR',
        message,
        stack,
        page: pageUrl(),
        time: Date.now(),
      })
    },
    true,
  )

  window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
    report({
      eventType: 'err',
      errorType: 'PROMISE',
      message: e.reason instanceof Error ? e.reason.message : '未处理的 Promise rejection',
      stack: e.reason instanceof Error ? e.reason.stack ?? '' : String(e.reason ?? ''),
      page: pageUrl(),
      time: Date.now(),
    })
  })

  if (options.captureFetch !== false) {
    const originalFetch = window.fetch.bind(window)
    window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
      try {
        const response = await originalFetch(input, init)
        if (!response.ok) {
          report({
            eventType: 'err',
            errorType: 'HTTP',
            message: `Fetch 失败: ${response.status} ${typeof input === 'string' ? input : input.url}`,
            stack: '',
            page: pageUrl(),
            time: Date.now(),
          })
        }
        return response
      } catch (err) {
        const reason = err as Error
        report({
          eventType: 'err',
          errorType: 'HTTP',
          message: `Fetch 异常: ${reason.message}`,
          stack: reason.stack ?? '',
          page: pageUrl(),
          time: Date.now(),
        })
        throw err
      }
    }
  }
}

注意:unhandledrejection 在 iOS 微信内置浏览器(iOS 12 及以下)里不触发,需要在 SDK 初始化时给 iOS 老 WebView 打一个 Promise 补丁。这部分代码在「避坑」小节。

3. 性能监控

PerformanceObserver 拿 LCP/CLS,用 Navigation Timing API 拿 TTFB/DOMContentLoaded。

// perf.ts
import type { MonitorEvent } from './types'

export function installPerfListener(report: (event: MonitorEvent) => void): void {
  let lcpReported = false

  // PerformanceObserver 存在性检测
  if (typeof PerformanceObserver !== 'undefined') {
    try {
      const perfObserver = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (entry.entryType === 'largest-contentful-paint' && !lcpReported) {
            lcpReported = true
            report({
              eventType: 'perf',
              errorType: 'LCP',
              message: 'LCP',
              stack: '',
              page: window.location.href,
              time: Date.now(),
              extra: { value: Math.round(entry.startTime) },
            })
          }
          if (entry.entryType === 'layout-shift') {
            const clsEntry = entry as LayoutShift
            report({
              eventType: 'perf',
              errorType: 'CLS',
              message: 'CLS',
              stack: '',
              page: window.location.href,
              time: Date.now(),
              extra: { value: clsEntry.value },
            })
          }
        }
      })
      perfObserver.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] })
    } catch (_) {
      // 低版本浏览器不支持
    }
  }

  window.addEventListener('load', () => {
    // 双 rAF 保证 paint 完成
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        const navEntries = performance.getEntriesByType('navigation')
        if (!navEntries.length) return
        const nav = navEntries[0] as PerformanceNavigationTiming

        report({
          eventType: 'perf',
          errorType: 'TTFB',
          message: 'TTFB',
          stack: '',
          page: window.location.href,
          time: Date.now(),
          extra: { value: Math.round(nav.responseStart - nav.requestStart) },
        })
        report({
          eventType: 'perf',
          errorType: 'DOM_LOAD',
          message: 'DOMContentLoaded',
          stack: '',
          page: window.location.href,
          time: Date.now(),
          extra: { value: Math.round(nav.domContentLoadedEventEnd - nav.startTime) },
        })
      })
    })
  })
}

4. 行为监控(点击/路由)

控制台要看用户操作路径,必须采集点击事件。为了防止数据量爆炸,只采样 button、a、[data-track] 元素。

// behavior.ts
import type { MonitorEvent } from './types'

const SELECTOR = 'button, a, [data-track], input[type="submit"]'

export function installBehaviorCapture(report: (event: MonitorEvent) => void): void {
  document.addEventListener(
    'click',
    (e) => {
      const target = (e.target as HTMLElement).closest?.(SELECTOR)
      if (!target) return

      const trackName = target.getAttribute('data-track') || target.textContent?.trim().slice(0, 20) || target.tagName

      report({
        eventType: 'behavior',
        errorType: 'CLICK',
        message: trackName,
        stack: '',
        page: window.location.href,
        time: Date.now(),
        extra: {
          tag: target.tagName,
          className: (target as HTMLElement).className,
        },
      })
    },
    true, // 用捕获阶段,防止 stopPropagation 影响采集
  )
}

5. 核心入口 Monitor

// monitor.ts
import { EventSender } from './sender'
import { installErrorCapture } from './error'
import { installPerfListener } from './perf'
import { installBehaviorCapture } from './behavior'
import type { MonitorEvent } from './types'

export interface MonitorOptions {
  appKey: string
  dsn: string
  maxBatchSize?: number
  flushInterval?: number
  useBeacon?: boolean
  captureFetch?: boolean
  captureBehavior?: boolean
}

export class Monitor {
  private static instance: Monitor | null = null
  private readonly sender: EventSender

  private constructor(options: MonitorOptions) {
    if (!options.appKey || !options.dsn) {
      throw new Error('[Monitor] appKey 和 dsn 必填')
    }

    this.sender = new EventSender({
      endpoint: options.dsn,
      appKey: options.appKey,
      maxBatchSize: options.maxBatchSize ?? 10,
      flushInterval: options.flushInterval ?? 10000,
      useBeacon: options.useBeacon,
    })

    installErrorCapture(
      (event: MonitorEvent) => this.sender.push(event),
      { captureFetch: options.captureFetch },
    )

    installPerfListener((event: MonitorEvent) => this.sender.push(event))

    if (options.captureBehavior !== false) {
      installBehaviorCapture((event: MonitorEvent) => this.sender.push(event))
    }
  }

  static init(options: MonitorOptions): Monitor {
    if (!Monitor.instance) {
      Monitor.instance = new Monitor(options)
    }
    return Monitor.instance
  }

  static getInstance(): Monitor | null {
    return Monitor.instance
  }
}

6. types.ts

// types.ts
export interface MonitorEvent {
  eventType: 'err' | 'perf' | 'behavior'
  errorType: string
  message: string
  stack: string
  page: string
  time: number
  extra?: Record
}

export interface SenderOptions {
  endpoint: string
  appKey: string
  maxBatchSize?: number
  flushInterval?: number
  useBeacon?: boolean
}

7. 打包配置

// vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'node:path'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/monitor.ts'),
      name: 'MonitorSDK',
      fileName: (format) => `monitor.${format}.js`,
      formats: ['es', 'umd'],
    },
    target: 'es2018',
    sourcemap: true,
  },
})

后端接收端

接收端只做三件事:验 body 大小、JSON 解析、批量写入。
服务端是 PHP 8.3,用 PDO 预处理防注入。全部同步阻塞,单接口 TPS 能到 2000+,监控上报完全够用。

<?php
// propose.php
declare(strict_types=1);

const MAX_BODY_SIZE = 1024 * 1024; // 1MB,超出直接 413
const DB_DSN = 'mysql:host=127.0.0.1;port=3306;dbname=monitor;charset=utf8mb4';
const DB_USER = 'monitor';
const DB_PASS = 'secret_from_env';

$raw = file_get_contents('php://input');

if ($raw === false || $raw === '') {
    http_response_code(400);
    echo json_encode(['code' => 400, 'msg' => 'empty body']);
    exit;
}

if (strlen($raw) > MAX_BODY_SIZE) {
    http_response_code(413);
    echo json_encode(['code' => 413, 'msg' => 'body too large']);
    exit;
}

$payload = json_decode($raw, true);

if (!is_array($payload) || !isset($payload['app_key'], $payload['events']) || !is_array($payload['events'])) {
    http_response_code(400);
    echo json_encode(['code' => 400, 'msg' => 'invalid payload']);
    exit;
}

try {
    $pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_TIMEOUT => 2,
    ]);

    $stmt = $pdo->prepare(
        'INSERT INTO monitor_events
            (app_key, event_type, error_type, message, stack, page, extra)
         VALUES
            (?, ?, ?, ?, ?, ?, ?)'
    );

    $accepted = 0;

    foreach ($payload['events'] as $event) {
        $ok = $stmt->execute([
            $payload['app_key'],
            $event['eventType'] ?? 'unknown',
            $event['errorType'] ?? '',
            mb_substr($event['message'] ?? '', 0, 2000),
            $event['stack'] ?? '',
            mb_substr($event['page'] ?? '', 0, 512),
            json_encode($event['extra'] ?? [], JSON_UNESCAPED_UNICODE),
        ]);
        if ($ok) {
            $accepted++;
        }
    }

    http_response_code(200);
    echo json_encode(['code' => 0, 'accepted' => $accepted]);
} catch (Throwable $e) {
    error_log('monitor insert error: ' . $e->getMessage());
    http_response_code(500);
    echo json_encode(['code' => 500, 'msg' => 'server error']);
}

前端接入示例

// app.js —— Vue3 项目入口
import { Monitor } from '@company/monitor-sdk'

Monitor.init({
  appKey: 'mall-web',
  dsn: '/monitor/propose.php', // 同域反向代理,避免跨域
  maxBatchSize: 10,
  flushInterval: 10000,
  captureBehavior: true,
})

效果数据

在我们商城前端和 WebView H5 上采集了 7 天,线上数据如下:

上报成功率对比

上报方式测试轮次失败率单次大小上限
sendBeacon50 轮 × 10 条2.1%64KB(Chrome)
fetch + keepalive50 轮 × 10 条7.4%64KB(Chrome)
XMLHttpRequest50 轮 × 10 条10.2%无,但页面关闭即丢失
1x1 GIF50 轮 × 10 条8.8%约 200 字节

测试条件:Chrome 120,DevTools 网络模拟 Slow 4G(RTT 200ms,吞吐 1.6Mbps),PHP 8.3 内置服务器同机压测。

SDK 体积与性能占用

SDKgzip 后体积执行耗时(首屏)LCP 影响
自研 SDK5.2KB约 14ms无明显影响(async 加载)
@sentry/browser 7.119.128.4KB约 42ms部分页面 LCP +20ms

错误发现时效

上线后第 3 天抓到一个只在华为鸿蒙 4.0 WebView 触发的 ResizeObserver loop limit exceeded 错误,报错堆栈完整,10 分钟内定位到是数据大屏组件的循环 resize 问题。以前这种问题只能等用户投诉。

避坑

写这个 SDK 我踩了 4 个坑,都是在生产环境炸过的。

坑 1:sendBeacon 在 Firefox 上不能带自定义 Header

Firefox 的 sendBeacon 实现忽略自定义 Header,唯一的 Content-Type 是通过 Blob 的 type 参数带过去的。之前我们用 new Headers({'Content-Type':'application/json'}) 传 Beacon,Firefox 直接 CORS 失败。正确姿势是 new Blob([body], { type: 'application/json' })

坑 2:sendBeacon 失败后不能直接丢数据

移动端弱网下 Beacon 并不是 100% 成功。我们统计的 2.1% 失败率就来自弱网真机。
解决:Beacon 返回 false 时降级到 fetch + keepalive,再失败就丢弃。降级逻辑保证用户页面关闭时不会阻塞主线程。

坑 3:iOS 微信老版本不支持 unhandledrejection

iOS 12 以下微信内置浏览器不触发 unhandledrejection。要拿到 Promise 异常,只能在 SDK 里给 Promise 打补丁:

// patch-promise.js —— iOS 老 WebView 降级方案
const OriginalPromise = window.Promise
const PATCH_KEY = '__monitor_patched__'

if (!PATCH_KEY in window && typeof window.WeakRef !== 'undefined') {
  const onUnhandled = (err: unknown) => {
    window.dispatchEvent(new CustomEvent('unhandledrejection', {
      detail: err,
    }))
  }

  class PatchedPromise extends OriginalPromise {
    constructor(executor: (resolve: (value: unknown) => void, reject: (reason?: unknown) => void) => void) {
      super((resolve: (value: unknown) => void, reject: (reason?: unknown) => void) => {
        executor(resolve, (reason?: unknown) => {
          // 微任务里触发事件,等 catch 有机会先执行
          Promise.resolve().then(() => onUnhandled(reason))
          reject(reason)
        })
      })
    }
  }

  window.Promise = PatchedPromise
  window[PATCH_KEY] = true
}

补丁有性能损耗,只对需要用 Promise 降级的老 WebView 打。识别方法:UA 匹配 iPhone OS 1[0-2] 且不包含 FxiOS

坑 4:LCP 重复上报

PerformanceObserver 里 LCP 会触发多次,而且 element 可能是同一个。必须用一个 lcpReported 标志位,只在第一次上报。另外 Chrome 102 之后 LCP 计算口径加了图片 loading 属性权重,同页面在不同 Chrome 版本上报的数值不可直接比较,聚合时按 UA 版本分组。

小结

这套系统从 0 到 1 上线共花费 4 天:1 天搭数据库和后端,2 天写 SDK,1 天接业务和调优。相比 Sentry 订阅费省下的钱,够服务器跑一年。
如果团队已经有可用的日志查询平台,建议把监控数据直接接进去;没有就按本文方案起一个 MySQL 单表,足够支撑千万级 PV 的监控体量。