前端错误监控的简单设计与实现

前端错误监控的简单设计与实现

在之前的博文中讲了在前端React的错误监控系统要如何设计《React 错误处理和日志记录的思考与设计》

这篇博文主要讲一下根据如下的业务场景并结合《React 错误处理和日志记录的思考与设计》,如何设计一个简单的前端错误监控功能。

首先业务场景比较简单,只是为了让我们的开发人员能够发现用户在前端操作出现的一些前端错误,能够尽早发现和定位问题。我们暂定是使用邮件的形式来通知我们的开发人员。而且我们并不要求所有的前端错误都能够实时全量的通知给开发人员,因为当前端有问题的时候,可能前端报错特别多,会导致上报的数据会很多,从而造成发送很多邮件,而实际上我们只是想关心发生了什么错误,而不是关心发生了多少错误。所以我们会对监控上报和邮件通知进行限制,保证不会有瞬间过多的监控数据请求到后端。

最后要强调的是,本篇博文的设计只是针对某些业务场景进行设计的,并不适用于中大型的系统,也不适用于专门的前端监控系统,也不建议直接照搬到生产环境。如果你对大厂前端监控怎么设计和埋点的,可以参考文章最下方的链接,这里就不过多的赘述了。

前端埋点

《React 错误处理和日志记录的思考与设计》中讲述了几种前端异常捕获的方式。 我们这里主要采用的是windows对象中的事件监听器,使用window.addEventLinstener去注册事件监听器。
我们主要关心的其实只有两种事件window.addEventListener('error', ....)window.addEventListener('unhandledrejection',...)

其中这里很多小伙伴有疑问,为什么不用 window.onerror 全局监听呢? 到底window.addEventLinstener('error')window.onerror 有什么区别呢?

我们可以从MDN网站中看到更加推荐使用addEventListener()的方式

Note: The addEventListener() method is the recommended way to register an event listener. The benefits are as follows:

  1. It allows adding more than one handler for an event. This is particularly useful for libraries, JavaScript modules, or any other kind of code that needs to work well with other libraries or extensions.
  2. In contrast to using an onXYZ property, it gives you finer-grained control of the phase when the listener is activated (capturing vs. bubbling).
  3. It works on any event target, not just HTML or SVG elements.

参考:https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener

首先window.onerrorwindow.addEventListener('error', ....)这两个函数功能基本一致,都可以全局捕获 js 异常。但是有一类异常叫做 资源加载异常,就是在代码中引用了不存在的图片,js,css 等静态资源导致的异常,比如:

const loadCss = ()=> {
  let link = document.createElement('link')
  link.type = 'text/css'
  link.rel = 'stylesheet'
  link.href = 'https://baidu.com/15.css'
  document.getElementsByTagName('head')[10].append(link)
}
render() {
  return <div>
    <img src='./bbb.png'/>
    <button onClick={loadCss}>加载样式<button/>
  </div>
}

上述代码中的 baidu.com/15.cssbbb.png 是不存在的,JS 执行到这里肯定会报一个资源找不到的错误。但是默认情况下,上面两种 window 对象上的全局监听函数都监听不到这类异常。

因为资源加载的异常只会在当前元素触发,异常不会冒泡到 window,因此监听 window 上的异常是捕捉不到的。那怎么办呢?

如果你熟悉 DOM 事件你就会明白,既然冒泡阶段监听不到,那么在捕获阶段一定能监听到。

方法就是给 window.addEventListene 函数指定第三个参数,很简单就是 true,表示该监听函数会在捕获阶段执行,这样就能监听到资源加载异常了。

// 捕获阶段全局监听
window.addEventListene(
  'error',
  (error) => {
    if (error.target != window) {
      console.log(error.target.tagName, error.target.src);
    }
    handleError(error);
  },
  true,
);

上述方式可以很轻松的监听到图片加载异常,这就是为什么更推荐 window.addEventListene 的原因。不过要记得,第三个参数设为 true,监听事件捕获,就可以全局捕获到 JS 异常和资源加载异常。

接下来就是window.addEventListener('unhandledrejection',...)了,我们可以从MDN网站中看到unhandledrejection事件的功能如下:

The unhandledrejection event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected; typically, this is the window, but may also be a Worker.

This is useful for debugging and for providing fallback error handling for unexpected situations.

参考:https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event

因为window.addEventListener('error', ....)不能捕获 Promise 异常。不管是 Promise.then() 写法还是 async/await 写法,发生异常时都不能捕获。所以我们才需要全局监听一个 unhandledrejection 事件来捕获未处理的 Promise 异常。unhandledrejection 事件会在 Promise 发生异常并且没有指定 catch 的时候触发,这个函数会捕捉到运行时意外发生的 Promise 异常,这对我们异常监控非常有用。

