前端监控体系搭建实战

Created on

前言

“线上怎么又白屏了?”、“为什么加载这么慢?”—— 如果你经常听到这些问题却无从下手,说明你需要一套完善的前端监控系统。本文将带你从零搭建生产可用的监控平台。

监控体系架构

┌─────────────────────────────────────────┐
│           前端应用 (SDK)                  │
│  ┌─────────┬─────────┬──────────────┐   │
│  │性能监控│错误监控│  行为追踪     │   │
│  └────┬────┴────┬────┴──────┬───────┘   │
│       │         │           │           │
│       └─────────┴───────────┘           │
│                 │                        │
│          数据收集 & 上报                 │
└─────────────────┼───────────────────────┘


┌─────────────────────────────────────────┐
│          数据接收服务 (API)               │
│  ┌──────────────────────────────────┐   │
│  │ 数据验证 → 清洗 → 聚合 → 存储   │   │
│  └──────────────────────────────────┘   │
└─────────────────┼───────────────────────┘


┌─────────────────────────────────────────┐
│         数据分析 & 可视化                 │
│  ┌────────┬─────────┬──────────────┐   │
│  │Dashboard│告警系统 │数据分析报表 │   │
│  └────────┴─────────┴──────────────┘   │
└─────────────────────────────────────────┘

性能监控

Web Vitals 核心指标

// monitor/performance.js
import { onCLS, onFID, onLCP, onFCP, onTTFB } from "web-vitals";

class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // Largest Contentful Paint - 最大内容绘制
    onLCP((metric) => {
      this.reportMetric("LCP", metric);
    });

    // First Input Delay - 首次输入延迟
    onFID((metric) => {
      this.reportMetric("FID", metric);
    });

    // Cumulative Layout Shift - 累积布局偏移
    onCLS((metric) => {
      this.reportMetric("CLS", metric);
    });

    // First Contentful Paint - 首次内容绘制
    onFCP((metric) => {
      this.reportMetric("FCP", metric);
    });

    // Time to First Byte - 首字节时间
    onTTFB((metric) => {
      this.reportMetric("TTFB", metric);
    });
  }

  reportMetric(name, metric) {
    const data = {
      name,
      value: metric.value,
      rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
      delta: metric.delta,
      id: metric.id,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    };

    this.metrics[name] = data;
    this.send(data);
  }

  send(data) {
    // 数据上报
    navigator.sendBeacon("/api/metrics", JSON.stringify(data));
  }
}

export default new PerformanceMonitor();
// 获取页面加载性能数据
function getNavigationTiming() {
  const timing = performance.getEntriesByType("navigation")[0];

  if (!timing) return null;

  return {
    // DNS 查询耗时
    dnsTime: timing.domainLookupEnd - timing.domainLookupStart,

    // TCP 连接耗时
    tcpTime: timing.connectEnd - timing.connectStart,

    // SSL 安全连接耗时
    sslTime: timing.secureConnectionStart
      ? timing.connectEnd - timing.secureConnectionStart
      : 0,

    // 请求响应耗时
    requestTime: timing.responseEnd - timing.requestStart,

    // DOM 解析耗时
    domParseTime: timing.domInteractive - timing.responseEnd,

    // 资源加载耗时
    resourceTime: timing.loadEventStart - timing.domContentLoadedEventEnd,

    // 首字节时间
    ttfb: timing.responseStart - timing.requestStart,

    // DOM Ready 时间
    domReady: timing.domContentLoadedEventEnd - timing.fetchStart,

    // 页面完全加载时间
    loadTime: timing.loadEventEnd - timing.fetchStart,

    // 重定向次数
    redirectCount: timing.redirectCount,

    // 传输类型 (navigate/reload/back_forward/prerender)
    type: timing.type,
  };
}

// 在页面加载完成后上报
window.addEventListener("load", () => {
  setTimeout(() => {
    const perfData = getNavigationTiming();
    navigator.sendBeacon("/api/perf", JSON.stringify(perfData));
  }, 0);
});

Resource Timing 资源加载性能

// 监控资源加载性能
function getResourceTiming() {
  const resources = performance.getEntriesByType("resource");

  return resources.map((resource) => ({
    name: resource.name,
    type: resource.initiatorType,
    duration: resource.duration,
    size: resource.transferSize,
    cached: resource.transferSize === 0,
    startTime: resource.startTime,
    // 慢资源标记 (超过 1s)
    slow: resource.duration > 1000,
  }));
}

// 监听新资源加载
const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (entry.duration > 1000) {
      // 上报慢资源
      reportSlowResource({
        name: entry.name,
        duration: entry.duration,
        size: entry.transferSize,
      });
    }
  });
});

