CC 咖啡猫的工作空间 Coding Space

数据埋点与监控

1. 数据埋点基础

什么是埋点

在用户交互关键节点采集行为数据,用于分析用户行为、改进产品、数据驱动决策。本质是在应用代码中嵌入采集逻辑,将用户「做了什么、在哪儿做的、怎么做」记录为结构化事件。

埋点的价值

维度 价值 典型指标
用户行为分析 转化漏斗、用户路径、留存率 点击率、页面跳出率、次日留存
产品迭代依据 功能使用率、A/B 测试、版本效果 功能渗透率、实验组转化差异
技术监控 性能数据、错误分布、稳定性 JS 错误率、API 成功率、LCP

埋点 vs 监控

维度 埋点 (Tracking) 监控 (Monitoring)
关注对象 业务行为和用户行为 技术指标和系统状态
数据特性 有语义的业务事件 时序数值指标
典型数据 按钮点击、页面浏览、下单 JS Error、API 耗时、CPU 内存
下游用途 产品决策、增长分析 告警、故障排查、SLA 保障
互补关系 埋点发现产品问题 → 监控定位技术根因

2. 埋点方案对比

三种方案对比

维度 代码埋点 可视化埋点 全埋点/无埋点
精度 最高,精确控制每个事件 较高,依赖 DOM 结构 较低,事件噪声多
开发成本 高,每事件需手动编码 中,后台圈选配置 低,接入 SDK 即生效
灵活性 最灵活,可携带任意上下文 中等,只能采集可见元素 低,采集已有事件不能自定义
数据量 可控,按需采集 较大,圈选范围决定 最大,全量采集所有交互
适用阶段 全阶段,尤其是成熟产品 快速验证期 产品早期/探索期
维护成本 需随业务迭代维护 需维护圈选规则 低,SDK 升级即可
典型工具 自建/神策 SDK GrowingIO 全埋点 SDK

代码埋点(重点)

事件设计三要素

interface TrackingEvent {
  event_name: string;      // 事件名(必填)
  event_params: Record;    // 事件属性(随事件变化)
  user_properties: Record; // 用户属性(可选,描述用户状态)
}

命名规范

// snake_case + 层级化
page_view                        // 页面浏览
product_click                    // 通用商品点击
product_detail_view              // 商品详情浏览
product_add_to_cart              // 加入购物车
order_submit                     // 提交订单
order_pay_success                // 支付成功
order_pay_fail                   // 支付失败

// 参数同理,避免大小写混用
{ "source": "homepage_recommend" }

参数设计原则

  • 通用参数(自动采集):user_id, device_id, os, browser, app_version, page_url, session_id, timestamp
  • 事件特有参数:按业务需求定义,遵循最小化原则,不采集无用字段

埋点 SDK 封装原则

// 不直接调底层 API,通过封装层隔离
class Tracker {
  track(event: string, params?: Record<string, any>) {}
  pageView(url: string, referrer?: string) {}
  click(element: string, extra?: Record<string, any>) {}
}

3. 埋点核心概念

事件模型

interface TrackEvent {
  event: string;                    // 事件名,如 "product_detail_view"
  properties: {                     // 事件属性
    product_id: string;
    price: number;
    source: string;                 // 来源页面/渠道
  };
  user_traits?: {                   // 用户属性(非每次必传)
    is_vip: boolean;
    level: number;
  };
  timestamp: number;                // 事件发生时间(毫秒时间戳)
  session_id: string;               // 会话 ID
  uuid: string;                     // 事件唯一 ID,用于去重
}

通用参数自动采集

function getCommonParams(): Record<string, any> {
  return {
    user_id: getUserId(),
    device_id: getDeviceId(),          // 设备指纹/持久化 ID
    os: navigator.platform,
    browser: getBrowser(),             // ua 解析
    app_version: __APP_VERSION__,      // 构建时注入
    page_url: location.href,
    referrer: document.referrer,
    session_id: getSessionId(),        // 会话持久
    timestamp: Date.now(),
    screen: `${screen.width}x${screen.height}`,
    language: navigator.language,
  };
}

用户标识与关联

匿名 -> 注册打通流程

用户打开 App → device_id 标识匿名用户
用户注册/登录 → 服务端关联 device_id + user_id
绑定后后续事件携带 user_id,历史匿名数据可归因

多端用户统一

// 注册时服务端建立映射表
{
  "user_id": "u_10086",
  "identities": [
    { "type": "device_id", "value": "device_abc123" },  // 手机端
    { "type": "device_id", "value": "device_xyz789" },  // PC 端
    { "type": "email", "value": "user@example.com" },
    { "type": "phone", "value": "138xxxx1234" }
  ]
}