而且我们请求后端API也是使用的Promise,所以可以不使用类似umi-request中错误拦截器来捕获异常,直接监听unhandledrejection事件也能捕获到这类的异常。

错误类型

上面我们在埋点的时候讲到,我们会注册两个事件监听器,window.addEventListener('error', ....,true)window.addEventListener('unhandledrejection',...)。 这两个事件监听器,其实最终可能产生4种类型的错误。

首先还是window.addEventListener('error', ...., true)会产生两种类型的错误,一种是代码中的错误,我们称之为ScriptError。还有一种错误就是第三个参数true的作用,也就是可能是静态资源导致的错误,我们称之为ResourceError

而对于window.addEventListener('unhandledrejection',...)的错误也分为两种,一种是我们前面说到的请求后端API的错误,我们称之为ResponseError。还有一种是其他的Promise 异常,我们称之为PromiseError

所以一共分成4种类型的错误:

  1. ScriptError
  2. ResourceError
  3. ResponseError
  4. PromiseError

异常上报的数据格式

根据上面不同的错误类型,最终上报的数据格式可能也是不一样的,所以我们定义了如下的格式。

首先公共上报数据格式如下:

export type ErrorMonitorInfo = {
  domain: string,
  referrer: string,
  openURL: string,
  pageTitle: string,
  language: string,
  userAgent: string,
  currentUserName: string,
  errorType: string,
  errorDateTimeWithGMT8: string,
  error: ErrorContent,
  type: string
}

其中ErrorContent中分为四种错误类型

export type ErrorContent = {
  resourceError: ResourceError,
  promiseError: PromiseError,
  responseError: ResponseError,
  scriptError: ScriptError
}

每一种错误类型有对应的数据格式:

export type ResourceError = {
  resourceErrorDOM: string
}

export type PromiseError = {
  message: string
}

export type ResponseError = {
  message: string,
  data: string,
  request: string,
  errorStack: string
}

export type ScriptError = {
  filename: string,
  message: string,
  errorStack: string,
}

异常上报防抖处理

在之前的博文中《React 错误处理和日志记录的思考与设计》讲到了几个存在的问题,如果一次操作有很多个重复的错误,所以可能会出现多次重复请求的情况。 举个例子,我们在渲染表格数据的时候,如果column的render方法有问题的话,那在渲染表格的时候可能就会触发很多次相同的错误,并且都会被我们的错误事件监听器捕获到。所以我们考虑需要对前端异常上报的功能做一个速率的限制。

比如可以考虑对上报的API做Promise并发控制,限制并发数,可以参考如下的文章:

Promise实现限制并发数

如何控制Promise并发

5分钟搞定Promise控制并发

Promise的并发控制

而我们这里采用的是另一种比较简单有效的方式,就是JS防抖处理,这种防抖处理在很多功能上都会有应用。那什么叫做防抖?, 防抖就是将一组例如按下按键这种密集的事件归并成一个单独事件。举例来说,比如要搜索某个字符串,基于性能考虑,肯定不能用户每输入一个字符就发送一次搜索请求,一种方法就是等待用户停止输入,比如过了500ms用户都没有再输入,那么就搜索此时的字符串,这就是防抖。还有另外一种叫做节流,这里就不过多赘述,可以参考如下的文章:

JS中的防抖

js防抖和节流的实现

手撕源码系列 —— lodash 的 debounce 与 throttle

JavaScript 闭包

所以我们采用的是lodash中的debounce方法来进行防抖处理。

缓存异常上报数据来限制上报频率

为啥要做异常上报的缓存呢,其实目的也是为了不上报太多相同类型的错误数据。因为前面的防抖处理,只能处理短暂时间内大量的异常了触发错误监听器。但如果用户在当前页面停留一段时间,再次操作时候还是遇到一样的错误,我们其实没必要上报和通知,我们会根据异常类型和数据缓存下来,之后遇到同样的错误我们就忽略不进行上报了。

所以我们提供了一个缓存工具类

import ExpiresCache from "@/util/ExpiresCache";

class ExpiresCacheUtils {

  private static cacheMap = new Map();

  static isExpires(key: string) {
    const data = ExpiresCacheUtils.cacheMap.get(key);
    if (data == null) {
      return true;
    }
    const currentTime = (new Date()).getTime()
    const expireTime = (currentTime - data.cacheTime) / 1000;
    if (Math.abs(expireTime) > data.timeout) {
      ExpiresCacheUtils.cacheMap.delete(key);
      return true;
    }
    return false;
  }