observer.observe({ entryTypes: ["resource"] });

错误监控

JavaScript 错误捕获

// monitor/error.js
class ErrorMonitor {
  constructor() {
    this.errors = [];
    this.init();
  }

  init() {
    // 全局错误监听
    window.addEventListener(
      "error",
      (event) => {
        this.handleError({
          type: "jsError",
          message: event.message,
          filename: event.filename,
          lineno: event.lineno,
          colno: event.colno,
          stack: event.error?.stack,
          timestamp: Date.now(),
        });
      },
      true
    );

    // Promise 未捕获错误
    window.addEventListener("unhandledrejection", (event) => {
      this.handleError({
        type: "promiseError",
        message: event.reason?.message || event.reason,
        stack: event.reason?.stack,
        timestamp: Date.now(),
      });
    });

    // 资源加载错误
    window.addEventListener(
      "error",
      (event) => {
        const target = event.target;
        if (target !== window) {
          this.handleError({
            type: "resourceError",
            tagName: target.tagName,
            src: target.src || target.href,
            timestamp: Date.now(),
          });
        }
      },
      true
    );
  }

  handleError(error) {
    // 添加上下文信息
    const enrichedError = {
      ...error,
      url: window.location.href,
      userAgent: navigator.userAgent,
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      // 用户信息 (如果有)
      userId: window.__USER_ID__,
      // 环境信息
      env: process.env.NODE_ENV,
    };

    this.errors.push(enrichedError);
    this.report(enrichedError);
  }

  report(error) {
    // 错误去重
    const errorKey = `${error.type}_${error.message}_${error.filename}_${error.lineno}`;
    if (this.hasReported(errorKey)) {
      return;
    }

    // 采样上报 (生产环境可能需要)
    if (Math.random() > 0.1) {
      // 10% 采样率
      return;
    }

    navigator.sendBeacon("/api/error", JSON.stringify(error));
    this.markReported(errorKey);
  }

  hasReported(key) {
    return sessionStorage.getItem(`error_${key}`) === "1";
  }

  markReported(key) {
    sessionStorage.setItem(`error_${key}`, "1");
  }
}

export default new ErrorMonitor();

Source Map 还原

// 后端 Source Map 解析
const sourceMap = require("source-map");
const fs = require("fs");

async function parseError(error) {
  try {
    // 读取 Source Map 文件
    const mapContent = fs.readFileSync(`./dist/${error.filename}.map`, "utf8");
    const consumer = await new sourceMap.SourceMapConsumer(mapContent);

    // 获取原始位置
    const originalPosition = consumer.originalPositionFor({
      line: error.lineno,
      column: error.colno,
    });

    return {
      ...error,
      originalSource: originalPosition.source,
      originalLine: originalPosition.line,
      originalColumn: originalPosition.column,
      originalName: originalPosition.name,
    };
  } catch (e) {
    return error;
  }
}

用户行为追踪

页面访问追踪

// monitor/pageview.js
class PageViewTracker {
  constructor() {
    this.startTime = Date.now();
    this.init();
  }

  init() {
    // 页面进入
    this.trackPageEnter();

    // 页面离开
    window.addEventListener("beforeunload", () => {
      this.trackPageLeave();
    });

    // 页面可见性变化
    document.addEventListener("visibilitychange", () => {
      if (document.hidden) {
        this.trackPageHide();
      } else {
        this.trackPageShow();
      }
    });

    // SPA 路由变化
    this.trackRouteChange();
  }

  trackPageEnter() {
    const data = {
      type: "pageEnter",
      url: window.location.href,
      referrer: document.referrer,
      timestamp: Date.now(),
    };
    this.send(data);
  }

  trackPageLeave() {
    const data = {
      type: "pageLeave",
      url: window.location.href,
      duration: Date.now() - this.startTime,
      timestamp: Date.now(),
    };
    this.send(data);
  }

  trackPageHide() {
    this.send({ type: "pageHide", timestamp: Date.now() });
  }

  trackPageShow() {
    this.send({ type: "pageShow", timestamp: Date.now() });
  }

  trackRouteChange() {
    // History API
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function (...args) {
      originalPushState.apply(this, args);
      this.trackPageEnter();
    }.bind(this);

    history.replaceState = function (...args) {
      originalReplaceState.apply(this, args);
      this.trackPageEnter();
    }.bind(this);

    // Hash 变化
    window.addEventListener("hashchange", () => {
      this.trackPageEnter();
    });
  }

  send(data) {
    navigator.sendBeacon("/api/pageview", JSON.stringify(data));
  }
}

export default new PageViewTracker();

用户点击追踪