用户标识方案对比

方案 持久性 跨域 用户可清除 适用场景
Cookie 中(可设过期) 不可跨域 Web 端基础方案
localStorage 永久(除非清除) 不跨域 SPA/固定域名
FingerprintJS 较高 不跨域 反作弊/补充方案
服务端登录态 取决于 Token 策略 可打通 受控 已登录用户
IDFA/OAID 系统级 跨应用 可重置 移动端

曝光埋点

IntersectionObserver 实现

class ExposureTracker {
  private observer: IntersectionObserver;
  private reported = new Set<string>(); // 去重

  constructor(threshold = 0.5) {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const id = entry.target.getAttribute('data-track-id');
            if (id && !this.reported.has(id)) {
              this.reported.add(id);
              Tracker.track('element_exposure', {
                element_id: id,
                visible_ratio: Math.round(entry.intersectionRatio * 100),
              });
            }
          }
        });
      },
      { threshold: [0, 0.25, 0.5, 0.75, 1] } // 多阈值精确控制
    );
  }

  observe(el: Element) {
    this.observer.observe(el);
  }

  disconnect() {
    this.observer.disconnect();
  }
}

列表滚动曝光去重

// Set 记录已上报元素 id,保证同元素只上报一次曝光
// 对于无限滚动列表:每次新加载的 item 设置 data-track-id
// 离开页面时重置:Session 级别曝光去重 vs 永久去重
class SessionDedupeExposure extends ExposureTracker {
  constructor() {
    super();
    // sessionStorage 可跨页面持久化同一 session 的去重记录
    this.reported = new Set(
      JSON.parse(sessionStorage.getItem('track_exposure') || '[]')
    );
  }

  protected report(id: string, ratio: number) {
    super.report(id, ratio);
    sessionStorage.setItem(
      'track_exposure',
      JSON.stringify([...this.reported])
    );
  }
}

虚拟列表中的曝光处理

// 虚拟列表只渲染可见 DOM,IntersectionObserver 天然适配
// 注意:回收 DOM 时 target 会被复用,需在元素复用前重置 data-track-id
// 使用 MutationObserver 监听 DOM 复用并重新 observe
function onVirtualItemMounted(el: HTMLElement, itemId: string) {
  el.setAttribute('data-track-id', `item_${itemId}`);
  exposureTracker.observe(el);
}

页面埋点

PV / UV

// PV:每次页面加载/路由切换上报 page_view 事件
// UV:服务端基于 user_id / device_id 去重

// SPA 路由切换
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function usePageView() {
  const location = useLocation();

  useEffect(() => {
    Tracker.track('page_view', {
      page_url: location.pathname + location.search,
      page_title: document.title,
      referrer: document.referrer || '',
    });
  }, [location]);
}

页面停留时长

class PageStayTracker {
  private enterTime: number;

  constructor() {
    this.enterTime = Date.now();
    this.setupVisibilityChange();
    this.setupBeforeUnload();
  }

  private setupVisibilityChange() {
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        // 切换到后台,记录已停留时长但不上报
        this.pauseTiming();
      } else {
        // 返回前台,恢复计时
        this.resumeTiming();
      }
    });
  }

  private setupBeforeUnload() {
    window.addEventListener('beforeunload', () => {
      const duration = Date.now() - this.enterTime;
      Tracker.track('page_leave', {
        page_url: location.href,
        duration_ms: duration,
      });
    });
  }
}

页面深度(滚动百分比)

let maxScroll = 0;
const SCROLL_DEPTH_THRESHOLDS = [25, 50, 75, 90, 100];

window.addEventListener('scroll', throttle(() => {
  const scrollTop = window.scrollY;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const percent = Math.min(100, Math.round((scrollTop / docHeight) * 100));

  // 仅记录达到的最大深度,避免重复上报
  SCROLL_DEPTH_THRESHOLDS.forEach((threshold) => {
    if (percent >= threshold && maxScroll < threshold) {
      maxScroll = threshold;
      Tracker.track('scroll_depth', { depth: threshold });
    }
  });
}, 200));

4. 埋点 SDK 设计

核心架构

初始化配置
   ↓
事件入队(Queue → localStorage 兜底)
   ↓
批量 flush(定时/定量触发)
   ↓
sendBeacon / fetch keepalive 上报
   ↓
服务端接收 → Kafka / ClickHouse

上报方式对比