  static has(key: string) {
    return !ExpiresCacheUtils.isExpires(key);
  }

  static delete(key: string) {
    return ExpiresCacheUtils.cacheMap.delete(key)
  }

  static get(key: string) {
    const isExpires = ExpiresCacheUtils.isExpires(key)
    return isExpires ? null : ExpiresCacheUtils.cacheMap.get(key).data
  }

  static set(key: string, data: any, timeout: number = 20 * 60) {
    if (key && data) {
      const expiresCache = new ExpiresCache(key, data, timeout);
      ExpiresCacheUtils.cacheMap.set(key, expiresCache)
    }
  }

}

export default ExpiresCacheUtils;

class ExpiresCache {
  private key: string;
  private data: any;
  private timeout?: number;
  private cacheTime?: number;

  constructor(key: string, data: any, timeout: number) {
    this.key = key;
    this.data = data;
    this.timeout = timeout;
    this.cacheTime = (new Date()).getTime()
  }


}

export default ExpiresCache;


异常上报可定制化配置

我们想要在前端配置中可以指定需要开启那些错误类型的监控,或者过滤上报哪些错误类型,或者是不监控哪些指定的页面。所以我们提供了一个配置如下:

export type ErrorMonitorConfiguration = {
  ignoreScriptErrors?: RegExp[],
  ignoreDetectAllErrorForOpenPageUrls?: RegExp[],
  ignoreErrorResponseCode?: number[],
  ignoreErrorResponseUrls?: RegExp[],
  enableResourceErrorDetect?: boolean,
  enablePromiseErrorDetect?: boolean,
  enableResponseErrorDetect?: boolean,
  enableScriptErrorDetect?: boolean,
  enable?: boolean,
  triggerReportErrorIntervalMillisecond?: number,
  cacheErrorIntervalSecond?: number,
  debounceOption?: any
}

export default {
  ignoreDetectAllErrorForOpenPageUrls: [
    /\/xxxx\/xxxx-search/i, //用于忽略指定URL页面上的所有错误,使用正则表达式
  ],
  ignoreScriptErrors: [
    /ResizeObserver loop limit exceeded/i, //用于忽略错误内容包含指定字符串ResizeObserver loop limit exceeded的ScriptError, 使用正则表达式
  ],
  ignoreErrorResponseCode: [ //用于忽略API请求中响应码包含401,400的ResponseError
    401, 400
  ],
  ignoreErrorResponseUrls: [ //用于忽略API请求中URL中包含指定正则表达式的ResponseError, 默认需要指定上报接口的API路径
    /\/xxxx\/xxxx-front-end-monitor/i,
  ],
  enableResourceErrorDetect: true,  // 开启静态资源异常监控
  enablePromiseErrorDetect: true,  //开启Promise异常监控
  enableResponseErrorDetect: true, //开启API请求Response异常监控
  enableScriptErrorDetect: true, //开启代码中脚本异常监控
  triggerReportErrorIntervalMillisecond: 1000,  //设置JS防抖的时间,用于控制上报速率,设置1s
  cacheErrorIntervalSecond: 60,  //设置上报数据的缓存时间,用于控制上报速率,设置60s
  enable: true // 是否启用前端监控功能
} as ErrorMonitorConfiguration

前端异常监控代码

import DateUtil from "@/util/DateUtil";
import ErrorMonitorConfig from "@/config/ErrorMonitorConfig";
import {debounce, isEqual} from "lodash";
import ExpiresCacheUtils from "@/util/ExpiresCacheUtils";
import {MonitorErrorTypeConstant} from "@/constants/constants";
import type {ErrorContent, ErrorMonitorInfo} from "@/model/monitor";
import {sendErrorMonitor} from "@/services/monitor";


class ErrorMonitorUtil {

  private static readonly _timeZone = "Asia/Shanghai";
  private static readonly _reportingInterval = ErrorMonitorConfig.triggerReportErrorIntervalMillisecond || 1000;
  private static readonly _cacheInterval = ErrorMonitorConfig.cacheErrorIntervalSecond || 0;
  private static readonly _options = ErrorMonitorConfig.debounceOption || {};