// monitor/click.js
class ClickTracker {
  init() {
    document.addEventListener(
      "click",
      (event) => {
        const target = event.target;

        // 获取元素信息
        const data = {
          type: "click",
          tagName: target.tagName,
          id: target.id,
          className: target.className,
          text: target.innerText?.slice(0, 50), // 限制长度
          xpath: this.getXPath(target),
          timestamp: Date.now(),
        };

        // 只追踪重要元素
        if (this.shouldTrack(target)) {
          this.send(data);
        }
      },
      true
    );
  }

  shouldTrack(element) {
    // 追踪按钮、链接、表单元素
    const trackedTags = ["BUTTON", "A", "INPUT", "SELECT"];
    return (
      trackedTags.includes(element.tagName) ||
      element.hasAttribute("data-track")
    );
  }

  getXPath(element) {
    if (element.id) {
      return `//*[@id="${element.id}"]`;
    }

    const parts = [];
    while (element && element.nodeType === Node.ELEMENT_NODE) {
      let index = 0;
      let sibling = element.previousSibling;
      while (sibling) {
        if (
          sibling.nodeType === Node.ELEMENT_NODE &&
          sibling.tagName === element.tagName
        ) {
          index++;
        }
        sibling = sibling.previousSibling;
      }

      const tagName = element.tagName.toLowerCase();
      const pathIndex = index > 0 ? `[${index + 1}]` : "";
      parts.unshift(`${tagName}${pathIndex}`);

      element = element.parentNode;
    }

    return "/" + parts.join("/");
  }

  send(data) {
    navigator.sendBeacon("/api/click", JSON.stringify(data));
  }
}

export default new ClickTracker();

数据上报策略

批量上报

class Reporter {
  constructor() {
    this.queue = [];
    this.maxSize = 10; // 队列最大长度
    this.maxWait = 5000; // 最大等待时间 (ms)
    this.timer = null;
  }

  add(data) {
    this.queue.push(data);

    if (this.queue.length >= this.maxSize) {
      this.flush();
    } else if (!this.timer) {
      this.timer = setTimeout(() => this.flush(), this.maxWait);
    }
  }

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

    const data = this.queue.splice(0);
    this.send(data);

    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }

  send(data) {
    // 优先使用 sendBeacon
    if (navigator.sendBeacon) {
      navigator.sendBeacon("/api/batch", JSON.stringify(data));
    } else {
      // 降级到 fetch
      fetch("/api/batch", {
        method: "POST",
        body: JSON.stringify(data),
        headers: { "Content-Type": "application/json" },
        keepalive: true,
      }).catch(console.error);
    }
  }
}

export default new Reporter();

请求失败重试

class RetryReporter {
  constructor() {
    this.maxRetries = 3;
    this.retryDelay = 1000;
  }

  async send(data, retries = 0) {
    try {
      const response = await fetch("/api/report", {
        method: "POST",
        body: JSON.stringify(data),
        headers: { "Content-Type": "application/json" },
      });

      if (!response.ok) {
        throw new Error("Request failed");
      }
    } catch (error) {
      if (retries < this.maxRetries) {
        // 指数退避重试
        const delay = this.retryDelay * Math.pow(2, retries);
        setTimeout(() => {
          this.send(data, retries + 1);
        }, delay);
      } else {
        // 重试失败,存储到本地
        this.saveToLocal(data);
      }
    }
  }

  saveToLocal(data) {
    try {
      const stored = JSON.parse(localStorage.getItem("failed_reports") || "[]");
      stored.push(data);
      localStorage.setItem("failed_reports", JSON.stringify(stored));
    } catch (e) {
      console.error("Failed to save report", e);
    }
  }

  // 页面加载时重新发送失败的数据
  retrySavedReports() {
    try {
      const stored = JSON.parse(localStorage.getItem("failed_reports") || "[]");
      if (stored.length > 0) {
        stored.forEach((data) => this.send(data));
        localStorage.removeItem("failed_reports");
      }
    } catch (e) {
      console.error("Failed to retry reports", e);
    }
  }
}

监控 SDK 封装

// monitor/index.js
class Monitor {
  constructor(options = {}) {
    this.options = {
      appId: options.appId,
      userId: options.userId,
      apiUrl: options.apiUrl || "/api/monitor",
      enablePerformance: options.enablePerformance !== false,
      enableError: options.enableError !== false,
      enableBehavior: options.enableBehavior !== false,
      sampleRate: options.sampleRate || 1,
    };

    this.init();
  }