方式 页面卸载可靠 请求类型 Payload 限制 特点
sendBeacon POST 64KB 不阻塞卸载,浏览器保证送达
fetch keepalive 任意 64KB(同 sendBeacon) 可控制 headers/cors
<img> 1x1 否(可能被阻断) GET URL 长度限制(~2KB) 跨域兼容最好,无需 CORS
XHR 否(异步可能被取消) 任意 传统方案,逐步淘汰
// sendBeacon 最佳实践:页面卸载兜底
function sendBeacon(data: Record<string, any>) {
  const url = 'https://t.example.com/collect';
  const blob = new Blob([JSON.stringify(data)], { type: 'text/plain' });
  const sent = navigator.sendBeacon(url, blob);
  if (!sent) {
    // sendBeacon 失败降级到 fetch keepalive
    fetch(url, {
      method: 'POST',
      body: JSON.stringify(data),
      keepalive: true,
      credentials: 'include',
    }).catch(() => {});
  }
}

上报策略

批量合并

const BATCH_SIZE = 10;    // 10 条或 5 秒触发上报
const FLUSH_INTERVAL = 5000;
const eventQueue: TrackEvent[] = [];

function enqueue(event: TrackEvent) {
  eventQueue.push(event);
  if (eventQueue.length >= BATCH_SIZE) {
    flush();
  }
}

// 定时 flush
setInterval(() => {
  if (eventQueue.length > 0) flush();
}, FLUSH_INTERVAL);

async function flush() {
  if (eventQueue.length === 0) return;
  const batch = eventQueue.splice(0);
  try {
    await fetch('/api/track', {
      method: 'POST',
      body: JSON.stringify({ events: batch, common: getCommonParams() }),
      headers: { 'Content-Type': 'application/json' },
    });
  } catch {
    // 失败放回队列(也可能丢,权衡)
    eventQueue.unshift(...batch);
  }
}

页面卸载立即上报

['beforeunload', 'pagehide', 'visibilitychange'].forEach((event) => {
  window.addEventListener(event, () => {
    if (eventQueue.length > 0) {
      sendBeacon({ events: eventQueue.splice(0) });
    }
  });
});

离线缓存

const OFFLINE_KEY = 'track_offline_queue';
const MAX_QUEUE_SIZE = 200; // 防止撑爆 localStorage

function saveToOffline(event: TrackEvent) {
  try {
    const queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]');
    if (queue.length >= MAX_QUEUE_SIZE) {
      queue.shift(); // 队首丢弃
    }
    queue.push(event);
    localStorage.setItem(OFFLINE_KEY, JSON.stringify(queue));
  } catch {
    // localStorage 满时清空
    localStorage.removeItem(OFFLINE_KEY);
  }
}

function flushOffline() {
  const queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]');
  if (queue.length === 0) return;
  localStorage.removeItem(OFFLINE_KEY);
  // 批量上报离线事件
  flushQueue(queue);
}

// 网络恢复时重试
window.addEventListener('online', flushOffline);

抽样上报

function shouldSample(rate: number): boolean {
  // rate: 0~1,0.1 表示 10% 概率上报
  return Math.random() < rate;
}

// 高频事件(mousemove/scroll 等)抽样处理
// 非关键 PV/曝光等场景也可抽样
Tracker.track = (event: string, params: any, sampleRate = 1) => {
  if (!shouldSample(sampleRate)) return;
  // ... 正常上报
};

数据压缩

// 字段缩写映射:减少 payload 体积
const FIELD_MAP: Record<string, string> = {
  event_name: 'en',
  event_params: 'ep',
  user_properties: 'up',
  timestamp: 'ts',
  session_id: 'sid',
  page_url: 'url',
};

function compress(event: TrackEvent): Record<string, any> {
  const result: Record<string, any> = {};
  for (const [key, value] of Object.entries(event)) {
    result[FIELD_MAP[key] || key] = value;
  }
  return result;
}

SDK 核心代码结构

interface TrackerConfig {
  appId: string;
  serverUrl: string;
  batchSize?: number;
  flushInterval?: number;
  enableOffline?: boolean;
  sampleRate?: number;
}

class TrackerSDK {
  private config: TrackerConfig;
  private queue: TrackEvent[] = [];
  private commonParams: Record<string, any>;
  private userId: string | null = null;

  constructor(config: TrackerConfig) {
    this.config = {
      batchSize: 10,
      flushInterval: 5000,
      enableOffline: true,
      sampleRate: 1,
      ...config,
    };
    this.commonParams = this.initCommonParams();
    this.setupPeriodicFlush();
    this.setupUnloadBeacon();
    this.flushOfflineIfNeeded();
  }

