Skip to content

监控SDK设计

js
class ErrorMonitor {
  constructor(options = {}) {
    this.options = {
      // 应用标识
      appId: 'default',
      // 上报接口
      reportUrl: '/api/error/report',
      // 是否启用UI崩溃检测
      enableUICrash: true,
      // 采样率 0-1
      sampleRate: 1,
      // 错误去重时间窗口(毫秒)
      dedupeInterval: 10000,
      // 检测的内容元素
      contentEl: document.body,
      ...options,
    };

    this.errorQueue = [];
    this.errorMap = new Map(); // 用于错误去重
    this.timer = null;

    this.init();
  }

  init() {
    // 检查环境
    if (typeof window === 'undefined') {
      console.warn('ErrorMonitor: 当前环境不支持浏览器API,监控功能将不可用');
      return;
    }
    
    this.setupJSErrorMonitor();
    this.setupPromiseErrorMonitor();
    this.setupResourceErrorMonitor();
    this.setupXHRMonitor();
    this.setupFetchMonitor();
    this.setupUserBehaviorTracker();
    this.setupPerformanceMonitor();
    this.setupNetworkMonitor();

    if (this.options.enableUICrash) {
      this.setupUICrashMonitor();
    }

    // 页面卸载前发送所有错误
    window.addEventListener('beforeunload', () => {
      this.sendErrors(true);
    });
  }

  // JS错误监控
  setupJSErrorMonitor() {
    window.onerror = (msg, url, line, column, error) => {
      this.captureError({
        type: 'js_error',
        msg: msg,
        url: url,
        line: line,
        column: column,
        stack: error && error.stack,
      });
      return true;
    };
  }

  // Promise错误监控
  setupPromiseErrorMonitor() {
    window.addEventListener('unhandledrejection', (event) => {
      this.captureError({
        type: 'promise_error',
        msg: event.reason instanceof Error ? event.reason.message : String(event.reason),
        stack: event.reason instanceof Error ? event.reason.stack : '',
      });
    });
  }

  // 资源加载错误监控
  setupResourceErrorMonitor() {
    window.addEventListener(
      'error',
      (event) => {
        if (event.target && (event.target.src || event.target.href)) {
          this.captureError({
            type: 'resource_error',
            msg: `Failed to load ${event.target.nodeName.toLowerCase()}: ${event.target.src || event.target.href}`,
            url: event.target.src || event.target.href,
            html: event.target.outerHTML,
          });
        }
      },
      true
    );
  }

  // XHR请求监控
  setupXHRMonitor() {
    const originalXHROpen = XMLHttpRequest.prototype.open;
    const originalXHRSend = XMLHttpRequest.prototype.send;
    const self = this;

    XMLHttpRequest.prototype.open = function (...args) {
      this._requestUrl = args[1];
      this._method = args[0];
      this._startTime = Date.now();

      // 在 open 方法中添加错误监听器,确保只添加一次
      if (!this._errorHandlerAdded) {
        this.addEventListener('error', () => {
          self.captureError({
            type: 'xhr_error',
            msg: `XHR Error: ${this._method} ${this._requestUrl}`,
            url: this._requestUrl,
            status: this.status,
            duration: Date.now() - this._startTime,
          });
        });

        this.addEventListener('timeout', () => {
          self.captureError({
            type: 'xhr_timeout',
            msg: `XHR Timeout: ${this._method} ${this._requestUrl}`,
            url: this._requestUrl,
            duration: Date.now() - this._startTime,
          });
        });
        this._errorHandlerAdded = true;
      }
      return originalXHROpen.apply(this, args);
    };

    XMLHttpRequest.prototype.send = function (...args) {
      return originalXHRSend.apply(this, args);
    };
  }

  // Fetch请求监控
  setupFetchMonitor() {
    const originalFetch = window.fetch;
    const self = this;

    window.fetch = function (...args) {
      const startTime = Date.now();
      const url = typeof args[0] === 'string' ? args[0] : args[0].url;

      return originalFetch
        .apply(this, args)
        .then((response) => {
          if (!response.ok) {
            self.captureError({
              type: 'fetch_error',
              msg: `Fetch Error: ${response.status} ${response.statusText}`,
              url: url,
              status: response.status,
              duration: Date.now() - startTime,
            });
          }
          return response;
        })
        .catch((error) => {
          self.captureError({
            type: 'fetch_error',
            msg: `Fetch Error: ${error.message}`,
            url: url,
            duration: Date.now() - startTime,
            stack: error.stack,
          });
          throw error;
        });
    };
  }