  init() {
    // 采样控制
    if (Math.random() > this.options.sampleRate) {
      return;
    }

    if (this.options.enablePerformance) {
      import("./performance").then((module) => {
        this.performance = module.default;
      });
    }

    if (this.options.enableError) {
      import("./error").then((module) => {
        this.error = module.default;
      });
    }

    if (this.options.enableBehavior) {
      Promise.all([import("./pageview"), import("./click")]).then(
        ([pageview, click]) => {
          this.pageview = pageview.default;
          this.click = click.default;
        }
      );
    }
  }

  // 自定义事件追踪
  track(eventName, properties = {}) {
    const data = {
      type: "customEvent",
      eventName,
      properties,
      timestamp: Date.now(),
      url: window.location.href,
    };

    navigator.sendBeacon(this.options.apiUrl, JSON.stringify(data));
  }

  // 设置用户信息
  setUser(userId, userInfo = {}) {
    this.options.userId = userId;
    this.options.userInfo = userInfo;
  }
}

// 使用示例
const monitor = new Monitor({
  appId: "my-app",
  userId: "user-123",
  sampleRate: 0.5, // 50% 采样
  enablePerformance: true,
  enableError: true,
  enableBehavior: true,
});

// 自定义事件
monitor.track("purchase", {
  productId: "12345",
  amount: 99.99,
});

export default Monitor;

后端接收服务

// server/monitor.js
const express = require("express");
const router = express.Router();

// 数据验证
function validateData(data) {
  return data && typeof data === "object" && data.timestamp;
}

// 接收错误数据
router.post("/api/error", async (req, res) => {
  const error = req.body;

  if (!validateData(error)) {
    return res.status(400).json({ error: "Invalid data" });
  }

  try {
    // 存储到数据库
    await db.errors.insert({
      ...error,
      receivedAt: new Date(),
    });

    // 严重错误实时告警
    if (error.type === "jsError") {
      await sendAlert(error);
    }

    res.json({ success: true });
  } catch (e) {
    res.status(500).json({ error: "Server error" });
  }
});

// 接收性能数据
router.post("/api/perf", async (req, res) => {
  const perfData = req.body;

  try {
    await db.performance.insert({
      ...perfData,
      receivedAt: new Date(),
    });

    res.json({ success: true });
  } catch (e) {
    res.status(500).json({ error: "Server error" });
  }
});

// 批量接收
router.post("/api/batch", async (req, res) => {
  const batch = req.body;

  if (!Array.isArray(batch)) {
    return res.status(400).json({ error: "Invalid batch data" });
  }

  try {
    // 按类型分组
    const grouped = batch.reduce((acc, item) => {
      acc[item.type] = acc[item.type] || [];
      acc[item.type].push(item);
      return acc;
    }, {});

    // 批量插入
    for (const [type, items] of Object.entries(grouped)) {
      await db[type].insertMany(items);
    }

    res.json({ success: true });
  } catch (e) {
    res.status(500).json({ error: "Server error" });
  }
});

module.exports = router;

告警系统

// server/alert.js
async function sendAlert(error) {
  // 告警规则
  const rules = {
    // 错误率超过阈值
    errorRate: {
      threshold: 0.01, // 1%
      window: 5 * 60 * 1000, // 5分钟
    },
    // 慢请求比例
    slowRequest: {
      threshold: 0.1, // 10%
      duration: 1000, // 1秒
    },
  };

  // 检查错误率
  const recentErrors = await db.errors.count({
    timestamp: { $gte: Date.now() - rules.errorRate.window },
  });

  const totalRequests = await db.pageviews.count({
    timestamp: { $gte: Date.now() - rules.errorRate.window },
  });

  const errorRate = recentErrors / totalRequests;

  if (errorRate > rules.errorRate.threshold) {
    // 发送告警
    await notify({
      level: "critical",
      message: `Error rate is ${(errorRate * 100).toFixed(2)}%`,
      errors: recentErrors,
    });
  }
}

async function notify(alert) {
  // 邮件通知
  await sendEmail({
    to: "[email protected]",
    subject: `[${alert.level.toUpperCase()}] Frontend Alert`,
    body: alert.message,
  });

  // 企业微信/钉钉通知
  await sendWebhook({
    url: process.env.WEBHOOK_URL,
    data: alert,
  });

  // Sentry
  if (process.env.SENTRY_DSN) {
    Sentry.captureMessage(alert.message, {
      level: alert.level,
      extra: alert,
    });
  }
}

总结

完整的前端监控体系包括:

  1. 性能监控: Web Vitals、资源加载、页面渲染
  2. 错误监控: JS 错误、Promise 错误、资源错误
  3. 行为监控: 页面访问、用户交互、自定义事件
  4. 数据上报: 批量上报、失败重试、采样控制
  5. 数据分析: 实时大盘、趋势分析、告警通知

记住:监控不是目的,提升用户体验才是最终目标。

参考资源