前端监控 SDK

构建一个前端性能及错误管理平台的简易实现。这个平台将专注于监测常见的性能指标(如 Web Vitals)和捕获前端错误(同步、异步、资源加载错误),并将这些数据上报到一个指定的(示例)地址。

核心思路

  1. 数据采集 : 利用浏览器提供的标准 API(如 PerformanceObserver, window.onerror, window.addEventListener)来捕获性能指标和错误信息。
  2. 数据格式化: 将采集到的原始数据处理成统一的结构,方便后续分析。
  3. 数据上报 : 将格式化后的数据通过 HTTP 请求(优先使用 navigator.sendBeacon)发送到后端接收接口。
  4. SDK 设计: 将监控逻辑封装成一个 SDK,方便在项目中引入和初始化。

监测指标

  • 性能指标 (Performance Metrics) :

    • FP (First Paint) : 首次绘制,标记浏览器首次在屏幕上渲染像素的时间点。
    • FCP (First Contentful Paint) : 首次内容绘制,标记浏览器首次渲染来自 DOM 内容的任何部分(文本、图像等)的时间点。
    • LCP (Largest Contentful Paint) : 最大内容绘制,标记视口内可见的最大图像或文本块的渲染时间。是核心 Web 指标之一。
    • FID (First Input Delay) : 首次输入延迟,测量从用户首次与页面交互(点击、轻触等)到浏览器实际能够开始处理事件处理程序的时间。是核心 Web 指标之一。
    • CLS (Cumulative Layout Shift) : 累积布局偏移,测量页面在加载期间发生的意外布局变化的总和。是核心 Web 指标之一。
    • TTFB (Time to First Byte) : 首字节时间,衡量浏览器从服务器接收到响应的第一个字节所需的时间。
    • Navigation Timing: 页面加载各阶段的详细时间,如 DNS 查询、TCP 连接、请求响应等。
    • Resource Timing: 页面加载的各项资源(JS, CSS, 图片等)的加载时间详情。
  • 错误类型 (Error Types) :

    • JS 运行时错误 (Runtime Errors) : 同步代码执行错误 (window.onerror)。
    • 未处理的 Promise 拒绝 (Unhandled Promise Rejections) : 异步操作中未被 catch 的错误 (unhandledrejection 事件)。
    • 资源加载错误 (Resource Errors) : 图片、脚本、样式表等资源加载失败 (error 事件捕获)。
    • 自定义错误 (Custom Errors) : 通过 try...catch 或主动上报的业务逻辑错误。

代码实现 (monitor.js)

JavaScript 复制代码
/**
 * 前端监控 SDK
 * @class MonitorSDK
 */
class MonitorSDK {
  constructor() {
    this.config = {
      reportUrl: null, // 上报地址
      appName: 'defaultApp', // 应用名称
      appVersion: '1.0.0', // 应用版本
      environment: 'production', // 环境:development, test, production
      userId: null, // 用户标识
      sessionId: this.generateSessionId(), // 会话标识
      enablePerformance: true, // 是否开启性能监控
      enableError: true, // 是否开启错误监控
      enableResourceTiming: false, // 是否收集资源加载详情 (可能产生大量数据)
      sampleRate: 1, // 数据采样率 (0 到 1)
      maxBatchSize: 10, // 最大批量上报数量
      maxWaitTime: 5000, // 最大等待上报时间 (ms)
    };
    this.queue = []; // 数据上报队列
    this.timeoutId = null; // 批量上报定时器
    this.initialized = false; // 是否已初始化
  }

  /**
   * 初始化 SDK
   * @param {object} options - 配置项
   */
  init(options) {
    if (this.initialized) {
      console.warn('MonitorSDK already initialized.');
      return;
    }

    // 合并配置
    this.config = { ...this.config, ...options };

    if (!this.config.reportUrl) {
      console.error('MonitorSDK Error: reportUrl is required.');
      return;
    }

    console.log('MonitorSDK initializing with config:', this.config);

    // 随机采样
    if (Math.random() > this.config.sampleRate) {
      console.log('MonitorSDK: Data collection skipped due to sample rate.');
      return; // 不进行初始化
    }

    if (this.config.enablePerformance) {
      this.initPerformanceMonitoring();
    }

    if (this.config.enableError) {
      this.initErrorMonitoring();
    }

    // 页面卸载时尝试发送剩余数据
    window.addEventListener('unload', () => {
      this.flushQueue();
    });

    this.initialized = true;
    console.log('MonitorSDK initialized successfully.');
  }

