Skip to content

Go back

基于前端监控架构的设计与实现

Published:  at 

为什么要做前端监控

前端监控系统是保障应用稳定性的基础设施。通过监控可以:

  1. 快速定位问题 - 及时发现并修复线上错误
  2. 优化用户体验 - 基于真实数据优化性能
  3. 了解用户行为 - 数据分析驱动产品决策
  4. 预防潜在问题 - 异常趋势预警

系统架构

数据从浏览器到看板的链路可以概括为一条流水线:

  1. Client SDK
    • Error 捕获器
    • Performance 采集器
    • Behavior 采集器
    • Custom 事件
  2. Data Pipeline — 收集、过滤、缓冲
  3. Upload API — 批量上报、压缩
  4. Web API — 接收、存储
  5. 存储层 — 如 ClickHouse / Elasticsearch
  6. 可视化平台 — Dashboard、告警

核心模块设计

// monitor-core.js - 核心入口
import { ErrorTracker } from "./error";
import { PerformanceTracker } from "./performance";
import { BehaviorTracker } from "./behavior";
import { DataPipeline } from "./pipeline";
import { UploadManager } from "./uploader";

class MonitorSDK {
  constructor(options) {
    this.options = {
      dsn: "", // 上报服务器地址
      appId: "", // 应用标识
      environment: "production",
      sampleRate: 1, // 采样率 0-1
      ...options,
    };

    // 初始化数据管道
    this.pipeline = new DataPipeline(this.options);

    // 初始化各模块
    this.error = new ErrorTracker(this.options, this.pipeline);
    this.performance = new PerformanceTracker(this.options, this.pipeline);
    this.behavior = new BehaviorTracker(this.options, this.pipeline);

    // 初始化上传管理器
    this.uploader = new UploadManager(this.options);
  }

  // 启动监控
  start() {
    this.error.install();
    this.performance.collect();
    this.behavior.track();
  }
}

export default MonitorSDK;

错误监控模块

错误分类

类型捕获方式示例
JS 运行时错误window.onerror变量未定义
Promise 异常unhandledrejectionPromise.reject()
资源加载错误error 事件图片 404
Vue/React 错误框架错误边界组件渲染异常
自定义错误主动上报业务逻辑错误

错误收集器实现

// error-tracker.js
import { getGlobal, isString, isFunction, isObject } from "../utils";
import { Severity } from "../severity";

export class ErrorTracker {
  constructor(options, pipeline) {
    this.options = options;
    this.pipeline = pipeline;
    this.global = getGlobal();
  }

  install() {
    // 1. 监听 JS 运行时错误
    this.global.onerror = (message, source, lineno, colno, error) => {
      this.handleError({
        type: "javascript",
        message,
        source,
        lineno,
        colno,
        stack: error?.stack,
        error,
      });
    };

    // 2. 监听未处理的 Promise 拒绝
    this.global.onunhandledrejection = event => {
      this.handleError({
        type: "unhandledrejection",
        message: event.reason?.message || "Unhandled Promise Rejection",
        stack: event.reason?.stack,
        error: event.reason,
      });
    };

    // 3. 监听资源加载错误
    this.global.addEventListener(
      "error",
      event => {
        if (event.target !== window) {
          this.handleResourceError(event.target);
        }
      },
      true
    );

    // 4. 监听 Vue 错误(如果存在)
    if (this.global.__VUE__) {
      this.installVueErrorHandler();
    }

    // 5. 监听 React 错误(如果存在)
    if (this.global.__REACT__) {
      this.installReactErrorHandler();
    }
  }

  handleError(errorData) {
    // 过滤忽略的错误
    if (this.isIgnoredError(errorData)) {
      return;
    }

    // 构造错误实体
    const errorEntity = {
      id: this.generateErrorId(),
      appId: this.options.appId,
      timestamp: Date.now(),
      environment: this.options.environment,
      userAgent: navigator.userAgent,
      url: location.href,
      referrer: document.referrer,

      // 错误信息
      type: errorData.type,
      message: errorData.message,
      stack: errorData.stack,
      filename: errorData.source,
      lineno: errorData.lineno,
      colno: errorData.colno,

      // 浏览器上下文
      name: errorData.error?.name,
      cause: errorData.error?.cause,

      // 严重级别
      severity: this.calculateSeverity(errorData),
    };

    // 上报错误
    this.pipeline.send({
      event: "error",
      data: errorEntity,
    });
  }

  handleResourceError(target) {
    const errorEntity = {
      id: this.generateErrorId(),
      appId: this.options.appId,
      timestamp: Date.now(),
      type: "resource",

      // 资源信息
      src: target.src || target.href,
      tagName: target.tagName,
      rel: target.rel,

      // 页面上下文
      url: location.href,
      referrer: document.referrer,

      severity: Severity.WARNING,
    };

    this.pipeline.send({
      event: "error",
      data: errorEntity,
    });
  }