  private initCommonParams(): Record<string, any> {
    return {
      app_id: this.config.appId,
      device_id: getDeviceId(),
      session_id: generateSessionId(),
      os: navigator.platform,
      browser: navigator.userAgent,
      screen: `${screen.width}x${screen.height}`,
      language: navigator.language,
      timestamp: Date.now(),
    };
  }

  track(event: string, params?: Record<string, any>) {
    if (Math.random() >= (this.config.sampleRate ?? 1)) return;

    const eventObj: TrackEvent = {
      uuid: crypto.randomUUID(),
      event,
      properties: { ...params },
      timestamp: Date.now(),
      session_id: this.commonParams.session_id,
      ...(this.userId && this.commonParams.user_traits),
    };

    this.queue.push(eventObj);
    if (this.queue.length >= (this.config.batchSize ?? 10)) {
      this.flush();
    }
  }

  private async flush() {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0);
    const payload = {
      events: batch,
      common: this.commonParams,
    };

    try {
      // 低优先级 flush 走 requestIdleCallback
      requestIdleCallback(() => {
        navigator.sendBeacon(
          this.config.serverUrl,
          new Blob([JSON.stringify(payload)], { type: 'text/plain' })
        );
      });
    } catch {
      if (this.config.enableOffline) {
        this.saveToOffline(batch);
      }
    }
  }

  private setupPeriodicFlush() {
    setInterval(() => this.flush(), this.config.flushInterval);
  }

  private setupUnloadBeacon() {
    window.addEventListener('beforeunload', () => {
      if (this.queue.length > 0) {
        const payload = { events: this.queue.splice(0), common: this.commonParams };
        navigator.sendBeacon(
          this.config.serverUrl,
          new Blob([JSON.stringify(payload)], { type: 'text/plain' })
        );
      }
    });
  }

  setUserId(id: string) {
    this.userId = id;
    // 匿名 -> 登录关联专用事件
    this.track('user_login', { user_id: id });
  }
}

5. 埋点平台与工具

主流平台对比

平台 类型 核心能力 收费模式 适合场景
神策数据 国内 SaaS/私有化 全埋点 + 用户画像 + 分析模型 按量计费 中大型企业,需私有化部署
GrowingIO 国内 SaaS 无埋点 + 增长分析 + 智能运营 按量/企业版 增长驱动型产品
友盟+ 国内 SaaS 免费基础分析 + 阿系生态集成 免费/高级版 中小产品,快速接入
火山引擎 国内 SaaS 增长分析 + A/B 实验 + 智能运营 按量计费 字节生态/多云架构
Google Analytics 4 国际 SaaS 事件模型 + 机器学习 + 跨设备 免费 出海产品/全球用户
Mixpanel 国际 SaaS 产品分析 + 漏斗/留存 + AI 分析 按量免费 + 付费 产品驱动型团队
Amplitude 国际 SaaS 行为分析 + 预测 + 实验 按量付费 数据成熟度高产品
Segment 国际 CDP 数据管道 + 多工具分发路由 按量计费 多下游工具统一入口

私有化 vs SaaS

维度 SaaS 私有化部署
数据安全 数据在第三方服务器 数据完全自控
成本 按量付费,前期低 服务器 + 运维 + 许可证,前期高
维护 无需维护 需专职运维,版本升级
合规 需签署 DPA/数据协议 适合金融/政务等强合规行业
灵活性 产品功能受限 可深度定制,二次开发

数据治理

事件字典 Schema 定义

# schema/events/product_detail_view.yaml
event_name: product_detail_view
description: 商品详情页浏览
version: 1.0.0
owner: @team-growth

properties:
  product_id:
    type: string
    required: true
    description: 商品 ID
  price:
    type: number
    required: true
    description: 商品当前价格(分)
  source:
    type: string
    required: false
    enum: [homepage_recommend, search, category, external_share]
    description: 来源渠道

埋点验收流程

开发 → 测试环境验证事件 schema 完整性
    → 数据校验:必填字段非空、枚举值合法、类型正确
    → 联调环境确认数据落库
    → 预发环境回归验证
    → 上线后实时监控事件量波动

数据质量监测

// 检测字段缺失率异常
if (missingRate > threshold) {
  alert('商品详情浏览量异常下降', eventName);
}

// 检测枚举值异常
if (unknownSourceRate > 0.05) {
  alert('source 字段非法值占比过高', eventName);
}

// PV 波动告警
if (Math.abs(currentPV - avgPV) / avgPV > 0.3) {
  alert('页面 PV 异常波动', pageUrl);
}

6. 前端监控体系

