前端错误收集与监控
从错误捕获到性能监控的完整前端可观测性方案
目录
错误捕获
全局错误捕获
javascript
// 1. 同步错误 + 资源加载错误
window.addEventListener('error', (event) => {
if (event.target && (event.target.src || event.target.href)) {
// 资源加载错误
reportError({
type: 'resource',
url: event.target.src || event.target.href,
tagName: event.target.tagName
});
} else {
// JS 运行时错误
reportError({
type: 'javascript',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack
});
}
}, true); // 使用捕获阶段
// 2. Promise 未捕获错误
window.addEventListener('unhandledrejection', (event) => {
reportError({
type: 'promise',
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack
});
});React ErrorBoundary
tsx
import { Component, ErrorInfo, 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 };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 上报错误
reportError({
type: 'react',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
});
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// 使用
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>请求错误捕获
javascript
// Fetch 拦截
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const startTime = Date.now();
try {
const response = await originalFetch(...args);
if (!response.ok) {
reportError({
type: 'api',
url: args[0],
status: response.status,
duration: Date.now() - startTime
});
}
return response;
} catch (error) {
reportError({
type: 'network',
url: args[0],
message: error.message
});
throw error;
}
};性能监控
Web Vitals
javascript
import { onLCP, onFID, onCLS, onTTFB, onINP } from 'web-vitals';
function reportMetric(metric) {
console.log({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
entries: metric.entries
});
}
onLCP(reportMetric); // Largest Contentful Paint
onFID(reportMetric); // First Input Delay
onCLS(reportMetric); // Cumulative Layout Shift
onTTFB(reportMetric); // Time to First Byte
onINP(reportMetric); // Interaction to Next Paint指标阈值
| 指标 | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | ≤2.5s | ≤4s | >4s |
| FID | ≤100ms | ≤300ms | >300ms |
| CLS | ≤0.1 | ≤0.25 | >0.25 |
| INP | ≤200ms | ≤500ms | >500ms |
| TTFB | ≤800ms | ≤1800ms | >1800ms |
自定义性能埋点
javascript
// 使用 Performance API
performance.mark('feature-start');
// ... 执行操作 ...
performance.mark('feature-end');
performance.measure('feature-duration', 'feature-start', 'feature-end');
const measures = performance.getEntriesByName('feature-duration');
console.log(`耗时: ${measures[0].duration}ms`);
// 资源加载分析
const resources = performance.getEntriesByType('resource');
resources.forEach(resource => {
console.log({
name: resource.name,
type: resource.initiatorType,
duration: resource.duration,
transferSize: resource.transferSize
});
});长任务监控
javascript
// 监控超过 50ms 的长任务
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
reportMetric({
type: 'long-task',
duration: entry.duration,
startTime: entry.startTime
});
}
});
});
observer.observe({ entryTypes: ['longtask'] });用户行为追踪
面包屑 (Breadcrumbs)
javascript
const breadcrumbs = [];
const MAX_BREADCRUMBS = 50;
function addBreadcrumb(breadcrumb) {
breadcrumbs.push({
...breadcrumb,
timestamp: Date.now()
});
if (breadcrumbs.length > MAX_BREADCRUMBS) {
breadcrumbs.shift();
}
}
// 点击事件
document.addEventListener('click', (e) => {
const target = e.target;
addBreadcrumb({
type: 'click',
category: 'ui',
message: `Clicked ${target.tagName}`,
data: {
selector: getSelector(target),
text: target.textContent?.slice(0, 100)
}
});
}, true);
// 路由变化
window.addEventListener('popstate', () => {
addBreadcrumb({
type: 'navigation',
category: 'navigation',
message: `Navigated to ${location.pathname}`
});
});
// Console 拦截
['log', 'warn', 'error'].forEach((level) => {
const original = console[level];
console[level] = (...args) => {
addBreadcrumb({
type: 'console',
category: 'console',
level,
message: args.map(String).join(' ')
});
original.apply(console, args);
};
});Session Replay
javascript
// 简化的 DOM 变化记录
const mutations = [];
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach((mutation) => {
mutations.push({
type: mutation.type,
target: getSelector(mutation.target),
timestamp: Date.now()
});
});
});
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
characterData: true
});错误上报与聚合
上报策略
javascript
const errorQueue = [];
let timer = null;
function reportError(error) {
errorQueue.push({
...error,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent,
breadcrumbs: [...breadcrumbs]
});
// 批量上报
if (!timer) {
timer = setTimeout(() => {
flushErrors();
timer = null;
}, 5000);
}
}
function flushErrors() {
if (errorQueue.length === 0) return;
const errors = errorQueue.splice(0);
// 使用 Beacon API 保证页面关闭时也能发送
const blob = new Blob([JSON.stringify(errors)], { type: 'application/json' });
navigator.sendBeacon('/api/errors', blob);
}
// 页面卸载时发送
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushErrors();
}
});采样策略
javascript
const SAMPLE_RATE = 0.1; // 10% 采样
function shouldSample() {
return Math.random() < SAMPLE_RATE;
}
function reportError(error) {
// 严重错误全量上报,普通错误采样
if (error.level === 'fatal' || shouldSample()) {
doReport(error);
}
}错误聚合
javascript
// 基于错误堆栈生成指纹
function generateFingerprint(error) {
const stack = error.stack || '';
// 移除动态部分(行号、列号等)
const normalized = stack
.split('\n')
.slice(0, 5)
.map(line => line.replace(/:\d+:\d+/g, ''))
.join('');
return hashCode(normalized);
}
function hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash.toString(16);
}Source Map 反解析
上传 Source Map
javascript
// 构建后上传到监控服务
// upload-sourcemap.js
const { upload } = require('@sentry/cli');
upload({
release: process.env.RELEASE_VERSION,
include: ['./dist'],
urlPrefix: '~/',
ignore: ['node_modules']
});服务端解析
javascript
// 使用 source-map 库解析
const { SourceMapConsumer } = require('source-map');
async function parseStackTrace(stack, sourceMapUrl) {
const sourceMap = await fetch(sourceMapUrl).then(r => r.json());
const consumer = await new SourceMapConsumer(sourceMap);
const parsed = stack.split('\n').map(line => {
const match = line.match(/:(\d+):(\d+)/);
if (!match) return line;
const [, lineNo, colNo] = match;
const original = consumer.originalPositionFor({
line: parseInt(lineNo),
column: parseInt(colNo)
});
return ` at ${original.name} (${original.source}:${original.line}:${original.column})`;
});
consumer.destroy();
return parsed.join('\n');
}Sentry 实践
基础配置
typescript
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: 'https://xxx@sentry.io/xxx',
release: process.env.REACT_APP_VERSION,
environment: process.env.NODE_ENV,
// 采样率
sampleRate: 1.0, // 错误采样
tracesSampleRate: 0.1, // 性能追踪采样
replaysSessionSampleRate: 0.1, // Session Replay 采样
// 过滤
ignoreErrors: [
'ResizeObserver loop limit exceeded',
/Loading chunk .* failed/
],
beforeSend(event, hint) {
// 过滤或修改事件
if (event.exception?.values?.[0]?.type === 'ChunkLoadError') {
return null; // 丢弃
}
return event;
}
});手动上报
typescript
// 捕获异常
try {
dangerousOperation();
} catch (error) {
Sentry.captureException(error, {
tags: { feature: 'payment' },
extra: { orderId: '12345' }
});
}
// 捕获消息
Sentry.captureMessage('Something went wrong', 'warning');
// 设置用户上下文
Sentry.setUser({ id: 'user-123', email: 'user@example.com' });
// 添加面包屑
Sentry.addBreadcrumb({
category: 'auth',
message: 'User logged in',
level: 'info'
});高频面试题
Q1: 如何捕获所有类型的前端错误?
| 错误类型 | 捕获方式 |
|---|---|
| JS 运行时错误 | window.onerror / window.addEventListener('error') |
| Promise 未捕获 | unhandledrejection |
| 资源加载错误 | error 事件(捕获阶段) |
| React 渲染错误 | ErrorBoundary |
| 网络请求错误 | Fetch/XHR 拦截 |
Q2: 如何避免错误上报影响用户体验?
- 异步非阻塞:使用
requestIdleCallback或宏任务 - 批量上报:聚合错误,定时发送
- 使用 Beacon API:页面卸载时也能发送
- 采样:非关键错误采样上报
- 失败重试:本地缓存 + 重试机制
Q3: Source Map 安全性如何处理?
- 不部署 Source Map:只在监控平台解析
- 独立域名:Source Map 放在内部域名
- 鉴权访问:需要 Token 才能下载
- 构建时上传:CI 中上传到 Sentry 后删除本地文件
Q4: Web Vitals 优化方向?
| 指标 | 优化方向 |
|---|---|
| LCP | 优化关键资源加载、预加载、减少阻塞 |
| FID/INP | 减少长任务、代码拆分、Web Worker |
| CLS | 预留图片/广告空间、避免动态插入内容 |
| TTFB | CDN、缓存、服务端优化 |
监控系统架构
┌─────────────────────────────────────────────────────────────┐
│ 前端应用 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │错误捕获 │ │性能采集 │ │行为追踪 │ │日志收集 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
└───────┼────────────┼────────────┼────────────┼──────────────┘
│ │ │ │
└────────────┴────────────┴────────────┘
│
┌──────▼──────┐
│ SDK Core │ 批量、采样、本地缓存
└──────┬──────┘
│ Beacon / Fetch
┌──────▼──────┐
│ 接收服务 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌──────▼──────┐ ┌───▼───┐ ┌──────▼──────┐
│ Kafka 队列 │ │存储 │ │ Source Map │
└──────┬──────┘ └───────┘ │ 解析服务 │
│ └──────────────┘
┌──────▼──────┐
│ 分析聚合 │
└──────┬──────┘
│
┌──────▼──────┐
│ Dashboard │ 告警、报表、查询
└─────────────┘