监控之错误监控

7 阅读6分钟

一个完整的前端监控体系通常围绕以下几个核心维度构建:

监控维度核心目标关键指标/关注点
错误监控捕获并分析应用中的各类异常,防止页面白屏或功能失效JavaScript执行错误、资源加载失败、异步请求异常等
性能监控衡量页面加载与渲染效率,优化用户体验首屏时间、可交互时间、资源加载耗时等
用户行为监控追踪用户交互路径,分析产品使用情况,辅助优化产品设计页面浏览量(PV)/独立访客(UV)、点击流、用户操作流程

错误监控分类总览

错误类型监控方式关键信息特殊处理
JS运行时错误window.onerror错误信息、文件、行列号跨域脚本需CORS
资源加载错误事件捕获资源URL、标签类型需事件捕获阶段监听
Promise拒绝错误unhandledrejectionPromise拒绝原因需主动处理避免控制台警告
跨域脚本错误错误监听+CORS基本错误信息需服务器配置CORS头
框架特定错误错误边界/错误钩子组件栈、状态快照框架特定API
异步代码错误错误包装/Async监听异步上下文信息需特殊错误捕获机制
控制台错误console重写开发者主动记录需保持原始功能

1. JS运行时错误

通过监听error事件。

 window.addEventListener('error', (event) => {
      
      this.handleRuntimeError(event);
    }, true); // 使用捕获阶段确保捕获所有错误
  }

2. 资源加载错误

通过监听error事件。判断错误的目标的tagName,如果命中type如img等,则判断为资源加载错误

 window.addEventListener('error', (event) => {
      
      // 判断是否为资源加载错误
      if (this.isResourceElement(target)) {
        this.handleResourceError(event, target);
      }
    }, true); // 使用捕获阶段确保捕获所有错误
  }

isResourceElement(element) {
    if (!element || !element.tagName) return false;
    
    const tagName = element.tagName.toLowerCase();
    const resourceTypes = {
      'img': 'image',
      'script': 'script',
      'link': 'css',
      'audio': 'audio',
      'video': 'video'
    };
    
    return resourceTypes[tagName] && 
           this.options.monitorTypes.includes(resourceTypes[tagName]);
  }

3. Promise拒绝错误监控 (异步无法被error捕获)

通过unhandledrejection事件

window.addEventListener('unhandledrejection', (event) => {
      this.handlePromiseRejection(event);
      
      // 可选:阻止浏览器默认的未处理拒绝警告
      if (this.options.preventDefaultWarning) {
        event.preventDefault();
      }
    });

4. 跨域脚本错误监控

跨域脚本错误的典型特征

window.addEventListener('error', (event) => {
      // 检测跨域脚本错误特征
      if (this.isCrossOriginError(event)) {
        this.handleCrossOriginError(event);
      }
    });

isCrossOriginError(event) {
    // 跨域脚本错误的典型特征
    return event.message === 'Script error.' || 
           event.message === 'Script error' ||
           (!event.filename && event.lineno === 0 && event.colno === 0);
  }

5. 框架特定错误监控

React错误边界示例

ErrorBoundary
// 有componentDidCatch, 捕获错误

vue

vue2 Vue.config.errorHandler捕获。 vue3 app.config.errorHandler捕获

// Vue 2.x 错误处理
const Vue2ErrorHandler = {
  install(Vue, options = {}) {
    const reportUrl = options.reportUrl || '/api/vue-error';
    
    // 全局错误处理器
    Vue.config.errorHandler = (err, vm, info) => {
      this.handleVueError(err, vm, info, reportUrl);
    };

    // 全局警告处理器(可选)
    Vue.config.warnHandler = (msg, vm, trace) => {
      console.warn('Vue警告:', msg, trace);
    };
  },

  handleVueError(err, vm, info, reportUrl) {
    const errorInfo = {
      type: 'vue_error',
      timestamp: Date.now(),
      error: {
        name: err.name,
        message: err.message,
        stack: err.stack
      },
      vueInfo: {
        hook: info, // 生命周期钩子名称
        component: vm?.$options?.name || 'AnonymousComponent',
        file: vm?.$options?.__file || 'unknown'
      },
      url: window.location.href,
      userAgent: navigator.userAgent
    };

    this.reportError(errorInfo, reportUrl);
  },

  reportError(errorInfo, reportUrl) {
    if (navigator.sendBeacon) {
      const data = new Blob([JSON.stringify(errorInfo)], {
        type: 'application/json'
      });
      navigator.sendBeacon(reportUrl, data);
    }
  }
};

// Vue 3.x 错误处理
const Vue3ErrorHandler = {
  install(app, options = {}) {
    const reportUrl = options.reportUrl || '/api/vue-error';
    
    app.config.errorHandler = (err, vm, info) => {
      this.handleVueError(err, vm, info, reportUrl);
    };
  },

  handleVueError(err, instance, info, reportUrl) {
    const errorInfo = {
      type: 'vue_error',
      timestamp: Date.now(),
      error: {
        name: err.name,
        message: err.message,
        stack: err.stack
      },
      vueInfo: {
        hook: info,
        component: instance?.type?.name || 'AnonymousComponent'
      },
      url: window.location.href,
      userAgent: navigator.userAgent
    };

    this.reportError(errorInfo, reportUrl);
  },

  reportError(errorInfo, reportUrl) {
    if (navigator.sendBeacon) {
      const data = new Blob([JSON.stringify(errorInfo)], {
        type: 'application/json'
      });
      navigator.sendBeacon(reportUrl, data);
    }
  }
};

6. 异步代码错误监控

重写,包装异步API

a. 定时器包装(setTimeout/setInterval)