  /**
   * 生成简单的会话 ID
   * @returns {string}
   */
  generateSessionId() {
    return `session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
  }

  // =========================================================================
  // 性能监控 (Performance Monitoring)
  // =========================================================================

  initPerformanceMonitoring() {
    console.log('Initializing performance monitoring...');
    this.monitorWebVitals();
    this.monitorNavigationTiming();
    if (this.config.enableResourceTiming) {
      this.monitorResourceTiming();
    }
  }

  /**
   * 监测核心 Web 指标 (Web Vitals) 及其他性能指标
   */
  monitorWebVitals() {
    try {
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          const entryType = entry.entryType;
          let metricData = null;

          if (entryType === 'paint') {
            if (entry.name === 'first-paint') {
              metricData = this.createMetric('performance', 'FP', entry.startTime);
            } else if (entry.name === 'first-contentful-paint') {
              metricData = this.createMetric('performance', 'FCP', entry.startTime);
            }
          } else if (entryType === 'largest-contentful-paint') {
            metricData = this.createMetric('performance', 'LCP', entry.startTime, {
              element: entry.element ? entry.element.tagName : 'unknown',
              url: entry.url,
              size: entry.size,
              loadTime: entry.loadTime,
              renderTime: entry.renderTime,
            });
          } else if (entryType === 'first-input') {
            metricData = this.createMetric('performance', 'FID', entry.processingStart - entry.startTime, {
              duration: entry.duration,
              target: entry.target ? entry.target.tagName : 'unknown',
            });
          } else if (entryType === 'layout-shift') {
            // CLS 通常需要累加,这里只上报单次偏移,更准确的 CLS 计算建议使用 web-vitals 库
            // 这里仅作示例,上报单次大于 0.1 的偏移
            if (!entry.hadRecentInput && entry.value > 0.1) {
               metricData = this.createMetric('performance', 'LayoutShift', entry.value, {
                 startTime: entry.startTime,
                 sources: entry.sources?.map(s => ({
                   node: s.node?.tagName,
                   previousRect: s.previousRect,
                   currentRect: s.currentRect,
                 })),
               });
            }
          }

          if (metricData) {
            this.addToQueue(metricData);
          }
        }
      });

      // 注册需要观察的性能条目类型
      observer.observe({ entryTypes: ['paint', 'largest-contentful-paint', 'first-input', 'layout-shift'] });
      console.log('PerformanceObserver for Web Vitals initialized.');

    } catch (error) {
      console.error('Failed to initialize PerformanceObserver for Web Vitals:', error);
      this.reportCaughtError(error, 'sdk-error', { context: 'monitorWebVitals' });
    }
  }

  /**
   * 监测页面导航计时 (Navigation Timing)
   */
  monitorNavigationTiming() {
    try {
      // 使用 PerformanceNavigationTiming (更现代)
      const handleEntries = (entries) => {
        entries.forEach(entry => {
          if (entry.entryType === 'navigation') {
            const timing = entry;
            const navigationData = {
              fetchStart: timing.fetchStart,
              domainLookupStart: timing.domainLookupStart,
              domainLookupEnd: timing.domainLookupEnd,
              connectStart: timing.connectStart,
              connectEnd: timing.connectEnd,
              requestStart: timing.requestStart,
              responseStart: timing.responseStart, // TTFB 近似值
              responseEnd: timing.responseEnd,
              domInteractive: timing.domInteractive,
              domContentLoadedEventStart: timing.domContentLoadedEventStart,
              domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
              domComplete: timing.domComplete,
              loadEventStart: timing.loadEventStart,
              loadEventEnd: timing.loadEventEnd,
              // 计算的指标
              dnsLookupTime: timing.domainLookupEnd - timing.domainLookupStart,
              tcpConnectTime: timing.connectEnd - timing.connectStart,
              ttfb: timing.responseStart - timing.requestStart, // 更精确的 TTFB
              requestTime: timing.responseEnd - timing.requestStart,
              domContentLoadedTime: timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart,
              loadTime: timing.loadEventEnd - timing.fetchStart, // 页面总加载时间
              domReadyTime: timing.domInteractive - timing.fetchStart,
              type: timing.type, // 'navigate', 'reload', 'back_forward'
              redirectCount: timing.redirectCount,
            };
            const metricData = this.createMetric('performance', 'NavigationTiming', navigationData.loadTime, navigationData);
            this.addToQueue(metricData);
            console.log('Navigation Timing captured:', navigationData);
          }
        });
      };

      if (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('navigation')) {
        const observer = new PerformanceObserver((list) => {
            handleEntries(list.getEntries());
        });
        // 延迟执行,确保 navigation entry 已经产生
         setTimeout(() => {
            // 有些浏览器可能在 load 事件后才完全准备好 navigation entry
            const entries = performance.getEntriesByType('navigation');
            if (entries.length > 0) {
                handleEntries(entries);
            } else {
                // 如果还未获取到,尝试观察
                 observer.observe({ type: 'navigation', buffered: true });
                 console.log('PerformanceObserver for Navigation Timing initialized.');
            }
         }, 500); // 延迟一点确保 navigation entry 可用

      } else if (performance.timing) {
        // 兼容旧版浏览器 (Navigation Timing Level 1)
        window.addEventListener('load', () => {
          setTimeout(() => { // 确保 loadEventEnd 有值
            const timing = performance.timing;
            const navigationData = {
              fetchStart: timing.fetchStart,
              domainLookupStart: timing.domainLookupStart,
              domainLookupEnd: timing.domainLookupEnd,
              connectStart: timing.connectStart,
              connectEnd: timing.connectEnd,
              requestStart: timing.requestStart,
              responseStart: timing.responseStart,
              responseEnd: timing.responseEnd,
              domInteractive: timing.domInteractive,
              domContentLoadedEventStart: timing.domContentLoadedEventStart,
              domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
              domComplete: timing.domComplete,
              loadEventStart: timing.loadEventStart,
              loadEventEnd: timing.loadEventEnd,
              // 计算的指标
              dnsLookupTime: timing.domainLookupEnd - timing.domainLookupStart,
              tcpConnectTime: timing.connectEnd - timing.connectStart,
              ttfb: timing.responseStart - timing.requestStart,
              requestTime: timing.responseEnd - timing.requestStart,
              domContentLoadedTime: timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart,
              loadTime: timing.loadEventEnd - timing.fetchStart,
              domReadyTime: timing.domInteractive - timing.fetchStart,
            };
            // Navigation Type 在 Level 1 中不易获取
            if (navigationData.loadTime >= 0) { // 确保数据有效
                const metricData = this.createMetric('performance', 'NavigationTimingCompat', navigationData.loadTime, navigationData);
                this.addToQueue(metricData);
                console.log('Navigation Timing (Compat) captured:', navigationData);
            } else {
                 console.warn('Performance.timing data seems invalid.');
            }
          }, 0);
        });
      } else {
        console.warn('Navigation Timing API not supported.');
      }
    } catch (error) {
      console.error('Failed to initialize Navigation Timing monitoring:', error);
      this.reportCaughtError(error, 'sdk-error', { context: 'monitorNavigationTiming' });
    }
  }

  /**
   * 监测资源加载计时 (Resource Timing)
   */
  monitorResourceTiming() {
     try {
        const handleEntries = (entries) => {
             entries.forEach(entry => {
                // 过滤掉自身上报请求和非必要资源类型(可选)
                if (entry.name.startsWith(this.config.reportUrl) || ['fetch', 'xmlhttprequest'].includes(entry.initiatorType)) {
                    return;
                }

                 const resourceData = {
                     name: entry.name, // 资源 URL
                     entryType: entry.entryType, // 'resource'
                     initiatorType: entry.initiatorType, // 'script', 'link', 'img', etc.
                     startTime: entry.startTime,
                     duration: entry.duration,
                     fetchStart: entry.fetchStart,
                     domainLookupStart: entry.domainLookupStart,
                     domainLookupEnd: entry.domainLookupEnd,
                     connectStart: entry.connectStart,
                     connectEnd: entry.connectEnd,
                     requestStart: entry.requestStart,
                     responseStart: entry.responseStart,
                     responseEnd: entry.responseEnd,
                     transferSize: entry.transferSize, // 传输大小
                     encodedBodySize: entry.encodedBodySize, // 编码后的 body 大小
                     decodedBodySize: entry.decodedBodySize, // 解码后的 body 大小
                     // 计算指标
                     dnsLookupTime: entry.domainLookupEnd - entry.domainLookupStart,
                     tcpConnectTime: entry.connectEnd - entry.connectStart,
                     ttfb: entry.responseStart - entry.requestStart,
                     contentDownloadTime: entry.responseEnd - entry.responseStart,
                 };
                 const metricData = this.createMetric('performance', 'ResourceTiming', resourceData.duration, resourceData);
                 this.addToQueue(metricData);
             });
        };

       if (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('resource')) {
          const observer = new PerformanceObserver((list) => {
             handleEntries(list.getEntries());
          });
          // buffered: true 获取页面加载时已经产生的资源条目
          observer.observe({ type: 'resource', buffered: true });
          console.log('PerformanceObserver for Resource Timing initialized.');
       } else {
           // 对于不支持 PO 的情况,可以在 load 事件后获取所有资源
           window.addEventListener('load', () => {
               const entries = performance.getEntriesByType('resource');
               handleEntries(entries);
               console.log('Resource Timing (Compat) captured after load.');
           });
           console.warn('PerformanceObserver not fully supported for Resource Timing, using fallback.');
       }
     } catch (error) {
        console.error('Failed to initialize Resource Timing monitoring:', error);
        this.reportCaughtError(error, 'sdk-error', { context: 'monitorResourceTiming' });
     }
  }


  // =========================================================================
  // 错误监控 (Error Monitoring)
  // =========================================================================

  initErrorMonitoring() {
    console.log('Initializing error monitoring...');
    this.monitorJsErrors();
    this.monitorPromiseRejections();
    this.monitorResourceErrors();
    // 可以考虑增加对 console.error 的劫持等
  }

  /**
   * 监测 JavaScript 运行时错误
   */
  monitorJsErrors() {
    const originalOnError = window.onerror;
    window.onerror = (message, source, lineno, colno, error) => {
      console.log('onerror captured:', message, source, lineno, colno, error);
      try {
        const errorData = this.createError(
          'js-error',
          message,
          {
            filename: source,
            lineno: lineno,
            colno: colno,
            stack: error ? error.stack : null, // 尝试获取错误堆栈
          }
        );
        this.addToQueue(errorData);
      } catch (e) {
        console.error('Error in window.onerror handler:', e);
      }

      // 如果原始的 onerror 存在,则调用它
      if (typeof originalOnError === 'function') {
        return originalOnError.call(window, message, source, lineno, colno, error);
      }
      // 返回 false 以允许默认的浏览器错误处理继续执行
      // 返回 true 会阻止默认处理(例如,控制台不再显示错误)
      return false;
    };
    console.log('window.onerror handler attached.');
  }

  /**
   * 监测未处理的 Promise 拒绝
   */
  monitorPromiseRejections() {
    window.addEventListener('unhandledrejection', (event) => {
      console.log('unhandledrejection captured:', event);
      try {
        let message = 'Unhandled Promise Rejection';
        let stack = null;
        let details = {};

        if (event.reason) {
          if (event.reason instanceof Error) {
            message = event.reason.message;
            stack = event.reason.stack;
          } else if (typeof event.reason === 'string') {
            message = event.reason;
          } else {
             try {
                 message = JSON.stringify(event.reason);
             } catch (e) {
                 message = 'Unhandled Promise Rejection with non-serializable reason';
             }
          }
          details.reason = String(event.reason); // 记录原始 reason
        }

        const errorData = this.createError(
          'promise-rejection',
          message,
          {
            stack: stack,
            details: details,
          }
        );
        this.addToQueue(errorData);
      } catch (e) {
        console.error('Error in unhandledrejection handler:', e);
      }
      // event.preventDefault(); // 可以阻止错误在控制台显示
    });
    console.log('unhandledrejection listener attached.');
  }

  /**
   * 监测资源加载错误 (img, script, css, etc.)
   * 注意:这个方法捕获的是网络层面的资源加载失败,而不是 JS 错误
   */
  monitorResourceErrors() {
    // 使用捕获阶段,因为很多资源错误在冒泡阶段可能不会到达 window
    window.addEventListener('error', (event) => {
      // 过滤掉 JS 运行时错误 (onerror 会处理)
      if (event instanceof ErrorEvent) {
        // console.log('Skipping ErrorEvent in resource error listener.');
        return;
      }

      const target = event.target || event.srcElement;
      // 确保是元素节点并且是资源类型 (IMG, SCRIPT, LINK)
      if (target instanceof HTMLElement && ['IMG', 'SCRIPT', 'LINK'].includes(target.tagName)) {
        console.log('Resource error captured:', event);
        try {
          const errorData = this.createError(
            'resource-error',
            `Failed to load resource: ${target.src || target.href}`,
            {
              tagName: target.tagName,
              source: target.src || target.href, // 获取资源 URL
              outerHTML: target.outerHTML.substring(0, 200), // 记录元素的部分 HTML
            }
          );
          this.addToQueue(errorData);
        } catch (e) {
          console.error('Error in resource error handler:', e);
        }
      } else {
         // 可能还有其他类型的 error 事件,比如来自 Web Worker 或其他地方
         // console.log('Non-resource error event captured:', event);
      }
    }, true); // 使用捕获阶段!
    console.log('Resource error listener attached (capturing phase).');
  }

  /**
   * 手动上报捕获到的错误 (例如在 try...catch 中)
   * @param {Error} error - 错误对象
   * @param {string} type - 自定义错误类型 (e.g., 'api-error', 'business-logic-error')
   * @param {object} [extraData={}] - 附加信息
   */
  reportCaughtError(error, type = 'caught-error', extraData = {}) {
     if (!this.initialized) return; // 未初始化则不上报
     console.log(`Manually reporting caught error (${type}):`, error, extraData);
     try {
        const errorData = this.createError(
            type,
            error.message,
            {
                stack: error.stack,
                ...extraData, // 合并附加信息
            }
        );
        this.addToQueue(errorData);
     } catch (e) {
         console.error('Error reporting caught error:', e);
     }
  }

  // =========================================================================
  // 数据格式化与上报 (Data Formatting & Reporting)
  // =========================================================================

  /**
   * 创建标准化的指标数据结构
   * @param {string} category - 'performance' or 'error' or 'custom'
   * @param {string} type - 具体指标/错误类型 (e.g., 'FCP', 'js-error')
   * @param {*} value - 指标值或错误消息
   * @param {object} [payload={}] - 附加数据
   * @returns {object}
   */
  createMetric(category, type, value, payload = {}) {
    return {
      timestamp: Date.now(),
      category: category,
      type: type,
      value: value,
      payload: payload,
      // 通用上下文信息
      context: this.getCommonContext(),
    };
  }

  /**
   * 创建标准化的错误数据结构
   * @param {string} type - 错误类型 (e.g., 'js-error', 'resource-error')
   * @param {string} message - 错误消息
   * @param {object} [payload={}] - 附加数据 (stack, filename, lineno, etc.)
   * @returns {object}
   */
  createError(type, message, payload = {}) {
     return {
       timestamp: Date.now(),
       category: 'error',
       type: type,
       message: message,
       payload: payload,
       // 通用上下文信息
       context: this.getCommonContext(),
     };
  }

  /**
   * 获取通用的上下文信息
   * @returns {object}
   */
  getCommonContext() {
    return {
      appName: this.config.appName,
      appVersion: this.config.appVersion,
      environment: this.config.environment,
      userId: this.config.userId, // 可能需要动态获取
      sessionId: this.config.sessionId,
      url: window.location.href,
      userAgent: navigator.userAgent,
      language: navigator.language,
      // 可以添加屏幕分辨率、网络状态等
      screenResolution: `${window.screen.width}x${window.screen.height}`,
      connection: navigator.connection ? {
          effectiveType: navigator.connection.effectiveType, // '4g', '3g', etc.
          rtt: navigator.connection.rtt, // 往返时间
          downlink: navigator.connection.downlink, // 下行速度 Mbps
      } : null,
    };
  }

  /**
   * 将数据添加到上报队列,并触发延迟上报
   * @param {object} data - 格式化后的监控数据
   */
  addToQueue(data) {
    if (!this.initialized) return; // 未初始化则不添加

    this.queue.push(data);
    console.log('Data added to queue:', data);
    console.log('Current queue size:', this.queue.length);

    // 如果队列达到最大批次大小,立即发送
    if (this.queue.length >= this.config.maxBatchSize) {
      console.log('Queue reached max size, flushing immediately.');
      this.flushQueue();
    } else if (!this.timeoutId) {
      // 如果定时器未启动,则启动一个延迟发送的定时器
      console.log(`Starting batch timer (${this.config.maxWaitTime}ms)...`);
      this.timeoutId = setTimeout(() => {
        console.log('Batch timer expired, flushing queue.');
        this.flushQueue();
      }, this.config.maxWaitTime);
    }
  }

  /**
   * 发送队列中的所有数据
   */
  flushQueue() {
    // 清除可能存在的定时器
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
      console.log('Batch timer cleared.');
    }

    if (this.queue.length === 0) {
      // console.log('Queue is empty, nothing to flush.');
      return;
    }

    const dataToSend = [...this.queue];
    this.queue = []; // 清空队列

    console.log(`Flushing ${dataToSend.length} items to ${this.config.reportUrl}`);

    // 优先使用 sendBeacon,它在页面卸载时更可靠,且是异步非阻塞的
    if (navigator.sendBeacon) {
      try {
        const blob = new Blob([JSON.stringify(dataToSend)], { type: 'application/json; charset=UTF-8' });
        const success = navigator.sendBeacon(this.config.reportUrl, blob);
        if (success) {
          console.log('Data successfully sent via sendBeacon.');
        } else {
          console.error('sendBeacon returned false. Data might not have been sent.');
          // 可以考虑回退到 fetch 或 XMLHttpRequest (但在 unload 中可能不可靠)
          // this.fallbackReport(dataToSend);
        }
      } catch (error) {
        console.error('Error sending data via sendBeacon:', error);
        // this.fallbackReport(dataToSend);
        this.reportCaughtError(error, 'sdk-error', { context: 'sendBeacon' });
      }
    } else {
      // 回退到 fetch 或 XMLHttpRequest
      this.fallbackReport(dataToSend);
    }
  }

  /**
   * 使用 fetch 或 XMLHttpRequest 作为备用上报方式
   * @param {Array} data - 要发送的数据数组
   */
  fallbackReport(data) {
    console.log('Using fallback reporting method (fetch)...');
    try {
      fetch(this.config.reportUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json; charset=UTF-8',
        },
        body: JSON.stringify(data),
        keepalive: true, // 尝试在页面卸载后保持请求 (并非所有浏览器都完美支持)
      })
      .then(response => {
        if (!response.ok) {
          console.error(`Fallback report failed with status: ${response.status}`);
        } else {
          console.log('Data successfully sent via fetch (fallback).');
        }
      })
      .catch(error => {
        console.error('Error sending data via fetch (fallback):', error);
        this.reportCaughtError(error, 'sdk-error', { context: 'fetchFallback' });
      });
    } catch (error) {
       console.error('Error initiating fetch (fallback):', error);
       this.reportCaughtError(error, 'sdk-error', { context: 'fetchFallbackInitiation' });
    }
  }
}

// 导出单例或类,取决于使用方式
// export default new MonitorSDK(); // 单例模式
// export { MonitorSDK }; // 导出类,允许创建多个实例 (不太常见)

// 为了方便在 HTML 中直接使用,挂载到 window (非模块化环境)
window.MonitorSDKInstance = new MonitorSDK();

代码讲解

  1. MonitorSDK:

    • constructor : 初始化 config (默认配置)、queue (上报队列)、timeoutId (批量上报定时器) 和 initialized 状态。sessionId 在实例化时生成。

    • init(options) :

      • 防止重复初始化。
      • 合并用户传入的 options 和默认 config
      • 检查 reportUrl 是否提供。
      • 根据 sampleRate (采样率) 决定是否继续初始化,用于控制数据量。
      • 根据配置调用 initPerformanceMonitoringinitErrorMonitoring
      • 监听 unload 事件,在页面关闭前尝试发送队列中剩余的数据。
      • 标记 initializedtrue
    • generateSessionId() : 生成一个简单的唯一会话标识。

  2. 性能监控 (initPerformanceMonitoring, monitorWebVitals, monitorNavigationTiming, monitorResourceTiming) :

    • initPerformanceMonitoring: 根据配置启动各项性能监控。

    • monitorWebVitals:

      • 使用 PerformanceObserver API,这是现代浏览器推荐的方式,可以异步、高效地获取性能条目。
      • 观察 paint (FP, FCP), largest-contentful-paint (LCP), first-input (FID), layout-shift (CLS 的组成部分)。
      • 对获取到的 entry 进行判断和处理,提取关键信息(如 LCP 的元素、大小,FID 的延迟时间、目标元素等)。
      • 调用 createMetric 格式化数据,然后 addToQueue
      • 包含 try...catch 以处理 API 不可用或执行出错的情况。
      • 注意 : CLS 的准确计算通常需要累加整个页面的生命周期内的布局偏移,这里仅作单次偏移的上报示例。推荐使用 Google 的 web-vitals 库来获取更准确的 CLS 和其他核心 Web 指标。
    • monitorNavigationTiming:

      • 优先使用 PerformanceObserver 监听 navigation 类型的条目 (Navigation Timing Level 2 API),它提供了更详细和准确的数据。
      • 如果 PerformanceObservernavigation 类型不支持,则回退到监听 window.onload 事件,并在事件触发后访问 performance.timing (Navigation Timing Level 1 API)。Level 1 的数据不如 Level 2 丰富(例如缺少 typeredirectCount)。
      • timing 对象中提取各个阶段的时间戳,并计算出有意义的指标(DNS 查询耗时、TCP 连接耗时、TTFB、页面总加载时间等)。
      • 格式化数据并加入队列。
      • 使用 setTimeout 确保在 load 事件后 performance.timingloadEventEnd 等属性有值。
    • monitorResourceTiming:

      • 同样优先使用 PerformanceObserver 监听 resource 类型的条目。设置 buffered: true 可以获取在观察者创建之前就已经加载完成的资源条目。
      • 如果 PerformanceObserver 不支持,则回退到在 window.onload 后使用 performance.getEntriesByType('resource') 获取所有已加载资源的信息。
      • 对每个资源条目 (entry),提取 URL (name)、发起者类型 (initiatorType)、耗时 (duration)、大小 (transferSize 等) 以及各个阶段的时间。
      • 可以根据需要过滤掉某些资源(如监控 SDK 自身的上报请求)。
      • 格式化数据并加入队列。注意:开启此项可能会产生大量数据。
  3. 错误监控 (initErrorMonitoring, monitorJsErrors, monitorPromiseRejections, monitorResourceErrors, reportCaughtError) :

    • initErrorMonitoring: 启动各项错误监控。

    • monitorJsErrors:

      • 覆盖 window.onerror 处理函数。这是捕获全局未被 try...catch 的同步 JavaScript 运行时错误的主要方式。
      • 回调函数接收 message, source (文件名), lineno (行号), colno (列号), error (错误对象)。
      • 尝试从 error 对象获取 stack (错误堆栈信息),这对于调试非常重要。
      • 调用 createError 格式化错误数据,然后 addToQueue
      • 保留了调用原始 onerror 的逻辑(如果存在)。返回 false 让浏览器继续默认的错误处理(在控制台打印错误)。
    • monitorPromiseRejections:

      • 监听 unhandledrejection 事件。这用于捕获 Promise 中发生但没有被 .catch() 处理的拒绝(异步错误)。
      • 事件对象 eventreason 属性包含拒绝的原因,可能是 Error 对象、字符串或其他类型。
      • 提取 messagestack(如果 reason 是 Error 对象)。
      • 格式化错误数据并加入队列。
    • monitorResourceErrors:

      • 监听 error 事件,关键在于第三个参数设置为 true ,表示在捕获阶段 监听。资源加载错误(如 <img>, <script>, <link> 加载失败)通常不会冒泡到 window,必须在捕获阶段捕获。
      • 通过检查 event.target (或 event.srcElement) 来判断是否是元素以及是否是目标资源标签(IMG, SCRIPT, LINK)。
      • 过滤掉 ErrorEvent 实例,因为 JS 运行时错误会由 onerror 处理,避免重复上报。
      • 提取资源 srchref 作为错误信息的一部分。
      • 格式化错误数据并加入队列。
    • reportCaughtError:

      • 提供一个公共方法,允许业务代码在 try...catch 块中捕获到错误后,手动调用此方法进行上报。
      • 接收 error 对象、自定义 typeextraData (附加信息)。
  4. 数据格式化与上报 (createMetric, createError, getCommonContext, addToQueue, flushQueue, fallbackReport) :

    • createMetric, createError : 定义了两种主要的数据结构(指标和错误),包含时间戳、类别、类型、值/消息、附加数据 (payload) 以及通用上下文 (context)。

    • getCommonContext: 提取通用的上下文信息,如应用名称/版本、环境、用户 ID、会话 ID、当前页面 URL、User Agent、语言、屏幕分辨率、网络状态等。这些信息对于后续分析非常有用。

    • addToQueue:

      • 将格式化后的数据推入 queue 数组。

      • 实现批量上报逻辑:

        • 如果队列大小达到 maxBatchSize,立即调用 flushQueue 发送。
        • 否则,如果当前没有等待发送的定时器 (timeoutId 为 null),则启动一个 setTimeout,在 maxWaitTime 后调用 flushQueue。这可以减少短时间内频繁的网络请求。
    • flushQueue:

      • 实际执行数据发送的函数。

      • 先清除可能存在的 setTimeout 定时器。

      • 如果队列为空,则不执行任何操作。

      • 取出队列中的所有数据 (dataToSend),并清空 queue

      • 优先使用 navigator.sendBeacon(url, data) :

        • sendBeacon 是专门设计用于发送少量统计数据的 API。
        • 它是异步的,不会阻塞主线程。
        • 它能在页面卸载(unload/beforeunload)过程中更可靠地完成发送,即使页面即将关闭。
        • 数据需要是 Blob, FormDataArrayBufferView 等类型,这里将 JSON 数组字符串化后创建 Blob
        • 检查返回值,true 表示浏览器已成功将请求加入发送队列,false 表示失败(可能是数据过大或浏览器限制)。
      • 回退机制 (fallbackReport) :

        • 如果 sendBeacon 不可用或返回 false,则使用 fetch (或 XMLHttpRequest) 作为备选。
        • 使用 POST 方法,设置 Content-Typeapplication/json
        • keepalive: true : 这是一个重要的 fetch 选项,它指示浏览器在页面卸载后尝试保持该请求的连接,提高了在页面关闭时发送成功的概率,但并非所有浏览器都完美支持,且可靠性不如 sendBeacon
        • 包含错误处理逻辑。

如何在项目中使用

  1. 引入 SDK:

    • 如果使用模块化(ESM/CommonJS),可以 import MonitorSDK from './monitor.js';const { MonitorSDK } = require('./monitor.js');
    • 如果直接在 HTML 中使用 <script> 标签引入 monitor.js,它会将实例挂载到 window.MonitorSDKInstance
  2. 初始化 : 在你的应用入口文件(如 main.js, app.js 或 HTML 的 <script> 块)的早期阶段进行初始化。

    JavaScript 复制代码
    // 示例:在 Vue 或 React 应用的入口文件
    // import MonitorSDKInstance from './monitor'; // 或者 const MonitorSDKInstance = window.MonitorSDKInstance;
    
    MonitorSDKInstance.init({
      reportUrl: 'https://your-backend-endpoint.com/report', // 替换为你的后端接收地址
      appName: 'myWebApp',
      appVersion: '1.2.3', // 可以从 package.json 或构建变量获取
      environment: process.env.NODE_ENV || 'production', // 根据环境设置
      userId: getUserIdentifier(), // 获取用户 ID 的函数(如果需要)
      enablePerformance: true,
      enableError: true,
      enableResourceTiming: false, // 按需开启
      sampleRate: 1, // 1 表示 100% 采样
      // 其他配置...
    });
    
    // 获取用户 ID 的示例函数 (需要根据你的认证系统实现)
    function getUserIdentifier() {
      // 尝试从 localStorage, cookie 或全局状态获取
      return localStorage.getItem('userId') || 'anonymous';
    }
    
    // 如果需要在 try...catch 中手动上报错误
    try {
      // some potentially problematic code
      // ...
      if (someConditionFails) {
          throw new Error("Business logic validation failed for condition X.");
      }
    } catch (error) {
      console.error("Caught an error:", error);
      // 手动上报,可以附加更多业务上下文信息
      MonitorSDKInstance.reportCaughtError(error, 'business-logic-error', {
          context: 'processing user data',
          userId: getUserIdentifier(), // 再次确认用户 ID
          relevantData: { /* 一些相关数据 */ }
      });
      // 可能还需要进行错误恢复处理
    }

后续增强方向

  1. Source Map 支持: 对于压缩混淆后的代码,错误堆栈信息可读性很差。需要后端配合,根据上报的行/列号和 Source Map 文件,将堆栈信息还原成源码位置。这通常需要在上报错误时携带应用版本号,后端根据版本号查找对应的 Source Map 文件。

  2. 更精确的 Web Vitals : 使用 Google 的 web-vitals 库 (npm install web-vitals) 可以更简单、准确地获取 LCP, FID, CLS 等核心指标,它处理了许多边界情况。

    kotlin 复制代码
    import { getLCP, getFID, getCLS } from 'web-vitals';
    
    // 在 monitorWebVitals 中替换或补充
    getCLS(metric => this.addToQueue(this.createMetric('performance', 'CLS', metric.value, { delta: metric.delta, entries: metric.entries })));
    getFID(metric => this.addToQueue(this.createMetric('performance', 'FID', metric.value, { delta: metric.delta, entries: metric.entries })));
    getLCP(metric => this.addToQueue(this.createMetric('performance', 'LCP', metric.value, { element: metric.entries[0]?.element?.tagName, size: metric.size, loadTime: metric.loadTime, renderTime: metric.renderTime })));
  3. 用户行为追踪: 监听点击事件、路由变化(对于 SPA)、页面可见性变化等,将这些行为信息与错误或性能问题关联起来,有助于复现问题。

  4. API 请求监控 : 劫持 fetchXMLHttpRequest 来监控 API 请求的成功/失败、耗时、请求/响应大小等。

  5. 会话追踪 (Session Replay) : 记录用户的操作序列(点击、滚动、输入等)和页面变化,用于回放用户会话,可视化地复现 Bug(实现复杂,通常使用成熟的第三方库)。

  6. 更完善的采样策略: 除了初始化时的随机采样,还可以针对特定事件类型(如严重错误全量上报,性能指标采样上报)或用户群体进行采样。

  7. SDK 异常捕获: SDK 自身也可能出错,需要有机制捕获 SDK 内部的异常,避免影响业务代码,并考虑是否上报这些元错误。

  8. 配置热更新: 允许通过后端下发配置,动态调整采样率、开关某些监控项等。

  9. 离线存储 : 当网络不可用时,将数据暂存到 localStorageIndexedDB,待网络恢复后再次尝试上报。

这个实现提供了一个相对完整的前端监控基础框架。你可以根据项目的具体需求,选择性地实现或增强其中的功能。记住,监控本身也可能带来微小的性能开销,需要权衡监控的全面性和对用户体验的影响。

相关推荐
拉不动的猪12 分钟前
SDK与API简单对比
前端·javascript·面试
runnerdancer14 分钟前
微信小程序蓝牙通信开发之分包传输通信协议开发
前端
山海上的风28 分钟前
Vue里面elementUi-aside 和el-main不垂直排列
前端·vue.js·elementui
电商api接口开发39 分钟前
ASP.NET MVC 入门指南二
前端·c#·html·mvc
亭台烟雨中1 小时前
【前端记事】关于electron的入门使用
前端·javascript·electron
泯泷1 小时前
「译」解析 JavaScript 中的循环依赖
前端·javascript·架构
抹茶san1 小时前
前端实战:从 0 开始搭建 pnpm 单一仓库(1)
前端·架构
Senar2 小时前
Web端选择本地文件的几种方式
前端·javascript·html