  private static exportSendErrorMonitorInfo = () => {
    return (userInfo: any, error: any, callback: any) => {
      try {
        if (!ErrorMonitorConfig.enable) {
          return;
        }
        const info: ErrorMonitorInfo = callback(userInfo, error);
        const ignore = (ErrorMonitorConfig.ignoreDetectAllErrorForOpenPageUrls || []).some(item => item.test(info?.openURL));
        if (ignore) {
          return;
        }
        const key = `${info.type}-${info.currentUserName}-${info.openURL}`;
        const cache = ExpiresCacheUtils.get(key);
        if (cache && isEqual(cache, info.error)) {
          return;
        }
        ExpiresCacheUtils.set(key, info.error, this._cacheInterval);
        sendErrorMonitor(info).catch((e: any) => {
          console.log("send error monitor with error: ", e);
        })
      } catch (e) {
        console.log("handle error monitor with error: ", e);
      }
    }
  }

  private static constructScriptErrorMonitorInfo = (userInfo: any, error: any): ErrorMonitorInfo => {
    const info = ErrorMonitorUtil.constructCommonErrorMonitorInfo(userInfo, error?.type, MonitorErrorTypeConstant.SCRIPT_ERROR);
    info.error.scriptError = {
      filename: error?.filename || '',
      message: error?.message || '',
      errorStack: (error?.error || {}).stack || ''
    }
    return info;
  }

  private static constructResourceErrorMonitorInfo = (userInfo: any, error: any): ErrorMonitorInfo => {
    const info = ErrorMonitorUtil.constructCommonErrorMonitorInfo(userInfo, error?.type, MonitorErrorTypeConstant.RESOURCE_ERROR);
    info.error.resourceError = {
      resourceErrorDOM: error?.target !== window ? (error?.target?.outerHTML || '') : ''
    }
    return info;
  }

  private static constructPromiseErrorMonitorInfo = (userInfo: any, error: any): ErrorMonitorInfo => {
    const info = ErrorMonitorUtil.constructCommonErrorMonitorInfo(userInfo, error?.type, MonitorErrorTypeConstant.PROMISE_ERROR);
    info.error.promiseError = {
      message: JSON.stringify(error)
    }
    return info;
  }

  private static constructResponseErrorMonitorInfo = (userInfo: any, error: any): ErrorMonitorInfo => {
    const info = ErrorMonitorUtil.constructCommonErrorMonitorInfo(userInfo, error?.type, MonitorErrorTypeConstant.HTTP_ERROR);
    info.error.responseError = {
      message: error?.message || '',
      data: JSON.stringify(error?.data) || '',
      request: JSON.stringify(error?.request) || '',
      errorStack: error?.stack || ''
    }
    return info;
  }

  private static constructCommonErrorMonitorInfo = (userInfo: any, errorType: string, type: string): ErrorMonitorInfo => {
    return {
      domain: document?.domain || '',
      openURL: document?.URL || '',
      pageTitle: document?.title || '',
      referrer: document?.referrer || '',
      language: navigator?.language || '',
      userAgent: navigator?.userAgent || '',
      currentUserName: userInfo?.userName || '',
      errorType: errorType || '',
      errorDateTimeWithGMT8: DateUtil.getCurrentTimeByTimeZone(this._timeZone),
      type: type,
      error: {} as ErrorContent
    } as ErrorMonitorInfo;
  }

  public static exportErrorHandleListener = (userInfo: any) => {
    const sendScriptErrorFun = debounce(ErrorMonitorUtil.exportSendErrorMonitorInfo(), this._reportingInterval, this._options);
    const sendResourceErrorFun = debounce(ErrorMonitorUtil.exportSendErrorMonitorInfo(), this._reportingInterval, this._options);
    return (event: any) => {
      if (event.target !== window) {
        this.handleResourceError(event, sendResourceErrorFun, userInfo);
      } else {
        this.handleScriptError(event, sendScriptErrorFun, userInfo);
      }
    }
  }

  private static handleScriptError(event: any, sendScriptErrorFun: any, userInfo: any) {
    const ignore = (ErrorMonitorConfig.ignoreScriptErrors || []).some(item => item.test(event.message));
    if (!ErrorMonitorConfig.enableScriptErrorDetect || ignore) {
      return;
    }
    sendScriptErrorFun(userInfo, event, ErrorMonitorUtil.constructScriptErrorMonitorInfo);
  }

  private static handleResourceError(event: any, sendResourceErrorFun: any, userInfo: any) {
    if (!ErrorMonitorConfig.enableResourceErrorDetect) {
      return;
    }
    sendResourceErrorFun(userInfo, event, ErrorMonitorUtil.constructResourceErrorMonitorInfo);
  }