  isIgnoredError(errorData) {
    // 忽略列表
    const ignorePatterns = [
      /ResizeObserver/,
      /Network Error/,
      /chunk load fail/i,
      /loading chunk/i,
    ];

    return ignorePatterns.some(pattern => pattern.test(errorData.message));
  }

  calculateSeverity(errorData) {
    // 根据错误类型和堆栈判断严重级别
    if (errorData.type === "unhandledrejection") {
      return Severity.WARNING;
    }
    if (errorData.stack?.includes("core-js")) {
      return Severity.INFO;
    }
    return Severity.ERROR;
  }

  generateErrorId() {
    return `err_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
  }
}

性能监控模块

性能指标体系

指标说明计算方式
FPFirst Paint 首次绘制页面开始绘制
FCPFirst Contentful Paint 首次内容绘制第一个内容渲染
LCPLargest Contentful Paint 最大内容绘制最大元素渲染
FMPFirst Meaningful Paint 首次有意义绘制主要内容出现
TTIInteractive 可交互时间可交互
TBTTotal Blocking Time 总阻塞时间FCP 到 TTI
CLSCumulative Layout Shift 累积布局偏移视觉稳定性
SISpeed Index 速度指数视觉填充速度

性能采集器实现

// performance-tracker.js
import { getGlobal } from '../utils';

export class PerformanceTracker {
  constructor(options, pipeline) {
    this.options = options;
    this.pipeline = pipeline;
    this.global = getGlobal();
    this Marks = new Map();
    this.Measures = [];
  }

  collect() {
    // 1. 监听页面加载完成
    this.global.addEventListener('load', () => {
      // 延迟获取,确保所有指标就绪
      setTimeout(() => this.collectWebVitals(), 0);
    });

    // 2. 监听页面卸载,用于上报 pv 结束
    this.global.addEventListener('beforeunload', () => {
      this.reportPageEnd();
    });

    // 3. 监听路由变化(SPA)
    this.observeRouteChange();

    // 4. 监听长任务
    this.observeLongTasks();
  }

  collectWebVitals() {
    const paint = performance.getEntriesByType('paint');
    const navigation = performance.getEntriesByType('navigation')[0];
    const resource = performance.getEntriesByType('resource');

    // 计算核心指标
    const vitals = {
      // Paint指标
      fp: this.getEntryByName(paint, 'first-paint'),
      fcp: this.getEntryByName(paint, 'first-contentful-paint'),

      // Navigation指标
      domContentLoaded: navigation?.domContentLoadedEventEnd,
      loadComplete: navigation?.loadEventEnd,
      firstByte: navigation?.responseStart,
      resourceCount: resource?.length || 0,
      resourceSize: this.calculateResourceSize(resource),

      // 计算指标
      ttfb: navigation?.responseStart - navigation?.requestStart,
      domReady: navigation?.domContentLoadedEventEnd - navigation?.requestStart,
      pageLoad: navigation?.loadEventEnd - navigation?.requestStart,
    };

    // 上报性能数据
    this.pipeline.send({
      event: 'performance',
      data: {
        ...vitals,
        timestamp: Date.now(),
        url: location.href,
        appId: this.options.appId,
      },
    });
  }

  observeRouteChange() {
    // MutationObserver 监听 hash/router 变化
    let lastUrl = location.href;
    const checkUrlChange = () => {
      if (location.href !== lastUrl) {
        this.reportPageEnd();
        lastUrl = location.href;
      }
    };

    setInterval(checkUrlChange, 1000);
    this.global.addEventListener('popstate', checkUrlChange);
  }

  observeLongTasks() {
    // ���用 PerformanceObserver 监听长任务
    if ('PerformanceObserver' in this.global) {
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (entry.blockingTime > 50) {
            this.pipeline.send({
              event: 'longtask',
              data: {
                duration: entry.duration,
                blockingTime: entry.blockingTime,
                startTime: entry.startTime,
                url: location.href,
              },
            });
          }
        }
      });
      observer.observe({ type: 'longtask', buffered: true });
    }
  }

  getEntryByName(entries, name) {
    return entries.find((e) => e.name === name)?.startTime || 0;
  }

  calculateResourceSize(resources) {
    return resources.reduce((total, r) => total + (r.transferSize || 0), 0);
  }

  reportPageEnd() {
    // 上报页面停留时长等
    this.pipeline.send({
      event: 'page_end',
      data: {
        duration: performance.now(),
        url: location.href,
        timestamp: Date.now(),
      },
    });
  }

  // 记录自定义时间点
  mark(name) {
    this.Marks.set(name, performance.now());
  }

  // 测量两个时间点之间的耗时
  measure(name, startMark, endMark = null) {
    const startTime = this.Marks.get(startMark);
    const endTime = endMark ? this.Marks.get(endMark) : performance.now();

    if (startTime !== undefined) {
      this.Measures.push({
        name,
        duration: endTime - startTime,
        startTime,
        endTime,
      });
    }
  }
}

行为监控模块

采集内容

类别内容用途
PV/UV页面访问流量统计
点击元素点击、按钮点击用户交互分析
停留页面停留时长内容热度
滚动滚动深度、速度用户阅读行为
性能请求耗时、成功率接口监控

行为采集器实现

// behavior-tracker.js
import { getGlobal, throttle } from "../utils";
import { getSelector } from "../dom";

export class BehaviorTracker {
  constructor(options, pipeline) {
    this.options = options;
    this.pipeline = pipeline;
    this.global = getGlobal();
    this.maxStack = 10;
  }

  track() {
    // 1. 上报页面浏览
    this.trackPageView();

    // 2. 监听点击事件
    this.trackClick();

    // 3. 监听表单提交
    this.trackFormSubmit();

    // 4. 监听接口请求
    this.trackApiRequest();

    // 5. 监听滚动行为
    this.trackScroll();

    // 6. 监听页面可见性
    this.trackVisibility();
  }

  trackPageView() {
    this.pipeline.send({
      event: "page_view",
      data: {
        url: location.href,
        referrer: document.referrer,
        title: document.title,
        timestamp: Date.now(),
        screenWidth: screen.width,
        screenHeight: screen.height,
        viewportWidth: window.innerWidth,
        viewportHeight: window.innerHeight,
      },
    });

    // 记录进入时间
    this.pageStartTime = Date.now();
  }

  trackClick() {
    const clickHandler = throttle(event => {
      const target = event.target;
      const selector = getSelector(target);

      this.pipeline.send({
        event: "click",
        data: {
          url: location.href,
          selector,
          tagName: target.tagName,
          text: target.textContent?.slice(0, 50),
          className: target.className,
          id: target.id,
          timestamp: Date.now(),
          x: event.clientX,
          y: event.clientY,
        },
      });
    }, 300);

    this.global.document.addEventListener("click", clickHandler, true);
  }

  trackFormSubmit() {
    this.global.document.addEventListener(
      "submit",
      event => {
        const form = event.target;
        const formData = new FormData(form);

        this.pipeline.send({
          event: "form_submit",
          data: {
            url: location.href,
            action: form.action,
            method: form.method,
            selector: getSelector(form),
            timestamp: Date.now(),
          },
        });
      },
      true
    );
  }

  trackApiRequest() {
    // 拦截原生 fetch
    const originalFetch = this.global.fetch;
    this.global.fetch = async (...args) => {
      const startTime = Date.now();
      const [url, options] = args;

      try {
        const response = await originalFetch(...args);
        const duration = Date.now() - startTime;

        this.pipeline.send({
          event: "api",
          data: {
            url: url?.url || url,
            method: options?.method || "GET",
            status: response.status,
            duration,
            ok: response.ok,
            timestamp: Date.now(),
          },
        });

        return response;
      } catch (error) {
        const duration = Date.now() - startTime;

        this.pipeline.send({
          event: "api",
          data: {
            url: url?.url || url,
            method: options?.method || "GET",
            status: 0,
            duration,
            error: error.message,
            timestamp: Date.now(),
          },
        });

        throw error;
      }
    };

    // 拦截 XMLHttpRequest
    const originalXHROpen = XMLHttpRequest.prototype.open;
    const originalXHRSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function (method, url) {
      this._monitorData = { method, url, startTime: Date.now() };
      originalXHROpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function () {
      this.addEventListener("load", () => {
        const duration = Date.now() - this._monitorData.startTime;

        this.pipeline.send({
          event: "api",
          data: {
            url: this._monitorData.url,
            method: this._monitorData.method,
            status: this.status,
            duration,
            ok: this.status >= 200 && this.status < 300,
            timestamp: Date.now(),
          },
        });
      });

      originalXHRSend.apply(this, arguments);
    };
  }

  trackScroll() {
    let maxScrollDepth = 0;
    let lastScrollTime = 0;

    const scrollHandler = () => {
      const scrollTop =
        window.pageYOffset || document.documentElement.scrollTop;
      const docHeight = document.documentElement.scrollHeight;
      const viewHeight = window.innerHeight;

      // 计算滚动深度百分比
      const scrollDepth = Math.round(
        (scrollTop / (docHeight - viewHeight)) * 100
      );
      maxScrollDepth = Math.max(maxScrollDepth, scrollDepth);

      // 节流上报
      const now = Date.now();
      if (now - lastScrollTime > 1000) {
        lastScrollTime = now;
        this.pipeline.send({
          event: "scroll",
          data: {
            depth: maxScrollDepth,
            velocity: 0,
            timestamp: Date.now(),
          },
        });
      }
    };

    this.global.addEventListener("scroll", scrollHandler, { passive: true });
  }

  trackVisibility() {
    this.global.document.addEventListener("visibilitychange", () => {
      this.pipeline.send({
        event: "visibility",
        data: {
          state: document.visibilityState,
          duration: Date.now() - this.pageStartTime,
          timestamp: Date.now(),
        },
      });
    });
  }
}

数据管道与上报

数据管道设计

// pipeline.js
import { getGlobal } from "../utils";

export class DataPipeline {
  constructor(options) {
    this.options = options;
    this.buffer = [];
    this.maxBufferSize = 100;
    this.flushInterval = 5000; // 5秒
    this.global = getGlobal();

    // 启动定时flush
    this.startFlushTimer();
  }

  send(event) {
    // 采样过滤
    if (!this.shouldSample(event)) {
      return;
    }

    // 添加到缓冲区
    this.buffer.push(event);

    // 超过阈值立即上报
    if (this.buffer.length >= this.maxBufferSize) {
      this.flush();
    }
  }

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

    const data = this.buffer.splice(0, this.buffer.length);
    const payload = this.compress({
      events: data,
      appId: this.options.appId,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      sdkVersion: this.options.version,
    });

    // 发送数据
    this.upload(payload);
  }

  upload(payload) {
    const headers = {
      "Content-Type": "application/json",
      "X-App-ID": this.options.appId,
      "X-SDK-Version": this.options.version,
    };

    // 使用 sendBeacon 确保页面卸载前也能上报
    if (this.global.navigator?.sendBeacon) {
      const blob = new Blob([JSON.stringify(payload)], {
        type: "application/json",
      });
      this.global.navigator.sendBeacon(this.options.dsn, blob);
    } else {
      // 回退到 fetch
      fetch(this.options.dsn, {
        method: "POST",
        headers,
        body: JSON.stringify(payload),
        keepalive: true,
      }).catch(() => {});
    }
  }

  compress(data) {
    // 基础压缩:移除空值和默认值
    return this.removeEmptyValues(data);
  }

  removeEmptyValues(obj) {
    const result = {};
    for (const key in obj) {
      const value = obj[key];
      if (value !== null && value !== undefined && value !== "") {
        if (typeof value === "object" && !Array.isArray(value)) {
          result[key] = this.removeEmptyValues(value);
        } else {
          result[key] = value;
        }
      }
    }
    return result;
  }

  shouldSample(event) {
    // 根据采样率采样
    return Math.random() < this.options.sampleRate;
  }

  startFlushTimer() {
    this.global.setInterval(() => this.flush(), this.flushInterval);
  }
}

工具函数

DOM 选择器生成

// dom.js
export function getSelector(element) {
  if (!element || !element.tagName) return "";

  const parts = [];
  let current = element;

  while (current && current.nodeType === 1) {
    let selector = current.tagName.toLowerCase();

    if (current.id) {
      selector += `#${current.id}`;
      parts.unshift(selector);
      break;
    }