三层监控

┌────────────────────────────────────┐
│        业务监控                     │
│  埋点异常 / API 成功率 / PV 波动     │
├────────────────────────────────────┤
│        性能监控                     │
│  Web Vitals / 自定义指标 / 资源瀑布图 │
├────────────────────────────────────┤
│        错误监控                     │
│  JS Error / Promise Rejection / 资源│
│  加载 / 接口异常                    │
└────────────────────────────────────┘

错误监控详解

window.onerror(全局 JS 错误)

window.onerror = (message, source, lineno, colno, error) => {
  // message: 错误信息
  // source: 出错文件 URL
  // lineno/colno: 行/列号
  // error: Error 对象

  Reporter.error({
    type: 'js_error',
    message,
    source,
    lineno,
    colno,
    stack: error?.stack,
    page_url: location.href,
    timestamp: Date.now(),
  });

  return true; // 阻止默认错误处理
};

// 跨域脚本需要 crossorigin 属性才能获取完整堆栈
// <script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
// 否则 onerror 只能拿到 "Script error." 无堆栈信息

unhandledrejection(未捕获 Promise 异常)

window.addEventListener('unhandledrejection', (event) => {
  Reporter.error({
    type: 'unhandled_rejection',
    reason: event.reason?.toString(),
    stack: event.reason?.stack,
    page_url: location.href,
    timestamp: Date.now(),
  });

  // 阻止上报系统处理,但已记录
  event.preventDefault();
});

React ErrorBoundary

class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback?: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    Reporter.error({
      type: 'react_error',
      message: error.message,
      stack: error.stack,
      component_stack: info.componentStack,
      page_url: location.href,
      timestamp: Date.now(),
    });
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h2>页面出错了,请刷新重试</h2>;
    }
    return this.props.children;
  }
}

// 使用
<ErrorBoundary>
  <ProductDetail />
</ErrorBoundary>

资源加载错误

// 方案一:元素 onerror(推荐,可精确定位)
document.querySelectorAll('img, script, link[rel="stylesheet"]').forEach((el) => {
  el.addEventListener('error', (e) => {
    Reporter.error({
      type: 'resource_error',
      resource_url: (e.target as HTMLElement).src || (e.target as HTMLElement).href,
      tag_name: (e.target as HTMLElement).tagName,
      page_url: location.href,
    });
  });
});

// 方案二:window 捕获阶段(覆盖动态加载的资源)
window.addEventListener('error', (e) => {
  const target = e.target as HTMLElement;
  if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
    Reporter.error({
      type: 'resource_error',
      resource_url: target.src || (target as any).href,
      tag_name: target.tagName,
    });
  }
}, true); // 捕获阶段

接口错误(axios 拦截器)

import axios from 'axios';

const api = axios.create({ baseURL: '/api', timeout: 10000 });

// 响应拦截器
api.interceptors.response.use(
  (response) => {
    // 业务错误码
    const { code, message } = response.data;
    if (code !== 0 && code !== 200) {
      Reporter.error({
        type: 'api_biz_error',
        api: response.config.url,
        code,
        message,
        params: response.config.params,
        duration_ms: Date.now() - (response.config as any)._startTime,
      });
    }
    return response;
  },
  (error) => {
    // 网络错误/超时
    const info = {
      type: 'api_network_error',
      api: error.config?.url,
      status: error.response?.status,
      message: error.message,       // "Network Error" / "timeout of 10000ms exceeded"
      duration_ms: Date.now() - (error.config as any)._startTime,
    };
    Reporter.error(info);

    // 区分处理
    if (error.code === 'ECONNABORTED') {
      // 超时
    } else if (!error.response) {
      // 网络断开
    } else if (error.response.status >= 500) {
      // 服务端 5xx
    }
    return Promise.reject(error);
  }
);

// 请求拦截器注入开始时间
api.interceptors.request.use((config) => {
  (config as any)._startTime = Date.now();
  return config;
});

Source Map 还原

flowchart LR
    A[构建] --> B[生成 .js + .map]
    B --> C{部署}
    C --> D[CDN: .js]
    C --> E[Sentry/自建: .map]
    D --> F[浏览器报错]
    F --> G[Sentry 接收]
    E --> G
    G --> H[还原为源码堆栈]

关键要点

// 1. webpack/vite 构建生成 Source Map
// webpack: devtool: 'hidden-source-map'  # 不输出 source map 到 bundle 末尾
// vite:   build.sourcemap = 'hidden'

// 2. 上传到 Sentry(使用 sentry-cli 或 webpack plugin)
// sentry-cli releases files <version> upload-sourcemaps ./dist --rewrite

