Skip to content

前端错误收集与监控

从错误捕获到性能监控的完整前端可观测性方案

目录


错误捕获

全局错误捕获

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

指标阈值

指标GoodNeeds ImprovementPoor
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: 如何避免错误上报影响用户体验?

  1. 异步非阻塞:使用 requestIdleCallback 或宏任务
  2. 批量上报:聚合错误,定时发送
  3. 使用 Beacon API:页面卸载时也能发送
  4. 采样:非关键错误采样上报
  5. 失败重试:本地缓存 + 重试机制

Q3: Source Map 安全性如何处理?

  1. 不部署 Source Map:只在监控平台解析
  2. 独立域名:Source Map 放在内部域名
  3. 鉴权访问:需要 Token 才能下载
  4. 构建时上传:CI 中上传到 Sentry 后删除本地文件

Q4: Web Vitals 优化方向?

指标优化方向
LCP优化关键资源加载、预加载、减少阻塞
FID/INP减少长任务、代码拆分、Web Worker
CLS预留图片/广告空间、避免动态插入内容
TTFBCDN、缓存、服务端优化

监控系统架构

┌─────────────────────────────────────────────────────────────┐
│                        前端应用                              │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │
│  │错误捕获  │  │性能采集  │  │行为追踪  │  │日志收集  │        │
│  └────┬────┘  └────┬────┘  └────┬────┘  └────┬────┘        │
└───────┼────────────┼────────────┼────────────┼──────────────┘
        │            │            │            │
        └────────────┴────────────┴────────────┘

                    ┌──────▼──────┐
                    │   SDK Core   │  批量、采样、本地缓存
                    └──────┬──────┘
                           │ Beacon / Fetch
                    ┌──────▼──────┐
                    │  接收服务    │
                    └──────┬──────┘

              ┌────────────┼────────────┐
              │            │            │
       ┌──────▼──────┐ ┌───▼───┐ ┌──────▼──────┐
       │  Kafka 队列  │ │存储   │ │ Source Map  │
       └──────┬──────┘ └───────┘ │   解析服务   │
              │                  └──────────────┘
       ┌──────▼──────┐
       │   分析聚合   │
       └──────┬──────┘

       ┌──────▼──────┐
       │   Dashboard  │  告警、报表、查询
       └─────────────┘

前端面试知识库