为什么要做前端监控
前端监控系统是保障应用稳定性的基础设施。通过监控可以:
- 快速定位问题 - 及时发现并修复线上错误
- 优化用户体验 - 基于真实数据优化性能
- 了解用户行为 - 数据分析驱动产品决策
- 预防潜在问题 - 异常趋势预警
系统架构
数据从浏览器到看板的链路可以概括为一条流水线:
- Client SDK
- Error 捕获器
- Performance 采集器
- Behavior 采集器
- Custom 事件
- Data Pipeline — 收集、过滤、缓冲
- Upload API — 批量上报、压缩
- Web API — 接收、存储
- 存储层 — 如 ClickHouse / Elasticsearch
- 可视化平台 — Dashboard、告警
核心模块设计
// monitor-core.js - 核心入口
import { ErrorTracker } from "./error";
import { PerformanceTracker } from "./performance";
import { BehaviorTracker } from "./behavior";
import { DataPipeline } from "./pipeline";
import { UploadManager } from "./uploader";
class MonitorSDK {
constructor(options) {
this.options = {
dsn: "", // 上报服务器地址
appId: "", // 应用标识
environment: "production",
sampleRate: 1, // 采样率 0-1
...options,
};
// 初始化数据管道
this.pipeline = new DataPipeline(this.options);
// 初始化各模块
this.error = new ErrorTracker(this.options, this.pipeline);
this.performance = new PerformanceTracker(this.options, this.pipeline);
this.behavior = new BehaviorTracker(this.options, this.pipeline);
// 初始化上传管理器
this.uploader = new UploadManager(this.options);
}
// 启动监控
start() {
this.error.install();
this.performance.collect();
this.behavior.track();
}
}
export default MonitorSDK;
错误监控模块
错误分类
| 类型 | 捕获方式 | 示例 |
|---|
| JS 运行时错误 | window.onerror | 变量未定义 |
| Promise 异常 | unhandledrejection | Promise.reject() |
| 资源加载错误 | error 事件 | 图片 404 |
| Vue/React 错误 | 框架错误边界 | 组件渲染异常 |
| 自定义错误 | 主动上报 | 业务逻辑错误 |
错误收集器实现
// error-tracker.js
import { getGlobal, isString, isFunction, isObject } from "../utils";
import { Severity } from "../severity";
export class ErrorTracker {
constructor(options, pipeline) {
this.options = options;
this.pipeline = pipeline;
this.global = getGlobal();
}
install() {
// 1. 监听 JS 运行时错误
this.global.onerror = (message, source, lineno, colno, error) => {
this.handleError({
type: "javascript",
message,
source,
lineno,
colno,
stack: error?.stack,
error,
});
};
// 2. 监听未处理的 Promise 拒绝
this.global.onunhandledrejection = event => {
this.handleError({
type: "unhandledrejection",
message: event.reason?.message || "Unhandled Promise Rejection",
stack: event.reason?.stack,
error: event.reason,
});
};
// 3. 监听资源加载错误
this.global.addEventListener(
"error",
event => {
if (event.target !== window) {
this.handleResourceError(event.target);
}
},
true
);
// 4. 监听 Vue 错误(如果存在)
if (this.global.__VUE__) {
this.installVueErrorHandler();
}
// 5. 监听 React 错误(如果存在)
if (this.global.__REACT__) {
this.installReactErrorHandler();
}
}
handleError(errorData) {
// 过滤忽略的错误
if (this.isIgnoredError(errorData)) {
return;
}
// 构造错误实体
const errorEntity = {
id: this.generateErrorId(),
appId: this.options.appId,
timestamp: Date.now(),
environment: this.options.environment,
userAgent: navigator.userAgent,
url: location.href,
referrer: document.referrer,
// 错误信息
type: errorData.type,
message: errorData.message,
stack: errorData.stack,
filename: errorData.source,
lineno: errorData.lineno,
colno: errorData.colno,
// 浏览器上下文
name: errorData.error?.name,
cause: errorData.error?.cause,
// 严重级别
severity: this.calculateSeverity(errorData),
};
// 上报错误
this.pipeline.send({
event: "error",
data: errorEntity,
});
}
handleResourceError(target) {
const errorEntity = {
id: this.generateErrorId(),
appId: this.options.appId,
timestamp: Date.now(),
type: "resource",
// 资源信息
src: target.src || target.href,
tagName: target.tagName,
rel: target.rel,
// 页面上下文
url: location.href,
referrer: document.referrer,
severity: Severity.WARNING,
};
this.pipeline.send({
event: "error",
data: errorEntity,
});
}
isIgnoredError(errorData) {
// 忽略列表
const ignorePatterns = [
/ResizeObserver/,
/Network Error/,
/chunk load fail/i,
/loading chunk/i,
];
return ignorePatterns.some(pattern => pattern.test(errorData.message));
}
calculateSeverity(errorData) {
// 根据错误类型和堆栈判断严重级别
if (errorData.type === "unhandledrejection") {
return Severity.WARNING;
}
if (errorData.stack?.includes("core-js")) {
return Severity.INFO;
}
return Severity.ERROR;
}
generateErrorId() {
return `err_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
}
性能监控模块
性能指标体系
| 指标 | 说明 | 计算方式 |
|---|
| FP | First Paint 首次绘制 | 页面开始绘制 |
| FCP | First Contentful Paint 首次内容绘制 | 第一个内容渲染 |
| LCP | Largest Contentful Paint 最大内容绘制 | 最大元素渲染 |
| FMP | First Meaningful Paint 首次有意义绘制 | 主要内容出现 |
| TTI | Interactive 可交互时间 | 可交互 |
| TBT | Total Blocking Time 总阻塞时间 | FCP 到 TTI |
| CLS | Cumulative Layout Shift 累积布局偏移 | 视觉稳定性 |
| SI | Speed Index 速度指数 | 视觉填充速度 |
性能采集器实现
// performance-tracker.js
import { getGlobal } from '../utils';
export class PerformanceTracker {
constructor(options, pipeline) {
this.options = options;
this.pipeline = pipeline;
this.global = getGlobal();
this Marks = new Map();
this.Measures = [];
}
collect() {
// 1. 监听页面加载完成
this.global.addEventListener('load', () => {
// 延迟获取,确保所有指标就绪
setTimeout(() => this.collectWebVitals(), 0);
});
// 2. 监听页面卸载,用于上报 pv 结束
this.global.addEventListener('beforeunload', () => {
this.reportPageEnd();
});
// 3. 监听路由变化(SPA)
this.observeRouteChange();
// 4. 监听长任务
this.observeLongTasks();
}
collectWebVitals() {
const paint = performance.getEntriesByType('paint');
const navigation = performance.getEntriesByType('navigation')[0];
const resource = performance.getEntriesByType('resource');
// 计算核心指标
const vitals = {
// Paint指标
fp: this.getEntryByName(paint, 'first-paint'),
fcp: this.getEntryByName(paint, 'first-contentful-paint'),
// Navigation指标
domContentLoaded: navigation?.domContentLoadedEventEnd,
loadComplete: navigation?.loadEventEnd,
firstByte: navigation?.responseStart,
resourceCount: resource?.length || 0,
resourceSize: this.calculateResourceSize(resource),
// 计算指标
ttfb: navigation?.responseStart - navigation?.requestStart,
domReady: navigation?.domContentLoadedEventEnd - navigation?.requestStart,
pageLoad: navigation?.loadEventEnd - navigation?.requestStart,
};
// 上报性能数据
this.pipeline.send({
event: 'performance',
data: {
...vitals,
timestamp: Date.now(),
url: location.href,
appId: this.options.appId,
},
});
}
observeRouteChange() {
// MutationObserver 监听 hash/router 变化
let lastUrl = location.href;
const checkUrlChange = () => {
if (location.href !== lastUrl) {
this.reportPageEnd();
lastUrl = location.href;
}
};
setInterval(checkUrlChange, 1000);
this.global.addEventListener('popstate', checkUrlChange);
}
observeLongTasks() {
// ���用 PerformanceObserver 监听长任务
if ('PerformanceObserver' in this.global) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.blockingTime > 50) {
this.pipeline.send({
event: 'longtask',
data: {
duration: entry.duration,
blockingTime: entry.blockingTime,
startTime: entry.startTime,
url: location.href,
},
});
}
}
});
observer.observe({ type: 'longtask', buffered: true });
}
}
getEntryByName(entries, name) {
return entries.find((e) => e.name === name)?.startTime || 0;
}
calculateResourceSize(resources) {
return resources.reduce((total, r) => total + (r.transferSize || 0), 0);
}
reportPageEnd() {
// 上报页面停留时长等
this.pipeline.send({
event: 'page_end',
data: {
duration: performance.now(),
url: location.href,
timestamp: Date.now(),
},
});
}
// 记录自定义时间点
mark(name) {
this.Marks.set(name, performance.now());
}
// 测量两个时间点之间的耗时
measure(name, startMark, endMark = null) {
const startTime = this.Marks.get(startMark);
const endTime = endMark ? this.Marks.get(endMark) : performance.now();
if (startTime !== undefined) {
this.Measures.push({
name,
duration: endTime - startTime,
startTime,
endTime,
});
}
}
}
行为监控模块
采集内容
| 类别 | 内容 | 用途 |
|---|
| PV/UV | 页面访问 | 流量统计 |
| 点击 | 元素点击、按钮点击 | 用户交互分析 |
| 停留 | 页面停留时长 | 内容热度 |
| 滚动 | 滚动深度、速度 | 用户阅读行为 |
| 性能 | 请求耗时、成功率 | 接口监控 |
行为采集器实现
// behavior-tracker.js
import { getGlobal, throttle } from "../utils";
import { getSelector } from "../dom";
export class BehaviorTracker {
constructor(options, pipeline) {
this.options = options;
this.pipeline = pipeline;
this.global = getGlobal();
this.maxStack = 10;
}
track() {
// 1. 上报页面浏览
this.trackPageView();
// 2. 监听点击事件
this.trackClick();
// 3. 监听表单提交
this.trackFormSubmit();
// 4. 监听接口请求
this.trackApiRequest();
// 5. 监听滚动行为
this.trackScroll();
// 6. 监听页面可见性
this.trackVisibility();
}
trackPageView() {
this.pipeline.send({
event: "page_view",
data: {
url: location.href,
referrer: document.referrer,
title: document.title,
timestamp: Date.now(),
screenWidth: screen.width,
screenHeight: screen.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
},
});
// 记录进入时间
this.pageStartTime = Date.now();
}
trackClick() {
const clickHandler = throttle(event => {
const target = event.target;
const selector = getSelector(target);
this.pipeline.send({
event: "click",
data: {
url: location.href,
selector,
tagName: target.tagName,
text: target.textContent?.slice(0, 50),
className: target.className,
id: target.id,
timestamp: Date.now(),
x: event.clientX,
y: event.clientY,
},
});
}, 300);
this.global.document.addEventListener("click", clickHandler, true);
}
trackFormSubmit() {
this.global.document.addEventListener(
"submit",
event => {
const form = event.target;
const formData = new FormData(form);
this.pipeline.send({
event: "form_submit",
data: {
url: location.href,
action: form.action,
method: form.method,
selector: getSelector(form),
timestamp: Date.now(),
},
});
},
true
);
}
trackApiRequest() {
// 拦截原生 fetch
const originalFetch = this.global.fetch;
this.global.fetch = async (...args) => {
const startTime = Date.now();
const [url, options] = args;
try {
const response = await originalFetch(...args);
const duration = Date.now() - startTime;
this.pipeline.send({
event: "api",
data: {
url: url?.url || url,
method: options?.method || "GET",
status: response.status,
duration,
ok: response.ok,
timestamp: Date.now(),
},
});
return response;
} catch (error) {
const duration = Date.now() - startTime;
this.pipeline.send({
event: "api",
data: {
url: url?.url || url,
method: options?.method || "GET",
status: 0,
duration,
error: error.message,
timestamp: Date.now(),
},
});
throw error;
}
};
// 拦截 XMLHttpRequest
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._monitorData = { method, url, startTime: Date.now() };
originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener("load", () => {
const duration = Date.now() - this._monitorData.startTime;
this.pipeline.send({
event: "api",
data: {
url: this._monitorData.url,
method: this._monitorData.method,
status: this.status,
duration,
ok: this.status >= 200 && this.status < 300,
timestamp: Date.now(),
},
});
});
originalXHRSend.apply(this, arguments);
};
}
trackScroll() {
let maxScrollDepth = 0;
let lastScrollTime = 0;
const scrollHandler = () => {
const scrollTop =
window.pageYOffset || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight;
const viewHeight = window.innerHeight;
// 计算滚动深度百分比
const scrollDepth = Math.round(
(scrollTop / (docHeight - viewHeight)) * 100
);
maxScrollDepth = Math.max(maxScrollDepth, scrollDepth);
// 节流上报
const now = Date.now();
if (now - lastScrollTime > 1000) {
lastScrollTime = now;
this.pipeline.send({
event: "scroll",
data: {
depth: maxScrollDepth,
velocity: 0,
timestamp: Date.now(),
},
});
}
};
this.global.addEventListener("scroll", scrollHandler, { passive: true });
}
trackVisibility() {
this.global.document.addEventListener("visibilitychange", () => {
this.pipeline.send({
event: "visibility",
data: {
state: document.visibilityState,
duration: Date.now() - this.pageStartTime,
timestamp: Date.now(),
},
});
});
}
}
数据管道与上报
数据管道设计
// pipeline.js
import { getGlobal } from "../utils";
export class DataPipeline {
constructor(options) {
this.options = options;
this.buffer = [];
this.maxBufferSize = 100;
this.flushInterval = 5000; // 5秒
this.global = getGlobal();
// 启动定时flush
this.startFlushTimer();
}
send(event) {
// 采样过滤
if (!this.shouldSample(event)) {
return;
}
// 添加到缓冲区
this.buffer.push(event);
// 超过阈值立即上报
if (this.buffer.length >= this.maxBufferSize) {
this.flush();
}
}
flush() {
if (this.buffer.length === 0) {
return;
}
const data = this.buffer.splice(0, this.buffer.length);
const payload = this.compress({
events: data,
appId: this.options.appId,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
sdkVersion: this.options.version,
});
// 发送数据
this.upload(payload);
}
upload(payload) {
const headers = {
"Content-Type": "application/json",
"X-App-ID": this.options.appId,
"X-SDK-Version": this.options.version,
};
// 使用 sendBeacon 确保页面卸载前也能上报
if (this.global.navigator?.sendBeacon) {
const blob = new Blob([JSON.stringify(payload)], {
type: "application/json",
});
this.global.navigator.sendBeacon(this.options.dsn, blob);
} else {
// 回退到 fetch
fetch(this.options.dsn, {
method: "POST",
headers,
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
}
}
compress(data) {
// 基础压缩:移除空值和默认值
return this.removeEmptyValues(data);
}
removeEmptyValues(obj) {
const result = {};
for (const key in obj) {
const value = obj[key];
if (value !== null && value !== undefined && value !== "") {
if (typeof value === "object" && !Array.isArray(value)) {
result[key] = this.removeEmptyValues(value);
} else {
result[key] = value;
}
}
}
return result;
}
shouldSample(event) {
// 根据采样率采样
return Math.random() < this.options.sampleRate;
}
startFlushTimer() {
this.global.setInterval(() => this.flush(), this.flushInterval);
}
}
工具函数
DOM 选择器生成
// dom.js
export function getSelector(element) {
if (!element || !element.tagName) return "";
const parts = [];
let current = element;
while (current && current.nodeType === 1) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
parts.unshift(selector);
break;
}
if (current.className && typeof current.className === "string") {
const className = current.className.trim().split(/\s+/)[0];
selector += `.${className}`;
}
parts.unshift(selector);
current = current.parentElement;
}
return parts.slice(0, 4).join(" > ");
}
节流函数
// utils.js
export function throttle(func, wait) {
let previous = 0;
return function (...args) {
const now = Date.now();
if (now - previous > wait) {
previous = now;
return func.apply(this, args);
}
};
}
export function getGlobal() {
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
return {};
}
监控SDK使用
初始化
// 初始化监控SDK
import MonitorSDK from "./monitor";
const monitor = new MonitorSDK({
dsn: "https://monitor.example.com/api/collect",
appId: "my-app",
environment: process.env.NODE_ENV,
sampleRate: 0.1, // 10%采样
});
// 启动监控
monitor.start();
手动上报
// 上报自定义错误
monitor.error.capture(new Error("Custom error"), {
context: "用户点击",
});
// 上报业务事件
monitor.behavior.trackEvent("checkout", {
productId: "123",
amount: 99,
});
// 上报性能标记点
monitor.performance.mark("searchStart");
// ... 执行搜索 ...
monitor.performance.measure("search", "searchStart");