// 3. 私密 Source Map 措施
// - 不上传到 CDN 公网
// - 上传到 Sentry 私有服务器或内网服务
// - 或通过 token 鉴权访问(Nginx 限制 referer/IP)
// - 构建产物移除 .map 文件后部署到 CDN

Sentry 原理与使用

import * as Sentry from '@sentry/react';

// 初始化
Sentry.init({
  dsn: 'https://xxx@sentry.example.com/1',
  environment: process.env.NODE_ENV,
  release: `my-app@${__COMMIT_HASH__}`,   // 关联源码映射
  tracesSampleRate: 0.1,                   // 性能追踪采样率
  replaysSessionSampleRate: 0.1,          // Session Replay 采样
  replaysOnErrorSampleRate: 1.0,          // 出错时全量回放

  // 自定义指纹去重
  beforeSend(event) {
    // 聚合同类错误
    const fingerprint = [event.exception?.values?.[0]?.type || 'unknown'];
    if (event.request?.url) {
      fingerprint.push(event.request.url.split('?')[0]);
    }
    event.fingerprint = fingerprint;
    return event;
  },
});

// Breadcrumbs:自动记录用户操作轨迹
// 用户点击、路由变化、网络请求、console.log 等自动记录
// 错误发生时附带 breadcrumbs 帮助回溯现场

// 手动添加 breadcrumb
Sentry.addBreadcrumb({
  category: 'ui.click',
  message: '用户点击了立即购买按钮',
  level: 'info',
});

// 手动捕获异常
try {
  riskyOperation();
} catch (e) {
  Sentry.captureException(e);
}

Sentry 核心功能

功能 说明
错误捕获 自动捕获全局错误、Promise 异常、React 错误
Breadcrumbs 记录用户操作序列,还原出错前操作轨迹
Release 关联 绑定 commit hash/version,精确还原源码行
指纹去重 按堆栈/消息/自定义规则计算 hash,合并同类事件
性能追踪 Tracing 自动记录页面加载/API 请求/fetch 调用
Session Replay 录屏回放用户操作全过程(Canvas/iframe/input 脱敏)
告警规则 错误率上升、新错误出现、自定义条件触发通知

7. 性能监控深入

RUM(Real User Monitoring)

PerformanceObserver 采集 Web Vitals

class WebVitalsCollector {
  private metrics: Record<string, number> = {};

  constructor() {
    this.observeLCP();
    this.observeCLS();
    this.observeFCP();
    this.observeTTFB();
    this.observeINP();
  }

  private observeLCP() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const last = entries[entries.length - 1];
      this.metrics.LCP = last.startTime;
    });
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
  }

  private observeCLS() {
    let clsValue = 0;
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!(entry as any).hadRecentInput) {
          clsValue += (entry as any).value;
        }
      }
      this.metrics.CLS = clsValue;
    });
    observer.observe({ type: 'layout-shift', buffered: true });
  }

  private observeFCP() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      this.metrics.FCP = entries[0]?.startTime;
    });
    observer.observe({ type: 'paint', buffered: true });
  }

  private observeTTFB() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      this.metrics.TTFB = entries[0]?.responseStart;
    });
    observer.observe({ type: 'navigation', buffered: true });
  }

  private observeINP() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry) => {
        // INP 取所有交互的最大值或 P75
        const delay = (entry as any).processingStart - entry.startTime;
        this.metrics.INP = Math.max(this.metrics.INP || 0, delay);
      });
    });
    observer.observe({ type: 'first-input', buffered: true });
  }

  report() {
    // 页面离开时上报所有指标
    window.addEventListener('beforeunload', () => {
      navigator.sendBeacon('/api/vitals', JSON.stringify(this.metrics));
    });
  }
}

RUM 聚合分析维度

维度 分组依据 价值
设备 mobile / desktop / tablet 识别低端设备性能短板
地域 按 ISP/CDN 节点 CDN 覆盖问题定位
网络 4G / 3G / WiFi 弱网优化决策
版本 app_version 分组 版本回归监控

RUM vs Lab Data

维度 RUM (PerformanceObserver) Lab Data (Lighthouse)
数据来源 真实用户 模拟环境
样本量 全量/大样本 单次或少量
环境覆盖 各种设备/网络/地域 标准化环境
问题定位 定位慢在哪儿 定位为什么慢
监控价值 持续监控回归 CI 质量门禁

自定义指标

FMP(首屏有意义内容)

// 在关键元素上标记 data-fmp
// <div data-fmp="hero-banner">...</div>
// <div data-fmp="product-list">...</div>