  public static exportPromiseAndResponseErrorHandleListener = (userInfo: any) => {
    const sendResponseErrorFun = debounce(ErrorMonitorUtil.exportSendErrorMonitorInfo(), this._reportingInterval, this._options);
    const sendPromiseErrorFun = debounce(ErrorMonitorUtil.exportSendErrorMonitorInfo(), this._reportingInterval, this._options);
    return (error: any) => {
      if (error?.promise) {
        (error?.promise as Promise<any>)?.catch((e: any) => {
          if (e?.type === MonitorErrorTypeConstant.HTTP_ERROR) {
            this.handleHttpError(e, sendResponseErrorFun, userInfo);
          } else {
            this.handlePromiseError(e, sendPromiseErrorFun, userInfo);
          }
        })
      }
    }
  }

  private static handlePromiseError(e: any, sendPromiseErrorFun: any, userInfo: any) {
    if (!ErrorMonitorConfig.enablePromiseErrorDetect) {
      return;
    }
    sendPromiseErrorFun(userInfo, e, ErrorMonitorUtil.constructPromiseErrorMonitorInfo);
  }

  private static handleHttpError(e: any, sendResponseErrorFun: any, userInfo: any) {
    const ignoreStatus = (ErrorMonitorConfig.ignoreErrorResponseCode || []).some(item => item === e?.data?.status);
    const ignoreURL = (ErrorMonitorConfig.ignoreErrorResponseUrls || []).some(item => item.test(e?.data?.path));
    if (!ErrorMonitorConfig.enableResponseErrorDetect || ignoreStatus || ignoreURL) {
      return;
    }
    sendResponseErrorFun(userInfo, e, ErrorMonitorUtil.constructResponseErrorMonitorInfo);
  }
}


export default ErrorMonitorUtil;

埋点的地方使用全局监听器

const errorHandleListener = ErrorMonitorUtil.exportErrorHandleListener(userInfo); //传入当前用户对象
const promiseErrorHandleListener = ErrorMonitorUtil.exportPromiseAndResponseErrorHandleListener(userInfo);
window.addEventListener('error', errorHandleListener, true);
window.addEventListener('unhandledrejection', promiseErrorHandleListener);

后端限流

后端我们在每次接收到上报请求的时候,可以把请求内容打印出来,之后可以通过日志来查看上报数据。但是发邮件的功能我们不想在某些高并发的情况下,比如前端突然出现异常,很多用户都出现的异常的情况,所以可能会请求很多次后端的异常上报接口,但是我们不想一次性发送很多的邮件出去,所以我们想要对这个邮件功能进行限流,比如1秒中最多支持一次请求发送邮件,所以我们考虑使用Guava的RateLimiter 令牌桶算法来进行限流操作,这里就不过多赘述,下次有机会专门讲一下如何使用Guava的RateLimiter来进行限流。

异步发送邮件

发邮件是阻塞的,为了避免阻塞请求,我们采用异步的方式来发送邮件,所以简单配置了一个线程池来获取线程异步进行发送

@Bean(name = "commonThreadPoolExecutor")
  public Executor commonThreadPoolExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(20);
    executor.setKeepAliveSeconds(60);
    executor.setAllowCoreThreadTimeOut(true);
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setThreadNamePrefix("commonThreadPoolExecutor-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
    executor.initialize();
    return executor;
  }

参考:SpringBoot线程池参数搜一堆资料还是不会配,我花一天测试换你此生明白

发送邮件阻塞问题

发送邮件可能偶尔会阻塞住,导致整个线程阻塞,所以我们需要通过如下配置解决邮件阻塞问题

spring:
  mail:
    properties:
      mail:
        smtp:
          timeout: 10000
          connectiontimeout: 10000
          writetimeout: 10000

参考: JavaMail 发送邮件阻塞问题解决——设置 smtp 超时时间

springboot java mail 超时配置不生效

总结

至此,简单的前端错误监控就实现完了,我们在设计的时候主要考虑了大数据量的上报和并发,因此我们对这方面做了很多功夫,因为我们不想因为这个功能影响到整个系统。

最后再次强调,本篇博文的设计只是针对某些业务场景进行设计的,只是个人的一些思路见解和实现,并不适用于中大型的系统,也不适用于专门的前端监控系统,也不建议直接照搬到生产环境。

参考

为什么大数据的埋点接收服务都返回GIF格式的图片

为什么大厂前端监控都在用GIF做埋点?

打通大前端最后一公里之前端埋点与日志系统架构设计

前端埋点实现以及原理分析

前端埋点小知识

【第2133期】如何搭建一套 “无痕埋点” 体系?

搭建前端监控,如何采集异常数据?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值