  // UI崩溃监控(使用取点检测方式)
  setupUICrashMonitor() {
    // 取点检测白屏
    const checkWhiteScreen = () => {
      if (!this.contentEl) return;

      // 基础检测:DOM元素数量
      const childrenCount = this.contentEl.getElementsByTagName('*').length;
      
      // 取点检测是否为白屏
      const isWhiteScreen = this.checkPointsElement();
      
      // 如果DOM元素少于10个或者取点检测判断为白屏
      if (childrenCount < 10 || isWhiteScreen) {
        this.captureError({
          type: 'ui_crash',
          msg: 'UI Crash: Possible white screen detected',
          html: this.contentEl.outerHTML.length > 1000 ? this.contentEl.outerHTML.slice(0, 500) + '...' + this.contentEl.outerHTML.slice(-500) : this.contentEl.outerHTML,
          childrenCount,
          isWhiteScreen,
          screenWidth: window.innerWidth,
          screenHeight: window.innerHeight,
        });
      }
    };

    // 在页面加载完成后检测
    window.addEventListener('load', () => {
      setTimeout(checkWhiteScreen, 2000);
    });

    // 在路由变化后检测(适用于SPA)
    if (window.history && window.history.pushState) {
      const originalPushState = window.history.pushState;
      const originalReplaceState = window.history.replaceState;

      window.history.pushState = function (...args) {
        originalPushState.apply(this, args);
        setTimeout(checkWhiteScreen, 2000);
      };

      window.history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        setTimeout(checkWhiteScreen, 2000);
      };

      window.addEventListener('popstate', () => {
        setTimeout(checkWhiteScreen, 2000);
      });
    }
  }

  // 取点检测元素是否为body(判断白屏)
  checkPointsElement() {
    try {
      // 获取视口尺寸
      const viewportWidth = window.innerWidth;
      const viewportHeight = window.innerHeight;
      
      // 如果视口太小,不进行检测
      if (viewportWidth < 50 || viewportHeight < 50) return false;
      
      // 定义检测点的位置(3x3网格)
      const rows = 3;
      const cols = 3;
      const points = [];
      
      for (let i = 1; i <= rows; i++) {
        for (let j = 1; j <= cols; j++) {
          points.push({
            x: Math.floor(viewportWidth * (i / (rows + 1))),
            y: Math.floor(viewportHeight * (j / (cols + 1)))
          });
        }
      }
      
      // 检测每个点的元素
      let bodyCount = 0;
      const totalPoints = points.length;
      
      for (const point of points) {
        // 获取该点位置的元素
        const elementAtPoint = document.elementFromPoint(point.x, point.y);
        
        // 如果元素是body或html,计数加1
        if (!elementAtPoint || elementAtPoint === document.body || elementAtPoint === document.documentElement) {
          bodyCount++;
        }
      }
      
      // 计算body元素点的比例
      const bodyRatio = bodyCount / totalPoints;
      
      // 如果大部分点都是body元素,判定为白屏
      // 阈值可以根据实际情况调整
      return bodyRatio >= 0.7; // 70%的点为body时判定为白屏
    } catch (error) {
      console.error('白屏检测出错:', error);
      return false;
    }
  }

  // 捕获错误并加入队列
  captureError(error) {
    if (!error || Math.random() > this.options.sampleRate) {
      return; // 采样控制
    }

    const errorInfo = {
      ...error,
      appId: this.options.appId,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      // 可以添加更多上下文信息
      screenSize: `${window.screen.width}x${window.screen.height}`,
      viewportSize: `${window.innerWidth}x${window.innerHeight}`,
      eventId: this.generateUUID(),
    };

    // 错误去重
    const errorKey = `${errorInfo.type}:${errorInfo.msg}`;
    const now = Date.now();
    const lastErrorTime = this.errorMap.get(errorKey);
    
    if (lastErrorTime && now - lastErrorTime < this.options.dedupeInterval) {
      // 在去重时间窗口内,忽略重复错误
      return;
    }
    
    // 更新错误时间
    this.errorMap.set(errorKey, now);
    
    this.errorQueue.push(errorInfo);
    this.sendErrors();
  }

  // 发送错误队列
  sendErrors(immediate = false) {
    if (immediate) {
      if (this.errorQueue.length > 0) {
        this.doSendErrors();
      }
      return;
    }

    // 防抖,避免频繁发送
    if (this.timer) {
      clearTimeout(this.timer);
    }

    this.timer = setTimeout(() => {
      this.doSendErrors();
    }, 1000);
  }


  // 在ErrorMonitor类中添加
  setupUserBehaviorTracker() {
    const maxBehaviors = 50;
    const behaviors = [];

    // 记录点击事件
    document.addEventListener(
      'click',
      (event) => {
        const target = event.target;
        const behavior = {
          type: 'click',
          time: Date.now(),
          element: target.tagName,
          id: target.id,
          class: target.className,
          text: target.innerText?.substring(0, 50),
          path: this.getElementPath(target),
        };

        behaviors.push(behavior);
        if (behaviors.length > maxBehaviors) {
          behaviors.shift();
        }
      },
      true
    );

    // 在错误上报时附加用户行为
    this.userBehaviors = behaviors;
  }

  // 在ErrorMonitor类中添加
  setupPerformanceMonitor() {
    // 页面加载性能
    window.addEventListener('load', () => {
      setTimeout(() => {
        const performance = window.performance;
        if (!performance) return;

        const timing = performance.timing;
        const performanceData = {
          type: 'performance',
          // DNS查询时间
          dns: timing.domainLookupEnd - timing.domainLookupStart,
          // TCP连接时间
          tcp: timing.connectEnd - timing.connectStart,
          // 首字节时间
          ttfb: timing.responseStart - timing.requestStart,
          // DOM解析时间
          domParse: timing.domInteractive - timing.responseEnd,
          // DOM Ready时间
          domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
          // 页面完全加载时间
          load: timing.loadEventEnd - timing.navigationStart,
          // 资源加载时间
          resource: timing.loadEventEnd - timing.domContentLoadedEventEnd,
        };

        // 上报性能数据
        this.captureError(performanceData);

        // 收集资源加载性能
        const resources = performance.getEntriesByType('resource');
        const slowResources = resources
          .filter((item) => item.duration > 1000) // 超过1秒的资源
          .map((item) => ({
            name: item.name,
            duration: item.duration,
            size: item.transferSize,
            type: item.initiatorType,
          }));

        if (slowResources.length > 0) {
          this.captureError({
            type: 'slow_resources',
            resources: slowResources,
          });
        }
      }, 0);
    });
  
    // 添加对 PerformanceObserver 的支持,用于监控长任务
    if (window.PerformanceObserver && PerformanceObserver.supportedEntryTypes.includes('longtask')) {
      const observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          if (entry.duration > 50) { // 超过50ms的任务
            this.captureError({
              type: 'long_task',
              duration: entry.duration,
              startTime: entry.startTime,
              name: entry.name,
            });
          }
        });
      });
      observer.observe({ entryTypes: ['longtask'] });
    }
  }

    
  // 网络状态监控
  setupNetworkMonitor() {
    if ('connection' in navigator) {
      const connection = navigator.connection;
      
      const reportNetworkChange = () => {
        this.captureError({
          type: 'network_change',
          effectiveType: connection.effectiveType,
          downlink: connection.downlink,
          rtt: connection.rtt,
          saveData: connection.saveData,
          online: navigator.onLine
        });
      };
      
      connection.addEventListener('change', reportNetworkChange);
      
      // 监听在线/离线状态
      window.addEventListener('online', () => {
        this.captureError({
          type: 'network_status',
          status: 'online'
        });
      });
      
      window.addEventListener('offline', () => {
        this.captureError({
          type: 'network_status',
          status: 'offline'
        });
      });
    }
  }

  // 获取元素路径
  getElementPath(element) {
    const path = [];
    let currentElement = element;

    while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) {
      let selector = currentElement.nodeName.toLowerCase();
      if (currentElement.id) {
        selector += `#${currentElement.id}`;
      } else if (currentElement.className) {
        selector += `.${currentElement.className.split(' ').join('.')}`;
      }
      path.unshift(selector);
      currentElement = currentElement.parentNode;
    }

    return path.join(' > ');
  }

  // 生成唯一ID
  generateUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
      const r = (Math.random() * 16) | 0;
      const v = c === 'x' ? r : (r & 0x3) | 0x8;
      return v.toString(16);
    });
  }


  // 实际发送错误的方法
  doSendErrors() {
    if (this.errorQueue.length === 0) return;
    console.log('Received error reports:', this.errorQueue);
    return;
    // 发送错误数据到后端
    // 这里可以使用fetch或XMLHttpReques发送数据
    const errors = [...this.errorQueue];
    this.errorQueue = [];

    // 使用sendBeacon在页面卸载时也能发送数据
    if (navigator.sendBeacon) {
      const blob = new Blob([JSON.stringify(errors)], { type: 'application/json' });
      navigator.sendBeacon(this.options.reportUrl, blob);
    } else {
      // 降级方案
      fetch(this.options.reportUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(errors),
        // 不等待响应
        keepalive: true,
      }).catch(() => {
        // 忽略上报失败
      });
    }
  }

}

// 导出模块
if (typeof module !== 'undefined' && module.exports) {
  module.exports = ErrorMonitor;
} else if (typeof window !== 'undefined') {
  window.ErrorMonitor = ErrorMonitor;
}