跳到主要内容

前端监控与埋点

概述

前端监控是保障线上产品质量和用户体验的关键基础设施。一套完善的前端监控体系通常包含三大支柱:错误监控(发现问题)、性能监控(量化体验)和用户行为监控(理解用户)。面试中,考察前端监控不仅测试你对浏览器 API 的掌握程度,更考察你的工程化思维系统设计能力

核心要点

前端监控的本质是:采集 -> 上报 -> 存储 -> 分析 -> 告警。面试中回答监控相关问题时,从这条链路展开即可。


一、前端监控体系

1.1 三大监控维度

维度目的核心指标典型工具
错误监控发现线上 Bug、降低故障影响JS 错误数、错误率、影响用户数Sentry、Fundebug
性能监控量化用户体验、指导优化方向FCP、LCP、CLS、TTFB、FID/INPweb-vitals、Lighthouse
行为监控理解用户行为、辅助业务决策PV/UV、点击热力图、漏斗转化率GrowingIO、神策、Google Analytics

1.2 监控数据流转架构


二、错误捕获

错误捕获是前端监控的基石。不同类型的错误需要不同的捕获方式,这是面试中的高频考点。

2.1 window.onerror — 捕获 JS 运行时错误

window.onerror 是最经典的全局错误捕获方式,可以捕获同步错误异步回调中的错误(如 setTimeout),但无法捕获 Promise 异常和语法错误

error-capture/onerror.ts
// 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." 信息。解决方案:

  1. <script> 标签添加 crossorigin="anonymous" 属性
  2. 服务端响应头添加 Access-Control-Allow-Origin

2.2 window.addEventListener('error') — 捕获资源加载错误

window.onerror 无法捕获图片、CSS、JS 等资源加载失败的错误。需要在捕获阶段监听 error 事件:

error-capture/resource-error.ts
// 第三个参数 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 事件:

error-capture/promise-error.ts
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 机制,可以捕获子组件树中渲染阶段生命周期方法构造函数中的错误:

error-capture/ErrorBoundary.tsx
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
  • 异步代码(setTimeoutPromise
  • 服务端渲染(SSR)中的错误
  • Error Boundary 组件自身的错误

2.5 Vue errorHandler — 捕获 Vue 组件错误

Vue 提供了全局的 app.config.errorHandler,类似于 React 的 Error Boundary:

error-capture/vue-error-handler.ts
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 接口异常监控

通过拦截 fetchXMLHttpRequest 来监控接口调用异常:

error-capture/api-error.ts
// 拦截 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 是浏览器提供的性能数据接口,可以获取页面加载的详细时间线:

performance/timing.ts
// 使用 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() 更灵活:

performance/observer.ts
// 观察 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 的采集逻辑:

performance/web-vitals.ts
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 指标阈值参考

指标含义GoodNeeds ImprovementPoor
LCP最大内容绘制≤ 2.5s2.5s ~ 4s> 4s
INP交互到下一帧绘制≤ 200ms200ms ~ 500ms> 500ms
CLS累积布局偏移≤ 0.10.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 的上报方法。优点是灵活精准,缺点是与业务代码耦合、工作量大。

tracking/manual.ts
// 埋点 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 无痕埋点(全埋点)

无痕埋点通过全局监听用户的所有交互行为(点击、滚动、输入等),自动采集并上报。优点是无需手动埋点、覆盖全面,缺点是数据量大、无法携带自定义业务参数。

tracking/auto-track.ts
// 自动采集点击事件
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 运行时根据配置来决定哪些元素的行为需要上报。

tracking/visual-track.ts
// 可视化埋点配置
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。它将请求放入浏览器的发送队列,不会阻塞页面关闭,也不会因为页面卸载而被取消:

report/sendBeacon.ts
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 限制的特性,适合上报简单的少量数据:

report/img-report.ts
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 批量上报与缓冲队列

为了减少网络请求次数,通常将数据先放入缓冲队列,满足条件后批量上报:

report/batch-report.ts
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% 上报会给服务端带来巨大压力。通过采样率可以在保证数据统计准确性的前提下降低成本:

report/sampling.ts
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

vite.config.ts
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 库来还原错误堆栈:

server/source-map-parse.ts
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 基本接入

sentry/init.ts
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:

vite.config.ts
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/context.ts
// 设置用户信息
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 核心代码

sdk/monitor.ts
// 插件接口定义
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 错误监控插件示例

sdk/plugins/error-plugin.ts
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

app/main.ts
// 初始化 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');
SDK 设计要点
  1. 插件化架构:各功能模块解耦,按需引入
  2. 缓冲队列 + 批量上报:减少请求次数
  3. 采样率控制:降低服务端压力
  4. 多种上报方式降级:sendBeacon -> fetch(keepalive) -> XHR
  5. 页面卸载兜底:确保数据不丢失
  6. 错误重试与本地持久化:网络异常时的容错机制

常见面试问题

Q1: 前端如何做全链路错误监控?需要覆盖哪些类型的错误?

答案

全链路错误监控需要覆盖以下 5 种主要错误类型,每种错误使用不同的捕获方式:

错误类型捕获方式示例
JS 运行时错误window.onerror未定义变量、类型错误
未捕获的 Promiseunhandledrejection未 catch 的 async 函数
资源加载错误addEventListener('error', ..., true)图片 404、CDN 挂了
框架组件错误Error Boundary / errorHandler渲染阶段异常
接口异常拦截 fetch/XHR500 错误、超时

完整的错误监控实现:

full-error-monitor.ts
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)的核心区别在于:

特性sendBeaconXHR/fetch
页面卸载后是否继续✅ 浏览器保证发送❌ 可能被取消
是否阻塞页面关闭❌ 异步、不阻塞同步 XHR 会阻塞
请求类型POST任意方法
能否获取响应❌ 没有回调✅ 可获取响应
优先级低优先级队列正常优先级
数据量限制通常 64KB无硬性限制
返回值boolean(是否成功入队)Promise/回调

推荐使用 sendBeacon 的原因:

  1. 页面卸载时的可靠性:用户关闭页面、跳转时,普通 AJAX 请求可能被浏览器取消。sendBeacon 将请求放入浏览器内部的发送队列,即使页面已经卸载也会完成发送。

  2. 不阻塞页面关闭:早期为了保证数据上报,开发者使用同步 XHR 来阻塞页面关闭,这会严重影响用户体验。sendBeacon 天然异步且不阻塞。

  3. 对用户体验无影响: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
});
}
}
});
补充

fetchkeepalive: 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% 时触发)
  • 支持多渠道通知(钉钉、飞书、邮件、短信)

核心原则是:对业务代码零侵入、对页面性能零感知、对错误数据零遗漏


相关链接