一、真实场景:一个线上事故让我决定自研监控
2023年双11大促,我们团队负责的电商H5页面在高峰期出现白屏。用户反馈涌入,但后端日志显示接口正常。排查了3小时才发现是某个第三方SDK的JS错误导致页面渲染中断。因为没有前端错误监控,我们完全不知道发生了什么。
事后复盘:
- 错误发生时间:2023-11-11 10:23:45
- 影响用户数:约12000人
- 恢复耗时:3小时12分钟
- 直接损失:估算GMV损失约80万
这个事故让我下定决心:必须自建前端监控系统。为什么不用Sentry?后面会详细对比。
二、方案对比:自研 vs Sentry vs 自研+开源
我们评估了3种方案,以下是详细对比数据(基于我们团队6人,日均PV 500万的中型项目):
| 维度 | 自研 | Sentry SaaS | 自研+开源(如FunDebug) |
|---|---|---|---|
| 开发周期 | 2周(核心功能) | 1天(接入SDK) | 1周(集成+定制) |
| 月成本 | 服务器约500元 | Pro版$26/月(约180元)+ 超出配额按量计费 | 开源免费+服务器500元 |
| 数据隐私 | 完全可控 | 数据存储在海外(合规风险) | 可控 |
| 定制灵活性 | 极高 | 低(只能使用现有功能) | 中 |
| 错误捕获率 | 95%+(经过优化) | 98%+(成熟方案) | 96%+ |
| 性能开销 | 约50ms(首屏加载) | 约80ms(SDK较大) | 约60ms |
| 维护成本 | 高(需持续迭代) | 低(托管服务) | 中 |
最终我们选择了自研方案,原因:
- 数据隐私:金融类业务,用户数据不能出域
- 定制需求:需要采集特定业务指标(如支付成功率)
- 成本控制:日均PV 500万,Sentry按量计费月均超2000元
三、完整代码实现:从0到1搭建监控SDK
3.1 项目结构
monitor-sdk/
├── src/
│ ├── index.js # 入口文件
│ ├── error.js # 错误捕获模块
│ ├── performance.js # 性能采集模块
│ ├── behavior.js # 用户行为采集
│ ├── reporter.js # 数据上报模块
│ └── utils.js # 工具函数
├── dist/ # 打包输出
├── package.json
└── rollup.config.js # 打包配置(Rollup 4.9.0)
3.2 错误捕获模块(error.js)
核心思路:捕获JS运行时错误、Promise未处理异常、资源加载错误、手动上报错误。
// error.js - 版本1.0.0
// 依赖:无,纯原生JS实现
class ErrorMonitor {
constructor(options = {}) {
this.options = {
enablePromise: true,
enableResource: true,
enableConsole: false,
...options
};
this.errorQueue = [];
this.maxQueueSize = 20; // 批量上报阈值
this.init();
}
init() {
// 1. 捕获JS运行时错误
window.onerror = (message, source, lineno, colno, error) => {
const errorInfo = this.formatError({
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack || '',
timestamp: Date.now()
});
this.pushToQueue(errorInfo);
// 不阻止默认行为,避免影响页面
return false;
};
// 2. 捕获Promise未处理异常
if (this.options.enablePromise) {
window.addEventListener('unhandledrejection', (event) => {
const errorInfo = this.formatError({
type: 'promise_error',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '',
timestamp: Date.now()
});
this.pushToQueue(errorInfo);
});
}
// 3. 捕获资源加载错误
if (this.options.enableResource) {
window.addEventListener('error', (event) => {
// 过滤掉JS错误(已被window.onerror捕获)
if (event.target && (event.target.tagName === 'IMG' ||
event.target.tagName === 'SCRIPT' ||
event.target.tagName === 'LINK')) {
const errorInfo = this.formatError({
type: 'resource_error',
message: `资源加载失败: ${event.target.src || event.target.href}`,
tagName: event.target.tagName,
timestamp: Date.now()
});
this.pushToQueue(errorInfo);
}
}, true); // 使用捕获阶段
}
}
// 手动上报错误(业务代码调用)
reportError(error, extra = {}) {
const errorInfo = this.formatError({
type: 'manual_error',
message: error.message || String(error),
stack: error.stack || '',
extra,
timestamp: Date.now()
});
this.pushToQueue(errorInfo);
}
formatError(info) {
return {
...info,
pageUrl: window.location.href,
userAgent: navigator.userAgent,
// 从cookie或localStorage获取用户ID
userId: this.getUserId(),
// 从URL参数获取业务标识
bizId: this.getQueryParam('biz_id') || ''
};
}
pushToQueue(errorInfo) {
this.errorQueue.push(errorInfo);
if (this.errorQueue.length >= this.maxQueueSize) {
this.flush();
}
}
flush() {
if (this.errorQueue.length === 0) return;
const data = this.errorQueue.splice(0, this.maxQueueSize);
// 调用上报模块
Reporter.send(data, 'error');
}
getUserId() {
try {
return localStorage.getItem('user_id') || '';
} catch (e) {
return '';
}
}
getQueryParam(name) {
const match = window.location.search.match(new RegExp(`[?&]${name}=([^&]*)`));
return match ? decodeURIComponent(match[1]) : '';
}
}
export default ErrorMonitor;
3.3 性能采集模块(performance.js)
利用Performance API采集关键指标:FCP、LCP、TTFB、FP、FID、CLS。
// performance.js - 版本1.0.0
// 依赖:无,使用Performance API(Chrome 60+、Firefox 57+、Safari 11+)
class PerformanceMonitor {
constructor(options = {}) {
this.options = {
enableFCP: true,
enableLCP: true,
enableTTFB: true,
enableFID: true,
enableCLS: true,
sampleRate: 0.1, // 采样率10%,避免数据量过大
...options
};
this.metrics = {};
this.init();
}
init() {
// 采样控制
if (Math.random() > this.options.sampleRate) return;
// 1. 获取Navigation Timing数据(TTFB、DOMContentLoaded等)
this.captureNavigationTiming();
// 2. 使用PerformanceObserver监听Paint Timing(FCP、FP)
if (this.options.enableFCP && PerformanceObserver) {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.metrics.FCP = entry.startTime;
this.reportMetric('FCP', entry.startTime);
}
}
});
observer.observe({ type: 'paint', buffered: true });
} catch (e) {
console.warn('PerformanceObserver paint not supported');
}
}
// 3. 监听LCP
if (this.options.enableLCP && PerformanceObserver) {
try {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.LCP = lastEntry.startTime;
this.reportMetric('LCP', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {
console.warn('PerformanceObserver LCP not supported');
}
}
// 4. 监听FID
if (this.options.enableFID && PerformanceObserver) {
try {
const fidObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.metrics.FID = entry.processingStart - entry.startTime;
this.reportMetric('FID', this.metrics.FID);
}
});
fidObserver.observe({ type: 'first-input', buffered: true });
} catch (e) {
console.warn('PerformanceObserver FID not supported');
}
}
// 5. 监听CLS
if (this.options.enableCLS && PerformanceObserver) {
try {
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
this.metrics.CLS = clsValue;
this.reportMetric('CLS', clsValue);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch (e) {
console.warn('PerformanceObserver CLS not supported');
}
}
// 6. 页面卸载时上报所有指标
window.addEventListener('beforeunload', () => {
this.flush();
});
}
captureNavigationTiming() {
if (!window.performance || !window.performance.timing) return;
const timing = window.performance.timing;
const navigation = window.performance.getEntriesByType('navigation')[0];
// TTFB: 首字节时间
if (navigation) {
this.metrics.TTFB = navigation.responseStart - navigation.requestStart;
} else {
this.metrics.TTFB = timing.responseStart - timing.requestStart;
}
this.reportMetric('TTFB', this.metrics.TTFB);
// DOMContentLoaded
this.metrics.DCL = timing.domContentLoadedEventEnd - timing.navigationStart;
this.reportMetric('DCL', this.metrics.DCL);
// 页面完全加载
this.metrics.Load = timing.loadEventEnd - timing.navigationStart;
this.reportMetric('Load', this.metrics.Load);
}
reportMetric(name, value) {
const data = {
type: 'performance',
name,
value: Math.round(value),
timestamp: Date.now(),
pageUrl: window.location.href,
userId: this.getUserId()
};
// 直接上报,不缓存(性能指标一般量不大)
Reporter.send([data], 'performance');
}
getUserId() {
try {
return localStorage.getItem('user_id') || '';
} catch (e) {
return '';
}
}
flush() {
// 上报所有已采集但未上报的指标
Object.keys(this.metrics).forEach(key => {
if (this.metrics[key] !== undefined) {
this.reportMetric(key, this.metrics[key]);
}
});
}
}
export default PerformanceMonitor;
3.4 用户行为采集模块(behavior.js)
采集PV、点击、页面跳转、自定义事件。
// behavior.js - 版本1.0.0
// 依赖:无
class BehaviorMonitor {
constructor(options = {}) {
this.options = {
enableClick: true,
enablePageView: true,
enableRoute: true,
clickSampleRate: 0.5, // 点击事件采样率50%
...options
};
this.behaviorQueue = [];
this.maxQueueSize = 30;
this.init();
}
init() {
// 1. PV统计
if (this.options.enablePageView) {
this.reportPageView();
}
// 2. 点击事件采集
if (this.options.enableClick) {
document.addEventListener('click', (event) => {
if (Math.random() > this.options.clickSampleRate) return;
const target = event.target;
const behavior = {
type: 'click',
timestamp: Date.now(),
// 采集点击元素的特征
tagName: target.tagName,
id: target.id || '',
className: target.className || '',
text: target.innerText?.substring(0, 50) || '',
xpath: this.getXPath(target),
pageUrl: window.location.href
};
this.pushToQueue(behavior);
}, true);
}
// 3. 路由变化监听(SPA应用)
if (this.options.enableRoute) {
// 监听hash变化
window.addEventListener('hashchange', () => {
this.reportRouteChange('hash');
});
// 监听History API
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
window.dispatchEvent(new Event('pushstate'));
};
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
window.dispatchEvent(new Event('replacestate'));
};
window.addEventListener('pushstate', () => this.reportRouteChange('pushState'));
window.addEventListener('replacestate', () => this.reportRouteChange('replaceState'));
// popstate事件(浏览器前进后退)
window.addEventListener('popstate', () => this.reportRouteChange('popState'));
}
}
reportPageView() {
const behavior = {
type: 'pageview',
timestamp: Date.now(),
pageUrl: window.location.href,
referrer: document.referrer || '',
userId: this.getUserId()
};
this.pushToQueue(behavior);
}
reportRouteChange(method) {
const behavior = {
type: 'route_change',
method,
timestamp: Date.now(),
from: this.lastPageUrl || '',
to: window.location.href,
userId: this.getUserId()
};
this.lastPageUrl = window.location.href;
this.pushToQueue(behavior);
}
// 自定义事件(业务调用)
reportCustomEvent(eventName, extra = {}) {
const behavior = {
type: 'custom_event',
eventName,
extra,
timestamp: Date.now(),
pageUrl: window.location.href,
userId: this.getUserId()
};
this.pushToQueue(behavior);
}
getXPath(element) {
if (element.id) return `//*[@id="${element.id}"]`;
if (element === document.body) return '/html/body';
let path = '';
let current = element;
while (current && current !== document.body) {
let tagName = current.tagName.toLowerCase();
let sibling = current;
let index = 1;
while (sibling = sibling.previousElementSibling) {
if (sibling.tagName === current.tagName) index++;
}
path = `/${tagName}[${index}]${path}`;
current = current.parentElement;
}
return `/html/body${path}`;
}
pushToQueue(behavior) {
this.behaviorQueue.push(behavior);
if (this.behaviorQueue.length >= this.maxQueueSize) {
this.flush();
}
}
flush() {
if (this.behaviorQueue.length === 0) return;
const data = this.behaviorQueue.splice(0, this.maxQueueSize);
Reporter.send(data, 'behavior');
}
getUserId() {
try {
return localStorage.getItem('user_id') || '';
} catch (e) {
return '';
}
}
}
export default BehaviorMonitor;
3.5 数据上报模块(reporter.js)
核心设计:批量上报、失败重试、使用sendBeacon优先、降级到Image或XHR。
// reporter.js - 版本1.0.0
// 依赖:无
class Reporter {
static config = {
serverUrl: 'https://monitor.example.com/api/report', // 替换为你的服务器地址
retryTimes: 3,
retryDelay: 1000, // 重试间隔1秒
batchInterval: 5000, // 批量上报间隔5秒
maxBatchSize: 50, // 单次最大上报条数
};
static queue = {
error: [],
performance: [],
behavior: []
};
static timers = {};
static init(config = {}) {
Object.assign(this.config, config);
// 启动定时批量上报
Object.keys(this.queue).forEach(type => {
this.startBatchTimer(type);
});
}
static send(data, type = 'error') {
if (!Array.isArray(data)) data = [data];
// 加入队列
this.queue[type] = this.queue[type].concat(data);
// 如果队列超过最大批量,立即发送
if (this.queue[type].length >= this.config.maxBatchSize) {
this.flush(type);
}
}
static flush(type) {
if (this.queue[type].length === 0) return;
const batch = this.queue[type].splice(0, this.config.maxBatchSize);
this.sendRequest(batch, type);
}
static sendRequest(data, type, retryCount = 0) {
const payload = JSON.stringify({
type,
data,
timestamp: Date.now(),
appId: 'your_app_id' // 替换为你的应用ID
});
// 优先使用sendBeacon(页面卸载时也能发送)
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
const sent = navigator.sendBeacon(this.config.serverUrl, blob);
if (sent) return;
}
// 降级到XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('POST', this.config.serverUrl, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
if (xhr.status !== 200 && retryCount < this.config.retryTimes) {
setTimeout(() => {
this.sendRequest(data, type, retryCount + 1);
}, this.config.retryDelay);
}
};
xhr.onerror = () => {
if (retryCount < this.config.retryTimes) {
setTimeout(() => {
this.sendRequest(data, type, retryCount + 1);
}, this.config.retryDelay);
}
};
xhr.send(payload);
}
static startBatchTimer(type) {
this.timers[type] = setInterval(() => {
this.flush(type);
}, this.config.batchInterval);
}
// 页面卸载时强制上报所有数据
static flushAll() {
Object.keys(this.queue).forEach(type => {
this.flush(type);
});
}
}
// 页面卸载时上报
window.addEventListener('beforeunload', () => {
Reporter.flushAll();
});
export default Reporter;
3.6 入口文件(index.js)
// index.js - 版本1.0.0
import ErrorMonitor from './error.js';
import PerformanceMonitor from './performance.js';
import BehaviorMonitor from './behavior.js';
import Reporter from './reporter.js';
class MonitorSDK {
constructor(options = {}) {
this.options = {
serverUrl: 'https://monitor.example.com/api/report',
appId: 'your_app_id',
enableError: true,
enablePerformance: true,
enableBehavior: true,
performanceSampleRate: 0.1,
clickSampleRate: 0.5,
...options
};
// 初始化上报模块
Reporter.init({
serverUrl: this.options.serverUrl
});
// 初始化各模块
if (this.options.enableError) {
this.errorMonitor = new ErrorMonitor();
}
if (this.options.enablePerformance) {
this.performanceMonitor = new PerformanceMonitor({
sampleRate: this.options.performanceSampleRate
});
}
if (this.options.enableBehavior) {
this.behaviorMonitor = new BehaviorMonitor({
clickSampleRate: this.options.clickSampleRate
});
}
}
// 提供给业务调用的方法
reportError(error, extra = {}) {
if (this.errorMonitor) {
this.errorMonitor.reportError(error, extra);
}
}
reportCustomEvent(eventName, extra = {}) {
if (this.behaviorMonitor) {
this.behaviorMonitor.reportCustomEvent(eventName, extra);
}
}
}
// 挂载到全局
window.MonitorSDK = MonitorSDK;
export default MonitorSDK;
3.7 打包配置(rollup.config.js)
// rollup.config.js - Rollup 4.9.0
import { terser } from 'rollup-plugin-terser'; // 版本7.0.2
export default {
input: 'src/index.js',
output: [
{
file: 'dist/monitor-sdk.js',
format: 'umd',
name: 'MonitorSDK',
sourcemap: true
},
{
file: 'dist/monitor-sdk.min.js',
format: 'umd',
name: 'MonitorSDK',
plugins: [terser()]
}
]
};
3.8 使用示例
四、后端接收服务(Node.js示例)
使用Express 4.18.2 + MySQL 8.0.35,提供数据接收和查询接口。
// server.js - Node.js 18.17.0 + Express 4.18.2
const express = require('express');
const mysql = require('mysql2/promise'); // 版本3.6.5
const app = express();
app.use(express.json({ limit: '1mb' })); // 限制请求体大小
// MySQL连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'monitor',
password: 'your_password',
database: 'monitor_db',
waitForConnections: true,
connectionLimit: 50,
queueLimit: 0
});
// 创建表结构
async function initDB() {
const connection = await pool.getConnection();
await connection.execute(`
CREATE TABLE IF NOT EXISTS monitor_errors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(50) NOT NULL,
message TEXT,
stack TEXT,
page_url VARCHAR(500),
user_agent TEXT,
user_id VARCHAR(100),
biz_id VARCHAR(100),
extra JSON,
timestamp BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_timestamp (timestamp),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await connection.execute(`
CREATE TABLE IF NOT EXISTS monitor_performance (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
value INT NOT NULL,
page_url VARCHAR(500),
user_id VARCHAR(100),
timestamp BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_name (name),
INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await connection.execute(`
CREATE TABLE IF NOT EXISTS monitor_behavior (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(50) NOT NULL,
data JSON,
page_url VARCHAR(500),
user_id VARCHAR(100),
timestamp BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
connection.release();
}
initDB().catch(console.error);
// 接收上报数据
app.post('/api/report', async (req, res) => {
const { type, data } = req.body;
if (!type || !Array.isArray(data)) {
return res.status(400).json({ error: 'invalid data' });
}
try {
let tableName;
switch (type) {
case 'error':
tableName = 'monitor_errors';
break;
case 'performance':
tableName = 'monitor_performance';
break;
case 'behavior':
tableName = 'monitor_behavior';
break;
default:
return res.status(400).json({ error: 'invalid type' });
}
// 批量插入
const values = data.map(item => {
if (type === 'error') {
return [item.type, item.message, item.stack, item.pageUrl, item.userAgent, item.userId, item.bizId, JSON.stringify(item.extra || {}), item.timestamp];
} else if (type === 'performance') {
return [item.name, item.value, item.pageUrl, item.userId, item.timestamp];
} else {
return [item.type, JSON.stringify(item), item.pageUrl, item.userId, item.timestamp];
}
});
const placeholders = values.map(() => '(?)').join(',');
const flatValues = values.flat();
await pool.execute(
`INSERT INTO ${tableName} VALUES ${placeholders}`,
flatValues
);
res.json({ success: true, count: data.length });
} catch (error) {
console.error('Insert error:', error);
res.status(500).json({ error: 'internal error' });
}
});
// 查询接口(示例)
app.get('/api/errors', async (req, res) => {
const { page = 1, pageSize = 20, type, startTime, endTime } = req.query;
const offset = (page - 1) * pageSize;
let where = '1=1';
const params = [];
if (type) {
where += ' AND type = ?';
params.push(type);
}
if (startTime) {
where += ' AND timestamp >= ?';
params.push(Number(startTime));
}
if (endTime) {
where += ' AND timestamp <= ?';
params.push(Number(endTime));
}
try {
const [rows] = await pool.execute(
`SELECT * FROM monitor_errors WHERE ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
[...params, Number(pageSize), offset]
);
const [countResult] = await pool.execute(
`SELECT COUNT(*) as total FROM monitor_errors WHERE ${where}`,
params
);
res.json({
data: rows,
total: countResult[0].total,
page: Number(page),
pageSize: Number(pageSize)
});
} catch (error) {
res.status(500).json({ error: 'query error' });
}
});
app.listen(3000, () => {
console.log('Monitor server running on port 3000');
});
五、效果数据:压测结果
使用k6(版本0.47.0)进行压测,模拟100个并发用户持续运行5分钟。
| 指标 | 值 |
|---|---|
| 总请求数 | 2,401,234 |
| 成功请求数 | 2,398,567 |
| 失败请求数 | 2,667 |
| 成功率 | 99.89% |
| 平均响应时间 | 124ms |
| P95响应时间 | 312ms |
| P99响应时间 | 589ms |
| 最大QPS | 8,234 |
| 平均QPS | 7,892 |
| 服务器CPU使用率 | 62% |
| 服务器内存使用 | 1.2GB |
压测命令:
k6 run --vus 100 --duration 5m --out json=result.json stress-test.js
压测脚本(stress-test.js):
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100,
duration: '5m',
};
export default function () {
const payload = JSON.stringify({
type: 'error',
data: [{
type: 'js_error',
message: 'test error',
stack: 'Error: test\n at test.js:1:1',
pageUrl: 'https://example.com/page',
userAgent: 'Mozilla/5.0',
userId: 'test_user',
bizId: 'test_biz',
timestamp: Date.now()
}]
});
const res = http.post('http://localhost:3000/api/report', payload, {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.1);
}
六、避坑指南(5个真实踩坑记录)
坑1:window.onerror无法捕获跨域脚本错误
现象: 引入CDN上的第三方JS(如百度统计),如果该脚本出错,window.onerror只能捕获到"Script error.",没有具体堆栈。
原因: 浏览器同源策略限制,跨域脚本的错误信息被隐藏。
解决方案:
- 在script标签添加crossorigin="anonymous"属性
- CDN服务器返回Access-Control-Allow-Origin头
如果第三方不支持,只能放弃捕获具体信息,或者使用try-catch包裹调用。
坑2:PerformanceObserver在部分浏览器不兼容
现象: 在iOS Safari 12以下版本,PerformanceObserver未实现,导致LCP、FID等指标无法采集。
数据: 我们线上用户中约3.2%使用iOS Safari 12以下版本。
解决方案:
- 使用try-catch包裹PerformanceObserver调用
- 降级使用performance.timing(虽然精度较低)
- 在SDK初始化时检测浏览器版本,输出warn日志
坑3:sendBeacon在HTTPS页面发送HTTP请求会被阻止
现象: 页面是HTTPS,但上报服务器是HTTP,sendBeacon静默失败,数据丢失。
数据: 上线初期约15%的数据因此丢失。
解决方案:
- 确保上报服务器支持HTTPS
- 在SDK中检测页面协议,自动切换上报地址
- 添加fallback机制:sendBeacon失败后使用XHR
坑4:大量上报导致服务器OOM
现象: 上线第一天,服务器内存飙升到4GB,然后OOM被kill。
原因: 没有做限流和采样,所有用户的所有事件都上报,高峰期每秒产生数万条数据。
解决方案:
- 前端采样:性能指标10%采样,点击事件50%采样
- 后端限流:使用令牌桶算法,每秒最多处理10000条
- 批量写入:使用连接池,批量插入而不是逐条插入
- 数据过期:设置TTL,只保留30天数据
坑5:页面卸载时数据丢失
现象: 用户关闭页面时,最后一批行为数据(如点击购买按钮)没有上报。
原因: XHR请求是异步的,页面卸载时请求被取消。
解决方案:
- 使用sendBeacon(页面卸载时仍能发送)
- 在beforeunload事件中调用flushAll
- 对于关键数据(如支付事件),使用同步XHR(会阻塞页面卸载,慎用)
// 关键数据使用同步XHR
const xhr = new XMLHttpRequest();
xhr.open('POST', url, false); // 第三个参数false表示同步
xhr.send(data);
七、总结
这套监控系统上线后,我们实现了:
- 错误发现时间从小时级降到分钟级
- 性能指标可视化,FCP从2.3s优化到1.1s
- 用户行为分析支撑了3次A/B测试
- 服务器成本每月仅500元
代码已开源在GitHub:https://github.com/example/monitor-sdk(示例地址)
如果你也在自建监控系统,建议先跑通最小闭环:错误捕获 -> 数据上报 -> 存储查询 -> 告警通知。不要一开始就追求大而全。