CC 咖啡猫的工作空间 Coding Space

前端可观测性实践

前端可观测性是保障用户侧体验的基石。没有可观测性,线上问题就像"黑盒"——用户反馈卡了、白屏了、操作没反应,你却无从查起。前端可观测性的三大支柱:日志(Logging)指标(Metrics)链路追踪(Tracing)


一、为什么前端需要可观测性

1.1 传统排查痛点

传统场景:
  用户反馈"页面打不开" → 你问"什么浏览器?什么网络?"
  用户说"不知道" → 你登录服务器看 Nginx 日志 → 200 OK
  → 用户说"就是白屏" → 你让他清缓存 → 用户不会操作
  → 放弃

可观测性场景:
  Sentry 弹出一条错误 → Error: Failed to load chunk xxx
  → 定位到是新版本 JS 缓存未更新 → 通知运维 CDN 刷新
  → 10 分钟解决

1.2 前端三大支柱各解决什么问题

支柱 解决的问题 问题举例
日志(Logging) 用户端发生了什么错误? "报错了,但不知道什么错、哪个用户、哪个版本"
指标(Metrics) 页面性能正常吗? "最近 3 天 LCP 从 1.5s 涨到了 4s,是不是发版引入了问题?"
链路追踪(Tracing) 慢请求卡在哪个环节? "API 请求慢,是后端慢还是前端渲染慢?"

1.3 一次用户请求的可观测性全景

用户操作 → 页面加载 → API 请求 → 数据处理 → 页面渲染
    │          │           │           │           │
    ▼          ▼           ▼           ▼           ▼
  Logging   Performance  Network    Console    Render
  (点击事件)  (LCP/FID)   (API耗时)  (错误栈)    (卡顿)

Tracing: TraceID=xyz → 贯穿所有环节
Metrics: 聚合为 P50/P95/P99 指标上报

二、前端可观测性三支柱

2.1 日志(Logging)

前端日志 vs 后端日志的区别

维度 后端日志 前端日志
产生环境 可控的服务器 不可控的用户浏览器(各种浏览器版本、操作系统、网络)
日志量级 可预估,受 QPS 约束 不可控,受 UV 和用户行为影响
采集方式 Filebeat 采集文件 浏览器上报(Beacon / Fetch)
存储时长 7-30 天 通常 3-7 天(量太大)
PII 风险 较低(内网) 高危(可能包含身份证号等敏感信息)
关键字段 traceId, appName, host traceId, userId, pageUrl, browser, os, version

前端日志分级

// logs/logger.ts
export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3,
  FATAL = 4,
}

export interface LogEntry {
  level: LogLevel;
  message: string;
  timestamp: number;
  traceId?: string;
  userId?: string;
  pageUrl: string;
  browser: { name: string; version: string };
  os: { name: string; version: string };
  extra?: Record<string, unknown>;
}

日志上报策略

策略 说明 适用场景
即时上报 产生日志立即发送 ERROR / FATAL 级别
批量上报 缓存 N 条后一起发送 INFO / DEBUG / WARN 级别
闲时上报 requestIdleCallback 触发时发送 低优先级的日志
合并上报 同类型错误合并为一条+计数 高频重复错误

2.2 指标(Metrics)

指标分类

类别 指标 采集方式
性能指标 LCP, INP, CLS, FCP, TTFB PerformanceObserver / web-vitals
资源指标 JS/CSS/图片加载耗时、缓存命中率 Resource Timing API
网络指标 API 成功率、耗时、状态码分布 fetch/axios 拦截器
业务指标 PV、UV、点击率、转化率 埋点系统
设备指标 内存使用、CPU 使用(降级适用) performance.memory(仅 Chrome)

指标聚合方式

原始数据(单次请求耗时 320ms)
    │
    ├→ 上报到监控平台(如 Datadog / 自研)
    │      │
    │      ├→ 聚合为 P50/P95/P99
    │      ├→ 按页面分组求平均
    │      └→ 按版本对比
    │
    └→ 本地预聚合(减少上报量)
           │
           ├→ 每分钟计算一次平均值
           └→ 每分钟上报一次聚合值

2.3 链路追踪(Tracing)

前端 Trace 的生命周期

用户点击"提交订单"
    │
    ├→ TraceID 生成(UUID)────→ 注入到所有后续环节
    │
    ├→ Span 1: "click-submit-btn"        (10ms)
    │     ├── Span 1.1: "validate-form"   (5ms)
    │     └── Span 1.2: "show-loading"    (5ms)
    │
    ├→ Span 2: "api-create-order"         (800ms)
    │     ├── Span 2.1: "request-queue"   (2ms)
    │     ├── Span 2.2: "server-process"  (790ms)
    │     └── Span 2.3: "parse-response"  (8ms)
    │
    └→ Span 3: "render-success-page"      (50ms)
          ├── Span 3.1: "update-state"    (10ms)
          └── Span 3.2: "dom-update"      (40ms)

TraceID 传递机制

// tracing/trace-id.ts
const TRACE_ID_KEY = 'x-trace-id';

/**
 * 生成 TraceID
 * 前端生成后,通过 HTTP Header 传递给后端,后端再传递给下游
 */
export function generateTraceId(): string {
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

/**
 * 从 localStorage 获取或生成 TraceID
 * 同一个页面生命周期内的所有操作共用同一个 TraceID
 */
export function getOrCreateTraceId(): string {
  let traceId = sessionStorage.getItem(TRACE_ID_KEY);
  if (!traceId) {
    traceId = generateTraceId();
    sessionStorage.setItem(TRACE_ID_KEY, traceId);
  }
  return traceId;
}

三、前端错误监控

3.1 错误分类与捕获

前端错误分类

错误类型 捕获方式 典型场景
JS 运行时错误 window.onerror / error 事件 TypeError: Cannot read property of undefined
Promise 异常 unhandledrejection 事件 async/await 未 catch
资源加载错误 error 事件(Script/Img/Link/Font) CDN 挂掉、资源 404
Vue 组件错误 Vue.config.errorHandler 模板渲染报错
React 错误边界 componentDidCatch / getDerivedStateFromError Hooks 执行异常
HTTP 请求错误 Axios/Fetch 拦截器 接口 500、超时
自定义业务错误 代码中主动上报 支付失败、鉴权过期

全局错误捕获

// error/global-error.ts
/**
 * 全局错误捕获(防御式:永远不要信任用户的浏览器环境)
 */
export function setupGlobalErrorHandler(report: (error: ErrorInfo) => void): void {
  // 1. JS 运行时错误
  window.addEventListener('error', (event: ErrorEvent) => {
    // 排除资源加载错误(单独处理)
    if (event.target !== window && event.target instanceof HTMLElement) {
      report({
        type: 'RESOURCE_ERROR',
        message: `资源加载失败: ${(event.target as HTMLScriptElement).src || (event.target as HTMLLinkElement).href}`,
        stack: '',
        filename: '',
        lineno: 0,
        colno: 0,
        timestamp: Date.now(),
      });
      return;
    }

    report({
      type: 'JS_ERROR',
      message: event.message,
      stack: event.error?.stack || '',
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      timestamp: Date.now(),
    });
  });

  // 2. Promise 异常(必须捕获!)
  window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
    let message = '';
    let stack = '';

    if (event.reason instanceof Error) {
      message = event.reason.message;
      stack = event.reason.stack || '';
    } else if (typeof event.reason === 'string') {
      message = event.reason;
    } else {
      message = JSON.stringify(event.reason);
    }

    report({
      type: 'PROMISE_ERROR',
      message,
      stack,
      filename: '',
      lineno: 0,
      colno: 0,
      timestamp: Date.now(),
    });
  });
}

Vue 3 错误处理

// vue3/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { reportError } from './error/reporter';
import { getOrCreateTraceId } from './tracing/trace-id';

const app = createApp(App);

// Vue 全局错误处理器
app.config.errorHandler = (err: unknown, instance: ComponentPublicInstance | null, info: string) => {
  reportError({
    type: 'VUE_ERROR',
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : '',
    // 携带 Vue 组件信息,方便定位
    componentName: instance?.$options?.name || instance?.$options?._componentTag || 'Anonymous',
    lifecycleHook: info, // 如 'render function', 'setup function'
    traceId: getOrCreateTraceId(),
    timestamp: Date.now(),
  });
};

