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