一次线上事故
上个月 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 天,线上数据如下:
上报成功率对比
| 上报方式 | 测试轮次 | 失败率 | 单次大小上限 |
|---|---|---|---|
| sendBeacon | 50 轮 × 10 条 | 2.1% | 64KB(Chrome) |
| fetch + keepalive | 50 轮 × 10 条 | 7.4% | 64KB(Chrome) |
| XMLHttpRequest | 50 轮 × 10 条 | 10.2% | 无,但页面关闭即丢失 |
| 1x1 GIF | 50 轮 × 10 条 | 8.8% | 约 200 字节 |
测试条件:Chrome 120,DevTools 网络模拟 Slow 4G(RTT 200ms,吞吐 1.6Mbps),PHP 8.3 内置服务器同机压测。
SDK 体积与性能占用
| SDK | gzip 后体积 | 执行耗时(首屏) | LCP 影响 |
|---|---|---|---|
| 自研 SDK | 5.2KB | 约 14ms | 无明显影响(async 加载) |
| @sentry/browser 7.119.1 | 28.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 的监控体量。