前端监控与埋点
概述
前端监控是保障线上产品质量和用户体验的关键基础设施。一套完善的前端监控体系通常包含三大支柱:错误监控(发现问题)、性能监控(量化体验)和用户行为监控(理解用户)。面试中,考察前端监控不仅测试你对浏览器 API 的掌握程度,更考察你的工程化思维和系统设计能力。
前端监控的本质是:采集 -> 上报 -> 存储 -> 分析 -> 告警。面试中回答监控相关问题时,从这条链路展开即可。
一、前端监控体系
1.1 三大监控维度
| 维度 | 目的 | 核心指标 | 典型工具 |
|---|---|---|---|
| 错误监控 | 发现线上 Bug、降低故障影响 | JS 错误数、错误率、影响用户数 | Sentry、Fundebug |
| 性能监控 | 量化用户体验、指导优化方向 | FCP、LCP、CLS、TTFB、FID/INP | web-vitals、Lighthouse |
| 行为监控 | 理解用户行为、辅助业务决策 | PV/UV、点击热力图、漏斗转化率 | GrowingIO、神策、Google Analytics |
1.2 监控数据流转架构
二、错误捕获
错误捕获是前端监控的基石。不同类型的错误需要不同的捕获方式,这是面试中的高频考点。
2.1 window.onerror — 捕获 JS 运行时错误
window.onerror 是最经典的全局错误捕获方式,可以捕获同步错误和异步回调中的错误(如 setTimeout),但无法捕获 Promise 异常和语法错误。
// window.onerror 可捕获运行时错误
window.onerror = (
message: string | Event,
source?: string,
lineno?: number,
colno?: number,
error?: Error
): boolean => {
const errorInfo = {
type: 'js_error',
message: typeof message === 'string' ? message : message.type,
source: source ?? '',
lineno: lineno ?? 0,
colno: colno ?? 0,
stack: error?.stack ?? '',
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
};
// 上报错误
reportError(errorInfo);
// 返回 true 可以阻止默认的控制台错误输出
return true;
};
如果 JS 文件来自不同域(CDN),window.onerror 只会得到 "Script error." 信息。解决方案:
- 给
<script>标签添加crossorigin="anonymous"属性 - 服务端响应头添加
Access-Control-Allow-Origin
2.2 window.addEventListener('error') — 捕获资源加载错误
window.onerror 无法捕获图片、CSS、JS 等资源加载失败的错误。需要在捕获阶段监听 error 事件:
// 第三个参数 true 表示在捕获阶段监听
window.addEventListener('error', (event: ErrorEvent | Event) => {
const target = event.target as HTMLElement;
// 区分资源加载错误和 JS 运行时错误
if (target && (target instanceof HTMLScriptElement
|| target instanceof HTMLLinkElement
|| target instanceof HTMLImageElement)) {
const resourceError = {
type: 'resource_error',
tagName: target.tagName.toLowerCase(),
src: (target as HTMLImageElement).src
|| (target as HTMLLinkElement).href
|| '',
timestamp: Date.now(),
url: window.location.href,
};
reportError(resourceError);
}
}, true); // 必须使用捕获阶段,资源加载错误不会冒泡
2.3 unhandledrejection — 捕获 Promise 未处理异常
当 Promise 被 reject 但没有 .catch() 处理时,会触发 unhandledrejection 事件:
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const reason = event.reason;
const promiseError = {
type: 'promise_error',
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : '',
timestamp: Date.now(),
url: window.location.href,
};
reportError(promiseError);
// 阻止默认行为(控制台报错)
event.preventDefault();
});
2.4 React ErrorBoundary — 捕获组件渲染错误
React 16+ 引入了 Error Boundary 机制,可以捕获子组件树中渲染阶段、生命周期方法和构造函数中的错误:
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
// 渲染阶段捕获错误,更新 state 以显示 fallback UI
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
// 提交阶段记录错误信息,适合做上报
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const reactError = {
type: 'react_error',
message: error.message,
stack: error.stack ?? '',
componentStack: errorInfo.componentStack ?? '',
timestamp: Date.now(),
url: window.location.href,
};
reportError(reactError);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? <h1>页面出错了,请刷新重试</h1>;
}
return this.props.children;
}
}
export default ErrorBoundary;
Error Boundary 无法捕获以下错误:
- 事件处理函数中的错误(需要
try/catch) - 异步代码(
setTimeout、Promise) - 服务端渲染(SSR)中的错误
- Error Boundary 组件自身的错误
2.5 Vue errorHandler — 捕获 Vue 组件错误
Vue 提供了全局的 app.config.errorHandler,类似于 React 的 Error Boundary:
import { createApp } from 'vue';
const app = createApp(App);
app.config.errorHandler = (
err: unknown,
instance: any,
info: string
): void => {
const error = err instanceof Error ? err : new Error(String(err));
const vueError = {
type: 'vue_error',
message: error.message,
stack: error.stack ?? '',
// info 是 Vue 特有的错误信息,如 "render function", "watcher callback" 等
hookInfo: info,
componentName: instance?.$options?.name ?? 'Anonymous',
timestamp: Date.now(),
url: window.location.href,
};
reportError(vueError);
};
app.mount('#app');
2.6 接口异常监控
通过拦截 fetch 和 XMLHttpRequest 来监控接口调用异常:
// 拦截 fetch
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const startTime = Date.now();
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
try {
const response = await originalFetch(input, init);
const duration = Date.now() - startTime;
// 上报接口性能数据
reportApiPerf({ url, duration, status: response.status });
// 非 2xx 状态码视为异常
if (!response.ok) {
reportError({
type: 'api_error',
url,
status: response.status,
statusText: response.statusText,
duration,
timestamp: Date.now(),
});
}
return response;
} catch (error) {
// 网络错误(断网、超时、CORS 等)
reportError({
type: 'api_error',
url,
status: 0,
message: error instanceof Error ? error.message : 'Network Error',
duration: Date.now() - startTime,
timestamp: Date.now(),
});
throw error;
}
};
2.7 错误捕获方式对比
| 捕获方式 | 同步错误 | 异步错误 | Promise | 资源加载 | 框架渲染 |
|---|---|---|---|---|---|
try/catch | ✅ | ❌ | ❌ | ❌ | ❌ |
window.onerror | ✅ | ✅ | ❌ | ❌ | ❌ |
addEventListener('error', ..., true) | ✅ | ✅ | ❌ | ✅ | ❌ |
unhandledrejection | ❌ | ❌ | ✅ | ❌ | ❌ |
| React Error Boundary | ❌ | ❌ | ❌ | ❌ | ✅ |
| Vue errorHandler | ❌ | ❌ | ❌ | ❌ | ✅ |
一套完整的错误监控,至少需要组合使用以上 4-5 种方式,才能覆盖绝大多数场景。这也是 Sentry 等工具内部的做法。
三、性能数据采集
3.1 Performance API
Performance API 是浏览器提供的性能数据接口,可以获取页面加载的详细时间线:
// 使用 Navigation Timing Level 2 API(推荐)
function getNavigationTiming(): Record<string, number> | null {
const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
if (!entry) return null;
return {
// DNS 解析耗时
dns: entry.domainLookupEnd - entry.domainLookupStart,
// TCP 连接耗时
tcp: entry.connectEnd - entry.connectStart,
// SSL 握手耗时
ssl: entry.secureConnectionStart > 0
? entry.connectEnd - entry.secureConnectionStart
: 0,
// 首字节时间 (TTFB)
ttfb: entry.responseStart - entry.requestStart,
// 内容传输耗时
contentDownload: entry.responseEnd - entry.responseStart,
// DOM 解析耗时
domParse: entry.domInteractive - entry.responseEnd,
// DOM 完成耗时
domReady: entry.domContentLoadedEventEnd - entry.navigationStart,
// 页面完全加载
pageLoad: entry.loadEventEnd - entry.navigationStart,
// 首次可交互时间
interactive: entry.domInteractive - entry.navigationStart,
};
}
3.2 PerformanceObserver — 动态观察性能指标
PerformanceObserver 采用观察者模式,可以实时监听各类性能条目的产生,比 performance.getEntriesByType() 更灵活:
// 观察 Largest Contentful Paint (LCP)
function observeLCP(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
// LCP 可能触发多次,取最后一个
const lastEntry = entries[entries.length - 1] as PerformanceEntry;
reportPerf({
type: 'lcp',
value: lastEntry.startTime,
timestamp: Date.now(),
});
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
// 观察长任务(阻塞主线程超过 50ms 的任务)
function observeLongTasks(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
reportPerf({
type: 'long_task',
duration: entry.duration,
name: entry.name,
timestamp: Date.now(),
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// 观察首次输入延迟 (FID)
function observeFID(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEventTiming[];
for (const entry of entries) {
const fid = entry.processingStart - entry.startTime;
reportPerf({
type: 'fid',
value: fid,
timestamp: Date.now(),
});
}
});
observer.observe({ type: 'first-input', buffered: true });
}
3.3 Web Vitals — Google 核心体验指标
Google 提供了 web-vitals 库,封装了 Core Web Vitals 的采集逻辑:
import { onCLS, onFID, onLCP, onFCP, onTTFB, onINP } from 'web-vitals';
import type { Metric } from 'web-vitals';
function sendToAnalytics(metric: Metric): void {
const data = {
name: metric.name, // 指标名称:CLS、FID、LCP 等
value: metric.value, // 指标值
rating: metric.rating, // 评级:good、needs-improvement、poor
delta: metric.delta, // 距上次报告的变化量
id: metric.id, // 唯一 ID,用于去重
navigationType: metric.navigationType, // 导航类型
};
// 使用 sendBeacon 上报
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify(data));
}
}
// 注册所有 Core Web Vitals 指标
onCLS(sendToAnalytics); // 累积布局偏移
onFID(sendToAnalytics); // 首次输入延迟(已被 INP 替代)
onINP(sendToAnalytics); // 交互到下一帧绘制延迟
onLCP(sendToAnalytics); // 最大内容绘制
onFCP(sendToAnalytics); // 首次内容绘制
onTTFB(sendToAnalytics); // 首字节时间
Core Web Vitals 指标阈值参考:
| 指标 | 含义 | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP | 最大内容绘制 | ≤ 2.5s | 2.5s ~ 4s | > 4s |
| INP | 交互到下一帧绘制 | ≤ 200ms | 200ms ~ 500ms | > 500ms |
| CLS | 累积布局偏移 | ≤ 0.1 | 0.1 ~ 0.25 | > 0.25 |
2024 年 3 月起,Google 用 INP(Interaction to Next Paint) 正式替代了 FID(First Input Delay) 作为 Core Web Vitals 的响应性指标。INP 考量的是页面整个生命周期内所有交互的延迟,而 FID 只考量首次交互。
四、用户行为埋点
用户行为埋点是理解用户如何使用产品的重要手段。根据实现方式的不同,主要分为三种。
4.1 手动埋点(代码埋点)
最传统的方式,开发者在业务代码中手动调用埋点 SDK 的上报方法。优点是灵活精准,缺点是与业务代码耦合、工作量大。
// 埋点 SDK 接口定义
interface TrackEvent {
eventName: string;
params?: Record<string, string | number | boolean>;
timestamp?: number;
}
class Tracker {
private appId: string;
private userId: string;
constructor(appId: string) {
this.appId = appId;
this.userId = '';
}
// 手动埋点:在业务代码中显式调用
track(eventName: string, params?: Record<string, string | number | boolean>): void {
const event: TrackEvent = {
eventName,
params: {
...params,
appId: this.appId,
userId: this.userId,
url: window.location.href,
referrer: document.referrer,
},
timestamp: Date.now(),
};
this.send(event);
}
// PV 上报
trackPageView(): void {
this.track('page_view', {
title: document.title,
path: window.location.pathname,
});
}
setUserId(userId: string): void {
this.userId = userId;
}
private send(data: TrackEvent): void {
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/track', JSON.stringify(data));
}
}
}
// 使用示例
const tracker = new Tracker('my-app');
tracker.setUserId('user_123');
// 在按钮点击处手动埋点
function handlePurchase(productId: string, price: number): void {
tracker.track('purchase_click', { productId, price });
// ...业务逻辑
}
4.2 无痕埋点(全埋点)
无痕埋点通过全局监听用户的所有交互行为(点击、滚动、输入等),自动采集并上报。优点是无需手动埋点、覆盖全面,缺点是数据量大、无法携带自定义业务参数。
// 自动采集点击事件
function initAutoTrack(): void {
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target) return;
// 生成元素唯一标识(XPath 或 CSS 选择器)
const xpath = getXPath(target);
const clickData = {
type: 'click',
xpath,
tagName: target.tagName.toLowerCase(),
// 获取元素的文本内容(截断,防止信息过长)
text: (target.textContent ?? '').trim().slice(0, 50),
// 获取 data-track-* 属性作为辅助标识
trackId: target.dataset.trackId ?? '',
// 页面坐标
pageX: event.pageX,
pageY: event.pageY,
timestamp: Date.now(),
url: window.location.href,
};
reportBehavior(clickData);
}, true);
// 自动采集页面可见性变化(停留时长)
let enterTime = Date.now();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const duration = Date.now() - enterTime;
reportBehavior({
type: 'page_stay',
duration,
url: window.location.href,
timestamp: Date.now(),
});
} else {
enterTime = Date.now();
}
});
}
// 生成元素的 XPath
function getXPath(element: HTMLElement): string {
const paths: string[] = [];
let current: HTMLElement | null = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === current!.tagName
);
const index = siblings.indexOf(current) + 1;
paths.unshift(siblings.length > 1 ? `${tagName}[${index}]` : tagName);
} else {
paths.unshift(tagName);
}
current = parent;
}
return '/body/' + paths.join('/');
}
4.3 可视化埋点
可视化埋点通过一个可视化配置界面,让运营或产品人员直接在页面上圈选需要埋点的元素,后台自动生成埋点配置。SDK 运行时根据配置来决定哪些元素的行为需要上报。
// 可视化埋点配置
interface VisualTrackConfig {
// CSS 选择器或 XPath
selector: string;
// 事件名称
eventName: string;
// 监听的事件类型
eventType: 'click' | 'change' | 'submit';
// 附加参数提取规则
paramRules?: Array<{
key: string;
// 从元素的哪个属性提取值
source: 'text' | 'value' | 'dataset' | 'attribute';
attribute?: string;
}>;
}
// 根据远程配置初始化可视化埋点
async function initVisualTrack(): Promise<void> {
// 从服务端获取埋点配置
const response = await fetch('/api/track-config?app=my-app');
const configs: VisualTrackConfig[] = await response.json();
configs.forEach((config) => {
// 使用事件委托,监听匹配的元素
document.addEventListener(config.eventType, (event: Event) => {
const target = event.target as HTMLElement;
if (!target.matches(config.selector)) return;
// 根据配置提取参数
const params: Record<string, string> = {};
config.paramRules?.forEach((rule) => {
switch (rule.source) {
case 'text':
params[rule.key] = (target.textContent ?? '').trim();
break;
case 'value':
params[rule.key] = (target as HTMLInputElement).value;
break;
case 'dataset':
params[rule.key] = target.dataset[rule.attribute ?? ''] ?? '';
break;
case 'attribute':
params[rule.key] = target.getAttribute(rule.attribute ?? '') ?? '';
break;
}
});
reportBehavior({
eventName: config.eventName,
params,
timestamp: Date.now(),
url: window.location.href,
});
}, true);
});
}
4.4 三种埋点方式对比
| 特性 | 手动埋点 | 无痕埋点(全埋点) | 可视化埋点 |
|---|---|---|---|
| 开发成本 | 高 | 低 | 中 |
| 数据精度 | 高(自定义参数) | 低(无业务语义) | 中 |
| 覆盖范围 | 按需覆盖 | 全量覆盖 | 按需配置 |
| 维护成本 | 高(需跟随业务变化) | 低 | 中 |
| 数据量 | 小 | 大(冗余数据多) | 中 |
| 适用场景 | 核心业务指标 | 行为回溯、热力图 | 运营活动分析 |
实际项目中通常组合使用:全埋点做兜底覆盖,手动埋点做核心业务指标,可视化埋点做灵活的运营需求。
五、数据上报策略
采集到数据后,如何高效、可靠地上报到服务端,是监控系统设计的关键环节。
5.1 sendBeacon — 页面卸载时的可靠上报
navigator.sendBeacon() 是专门为页面卸载场景设计的 API。它将请求放入浏览器的发送队列,不会阻塞页面关闭,也不会因为页面卸载而被取消:
function reportWithBeacon(url: string, data: Record<string, unknown>): boolean {
if (navigator.sendBeacon) {
// sendBeacon 支持 Blob 和 FormData
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json',
});
return navigator.sendBeacon(url, blob);
}
// 降级方案:同步 XMLHttpRequest(不推荐,但兼容性好)
return false;
}
// 页面卸载时上报
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
reportWithBeacon('/api/report', {
events: getBufferedEvents(),
timestamp: Date.now(),
});
}
});
5.2 img 标签上报 — 最简单的跨域方案
利用 <img> 标签的 src 天然跨域且不受 CSP 限制的特性,适合上报简单的少量数据:
function reportWithImage(url: string, data: Record<string, unknown>): void {
const img = new Image(1, 1);
const params = new URLSearchParams();
// 将数据序列化为 query string
Object.entries(data).forEach(([key, value]) => {
params.append(key, String(value));
});
img.src = `${url}?${params.toString()}`;
// 可选:处理加载失败
img.onerror = () => {
// 降级处理或重试
console.warn('Report failed');
};
}
img 标签上报的限制:
- URL 长度有限制(各浏览器不同,通常 2KB ~ 8KB)
- 只能发送 GET 请求,无法携带大量数据
- 适合上报简单指标,不适合复杂数据结构
5.3 批量上报与缓冲队列
为了减少网络请求次数,通常将数据先放入缓冲队列,满足条件后批量上报:
interface ReportItem {
type: string;
data: Record<string, unknown>;
timestamp: number;
}
class BatchReporter {
private buffer: ReportItem[] = [];
private maxSize: number;
private flushInterval: number;
private timer: ReturnType<typeof setTimeout> | null = null;
private url: string;
constructor(options: {
url: string;
maxSize?: number; // 缓冲区最大条目数
flushInterval?: number; // 定时上报间隔(毫秒)
}) {
this.url = options.url;
this.maxSize = options.maxSize ?? 10;
this.flushInterval = options.flushInterval ?? 5000;
this.startTimer();
this.bindUnload();
}
// 添加数据到缓冲区
add(item: ReportItem): void {
this.buffer.push(item);
// 缓冲区满则立即上报
if (this.buffer.length >= this.maxSize) {
this.flush();
}
}
// 执行批量上报
private flush(): void {
if (this.buffer.length === 0) return;
const data = [...this.buffer];
this.buffer = [];
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json',
});
const success = navigator.sendBeacon(this.url, blob);
if (!success) {
// sendBeacon 失败时降级为 fetch
this.fallbackFetch(data);
}
} else {
this.fallbackFetch(data);
}
}
private fallbackFetch(data: ReportItem[]): void {
fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
keepalive: true, // 允许页面卸载后继续请求
}).catch(() => {
// 上报失败,存入 localStorage 等待下次重试
this.saveToLocal(data);
});
}
private saveToLocal(data: ReportItem[]): void {
try {
const existing = JSON.parse(localStorage.getItem('__report_retry__') ?? '[]');
localStorage.setItem('__report_retry__', JSON.stringify([...existing, ...data]));
} catch {
// localStorage 满或不可用时忽略
}
}
private startTimer(): void {
this.timer = setInterval(() => this.flush(), this.flushInterval);
}
// 页面卸载时清空缓冲区
private bindUnload(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
destroy(): void {
if (this.timer) clearInterval(this.timer);
this.flush();
}
}
5.4 采样率控制
对于高流量应用,100% 上报会给服务端带来巨大压力。通过采样率可以在保证数据统计准确性的前提下降低成本:
interface SamplingConfig {
// 全局采样率 0~1
globalRate: number;
// 按事件类型设置独立采样率
eventRates?: Record<string, number>;
// 某些关键事件强制 100% 上报
forceReportEvents?: string[];
}
function shouldReport(eventType: string, config: SamplingConfig): boolean {
// 关键事件始终上报(如致命错误)
if (config.forceReportEvents?.includes(eventType)) {
return true;
}
// 优先使用事件级别的采样率
const rate = config.eventRates?.[eventType] ?? config.globalRate;
// 使用随机数判断是否命中采样
return Math.random() < rate;
}
// 使用示例
const samplingConfig: SamplingConfig = {
globalRate: 0.1, // 默认 10% 采样
eventRates: {
js_error: 1.0, // JS 错误 100% 上报
api_error: 1.0, // 接口错误 100% 上报
page_view: 0.5, // PV 50% 采样
click: 0.01, // 点击行为 1% 采样
},
forceReportEvents: ['js_error', 'api_error', 'fatal_error'],
};
5.5 上报方式对比
| 上报方式 | 跨域支持 | 页面卸载可靠性 | 数据量限制 | 适用场景 |
|---|---|---|---|---|
sendBeacon | ✅ | ✅ 高 | 64KB(通常) | 页面卸载、批量上报 |
img 标签 | ✅ | ❌ 低 | URL 长度限制 | 简单指标、兼容性要求高 |
fetch (keepalive) | 需 CORS | ✅ 中 | 64KB | 大数据量、需要响应 |
XMLHttpRequest | 需 CORS | ❌ 低 | 无限制 | 需要同步上报(不推荐) |
六、Source Map 错误定位
生产环境的 JS 代码经过压缩混淆后,错误堆栈中的行列号无法直接对应源代码。Source Map 是连接压缩代码与源代码的桥梁。
6.1 Source Map 工作原理
6.2 构建时生成 Source Map
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// 生产环境生成 Source Map,但不关联到代码中
sourcemap: 'hidden', // 生成 .map 文件但不在 JS 中添加 //# sourceMappingURL
},
});
- 使用
sourcemap: 'hidden'而非sourcemap: true,避免.map文件暴露给用户 - Source Map 文件不要部署到生产 CDN,应上传到内部的错误分析平台(如 Sentry)
- Source Map 文件通常比源文件大 3-5 倍,务必注意存储成本
6.3 使用 source-map 库解析错误
在服务端可以使用 source-map 库来还原错误堆栈:
import { SourceMapConsumer } from 'source-map';
import * as fs from 'fs';
interface ParsedPosition {
source: string | null;
line: number | null;
column: number | null;
name: string | null;
}
async function parseErrorStack(
sourceMapPath: string,
line: number,
column: number
): Promise<ParsedPosition> {
const rawSourceMap = JSON.parse(
fs.readFileSync(sourceMapPath, 'utf-8')
);
const consumer = await new SourceMapConsumer(rawSourceMap);
// 将压缩后的行列号映射回源代码位置
const position = consumer.originalPositionFor({
line,
column,
});
consumer.destroy();
return position;
// 返回结果示例:
// { source: 'src/utils/format.ts', line: 42, column: 8, name: 'formatDate' }
}
七、Sentry 接入与使用
Sentry 是目前最流行的前端错误监控平台,提供了完善的错误采集、Source Map 解析、告警通知、性能追踪等功能。
7.1 基本接入
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
Sentry.init({
dsn: 'https://xxx@sentry.io/project-id',
// 版本号,与 Source Map 上传时的版本对应
release: 'my-app@1.2.3',
// 环境标识
environment: process.env.NODE_ENV,
// 性能追踪采样率
tracesSampleRate: 0.2,
// 错误采样率
sampleRate: 1.0,
integrations: [
new BrowserTracing({
// 自动追踪路由变化
routingInstrumentation: Sentry.reactRouterV6Instrumentation,
}),
],
// 错误过滤:忽略不需要关注的错误
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'Network Error',
/Loading chunk \d+ failed/,
],
// 发送前的钩子,可用于数据脱敏
beforeSend(event) {
// 移除用户敏感信息
if (event.request?.cookies) {
delete event.request.cookies;
}
return event;
},
});
7.2 上传 Source Map 到 Sentry
通过 Sentry CLI 或 Webpack/Vite 插件在构建时自动上传 Source Map:
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
sourcemap: 'hidden',
},
plugins: [
sentryVitePlugin({
org: 'my-org',
project: 'my-project',
authToken: process.env.SENTRY_AUTH_TOKEN,
release: {
name: 'my-app@1.2.3',
},
sourcemaps: {
// 指定需要上传的文件目录
assets: './dist/**',
},
}),
],
});
7.3 丰富错误上下文
// 设置用户信息
Sentry.setUser({
id: 'user_123',
username: 'zhangsan',
email: 'zhangsan@example.com',
});
// 设置自定义标签(用于过滤和搜索)
Sentry.setTag('page', 'checkout');
Sentry.setTag('feature', 'payment');
// 添加面包屑(Breadcrumbs)— 记录错误发生前的用户操作轨迹
Sentry.addBreadcrumb({
category: 'user-action',
message: '用户点击了「立即支付」按钮',
level: 'info',
data: {
orderId: 'order_456',
amount: 99.9,
},
});
// 手动捕获并上报错误
try {
riskyOperation();
} catch (error) {
Sentry.captureException(error, {
extra: {
operationType: 'payment',
retryCount: 3,
},
});
}
八、监控 SDK 设计思路
设计一个前端监控 SDK 是高级前端工程师面试中的常见系统设计题。以下是核心架构思路:
8.1 整体架构
8.2 SDK 核心代码
// 插件接口定义
interface MonitorPlugin {
name: string;
// 安装插件,接收 SDK 实例
setup(monitor: MonitorSDK): void;
// 卸载插件
destroy?(): void;
}
// SDK 配置
interface MonitorConfig {
appId: string;
reportUrl: string;
sampleRate?: number;
maxBufferSize?: number;
flushInterval?: number;
plugins?: MonitorPlugin[];
}
// 上报数据的通用结构
interface ReportData {
type: string;
subType: string;
data: Record<string, unknown>;
timestamp: number;
// 以下由 SDK 自动填充
appId?: string;
userId?: string;
sessionId?: string;
pageUrl?: string;
userAgent?: string;
}
class MonitorSDK {
private config: Required<MonitorConfig>;
private buffer: ReportData[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
private plugins: MonitorPlugin[] = [];
private userId = '';
private sessionId: string;
constructor(config: MonitorConfig) {
this.config = {
sampleRate: 1,
maxBufferSize: 10,
flushInterval: 5000,
plugins: [],
...config,
};
this.sessionId = this.generateSessionId();
this.init();
}
private init(): void {
// 安装内置插件和用户自定义插件
this.config.plugins.forEach((plugin) => this.use(plugin));
// 启动定时上报
this.timer = setInterval(() => this.flush(), this.config.flushInterval);
// 页面卸载时强制上报
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
// 注册插件
use(plugin: MonitorPlugin): void {
this.plugins.push(plugin);
plugin.setup(this);
}
// 对外暴露的上报方法,供插件调用
report(data: ReportData): void {
// 采样判断
if (Math.random() > this.config.sampleRate) return;
// 补充公共字段
const enrichedData: ReportData = {
...data,
appId: this.config.appId,
userId: this.userId,
sessionId: this.sessionId,
pageUrl: window.location.href,
userAgent: navigator.userAgent,
};
this.buffer.push(enrichedData);
if (this.buffer.length >= this.config.maxBufferSize) {
this.flush();
}
}
private flush(): void {
if (this.buffer.length === 0) return;
const data = [...this.buffer];
this.buffer = [];
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json',
});
if (navigator.sendBeacon) {
navigator.sendBeacon(this.config.reportUrl, blob);
} else {
fetch(this.config.reportUrl, {
method: 'POST',
body: blob,
keepalive: true,
}).catch(() => {
// 失败时放回缓冲区
this.buffer.unshift(...data);
});
}
}
setUser(userId: string): void {
this.userId = userId;
}
private generateSessionId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
destroy(): void {
if (this.timer) clearInterval(this.timer);
this.plugins.forEach((p) => p.destroy?.());
this.flush();
}
}
8.3 错误监控插件示例
const ErrorPlugin: MonitorPlugin = {
name: 'error',
setup(monitor: MonitorSDK): void {
// 1. JS 运行时错误
window.onerror = (message, source, lineno, colno, error) => {
monitor.report({
type: 'error',
subType: 'js_error',
data: {
message: String(message),
source: source ?? '',
lineno: lineno ?? 0,
colno: colno ?? 0,
stack: error?.stack ?? '',
},
timestamp: Date.now(),
});
return true;
};
// 2. Promise 异常
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
monitor.report({
type: 'error',
subType: 'promise_error',
data: {
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack ?? '' : '',
},
timestamp: Date.now(),
});
});
// 3. 资源加载错误
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement;
if (target && target !== window as unknown as HTMLElement
&& (target instanceof HTMLScriptElement
|| target instanceof HTMLLinkElement
|| target instanceof HTMLImageElement)) {
monitor.report({
type: 'error',
subType: 'resource_error',
data: {
tagName: target.tagName.toLowerCase(),
src: (target as HTMLImageElement).src
|| (target as HTMLLinkElement).href
|| '',
},
timestamp: Date.now(),
});
}
}, true);
},
destroy(): void {
// 清理事件监听(实际项目中应保存引用以便移除)
},
};
8.4 使用 SDK
// 初始化 SDK
const monitor = new MonitorSDK({
appId: 'my-app',
reportUrl: 'https://monitor-api.example.com/report',
sampleRate: 1.0,
maxBufferSize: 20,
flushInterval: 3000,
plugins: [
ErrorPlugin,
// PerfPlugin,
// BehaviorPlugin,
],
});
// 设置用户信息
monitor.setUser('user_123');
- 插件化架构:各功能模块解耦,按需引入
- 缓冲队列 + 批量上报:减少请求次数
- 采样率控制:降低服务端压力
- 多种上报方式降级:sendBeacon -> fetch(keepalive) -> XHR
- 页面卸载兜底:确保数据不丢失
- 错误重试与本地持久化:网络异常时的容错机制
常见面试问题
Q1: 前端如何做全链路错误监控?需要覆盖哪些类型的错误?
答案:
全链路错误监控需要覆盖以下 5 种主要错误类型,每种错误使用不同的捕获方式:
| 错误类型 | 捕获方式 | 示例 |
|---|---|---|
| JS 运行时错误 | window.onerror | 未定义变量、类型错误 |
| 未捕获的 Promise | unhandledrejection | 未 catch 的 async 函数 |
| 资源加载错误 | addEventListener('error', ..., true) | 图片 404、CDN 挂了 |
| 框架组件错误 | Error Boundary / errorHandler | 渲染阶段异常 |
| 接口异常 | 拦截 fetch/XHR | 500 错误、超时 |
完整的错误监控实现:
function initErrorMonitor(): void {
// 1. JS 运行时错误
window.onerror = (message, source, lineno, colno, error) => {
report({ type: 'js_error', message: String(message), stack: error?.stack ?? '' });
return true;
};
// 2. Promise 未处理异常
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
report({
type: 'promise_error',
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack ?? '' : '',
});
});
// 3. 资源加载错误(捕获阶段)
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement;
if (target instanceof HTMLScriptElement
|| target instanceof HTMLLinkElement
|| target instanceof HTMLImageElement) {
report({
type: 'resource_error',
tagName: target.tagName,
src: (target as HTMLImageElement).src || (target as HTMLLinkElement).href,
});
}
}, true);
// 4. 拦截 fetch 监控接口异常
const originalFetch = window.fetch;
window.fetch = async (...args) => {
try {
const response = await originalFetch(...args);
if (!response.ok) {
report({ type: 'api_error', status: response.status, url: String(args[0]) });
}
return response;
} catch (error) {
report({ type: 'network_error', message: (error as Error).message, url: String(args[0]) });
throw error;
}
};
}
此外还需注意:
- 跨域脚本需要添加
crossorigin="anonymous"才能获取详细错误信息 - 上报时需要携带用户标识、页面 URL、设备信息、错误堆栈等上下文
- 建议上传 Source Map 到 Sentry 等平台以便还原压缩代码的错误位置
- 错误需要做聚合去重,避免同一个错误重复告警
Q2: sendBeacon 和普通的 AJAX 请求有什么区别?为什么监控上报推荐使用 sendBeacon?
答案:
navigator.sendBeacon() 是专为数据上报场景设计的 API,与普通 AJAX(XHR/fetch)的核心区别在于:
| 特性 | sendBeacon | XHR/fetch |
|---|---|---|
| 页面卸载后是否继续 | ✅ 浏览器保证发送 | ❌ 可能被取消 |
| 是否阻塞页面关闭 | ❌ 异步、不阻塞 | 同步 XHR 会阻塞 |
| 请求类型 | POST | 任意方法 |
| 能否获取响应 | ❌ 没有回调 | ✅ 可获取响应 |
| 优先级 | 低优先级队列 | 正常优先级 |
| 数据量限制 | 通常 64KB | 无硬性限制 |
| 返回值 | boolean(是否成功入队) | Promise/回调 |
推荐使用 sendBeacon 的原因:
-
页面卸载时的可靠性:用户关闭页面、跳转时,普通 AJAX 请求可能被浏览器取消。sendBeacon 将请求放入浏览器内部的发送队列,即使页面已经卸载也会完成发送。
-
不阻塞页面关闭:早期为了保证数据上报,开发者使用同步 XHR 来阻塞页面关闭,这会严重影响用户体验。sendBeacon 天然异步且不阻塞。
-
对用户体验无影响:sendBeacon 的请求优先级低,不会与关键业务请求争抢带宽。
// 最佳实践:组合使用 visibilitychange + sendBeacon
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 页面进入后台或即将关闭时,批量上报缓冲区数据
const data = getBufferedData();
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const success = navigator.sendBeacon('/api/report', blob);
if (!success) {
// sendBeacon 失败(数据量过大等原因)时降级
fetch('/api/report', {
method: 'POST',
body: blob,
keepalive: true, // fetch 的 keepalive 选项类似 sendBeacon
});
}
}
});
fetch 的 keepalive: true 选项功能类似 sendBeacon,也能在页面卸载后继续发送请求,但数据量限制为所有 keepalive 请求总和 64KB。
Q3: 如何设计一个前端监控 SDK?需要考虑哪些方面?
答案:
设计前端监控 SDK 需要从以下几个维度考虑:
1. 架构设计 — 插件化
// 核心只提供生命周期管理、数据缓冲、上报通道
// 各功能模块作为插件按需引入
const sdk = new MonitorSDK({
appId: 'my-app',
reportUrl: '/api/report',
plugins: [
new ErrorPlugin(), // 错误监控
new PerfPlugin(), // 性能监控
new BehaviorPlugin(), // 行为监控
],
});
2. 数据采集 — 全面且不侵入
- 错误采集:
onerror+unhandledrejection+addEventListener('error', ..., true) - 性能采集:
PerformanceObserver+ web-vitals - 行为采集:全局事件委托 + 路由监听
3. 数据上报 — 高效且可靠
| 策略 | 说明 |
|---|---|
| 批量上报 | 缓冲队列 + 定时/定量触发 |
| 采样控制 | 全局采样 + 事件级采样 + 关键事件强制上报 |
| 上报方式降级 | sendBeacon -> fetch(keepalive) -> XHR -> img |
| 失败重试 | 存入 localStorage,下次访问时重试 |
| 页面卸载兜底 | visibilitychange 事件触发上报 |
4. 性能影响最小化
- SDK 体积控制在 10KB 以内(gzip 后)
- 采集逻辑放在
requestIdleCallback中执行 - 避免同步操作阻塞主线程
- 使用 Web Worker 处理数据序列化
5. 数据安全与隐私
beforeSend钩子支持数据脱敏(去除密码、Token 等)- 遵循 GDPR 等隐私法规,提供关闭监控的开关
- 用户标识匿名化处理
6. 错误聚合与告警
- 相同错误通过
message + stack计算指纹进行聚合 - 设置告警阈值(如错误率超过 1% 时触发)
- 支持多渠道通知(钉钉、飞书、邮件、短信)
核心原则是:对业务代码零侵入、对页面性能零感知、对错误数据零遗漏。