// 原始调用
setTimeout(() => {
  throw new Error('异步错误'); // 这个错误会直接导致脚本崩溃
}, 1000);

// 包装后的效果
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay, ...args) {
  // 包装回调函数
  const wrappedCallback = function() {
    try {
      return callback.apply(this, arguments); // 在try-catch中执行
    } catch (error) {
      // 捕获错误并上报
      reportError(error, { context: 'setTimeout' });
      throw error; // 重新抛出,保持原有行为
    }
  };
  
  return originalSetTimeout(wrappedCallback, delay, ...args);
};

b. 事件监听器包装

生效流程:

// 原始事件监听
button.addEventListener('click', () => {
  throw new Error('点击事件错误');
});

// 包装后的效果
const originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
  const wrappedListener = function(event) {
    try {
      return listener.call(this, event);
    } catch (error) {
      reportError(error, { context: `event:${type}` });
      throw error;
    }
  };

  return originalAddEventListener.call(this, type, wrappedListener, options);
};

c. 异步函数包装

生效流程:

// 原始异步函数
async function fetchData() {
  const response = await fetch('/api');
  return response.json(); // 可能抛出错误
}

// 包装后的效果
function wrapAsyncFunction(asyncFunc, name) {
  return async function(...args) {
    try {
      return await asyncFunc.apply(this, args);
    } catch (error) {
      reportError(error, { context: `async:${name}` });
      throw error;
    }
  };
}

// 使用包装函数
const safeFetchData = wrapAsyncFunction(fetchData, 'fetchData');

7. 控制台错误监控

包装console的方法。

wrapConsoleMethod(method) {  //method 比如error、warm
    // 保存原始方法
    this.originalConsole[method] = console[method];
    
    const self = this;
    console[method] = function(...args) {
      // 调用原始方法
      self.originalConsole[method].apply(console, args);
      // 监控错误信息
      self.handleConsoleLog(method, args);
    };
  }

为什么使用捕获阶段捕获错误?

资源错误不会冒泡

  • 资源加载错误是直接在目标元素上触发的
  • 这类错误默认不会冒泡到父元素
  • 只有在捕获阶段才能从window向下传播时拦截到

确保最早拦截错误

执行顺序:

  1. 捕获阶段:window → document → ... → 目标元素
  2. 目标阶段:在目标元素上触发
  3. 冒泡阶段:目标元素 → ... → window

错误上报的优化

1 错误信息标准化

错误类型、错误信息、错误堆栈、当前页面url、浏览器信息、时间戳、用户信息

2 上报策略优化

  1. 批量上报:合并多个错误减少请求数
  2. 智能防抖:延迟上报避免频繁请求
  3. 优先级队列:重要错误优先上报
  4. 自适应采样:根据错误频率动态调整采样率
  5. 指数退避重试:失败时智能重试
  6. 本地存储降级:网络异常时本地缓存
  7. 标签页不活跃的时候发送

3 页面关闭时上传

使用 sendBeacon API(推荐) navigator.sendBeacon。

navigator.sendBeacon()是浏览器提供的一个专门用于在页面卸载时可靠发送数据的API。

降级使用:图片打点降级方案。

img.src 拼接数据。 优点: 兼容性好、不会阻塞、跨域支持 缺点: 数据长度有限(2000)

class PageUnloadReporter {
  constructor() {
    this.pendingReports = [];
    this.initUnloadHandler();
  }

  initUnloadHandler() {
    // 1. beforeunload 事件(最后机会)
    window.addEventListener('beforeunload', () => {
      this.flushBeforeUnload();
    });

    // 2. pagehide 事件(更可靠)
    window.addEventListener('pagehide', (event) => {
      if (event.persisted) {
        // 页面被缓存(如iOS应用切换),稍后上报
        this.scheduleBackgroundReport();
      } else {
        // 页面完全卸载,立即上报
        this.flushOnUnload();
      }
    });

    // 3. visibilitychange 事件(页面隐藏时)
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flushOnHidden();
      }
    });
  }

  // 使用 sendBeacon 上报
  sendViaBeacon(data) {
    if (!navigator.sendBeacon) {
      return this.sendViaImage(data); // 降级方案
    }

    try {
      const blob = new Blob([JSON.stringify(data)], {
        type: 'application/json'
      });
      
      return navigator.sendBeacon(this.reportUrl, blob);
    } catch (error) {
      console.warn('sendBeacon失败,使用降级方案:', error);
      return this.sendViaImage(data);
    }
  }

  // 图片打点降级方案
  sendViaImage(data) {
    return new Promise((resolve) => {
      const img = new Image();
      const params = new URLSearchParams();
      
      // 简化数据,适应URL长度限制
      const simplifiedData = {
        t: data.type?.substr(0, 20),
        m: data.message?.substr(0, 100),
        ts: Date.now()
      };
      
      params.append('data', JSON.stringify(simplifiedData));
      
      img.onload = img.onerror = () => {
        resolve(true); // 无论成功失败,都认为已发送
      };
      
      img.src = `${this.reportUrl}?${params.toString()}`;
    });
  }


}

问题思考

1 为什么不能在卸载页面用接口请求?

因为异步请求会被直接取消。 同步请求会阻塞用户体验

2 为什么sendBeacon请求不会被取消

// 普通请求:绑定到页面生命周期

普通HTTP请求 → 页面卸载 → 请求被取消

// sendBeacon请求:独立于页面生命周期

sendBeacon请求 → 页面卸载 → 浏览器进程继续处理请求

3 为什么降级的图片请求不会被取消

get请求简单。 图片请求无阻塞性。浏览器策略会优先尝试完成请求