// 全局 warn 采集(Vue 3 生产环境不输出 warn,但开发环境有用)
app.config.warnHandler = (msg, instance, trace) => {
  console.warn('[Vue Warn]', msg, trace);
};

React 错误边界

// react/src/components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { reportError } from '../error/reporter';
import { getOrCreateTraceId } from '../tracing/trace-id';

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;      // 自定义降级 UI
  componentName?: string;    // 当前组件名称(用于定位)
  onError?: (error: Error, info: ErrorInfo) => void;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

/**
 * 错误边界组件
 *
 * ✅ 好:每个路由页面包裹 ErrorBoundary,故障隔离
 * ❌ 不好:只在根组件包裹一次,一个子组件崩溃导致整个页面白屏
 */
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    reportError({
      type: 'REACT_ERROR',
      message: error.message,
      stack: error.stack || '',
      componentName: this.props.componentName || 'Unknown',
      componentStack: errorInfo.componentStack,
      traceId: getOrCreateTraceId(),
      timestamp: Date.now(),
    });

    this.props.onError?.(error, errorInfo);
  }

  render(): ReactNode {
    if (this.state.hasError) {
      // 提供降级 UI,而不是直接白屏
      return this.props.fallback || (
        <div style={{ padding: 24, textAlign: 'center' }}>
          <h3>页面出错了</h3>
          <p>请刷新页面重试,如果问题持续,请联系技术支持</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            重试
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 使用方式:每个路由页面单独包裹
// <Route path="/orders" element={
//   <ErrorBoundary componentName="OrderPage">
//     <OrderPage />
//   </ErrorBoundary>
// } />

HTTP 请求错误拦截

// http/request-interceptor.ts
import axios, { AxiosError } from 'axios';
import { reportError } from '../error/reporter';
import { getOrCreateTraceId } from '../tracing/trace-id';

const http = axios.create({ timeout: 15000 });

// 请求拦截器:注入 TraceID
http.interceptors.request.use((config) => {
  config.headers['X-Trace-Id'] = getOrCreateTraceId();
  // 记录请求开始时间(用于计算耗时)
  (config as any)._startTime = performance.now();
  return config;
});

// 响应拦截器:捕获请求错误
http.interceptors.response.use(
  (response) => {
    const duration = performance.now() - (response.config as any)._startTime || 0;
    // 慢请求告警(超过 3s 的上报)
    if (duration > 3000) {
      reportError({
        type: 'SLOW_REQUEST',
        message: `请求超时预警: ${response.config.url}`,
        extra: { duration: Math.round(duration), url: response.config.url },
        timestamp: Date.now(),
      });
    }
    return response;
  },
  (error: AxiosError) => {
    const duration = performance.now() - (error.config as any)._startTime || 0;

    if (error.code === 'ECONNABORTED') {
      // 请求超时
      reportError({
        type: 'REQUEST_TIMEOUT',
        message: `请求超时: ${error.config?.url}`,
        extra: { timeout: error.config?.timeout, url: error.config?.url },
        timestamp: Date.now(),
      });
    } else if (error.response) {
      // 服务器返回错误状态码
      reportError({
        type: 'HTTP_ERROR',
        message: `HTTP ${error.response.status}: ${error.config?.url}`,
        extra: {
          status: error.response.status,
          url: error.config?.url,
          duration: Math.round(duration),
        },
        timestamp: Date.now(),
      });
    } else {
      // 网络错误(断网、DNS 解析失败等)
      reportError({
        type: 'NETWORK_ERROR',
        message: `网络异常: ${error.message}`,
        extra: { url: error.config?.url },
        timestamp: Date.now(),
      });
    }

    return Promise.reject(error);
  }
);

export default http;

3.2 Sentry 集成实践

方案对比:Sentry SDK 集成方式

集成方式 优点 缺点 推荐场景
@sentry/vue 开箱即用,自动捕获 Vue 错误 包体积 ~30KB gzip Vue 3 项目
@sentry/react 开箱即用,自动捕获 React 错误边界 包体积 ~30KB gzip React 项目
@sentry/tracing 自动追踪请求性能 增加 ~15KB 体积 需要 Tracing 时
@sentry/browser 纯手动 体积最小 ~10KB 需要自己实现所有采集 对体积敏感的项目

Vue 3 + Sentry 集成

// vue3/src/sentry.ts
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import { createApp } from 'vue';
import { Router } from 'vue-router';

export function setupSentry(app: ReturnType<typeof createApp>, router: Router): void {
  // ❌ 不好:明文写 DSN
  // Sentry.init({ dsn: 'https://xxx@xxx.ingest.sentry.io/12345' });

  // ✅ 好:DSN 从环境变量读取(构建时注入)
  Sentry.init({
    app,
    dsn: process.env.VITE_SENTRY_DSN || '',
    environment: process.env.VITE_APP_ENV || 'development',
    release: `my-app@${__APP_VERSION__}`, // 从构建工具注入版本号

    // 采样率:生产 0.2,开发 1.0
    sampleRate: process.env.VITE_APP_ENV === 'production' ? 0.2 : 1.0,
    tracesSampleRate: process.env.VITE_APP_ENV === 'production' ? 0.1 : 1.0,

    // 集成
    integrations: [
      new BrowserTracing({
        routingInstrumentation: Sentry.vueRouterInstrumentation(router),
        tracingOrigins: ['localhost', 'api.example.com', /^\//],
      }),
    ],

    // 集成前回调:过滤不需要的错误
    beforeSend(event) {
      // ❌ 过滤掉"ResizeObserver loop"——这类错误无法处理
      if (event?.exception?.values?.[0]?.value?.includes('ResizeObserver loop')) {
        return null;
      }
      // 开发环境不上报
      if (process.env.VITE_APP_ENV === 'development') {
        return null;
      }
      return event;
    },

    // Breadcrumb 回调:过滤敏感信息
    beforeBreadcrumb(breadcrumb) {
      // 过滤掉包含 token 或 password 的请求 URL
      if (
        breadcrumb.category === 'xhr' &&
        breadcrumb.data?.url &&
        /(token|password|secret)/i.test(breadcrumb.data.url)
      ) {
        return null;
      }
      return breadcrumb;
    },
  });
}

React + Sentry 集成

// react/src/sentry.ts
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import { createBrowserRouter } from 'react-router-dom';

export function initSentry(): void {
  Sentry.init({
    dsn: process.env.REACT_APP_SENTRY_DSN || '',
    environment: process.env.REACT_APP_ENV || 'development',
    release: `my-app@${process.env.REACT_APP_VERSION}`,

    // 采样率配置
    sampleRate: process.env.REACT_APP_ENV === 'production' ? 0.2 : 1.0,
    tracesSampleRate: process.env.REACT_APP_ENV === 'production' ? 0.1 : 1.0,

    integrations: [
      new BrowserTracing({
        // 使用 React Router 集成
        routingInstrumentation: Sentry.reactRouterV6Instrumentation(
          React.useEffect,
          React.useLocation,
          React.useNavigationType,
          createRoutesFromChildren,
          matchRoutes
        ),
      }),
    ],

    // 过滤不需要的错误
    beforeSend(event) {
      if (event?.exception?.values?.[0]?.value?.includes('ResizeObserver loop')) {
        return null;
      }
      return process.env.REACT_APP_ENV === 'production' ? event : null;
    },
  });
}

// 使用 Sentry 的 createReduxEnhancer(如果使用 Zustand 则不需要)
// 使用 Sentry 的 withProfiler 包裹需要性能分析的高阶组件
export { Sentry };

3.3 Source Map 上传与安全

Source Map 策略对比

策略 安全性 可调试性 说明
不上传 最高 完全看不到源码,但无法定位行号
上传到 Sentry Sentry 内部保留,不对外暴露(推荐)
上传到公开 CDN 极低 最好 任何人都可以反编译你的源码
内网保留 需要内网环境 适合有内网 Source Map 服务的团队

构建配置 Source Map 上传

// vite.config.ts (Vue 3)
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // 生产环境生成 Source Map(但不上传到 CDN)
    sourcemap: process.env.VITE_APP_ENV === 'production' ? 'hidden' : true,
  },
  plugins: [
    sentryVitePlugin({
      org: 'my-org',
      project: 'my-project',
      // auth token 从环境变量读取
      authToken: process.env.VITE_SENTRY_AUTH_TOKEN,
      // 自动上传 Source Map 并在构建完成后删除
      sourcemaps: {
        assets: './dist/**/*.js.map',
        ignore: ['node_modules/**'],
      },
      // 删除上传后的 Source Map 文件(避免暴露到 CDN)
      cleanArtifacts: true,
      // 关联 Git Commit
      setCommits: {
        auto: true,
      },
    }),
  ],
});

// Webpack 4/5 (React)
// webpack.config.js
// const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
// 同理配置

Source Map 安全注意事项

/**
 * ❌ 危险做法:Source Map 随 JS 一起部署到 CDN
 *   - 任何人都可以打开 DevTools → Sources 看到你的源码
 *   - 导致业务逻辑泄露、API 接口暴露、鉴权逻辑被绕过
 *
 * ✅ 正确做法:
 *   1. 构建时生成 Source Map(hidden 模式)
 *   2. 通过 Sentry CLI/Plugin 自动上传到 Sentry
 *   3. 构建完成后删除 .map 文件
 *   4. 确保 .map 文件不会出现在 CDN 上
 */

// 验证 CDN 上没有 Source Map
// curl -I https://cdn.example.com/js/app.abc123.js.map
// 如果返回 200,说明泄露了,需要立即处理!

3.4 错误去重与聚合

错误分组策略

策略 说明 优点 缺点
消息完全匹配 完全相同的 error message 归为一组 精度高 可能把同源错误分拆
消息正则归一化 将变量替换为占位符后匹配 分组准确 规则需要维护
堆栈指纹 取堆栈前三帧计算 hash 精确度高 计算成本略高
Sentry 默认策略 综合 message + stack + type 开箱即用 定制困难

客户端去重

// error/dedup.ts
/**
 * 错误去重:同一页面上同一错误只上报一次
 *
 * ✅ 好:低频错误逐条上报,高频错误合并上报
 * ❌ 不好:每个错误都上报(会把监控平台打爆)
 */
export class ErrorDeduplicator {
  private reported = new Map<string, { count: number; firstSeen: number }>();
  private readonly maxReportPerError = 3;   // 同一错误最多上报 3 次
  private readonly dedupWindow = 60000;     // 去重窗口 1 分钟
  private timer: ReturnType<typeof setTimeout> | null = null;

  /**
   * 生成错误指纹
   * 将错误消息中的动态部分(如变量名、时间戳)替换为占位符
   */
  private getFingerprint(error: ErrorEvent): string {
    let message = error.message;

    // 替换常见的变量模式
    message = message.replace(/"([^"]+)"/g, '"${value}"');
    message = message.replace(/'([^']+)'/g, "'${value}'");
    message = message.replace(/`([^`]+)`/g, '`${value}`');
    // 替换数字
    message = message.replace(/\d+/g, '{number}');

    // 取堆栈的前两帧作为指纹(堆栈位置决定错误本质)
    const stackLines = (error.stack || '').split('\n').slice(0, 2);
    const stackFingerprint = stackLines.join('').replace(/:(\d+):(\d+)/g, '');

    return `${message}|${stackFingerprint}`;
  }

  /**
   * 判断是否应该上报
   * 返回 true 表示已去重,不需要上报
   */
  public shouldDeduplicate(fingerprint: string): boolean {
    const now = Date.now();
    const record = this.reported.get(fingerprint);

    if (!record) {
      this.reported.set(fingerprint, { count: 1, firstSeen: now });
      return false;
    }

    // 超过去重窗口,重置计数
    if (now - record.firstSeen > this.dedupWindow) {
      this.reported.set(fingerprint, { count: 1, firstSeen: now });
      return false;
    }

    record.count++;

    if (record.count > this.maxReportPerError) {
      // 超过阈值,忽略
      return true;
    }

    return false;
  }
}

3.5 错误影响面评估

影响面数据采集

// error/impact.ts
export interface ErrorImpact {
  errorId: string;        // 错误唯一标识
  userId: string;         // 受影响用户
  sessionId: string;      // 会话 ID
  browser: string;        // 浏览器
  os: string;             // 操作系统
  pageUrl: string;        // 发生页面
  timestamp: number;      // 发生时间
  version: string;        // 应用版本
}

/**
 * 错误影响面分析报告(监控平台生成)
 *
 * ┌─────────────────────────────────────┐
 * │  错误: TypeError: Cannot read       │
 * │  properties of undefined            │
 * ├─────────────────────────────────────┤
 * │  影响用户数: 1,234                   │
 * │  发生次数: 4,567                     │
 * │  影响版本: v2.3.0, v2.3.1           │
 * │  影响浏览器: Chrome 80%(90例)        │
 * │              Safari 15%(20例)        │
 * │  影响页面:                          │
 * │    /orders           (80%)          │
 * │    /orders/detail    (15%)          │
 * │    /cart             (5%)           │
 * │  最早发生: 2024-06-01 10:00:00      │
 * │  最近发生: 2024-06-01 10:30:00      │
 * └─────────────────────────────────────┘
 */

四、前端性能监控

4.1 Core Web Vitals(核心网页指标)

指标说明

指标 全称 含义 良好 需改善
LCP Largest Contentful Paint 最大内容绘制(加载性能) ≤2.5s 2.5s-4.0s >4.0s
INP Interaction to Next Paint 交互到下一次绘制(交互响应) ≤200ms 200ms-500ms >500ms
FID First Input Delay 首次输入延迟(已废弃,用 INP 替代) ≤100ms 100ms-300ms >300ms
CLS Cumulative Layout Shift 累积布局偏移(视觉稳定性) ≤0.1 0.1-0.25 >0.25
FCP First Contentful Paint 首次内容绘制 ≤1.8s 1.8s-3.0s >3.0s
TTFB Time to First Byte 首字节时间 ≤800ms 800ms-1800ms >1800ms

使用 web-vitals 库自动采集

// performance/web-vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB, Metric } from 'web-vitals';

/**
 * 采集并上报 Core Web Vitals
 *
 * ✅ 好:v3 版本的 onLCP/onINP/onCLS 是 PerformanceObserver 实现
 * ❌ 不好:手动轮询检查性能指标(不准确且性能差)
 */
export function reportWebVitals(report: (metric: Metric) => void): void {
  // LCP:最大内容渲染时间
  onLCP((metric: Metric) => {
    report({
      ...metric,
      // 还需要额外信息:哪个元素是 LCP 元素?方便优化
      element: (metric as any).attribution?.largestContentfulPaintEntry?.element?.tagName || '',
    });
  });

  // INP(取代 FID):交互延迟
  onINP((metric: Metric) => {
    report(metric);
  });

  // CLS:累积布局偏移
  onCLS((metric: Metric) => {
    report(metric);
  });

  // FCP:首次内容绘制
  onFCP((metric: Metric) => {
    report(metric);
  });

  // TTFB:首字节时间
  onTTFB((metric: Metric) => {
    report(metric);
  });
}

PerformanceObserver 自动采集

// performance/observer.ts
/**
 * PerformanceObserver 是浏览器原生 API
 * 用于监听各种性能事件,避免手动轮询
 *
 * 支持的 entryType:
 *   - 'largest-contentful-paint': LCP
 *   - 'first-input': FID/INP
 *   - 'layout-shift': CLS
 *   - 'paint': FP, FCP
 *   - 'navigation': 页面导航性能
 *   - 'resource': 资源加载性能
 *   - 'longtask': 长任务(>50ms)
 *   - 'measure': User Timing API 自定义测量
 */
export function setupPerformanceObservers(metricsCallback: (data: PerformanceEntry) => void): void {
  const observerTypes = [
    'largest-contentful-paint',
    'first-input',
    'layout-shift',
    'paint',
    'navigation',
    'longtask',
  ];

  observerTypes.forEach((type) => {
    try {
      const observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          metricsCallback(entry);
        });
      });

      observer.observe({ type, buffered: true });
    } catch (e) {
      // 某些浏览器不支持某些 entryType,静默失败
      console.warn(`PerformanceObserver 不支持 ${type}`);
    }
  });
}

4.2 Navigation Timing & Resource Timing

Navigation Timing(页面加载全流程):

        ┌─ 重定向 ─┐ ┌─ DNS ─┐ ┌─ TCP ─┐ ┌─ TLS ─┐ ┌─ 请求 ─┐ ┌─ 响应 ─┐ ┌─ DOM ─┐ ┌─ 加载 ─┐
        │          │ │       │ │       │ │       │ │        │ │        │ │       │ │        │
startTime           │       │       │       │        │        │        │        │         loadEventEnd
     redirectStart   │       │       │       │        │        │        │        │
     redirectEnd    DNS    TCP    TLS    request   response  domInt   domComp
     (0 无重定向)     start   start   start   start    start    eractive  lete
// performance/navigation-timing.ts
export interface NavigationTimingData {
  dns: number;          // DNS 查询时间
  tcp: number;          // TCP 连接时间
  tls: number;          // TLS 握手时间
  ttfb: number;         // 首字节时间
  domParse: number;     // DOM 解析时间
  domReady: number;     // DOM Ready 时间
  loadComplete: number; // 页面完全加载时间
}

/**
 * 采集页面导航性能数据
 */
export function getNavigationTiming(): NavigationTimingData | null {
  const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];

  if (!entry) return null;

  // ❌ 注意:某些浏览器可能返回 0,需要做防御处理
  return {
    dns: entry.domainLookupEnd - entry.domainLookupStart || 0,
    tcp: entry.connectEnd - entry.connectStart || 0,
    tls: (entry as any).secureConnectionStart
      ? entry.connectEnd - (entry as any).secureConnectionStart
      : 0,
    ttfb: entry.responseStart - entry.requestStart || 0,
    domParse: entry.domInteractive - entry.responseEnd || 0,
    domReady: entry.domContentLoadedEventEnd - entry.fetchStart || 0,
    loadComplete: entry.loadEventEnd - entry.fetchStart || 0,
  };
}

Resource Timing 资源分析

// performance/resource-timing.ts
export interface ResourcePerformance {
  name: string;         // 资源 URL
  type: string;         // 资源类型
  duration: number;     // 总耗时
  dns: number;
  tcp: number;
  ttfb: number;
  transferSize: number; // 传输大小
  protocol: string;     // 协议 h2/h3
  cacheHit: boolean;    // 是否命中缓存
}

/**
 * 采集所有资源加载性能
 * 用于分析 CDN 缓存命中率、资源加载耗时
 */
export function getResourceTimings(): ResourcePerformance[] {
  const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];

  return entries
    .map((entry) => ({
      name: entry.name,
      type: getResourceType(entry.initiatorType),
      duration: Math.round(entry.duration),
      dns: Math.round(entry.domainLookupEnd - entry.domainLookupStart),
      tcp: Math.round(entry.connectEnd - entry.connectStart),
      ttfb: Math.round(entry.responseStart - entry.requestStart),
      transferSize: entry.transferSize,
      protocol: entry.nextHopProtocol || '',
      cacheHit: entry.transferSize === 0 && entry.duration > 0,
    }))
    // 过滤掉 base64 等内联资源
    .filter((r) => !r.name.startsWith('data:'));
}

function getResourceType(initiatorType: string): string {
  const map: Record<string, string> = {
    script: 'JS',
    link: 'CSS',
    img: 'Image',
    fetch: 'Fetch',
    xmlhttprequest: 'XHR',
    css: 'CSS (import)',
    font: 'Font',
  };
  return map[initiatorType] || initiatorType;
}

4.3 自定义性能打点(User Timing API)

// performance/user-timing.ts
/**
 * User Timing API 用于测量自定义操作耗时
 *
 * ✅ 好:使用 performance.mark 和 performance.measure
 *     不侵入业务代码,PerformanceObserver 统一采集
 * ❌ 不好:在业务代码中用 Date.now() 手动计算耗时
 *     (精度差、侵入性强、无法统一采集)
 */

// 通用测量容器
const perfMarkers = new Map<string, number>();

/**
 * 开始测量
 */
export function perfStart(key: string): void {
  if (typeof performance === 'undefined' || !performance.mark) return;

  performance.mark(`${key}:start`);
  perfMarkers.set(key, Date.now());
}

/**
 * 结束测量并返回耗时
 */
export function perfEnd(key: string): number | null {
  if (typeof performance === 'undefined' || !performance.mark) return null;

  try {
    performance.mark(`${key}:end`);
    performance.measure(key, `${key}:start`, `${key}:end`);

    // 通过 PerformanceObserver 采集 measure 事件
    const entries = performance.getEntriesByName(key, 'measure');
    const lastEntry = entries[entries.length - 1];
    return Math.round(lastEntry?.duration || 0);
  } catch {
    // fallback: 使用 Date.now 计算
    const start = perfMarkers.get(key);
    if (!start) return null;
    return Date.now() - start;
  }
}

Vue 3 中自定义性能打点

// vue3/src/hooks/usePerfMeasure.ts
import { onMounted, onUnmounted } from 'vue';
import { perfStart, perfEnd } from '../performance/user-timing';
import { reportMetric } from '../metrics/reporter';

/**
 * 组件性能测量 Hook
 *
 * 用法: usePerfMeasure('OrderList-Fetch');
 */
export function usePerfMeasure(name: string) {
  onMounted(() => {
    perfStart(name);
  });

  // 在组件中调用 perfEnd 并上报
  return {
    done() {
      const duration = perfEnd(name);
      if (duration !== null) {
        reportMetric({
          type: 'custom_perf',
          name,
          value: duration,
          timestamp: Date.now(),
        });
      }
      return duration;
    },
  };
}

// 使用示例
// <script setup lang="ts">
// import { ref } from 'vue';
// import { usePerfMeasure } from '../hooks/usePerfMeasure';
//
// const measure = usePerfMeasure('OrderList-Init');
// const orders = ref([]);
//
// async function loadOrders() {
//   const res = await api.getOrders();
//   orders.value = res.data;
//   measure.done(); // 上报性能数据
// }
// </script>

React Hooks 中自定义性能打点

// react/src/hooks/usePerfMeasure.ts
import { useEffect, useRef } from 'react';
import { reportMetric } from '../metrics/reporter';

interface UsePerfMeasureOptions {
  /** 自动在组件卸载时上报 */
  autoReport?: boolean;
  /** 额外的标签,用于聚合分析 */
  tags?: Record<string, string>;
}

/**
 * 组件性能测量 Hook
 *
 * ✅ 好:封装了 start/end 和自动上报逻辑
 * ❌ 不好:在每个组件中手动 Date.now() 计算
 */
export function usePerfMeasure(name: string, options: UsePerfMeasureOptions = {}) {
  const { autoReport = false, tags = {} } = options;
  const startTime = useRef(performance.now());

  useEffect(() => {
    startTime.current = performance.now();

    return () => {
      if (autoReport) {
        const duration = performance.now() - startTime.current;
        reportMetric({
          type: 'custom_perf',
          name,
          value: Math.round(duration),
          tags,
          timestamp: Date.now(),
        });
      }
    };
  }, []); // 只在挂载和卸载时执行

  return {
    /** 手动结束测量并上报 */
    end(): number {
      const duration = Math.round(performance.now() - startTime.current);
      reportMetric({
        type: 'custom_perf',
        name,
        value: duration,
        tags,
        timestamp: Date.now(),
      });
      return duration;
    },
    /** 重置开始时间 */
    reset(): void {
      startTime.current = performance.now();
    },
  };
}

// 使用示例
// function OrderList() {
//   const { end } = usePerfMeasure('OrderList-Render', {
//     autoReport: true,
//     tags: { page: 'orders' },
//   });
//
//   // 数据加载完成后手动结束
//   // end();
//
//   return <div>...</div>;
// }

4.4 首屏时间与白屏时间检测

指标 定义 检测方法
白屏时间 从输入 URL 到页面开始显示内容的时间 performance.timing.domLoading - fetchStart 或 FCP
首屏时间 从输入 URL 到首屏(可视区域)内容完全渲染的时间 MutationObserver 检测首屏元素渲染完成
可交互时间(TTI) 页面完全可交互的时间 Long Task + DOM 变化

白屏时间检测

// performance/first-screen.ts
/**
 * 首屏时间检测
 *
 * 原理:使用 MutationObserver 监听 DOM 变化
 * 当页面主要元素(如 #app > div)渲染完成后,认为首屏渲染完成
 */

export function measureFirstScreenTime(report: (duration: number) => void): void {
  // 排除初始 HTML 中的节点
  let isFirstMutation = true;
  let startTime = Date.now();

  const observer = new MutationObserver((mutations) => {
    // 第一次 DOM 变更跳过(通常是 Vue/React 初始化产生的)
    if (isFirstMutation) {
      isFirstMutation = false;
      return;
    }

    // 检查是否有内容节点被添加
    const hasContentNodes = mutations.some((m) =>
      Array.from(m.addedNodes).some(
        (node) =>
          node.nodeType === Node.ELEMENT_NODE &&
          (node as Element).children.length > 0 &&
          (node as HTMLElement).offsetHeight > 0 // 确保元素可见
      )
    );

    if (hasContentNodes) {
      const duration = Date.now() - startTime;
      report(duration);
      observer.disconnect();
    }
  });

  observer.observe(document.body || document.documentElement, {
    childList: true,
    subtree: true,
  });

  // 超时保护:10s 后自动断开
  setTimeout(() => observer.disconnect(), 10000);
}

五、用户行为监控

5.1 PV/UV 统计

统计口径 说明 实现方式
PV(Page View) 页面浏览次数,刷新算一次 路由变化时上报
UV(Unique Visitor) 独立访客数,按设备去重 Cookie/Storage 存储设备 ID
IPV 增量页面浏览,SPA 路由切换算一次 router.afterEach 钩子
新老用户 首次访问 vs 回访 localStorage 标记首次访问时间

Vue 3 路由统计

// vue3/src/router/stats.ts
import { createRouter } from 'vue-router';
import { reportMetric } from '../metrics/reporter';

export function createStatsRouter(routes: any[]) {
  const router = createRouter({ routes });

  let pageEnterTime = Date.now();

  router.afterEach((to, from) => {
    if (!from) {
      // 首次进入页面:统计 PV
      reportMetric({
        type: 'pv',
        pageUrl: to.fullPath,
        referrer: document.referrer,
        userId: getUserId(),
        timestamp: Date.now(),
      });
      pageEnterTime = Date.now();
      return;
    }

    // 页面离开:统计停留时长
    const stayDuration = Date.now() - pageEnterTime;
    reportMetric({
      type: 'page_exit',
      pageUrl: from.fullPath,
      stayDuration, // 页面停留时长(ms)
      timestamp: Date.now(),
    });

    // 新页面进入:统计 PV
    reportMetric({
      type: 'pv',
      pageUrl: to.fullPath,
      referrer: from.fullPath,
      timestamp: Date.now(),
    });

    pageEnterTime = Date.now();
  });

  return router;
}

React 路由统计

// react/src/hooks/usePageTracking.ts
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { reportMetric } from '../metrics/reporter';

/**
 * 页面 PV 和停留时长监听
 */
export function usePageTracking(userId?: string): void {
  const location = useLocation();
  const pageEnterTime = useRef(Date.now());
  const prevPath = useRef('');

  useEffect(() => {
    const currentPath = location.pathname + location.search;
    const now = Date.now();
    const stayDuration = now - pageEnterTime.current;

    // 上报离开页面的时长
    if (prevPath.current) {
      reportMetric({
        type: 'page_exit',
        pageUrl: prevPath.current,
        stayDuration,
        userId,
        timestamp: now,
      });
    }

    // 上报当前页 PV
    reportMetric({
      type: 'pv',
      pageUrl: currentPath,
      referrer: prevPath.current || document.referrer,
      userId,
      timestamp: now,
    });

    prevPath.current = currentPath;
    pageEnterTime.current = now;
  }, [location.pathname, location.search]);
}

5.2 用户操作路径分析

操作路径记录

// behavior/user-flow.ts
export interface UserAction {
  type: 'click' | 'input' | 'scroll' | 'route_change' | 'api_call';
  target?: string;      // 操作的元素描述
  value?: string;        // 输入的内容(脱敏处理)
  pageUrl: string;
  timestamp: number;
  duration?: number;
}

/**
 * 用户操作路径记录
 *
 * 示例输出:
 *   /home → 点击"订单" → /orders → 点击"查看详情"
 *   → /orders/123 → 点击"取消订单" → 弹出确认框 → 点击"确认"
 *   → API: POST /api/orders/123/cancel → 成功
 *
 * ❌ 注意: 不要记录输入框的原始值(可能包含密码/身份证号)
 */
export class UserFlowTracker {
  private actions: UserAction[] = [];
  private readonly MAX_ACTIONS = 200; // 单个会话最多记录 200 条

  record(action: Omit<UserAction, 'timestamp'>): void {
    if (this.actions.length >= this.MAX_ACTIONS) {
      // 超过阈值,不允许继续记录(防止内存溢出)
      return;
    }

    this.actions.push({ ...action, timestamp: Date.now() });
  }

  /**
   * 上报用户操作路径
   * 通常在页面关闭或触发错误时上报
   */
  flush(): void {
    if (this.actions.length === 0) return;

    reportMetric({
      type: 'user_flow',
      actions: this.actions,
      userId: getUserId(),
      timestamp: Date.now(),
    });

    this.actions = [];
  }
}

// 页面关闭前上报
window.addEventListener('beforeunload', () => {
  userFlowTracker.flush();
});

5.3 点击热力图

// behavior/heatmap.ts
/**
 * 点击热力图数据采集
 *
 * 原理:全局监听 click 事件,记录点击位置相对于视口的百分比坐标
 * 后端聚合后生成热力图
 *
 * ❌ 注意:
 *   1. 不要采集敏感区域(密码框、支付密码输入区)
 *   2. 需要做采样(如 1% 的用户开启)
 *   3. 坐标使用百分比,避免分辨率差异
 */
export function setupClickHeatmap(enable: boolean, sampleRate: number = 0.01): void {
  if (!enable || Math.random() > sampleRate) return;

  document.addEventListener('click', (event: MouseEvent) => {
    const target = event.target as HTMLElement;

    // ❌ 跳过敏感区域
    if (target.matches('input[type="password"], input[type="tel"]')) {
      return;
    }

    const rect = document.documentElement.getBoundingClientRect();

    reportMetric({
      type: 'click_heatmap',
      x: Math.round((event.clientX / rect.width) * 10000) / 100,  // 百分比,保留两位小数
      y: Math.round((event.clientY / rect.height) * 10000) / 100,
      pageUrl: window.location.pathname,
      elementTag: target.tagName,
      elementText: target.textContent?.slice(0, 20) || '',  // 截取前 20 字符
      timestamp: Date.now(),
    });
  });
}

六、前端埋点体系

6.1 埋点方案对比

方案 原理 优点 缺点 适用场景
代码埋点 在业务代码中显式调用埋点 API 精确、灵活、数据质量高 开发工作量大、维护成本高 核心业务事件
可视化埋点 通过可视化工具圈选元素,自动生成埋点配置 无需开发介入、快速上线 圈选不精准、不支持动态内容 运营活动页面
全埋点(无埋点) 自动采集所有用户交互事件 接入成本最低、全覆盖 数据量巨大、有效数据稀释、无法定义业务语义 用户行为分析初期
声明式埋点 通过属性/指令声明需要埋点的元素 代码入侵低、语义明确 需要框架支持、动态内容处理复杂 Vue/React 项目

代码埋点 SDK 封装

// tracking/tracker.ts
/**
 * 埋点 SDK 封装
 *
 * 核心职责:
 *   1. 统一埋点调用入口
 *   2. 自动注入公共参数(userId, traceId, browser 等)
 *   3. 数据上报策略管理
 *   4. 隐私脱敏
 */

export interface TrackEvent {
  eventName: string;
  properties?: Record<string, unknown>;
  /** 事件时间(自动生成,可覆盖) */
  timestamp?: number;
}

class Tracker {
  private queue: TrackEvent[] = [];
  private isProcessing = false;
  private readonly BATCH_SIZE = 10;
  private readonly FLUSH_INTERVAL = 5000; // 5s 批量上报一次
  private timer: ReturnType<typeof setInterval> | null = null;

  /** 公共参数(自动注入的上下文信息) */
  private getCommonParams() {
    return {
      userId: getUserId(),
      traceId: getOrCreateTraceId(),
      sessionId: getSessionId(),
      pageUrl: window.location.href,
      referrer: document.referrer,
      browser: getBrowserInfo(),
      os: getOSInfo(),
      screenWidth: window.screen.width,
      screenHeight: window.screen.height,
      timestamp: Date.now(),
      version: __APP_VERSION__,
    };
  }

  /** 上报事件 */
  track(eventName: string, properties?: Record<string, unknown>): void {
    const event: TrackEvent = {
      eventName,
      properties: {
        ...this.getCommonParams(),
        ...properties,
      },
      timestamp: Date.now(),
    };

    this.queue.push(event);

    // 达到批量阈值即刻发送
    if (this.queue.length >= this.BATCH_SIZE) {
      this.flush();
    }

    // 启动定时器
    if (!this.timer) {
      this.timer = setInterval(() => this.flush(), this.FLUSH_INTERVAL);
    }
  }

  /** 批量上报 */
  private async flush(): Promise<void> {
    if (this.queue.length === 0 || this.isProcessing) return;

    this.isProcessing = true;
    const events = this.queue.splice(0, this.BATCH_SIZE);

    try {
      // 优先使用 sendBeacon(页面关闭时也能送达)
      const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });

      if (navigator.sendBeacon) {
        navigator.sendBeacon('/api/track', blob);
      } else {
        // fallback: fetch
        await fetch('/api/track', {
          method: 'POST',
          body: blob,
          // ✅ 使用 keepalive 确保页面关闭时请求不中断
          keepalive: true,
        });
      }
    } catch {
      // 上报失败:重新放回队列
      this.queue.unshift(...events);
    } finally {
      this.isProcessing = false;
    }
  }

  /** 页面关闭时强制同步上报(同步 XHR 确保送达) */
  flushOnUnload(): void {
    if (this.queue.length === 0) return;

    const events = this.queue.splice(0);
    const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });

    // ❌ 注意:同步 XHR 会阻塞页面关闭,只用于关键事件
    const xhr = new XMLHttpRequest();
    xhr.open('POST', '/api/track', false); // false = 同步
    xhr.send(blob);
  }
}

export const tracker = new Tracker();

6.2 数据上报策略对比

策略 实现方式 送达率 性能影响 适用场景
img 标签 new Image().src = url 低(GET 请求) 简单事件上报
sendBeacon navigator.sendBeacon(url, data) 最高(页面关闭不影响) 低(浏览器调度) 关键事件、页面卸载
Fetch keepalive fetch(url, { keepalive: true }) 批量上报
XMLHttpRequest 同步 xhr.open('POST', url, false) 最高 阻塞页面关闭 最后的保底方案
WebSocket 长连接推送上报 中(需维护连接) 实时事件流
// tracking/report-strategy.ts
/**
 * 上报策略选择
 *
 * ✅ 推荐优先级:
 *   1. sendBeacon(能确保送达,不阻塞页面)
 *   2. fetch + keepalive(sendBeacon 不可用时)
 *   3. 同步 XHR(最后的保底,会阻塞页面关闭)
 */
export function report(data: Record<string, unknown>, endpoint: string): void {
  const payload = JSON.stringify(data);
  const blob = new Blob([payload], { type: 'application/json' });

  // 策略 1:sendBeacon(最佳)
  if (navigator.sendBeacon) {
    navigator.sendBeacon(endpoint, blob);
    return;
  }

  // 策略 2:fetch + keepalive
  try {
    fetch(endpoint, {
      method: 'POST',
      body: blob,
      keepalive: true,
      // 不关心响应,fire-and-forget
    }).catch(() => {
      // fallback 到同步 XHR
      fallbackReport(data, endpoint);
    });
  } catch {
    fallbackReport(data, endpoint);
  }
}

/** 同步 XHR 保底 */
function fallbackReport(data: Record<string, unknown>, endpoint: string): void {
  try {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', endpoint, false); // 同步
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(JSON.stringify(data));
  } catch {
    // 实在无法上报,丢弃(避免死循环)
    console.warn('上报失败:', data);
  }
}

6.3 Vue 指令埋点

Vue 3 自定义指令

// vue3/src/directives/v-track.ts
import { Directive, DirectiveBinding } from 'vue';
import { tracker } from '../../tracking/tracker';

/**
 * 声明式埋点指令 v-track
 *
 * 用法:
 *   <button v-track="{ eventName: 'click_submit', properties: { type: 'order' } }">
 *     提交订单
 *   </button>
 *
 * ✅ 好:声明式埋点,代码入侵低,埋点和业务逻辑分离
 * ❌ 不好:在业务代码中写 tracker.track()(侵入性强,不易维护)
 */
export const vTrack: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { eventName, properties } = binding.value || {};

    if (!eventName) {
      console.warn('v-track 缺少 eventName');
      return;
    }

    el.addEventListener('click', () => {
      tracker.track(eventName, properties);
    });
  },
};

// main.ts 注册
// import { vTrack } from './directives/v-track';
// app.directive('track', vTrack);

6.4 React 组件埋点

// react/src/components/TrackClick.tsx
import React, { cloneElement, ReactElement } from 'react';
import { tracker } from '../tracking/tracker';

interface TrackProps {
  eventName: string;
  properties?: Record<string, unknown>;
  children: ReactElement;
}

/**
 * 声明式埋点组件 TrackClick
 *
 * 用法:
 *   <TrackClick eventName="click_submit" properties={{ type: 'order' }}>
 *     <Button>提交订单</Button>
 *   </TrackClick>
 *
 * ✅ 好:埋点和 UI 解耦,通过 props 声明事件
 * ❌ 不好:在 onClick 里混合业务逻辑和埋点代码
 */
export function TrackClick({ eventName, properties, children }: TrackProps): ReactElement {
  return cloneElement(children, {
    onClick: (...args: any[]) => {
      // 先执行埋点
      tracker.track(eventName, properties);
      // 再执行原始 onClick(如果存在)
      if (children.props.onClick) {
        children.props.onClick(...args);
      }
    },
  });
}

// 高阶组件版本(用于需要批量埋点的场景)
export function withTrack<P extends object>(
  WrappedComponent: React.ComponentType<P>,
  defaultEventName: string,
  defaultProps?: Record<string, unknown>
) {
  return function TrackedComponent(props: P & { trackProps?: Record<string, unknown> }) {
    const { trackProps, ...rest } = props as any;

    return (
      <TrackClick
        eventName={defaultEventName}
        properties={{ ...defaultProps, ...trackProps }}
      >
        <WrappedComponent {...(rest as P)} />
      </TrackClick>
    );
  };
}

// 使用示例
// const TrackedButton = withTrack(Button, 'click_button', { module: 'order' });
// <TrackedButton type="primary" trackProps={{ action: 'submit' }}>
//   提交
// </TrackedButton>

6.5 隐私脱敏

// tracking/sanitize.ts
/**
 * 敏感信息脱敏
 *
 * ❌ 永远不要上报的字段:
 *   - 密码(password, pwd, passwd)
 *   - 身份证号(idCard, id_card)
 *   - 银行卡号(bankCard, cardNo)
 *   - 手机号(phone, mobile, tel)
 *   - Token / Secret Key
 */

const SENSITIVE_KEYS = [
  'password', 'pwd', 'passwd', 'secret', 'token',
  'idCard', 'id_card', 'idcard', 'certNo', 'cert_no',
  'bankCard', 'cardNo', 'card_no',
  'phone', 'mobile', 'tel',
];

/**
 * 递归脱敏
 */
export function sanitize(obj: Record<string, unknown>): Record<string, unknown> {
  const result: Record<string, unknown> = {};

  for (const [key, value] of Object.entries(obj)) {
    // 检查键名是否敏感
    if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k))) {
      result[key] = '***SANITIZED***';
      continue;
    }

    // 递归处理嵌套对象
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      result[key] = sanitize(value as Record<string, unknown>);
    } else if (Array.isArray(value)) {
      result[key] = value.map((item) =>
        typeof item === 'object' && item !== null
          ? sanitize(item as Record<string, unknown>)
          : item
      );
    } else {
      result[key] = value;
    }
  }

  return result;
}

七、监控告警

7.1 前端监控指标告警

指标 告警阈值 严重级别 说明
JS 错误率 > 1%(占总 PV) Critical 可能有代码 bug 发布
LCP P75 > 3s Warning 加载性能劣化
INP P75 > 400ms Warning 交互响应变慢
CLS P75 > 0.25 Warning 页面布局不稳
API 成功率 < 95% Critical 后端接口大面积报错
白屏率 > 0.5% Critical 页面白屏是最严重的用户体验问题
资源加载失败率 > 0.1% Warning CDN 或静态资源问题

7.2 告警规则设计

// alert/rules.ts
/**
 * 前端告警规则定义
 *
 * 原则:
 *   1. Critical 告警发即时通知(钉钉/企微)
 *   2. Warning 告警聚合后每天发一次 Report
 *   3. 避免"告警疲劳"——只有确实影响用户的指标才告警
 */
export const alertRules = {
  // ====== Critical 级别 ======
  JS_ERROR_RATE: {
    name: 'JS 错误率过高',
    severity: 'critical' as const,
    condition: (metrics: Metrics) => {
      // 窗口期 5 分钟内,错误率超过 1%
      const errorRate = metrics.jsErrorCount / metrics.pvCount;
      return errorRate > 0.01;
    },
    message: (metrics: Metrics) =>
      `JS 错误率 ${((metrics.jsErrorCount / metrics.pvCount) * 100).toFixed(2)}% (阈值 1%)`,
  },

  API_ERROR_RATE: {
    name: 'API 错误率过高',
    severity: 'critical' as const,
    condition: (metrics: Metrics) => {
      return metrics.apiErrorRate > 0.05; // 5%
    },
    message: (metrics: Metrics) =>
      `API 错误率 ${(metrics.apiErrorRate * 100).toFixed(2)}%`,
  },

  WHITE_SCREEN_RATE: {
    name: '白屏率过高',
    severity: 'critical' as const,
    condition: (metrics: Metrics) => {
      return metrics.whiteScreenRate > 0.005; // 0.5%
    },
    message: (metrics: Metrics) =>
      `白屏率 ${(metrics.whiteScreenRate * 100).toFixed(3)}%`,
  },

  // ====== Warning 级别 ======
  LCP_DEGRADATION: {
    name: 'LCP 劣化',
    severity: 'warning' as const,
    condition: (metrics: Metrics) => {
      // LCP P75 超过 3s
      return metrics.lcpP75 > 3000;
    },
    message: (metrics: Metrics) =>
      `LCP P75: ${metrics.lcpP75}ms (阈值 3000ms)`,
  },

  RESOURCE_LOAD_FAILURE: {
    name: '资源加载失败率过高',
    severity: 'warning' as const,
    condition: (metrics: Metrics) => {
      return metrics.resourceFailRate > 0.001; // 0.1%
    },
    message: (metrics: Metrics) =>
      `资源加载失败率: ${(metrics.resourceFailRate * 100).toFixed(2)}%`,
  },
};

7.3 性能劣化检测

// alert/regression-detector.ts
/**
 * 版本性能回归检测
 *
 * 思路:发新版后,对比新版本和旧版本的性能指标
 * 如果新版本的 P75 指标劣化超过 20%,自动触发告警
 */
export function detectRegression(oldVersion: VersionMetrics, newVersion: VersionMetrics): RegressionReport | null {
  const regressions: string[] = [];

  for (const metric of ['lcp', 'inp', 'cls', 'fcp', 'ttfb'] as const) {
    const oldVal = oldVersion[metric];
    const newVal = newVersion[metric];

    if (!oldVal || !newVal) continue;

    const change = ((newVal - oldVal) / oldVal) * 100;

    if (change > 20) { // 劣化超过 20%
      regressions.push(`${metric}: ${oldVal}ms → ${newVal}ms (${change.toFixed(1)}%)`);
    }
  }

  if (regressions.length === 0) return null;

  return {
    oldVersion: oldVersion.version,
    newVersion: newVersion.version,
    regressions,
    detectedAt: Date.now(),
  };
}

八、监控平台对比

8.1 主流方案对比

维度 Sentry Datadog RUM 自研方案
接入成本 低(30 分钟可接入) 低(1 小时可接入) 高(开发量 2-4 周)
错误监控 强大(自动分组、堆栈追踪) 良好 需自建
性能监控 基础(需配合 Tracing) 全面(RUM + Session Replay) 需自建
用户行为 基础(Breadcrumb) 强大(Session Replay 回放用户操作) 需自建
Source Map 自动上传和管理 支持 需自建
告警能力 基础规则 强大(可配置复杂条件) 灵活但需自建
价格 免费额度 5k events/月,之后按量付费 贵(按 Session 计费) 服务器成本低
数据安全 数据在 Sentry 服务器 数据在 Datadog 服务器 完全可控
自定义程度 中等 中等 最高
团队要求 无需专门运维 无需专门运维 需前端 + 后端 + 运维

8.2 选型建议

团队规模 推荐方案 理由
1-5 人小团队 Sentry(免费版) 0 成本接入,开箱即用
5-20 人团队 Sentry(付费版)+ web-vitals 成本可控,功能足够
20+ 人团队(有预算) Datadog RUM + APM 全链路打通,端到端分析
大厂/有自研能力 自研 + 开源组件(Sentry SDK 定制) 数据安全可控,成本低

8.3 自研方案架构

自研前端监控系统架构:

用户浏览器
    │
    ├→ 上报 SDK(采集错误 + 性能 + 行为)
    │      │
    │      ├→ sendBeacon / fetch → 上报网关(Nginx/LB)
    │      │
    │      ▼
    ├→ 数据接收服务(Go/Node.js)
    │      │
    │      ├→ 写入 Kafka(缓冲 + 削峰)
    │      │
    │      ▼
    ├→ 流处理(Flink / 实时计算)
    │      │
    │      ├→ 实时聚合:错误率、P50/P95/P99
    │      ├→ 异常检测:突发错误告警
    │      └→ 写入 ClickHouse / Elasticsearch
    │
    ├→ 告警引擎
    │      ├→ 基于规则(错误率 > 1% 告警)
    │      └→ 基于 ML(异常检测)
    │
    └→ 可视化平台(Grafana / 自建)
           ├→ 错误看板:趋势、TOP 错误、影响面
           ├→ 性能看板:LCP/INP/CLS 趋势、版本对比
           └→ 业务看板:PV/UV、转化漏斗、用户路径

九、最佳实践

9.1 前端可观测性成熟度模型

级别 说明 具备能力
L0 不可观测 没有任何监控 用户反馈了才知道出问题
L1 基础监控 接入错误监控 能知道哪里报错、报错量、影响面
L2 性能监控 + 性能指标采集 能知道页面加载慢、交互卡顿
L3 行为分析 + 用户行为采集 能还原用户操作路径、分析转化漏斗
L4 全链路 + Tracing + 告警 前端请求链路可视化、指标劣化自动告警
L5 主动预防 + ML 异常检测 在用户反馈前发现并修复问题

9.2 注意事项与踩坑点

1. 数据量爆炸
   - 问题:前端 PV 量大,全量上报会把服务器打爆
   - 解决:采样上报(生产 10-20%)、批量合并、高峰期降级

2. 上报性能影响
   - 问题:上报请求影响页面性能(特别是低端机)
   - 解决:使用 sendBeacon(不阻塞主线程)、控制上报频率
   -   ❌ sendBeacon 不支持自定义 header(Content-Type 固定为 text/plain)
   -   ✅ 如果不需要自定义 header,优先用 sendBeacon

3. Source Map 泄露
   - 问题:.map 文件部署到 CDN 导致源码泄露
   - 解决:hidden 模式构建,只上传到 Sentry 不部署到 CDN

4. 跨域错误信息丢失
   - 问题:CDN 资源的错误在 window.onerror 中 message = "Script error."
   - 解决:CDN 资源加 crossorigin="anonymous" 属性

5. 隐私合规
   - 问题:GDPR / 个人信息保护法要求用户知情同意
   - 解决:首次访问弹窗获取同意、提供"关闭数据采集"选项
   -   上报前脱敏处理(去掉用户 PII 信息)

6. 白屏误报
   - 问题:业务逻辑导致的页面空白被误报为"白屏"
   - 解决:白屏检测要有超时阈值和重试机制
   -   排除用户主动操作导致的白屏(如 SPA 路由加载中)

7. 去重策略
   - 问题:同一错误在短时间内重复上报 N 次
   - 解决:客户端本地去重(同类错误每分钟最多 3 次)

8. 版本匹配
   - 问题:新发布版本的 Source Map 和 JS 版本不匹配
   - 解决:构建时在 JS 文件名中注入版本 hash
   -   Source Map 上传时关联 Git commit

9.3 ✅ 好的实践 vs ❌ 不好的实践

上报策略

// ✅ 好:错误和性能分开通道上报,关键错误即时发送
function uploadError(error: ErrorInfo) {
  // 即时上报(高优先级)
  navigator.sendBeacon('/api/errors', JSON.stringify(error));
}

function uploadMetrics(metrics: Metric[]) {
  // 批量上报(低优先级)
  requestIdleCallback(() => {
    const blob = new Blob([JSON.stringify({ metrics })], { type: 'application/json' });
    navigator.sendBeacon('/api/metrics', blob);
  });
}

// ❌ 不好:所有数据都用同步 XHR 上报
// 阻塞主线程,导致页面卡顿

采样策略

// ✅ 好:根据环境 + 事件类型动态采样
function shouldSample(eventType: string): boolean {
  if (process.env.APP_ENV === 'development') return true;
  if (eventType === 'error') return true; // 错误全量
  if (eventType === 'performance') return Math.random() < 0.1; // 性能 10% 采样
  if (eventType === 'click') return Math.random() < 0.01; // 点击 1% 采样
  return false;
}

// ❌ 不好:所有事件全量上报
// 数据量巨大,服务器成本高,有效信息被稀释

错误处理

// ✅ 好:errorHandler 做兜底,但不吞掉原始错误
app.config.errorHandler = (err, instance, info) => {
  reportError(err);
  // 仍然 console.error 输出,便于开发调试
  console.error(err);
};

// ❌ 不好:全局错误处理吞掉错误,开发者看不到
// app.config.errorHandler = (err) => {
//   reportError(err);
//   // 没有 console.error,开发环境也看不到错误
// };

防御式编程

// ✅ 好:永远不要信任后端返回的数据
interface UserResponse {
  id: number;
  name: string;
  email?: string;
}

// 使用 Zod 校验后端返回的数据
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email().optional(),
});

async function fetchUser(id: number): Promise<UserResponse> {
  const res = await api.get(`/users/${id}`);
  const parsed = UserSchema.safeParse(res.data);

  if (!parsed.success) {
    // 数据格式不对 → 上报 + 抛出语义清晰的错误
    reportError({
      type: 'DATA_FORMAT_ERROR',
      message: `用户数据格式异常: ${parsed.error.message}`,
      extra: { rawData: res.data },
    });
    throw new Error('用户数据异常,请联系技术支持');
  }

  return parsed.data;
}

// ❌ 不好:直接信任后端数据
// async function fetchUser(id: number) {
//   const res = await api.get(`/users/${id}`);
//   return res.data; // 如果 res.data 为 null,调用处直接炸
// }

十、生产环境常见问题

10.1 上报接口成为性能瓶颈

现象:上线的第一天,/api/track 接口 QPS 暴增,后端扛不住
原因:全量上报 + 未做采样

解决方案:
  1. 增加采样率(生产 10%)
  2. 前端聚合后再上报(每分钟聚合一次)
  3. 上报接口独立部署(不跟业务接口混在一起)

10.2 跨域 Script Error

现象:window.onerror 拿到的是 "Script error.",没有具体错误信息
原因:跨域加载的 CDN 脚本出错时,浏览器默认不暴露错误详情

解决方案:
  <!-- CDN 脚本加 crossorigin 属性 -->
  <script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>

  // CDN 返回头也需要配置
  // Access-Control-Allow-Origin: *

10.3 低端机性能损耗

现象:低端 Android 手机安装了监控 SDK 后,页面加载更慢了
原因:SDK 在低端机上初始化耗时较长

解决方案:
  1. 监控 SDK 延迟加载(不阻塞页面渲染)
  2. 低端机降级(只采集错误,不采集性能和埋点)
  3. 按需加载模块(不用 Tracing 就不加载 Tracing 模块)

10.4 Source Map 泄露事故

现象:某天发现竞品公司的 API 文档里引用了自己产品的 API 接口
原因:.map 文件随 JS 一起部署到 CDN

教训:
  1. 构建时使用 hidden-source-map,不部署 .map 文件
  2. 部署后扫描 CDN,确认 .map 不存在
  3. 如果已泄露,立即在 CDN 上删除 .map 文件
  4. 考虑 API 鉴权和接口混淆

十一、面试必背要点

Q:前端可观测性和后端可观测性的核心区别是什么?

后端:受控环境(服务器),日志量可预估,可以采集所有请求
前端:不受控环境(用户浏览器),日志量大不可控,必须采样
      需要关注浏览器的兼容性差异
      隐私合规(GDPR)是必须考虑的维度

Q:什么是 Core Web Vitals?为什么重要?

Core Web Vitals 是 Google 定义的三个核心用户体验指标:
  LCP(加载性能)、INP(交互响应)、CLS(视觉稳定性)
  
重要原因:
  1. 影响 Google 搜索排名(SEO)
  2. 直接影响用户留存和转化率
  3. 提供统一的性能衡量标准,便于跨团队对齐

Q:sendBeacon 和 fetch 上报有什么区别?

sendBeacon:
  - 浏览器调度发送时机,不阻塞页面关闭
  - 更适合页面卸载时上报
  - 不支持自定义 Header
  - 发送大小有限制(通常 64KB)

fetch + keepalive:
  - 可以设置自定义 Header
  - keepalive 确保页面关闭时请求不中断
  - 兼容性比 sendBeacon 差一点点

Q:前端错误上报后如何快速定位问题?

1. Sentry 按错误类型分组,查看发生频率和影响用户数
2. 查看错误堆栈 + Source Map 还原源码位置
3. 查看错误 Breadcrumb(用户操作路径)
4. 查看用户浏览器、OS、版本信息
5. 关联后端日志 TraceID,定位全链路
6. 如果是版本回归,对比上个版本的错误率变化

Q:如何避免上报过多导致服务器崩溃?

1. 采样上报:生产环境 10-20% 采样率
2. 客户端去重:同类错误限制上报次数
3. 批量上报:缓存多条后合并发送
4. 闲时上报:requestIdleCallback / setTimeout 延迟
5. 按优先级分级:错误全量,性能采样,行为低采样
6. 后端限流:Nginx 限流 + Kafka 缓冲

Q:Source Map 应该怎么处理?

1. 构建时生成 hidden-source-map(不暴露到 CDN)
2. 通过 Sentry CLI/Plugin 自动上传到监控平台
3. 构建完成后删除 .map 文件
4. 确保 .map 不会被部署到 CDN(.gitignore + CI/CD 配置)
5. 发版后扫描 CDN 确认无 .map 文件

最后更新:2026/06/29