    if (current.className && typeof current.className === "string") {
      const className = current.className.trim().split(/\s+/)[0];
      selector += `.${className}`;
    }

    parts.unshift(selector);
    current = current.parentElement;
  }

  return parts.slice(0, 4).join(" > ");
}

节流函数

// utils.js
export function throttle(func, wait) {
  let previous = 0;
  return function (...args) {
    const now = Date.now();
    if (now - previous > wait) {
      previous = now;
      return func.apply(this, args);
    }
  };
}

export function getGlobal() {
  if (typeof window !== "undefined") return window;
  if (typeof global !== "undefined") return global;
  return {};
}

监控SDK使用

初始化

// 初始化监控SDK
import MonitorSDK from "./monitor";

const monitor = new MonitorSDK({
  dsn: "https://monitor.example.com/api/collect",
  appId: "my-app",
  environment: process.env.NODE_ENV,
  sampleRate: 0.1, // 10%采样
});

// 启动监控
monitor.start();

手动上报

// 上报自定义错误
monitor.error.capture(new Error("Custom error"), {
  context: "用户点击",
});

// 上报业务事件
monitor.behavior.trackEvent("checkout", {
  productId: "123",
  amount: 99,
});

// 上报性能标记点
monitor.performance.mark("searchStart");
// ... 执行搜索 ...
monitor.performance.measure("search", "searchStart");


Previous Post
从零实现AI助手 - 前后端项目搭建《一》
Next Post
基于 Web Vitals 的指标进行性能调优