function observeFMP() {
  const fmpEntries: Record<string, number> = {};

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.entryType === 'element') {
        const el = (entry as any).element;
        if (el?.hasAttribute?.('data-fmp')) {
          fmpEntries[el.getAttribute('data-fmp')] = entry.renderTime || entry.loadTime;
        }
      }
    }
  });

  // 监听 Element Timing API(需提前加上 elementtiming 属性)
  observer.observe({ type: 'element', buffered: true });

  // 兜底:取首次 DOM 稳定后的时间作为 FMP
  requestAnimationFrame(() => {
    setTimeout(() => {
      const fmp = Math.min(...Object.values(fmpEntries));
      Reporter.metric('FMP', fmp);
    }, 0);
  });
}

TTI(可交互时间)

// TTI 定义:页面进入可稳定交互状态的时间
// 要求:5 秒窗口内无 >50ms 长任务

async function measureTTI() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const duration = entry.duration;
      if (duration > 50) {
        // 记录长任务,TTI 需向后推
        Reporter.metric('long_task', {
          start: entry.startTime,
          duration,
        });
      }
    }
  });
  observer.observe({ type: 'longtask', buffered: true });

  // 简化版 TTI:以 DOMContentLoaded + 无后续长任务的时间
  await new Promise((resolve) => {
    if (document.readyState === 'complete') {
      resolve(true);
    } else {
      window.addEventListener('load', () => resolve(true));
    }
  });

  // 等待 5 秒空闲窗口
  await new Promise((r) => setTimeout(r, 5000));
  // 判断窗口内有无长任务...
  // 有则向后推时间窗口
}

Navigation Timing

function getNavigationTiming() {
  const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];

  if (!entry) return {};

  return {
    dns_lookup: entry.domainLookupEnd - entry.domainLookupStart,
    tcp_connect: entry.connectEnd - entry.connectStart,
    tls_handshake: entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0,
    ttfb: entry.responseStart - entry.requestStart,
    dom_interactive: entry.domInteractive - entry.fetchStart,
    dom_content_loaded: entry.domContentLoadedEventEnd - entry.fetchStart,
    dom_complete: entry.domComplete - entry.fetchStart,
    load: entry.loadEventEnd - entry.fetchStart,
    redirect_count: entry.redirectCount,
  };
}

PerformanceObserver 持续监听

const TYPES = ['resource', 'paint', 'longtask', 'largest-contentful-paint', 'layout-shift', 'element', 'navigation'];

TYPES.forEach((type) => {
  try {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        Reporter.metric(`perf_${type}`, {
          name: entry.name,
          duration: entry.duration,
          startTime: entry.startTime,
          ...(entry as any).value && { value: (entry as any).value },
        });
      }
    });
    observer.observe({ type, buffered: true });
  } catch {
    // 不支持的类型跳过
  }
});

Dashboard

监控维度 关键指标 告警阈值 可视化方式
错误率 JS Error 率 / API 错误率 > 1% 告警 折线图 + 柱状分布
API 延迟 P50 / P95 / P99 P99 > 5s 告警 分位线
Web Vitals LCP / CLS / INP LCP > 4s 告警 趋势 + 分布直方图
PV 波动 page_view 同比 下降 > 30% 告警 面积图
资源加载 资源错误率 / 加载耗时 > 5% 告警 瀑布图

Grafana 典型面板

┌──────────────────────────────────────────────────┐
│  错误率趋势(最近 7 天)                          │
│  ╱‾‾╲    ╱‾╲    ╱‾‾╲                             │
│ ╱    ╲  ╱  ╲  ╱    ╲                            │
│╱      ╲╱    ╲╱      ╲                           │
├──────────────────────────────────────────────────┤
│  API P99 分位线(按 API 分组)                    │
│  /api/order   ████████░░░ 1200ms                 │
│  /api/user    ████░░░░░░░  450ms                 │
│  /api/search  ██████████░ 2100ms                 │
├──────────────────────────────────────────────────┤
│  LCP 分布(设备维度)                             │
│  mobile   ████████░░ 0-2.5s │ ██░░ 2.5-4s │ ░░ >4s│
│  desktop  █████████░ 0-2.5s │ █░░ 2.5-4s  │ ░░ >4s│
├──────────────────────────────────────────────────┤
│  页面 PV 同比(昨日 vs 上周同日)                 │
│  /home       12,345 ▼ -5.2%                      │
│  /product     8,765 ▲ +12.3%                     │
│  /cart        3,210 ▼ -8.7%                      │
└──────────────────────────────────────────────────┘

告警降级策略

const ALERT_LEVELS = {
  critical: { threshold: 0.05, channels: ['sms', 'phone'], cooldown: 300 }, // 5% 错误率
  warning:  { threshold: 0.01, channels: ['dingtalk', 'email'], cooldown: 600 },
  info:     { threshold: 0.001, channels: ['daily_report'] },
};

// 降级策略:高负载时降低采样/关闭非关键埋点
function downgradeIfNeeded() {
  if (getErrorRate() > 0.05) {
    // 非关键事件降低采样率
    setSampleRate('non_critical', 0.1);
  }
  if (getApiP99() > 10000) {
    // 关闭非核心埋点
    disableTrackers(['scroll_depth', 'hover']);
  }
}

8. 数据合规与隐私

GDPR / 个人信息保护法

// 用户数据采集需明确告知并获得同意
// 实现示例:同意管理模块
class ConsentManager {
  private consent: Record<string, boolean> = {};
  private readonly STORAGE_KEY = 'track_consent';

  constructor() {
    this.load();
  }

  private load() {
    try {
      this.consent = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '{}');
    } catch {
      this.consent = {};
    }
  }

  getConsent(category: 'essential' | 'analytics' | 'marketing'): boolean {
    // 必要类别的默认同意(不影响基本功能)
    if (category === 'essential') return true;
    return this.consent[category] === true;
  }

  setConsent(category: string, granted: boolean) {
    this.consent[category] = granted;
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.consent));
    this.syncToServer(category, granted);
  }

  revokeAll() {
    localStorage.removeItem(this.STORAGE_KEY);
    this.consent = {};
    this.consent.essential = true; // 必要类不可撤销
  }

  private syncToServer(category: string, granted: boolean) {
    fetch('/api/consent', {
      method: 'POST',
      body: JSON.stringify({ category, granted, timestamp: Date.now() }),
    });
  }
}

// 数据最小化原则:只采集需要的字段
// 用户有权查看/删除被采集的数据
// 实现方式:服务端提供 data-export / data-deletion API
// Cookie Consent Banner 实现要点
// 1. 分为必要/分析/营销三类
// 2. 用户主动选择同意/拒绝,不能默认同意了
// 3. 记录用户选择持久化

// SameSite 属性影响
// SameSite=Lax  (默认)允许顶级导航发送 Cookie,不影响埋点跨站归因
// SameSite=Strict 完全不发送第三方 Cookie,跨站追踪失效
// SameSite=None + Secure 跨站可用但需 HTTPS,适用于多域名跟踪

// 第三方 Cookie 渐被淘汰,替代方案:
// - 第一方 Cookie(CNAME 伪装)
// - Server-Side Tracking(避免浏览器限制)
// - 广告归因改用 Conversion Measurement API

数据脱敏与安全

// 敏感字段脱敏规则
const SENSITIVE_KEYS = ['password', 'id_card', 'phone', 'email', 'token'];

function sanitize(payload: Record<string, any>): Record<string, any> {
  const result = { ...payload };

  for (const key of Object.keys(result)) {
    if (SENSITIVE_KEYS.some((sk) => key.includes(sk))) {
      const value = String(result[key]);
      if (value.length > 4) {
        // 手机号:138****1234
        // 邮箱:u***@example.com
        // 身份证:110101****5678
        result[key] = value.slice(0, 3) + '****' + value.slice(-4);
      } else {
        delete result[key];
      }
    }
  }

  return result;
}

// 数据传输强制 HTTPS/TLS
// 存储加密:服务端对敏感字段 AEAD 加密
// 日志脱敏:日志输出前过滤 SENSITIVE_KEYS 对应值
// 权限控制:数据访问需权限审批,不可导出全量原始数据

匿名化与假名化

方案 技术 可逆 法律要求 适用场景
匿名化 哈希(单向)+ 删除原始 ID 不可逆 GDPR 豁免 统计报表/分析
假名化 用户 ID 加盐哈希 或 可逆加密 控制方可还原 仍属个人数据 用户追踪/个性化
// 假名化(服务端实现)
// 用户 ID → SHA256(user_id + secret_salt) → 假名 ID
// 可加盐配置不同业务的假名不同,不可跨业务关联

// 数据分析场景:使用假名或匿名数据,不涉及真实用户
// 用户追踪场景:保留关联能力但严格控制访问权限

// GDPR 要求:即使假名化也需告知用户,用户有权要求删除假名化数据

参考:Google Analytics 4 文档 / Sentry 官方文档 / 神策数据埋点规范 / Performance API Spec