V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
alex1504
V2EX  ›  分享创造

基于 typescript 开发前端错误及性能监控 SDK

  •  
  •   alex1504 · 2021-05-05 14:46:23 +08:00 · 2155 次点击
    这是一个创建于 1331 天前的主题,其中的信息可能已经有所发展或是发生改变。

    poster

    前端的错误监控、性能数据往往对业务的稳定性有很重要的影响,即使我们在开发阶段十分小心,也难免线上会出现异常,并且线上环境的异常我们往往后知后觉。而页面的性能数据则关系到用户体验,因此采集页面的性能数据也十分的重要。

    现在第三方完整解决方案国外有 sentry,国内有 fundebug 、frontjs,他们提供前端接入的 SDK 和数据服务,然后有一定的免费额度,超出就需要使用付费方案。前端的 SDK 用户监控用户端异常和性能,后端服务用户可以创建应用,每个应用分配一个 APPKEY,然后 SDK 完成自动上报。

    本文不考虑数据服务,只对前端监控进行分析,讲下 web 如何进行监控和采集这些数据,并且通过 TS 集成这些功能做出一套前端监控 SDK 。

    既然需要采集数据,我们要明确下可能需要哪些数据,目前来看有如下一些数据:

    • 页面错误数据
    • 页面资源加载情况
    • 页面性能数据
    • 接口数据
    • 手机、浏览器数据
    • 页面访问数据
    • 用户行为数据
    • ...

    下面分析一下这些数据如何获取:

    页面错误数据

    • window.onerror AOP 捕获异常能力无论是异步还是非异步错误,onerror 都能捕获到运行时错误。
    • window.onerror不能捕获页面资源的加载错误,但资源加载错误能被window.addEventListener在捕获阶段捕获。由于addEventListener也能够捕获 js 错误,因此需要过滤避免重复触发事件钩子
    • window.onerror无法捕获 Promise 任务中未被处理的异常,通过unhandledrejection可以捕获

    页面资源加载异常

    window.addEventListener(
      "error",
      function (event) {
        const target: any = event.target || event.srcElement;
        const isElementTarget =
          target instanceof HTMLScriptElement ||
          target instanceof HTMLLinkElement ||
          target instanceof HTMLImageElement;
        if (!isElementTarget) return false;
    
        const url = target.src || target.href;
        onResourceError?.call(this, url);
      },
      true
    );
    

    页面逻辑和未 catch 的 promise 异常

     const oldOnError = window.onerror;
     const oldUnHandleRejection = window.onunhandledrejection;
    
     window.onerror = function (...args) {
       if (oldOnError) {
         oldOnError(...args);
       }
    
       const [msg, url, line, column, error] = args;
       onError?.call(this, {
         msg,
         url,
         line,
         column,
         error
       });
     };
    
     window.onunhandledrejection = function (e: PromiseRejectionEvent) {
       if (oldUnHandleRejection) {
         oldUnHandleRejection.call(window, e);
       }
    
       onUnHandleRejection && onUnHandleRejection(e);
     };
    

    在 Vue 中,我们应该通过Vue.config.errorHandler = function(err, vm, info) {};进行异常捕获,这样可以获取到更多的上下文信息。

    对于 React,React 16 提供了一个内置函数 componentDidCatch,使用它可以非常简单的获取到 react 下的错误信息

    componentDidCatch(error, info) {
        console.log(error, info);
    }
    

    页面性能数据

    通常我们会关注以下性能指标:

    • 白屏时间:从浏览器输入地址并回车后到页面开始有内容的时间;
    • 首屏时间:从浏览器输入地址并回车后到首屏内容渲染完毕的时间;
    • 用户可操作时间节点:domready 触发节点,点击事件有反应;
    • 总下载时间:window.onload 的触发节点。

    白屏时间

    白屏时间节点指的是从用户进入网站(输入 url 、刷新、跳转等方式)的时刻开始计算,一直到页面有内容展示出来的时间节点。 这个过程包括 dns 查询、建立 tcp 连接、发送首个 http 请求(如果使用 https 还要介入 TLS 的验证时间)、返回 html 文档、html 文档 head 解析完毕。

    首屏时间

    首屏时间的统计比较复杂,因为涉及图片等多种元素及异步渲染等方式。观察加载视图可发现,影响首屏的主要因素的图片的加载。通过统计首屏内图片的加载时间便可以获取首屏渲染完成的时间。

    • 页面存在 iframe 的情况下也需要判断加载时间
    • gif 图片在 IE 上可能重复触发 load 事件需排除
    • 异步渲染的情况下应在异步获取数据插入之后再计算首屏
    • css 重要背景图片可以通过 JS 请求图片 url 来统计(浏览器不会重复加载)
    • 没有图片则以统计 JS 执行时间为首屏,即认为文字出现时间

    用户可操作时间

    DOM 解析完毕时间,可统计 DomReady 时间,因为通常会在这个时间点绑定事件

    对于 web 端获取性能数据方法很简单,只需要使用浏览器自带的 Performance 接口

    页面性能数据采集

    Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API 、Navigation Timing API 、User Timing API 和 Resource Timing API 。

    performance

    从图中可以看到很多指标都是成对出现,这里我们直接求差值,就可以求出对应页面加载过程中关键节点的耗时,这里我们介绍几个比较常用的,比如:

    const timingInfo = window.performance.timing;
    
    // DNS 解析,DNS 查询耗时
    timingInfo.domainLookupEnd - timingInfo.domainLookupStart;
    
    // TCP 连接耗时
    timingInfo.connectEnd - timingInfo.connectStart;
    
    // 获得首字节耗费时间,也叫 TTFB
    timingInfo.responseStart - timingInfo.navigationStart;
    
    // *: domReady 时间(与 DomContentLoad 事件对应)
    timingInfo.domContentLoadedEventStart - timingInfo.navigationStart;
    
    // DOM 资源下载
    timingInfo.responseEnd - timingInfo.responseStart;
    
    // 准备新页面时间耗时
    timingInfo.fetchStart - timingInfo.navigationStart;
    
    // 重定向耗时
    timingInfo.redirectEnd - timingInfo.redirectStart;
    
    // Appcache 耗时
    timingInfo.domainLookupStart - timingInfo.fetchStart;
    
    // unload 前文档耗时
    timingInfo.unloadEventEnd - timingInfo.unloadEventStart;
    
    // request 请求耗时
    timingInfo.responseEnd - timingInfo.requestStart;
    
    // 请求完毕至 DOM 加载
    timingInfo.domInteractive - timingInfo.responseEnd;
    
    // 解释 dom 树耗时
    timingInfo.domComplete - timingInfo.domInteractive;
    
    // *:从开始至 load 总耗时
    timingInfo.loadEventEnd - timingInfo.navigationStart;
    
    // *: 白屏时间
    timingInfo.responseStart - timingInfo.fetchStart;
    
    // *: 首屏时间
    timingInfo.domComplete - timingInfo.fetchStart;
    

    接口数据

    接口数据主要包括接口耗时、接口请求异常,耗时可以通过对 XmlHttpRequest 和 fetch 请求的拦截过程中进行时间统计,异常通过 xhr 的 readyState 和 status 属性判断。

    XmlHttpRequest 拦截:修改 XMLHttpRequest 的原型,在发送请求时开启事件监听,注入 SDK 钩子 XMLHttpRequest.readyState 的五种就绪状态:

    • 0:请求未初始化(还没有调用 open())。
    • 1:请求已经建立,但是还没有发送(还没有调用 send())。
    • 2:请求已发送,正在处理中(通常现在可以从响应中获取内容头)。
    • 3:请求在处理中;通常响应中已有部分数据可用了,但是服务器还没有完成响应的生成。
    • 4:响应已完成;您可以获取并使用服务器的响应了。
    XMLHttpRequest.prototype.open = function (method: string, url: string) {
      // ...省略
      return open.call(this, method, url, true);
    };
    XMLHttpRequest.prototype.send = function (...rest: any[]) {
      // ...省略
      const body = rest[0];
    
      this.addEventListener("readystatechange", function () {
        if (this.readyState === 4) {
          if (this.status >= 200 && this.status < 300) {
            // ...省略
          } else {
            // ...省略
          }
        }
      });
      return send.call(this, body);
    };
    

    Fetch 拦截:Object.defineProperty

    Object.defineProperty(window, "fetch", {
      configurable: true,
      enumerable: true,
      get() {
        return (url: string, options: any = {}) => {
          return originFetch(url, options)
            .then((res) => {
    	        // ...
            })
        };
      }
    });
    

    手机、浏览器数据

    通过 navigatorAPI 获取在进行解析,使用第三方包mobile-detect帮助我们获取解析

    页面访问数据

    全局数据增加 url 、页面标题、用户标识,SDK 可以自动为网页 session 分配一个随机用户 label 作为标识,以此标识单个用户

    用户行为数据

    主要包含用户点击页面元素、控制台信息、用户鼠标移动轨迹。

    • 用户点击元素:window 事件代理
    • 控制台信息:重写 console
    • 用户鼠标移动轨迹:第三方库rrweb

    下面是针对这些数据进行统一的监控 SDK 设计

    SDK 开发

    为更好的解耦模块,我决定使用基于事件订阅的方式,整个 SDK 分成几个核心的模块,由于使用 ts 开发并且代码会保持良好的命名规范和语义化,只有在关键的地方才会有注释,完整的代码实现见文末 Github 仓库。

    • class: WebMonitor:核心监控类
    • class:AjaxInterceptor:拦截 ajax 请求
    • class:ErrorObserver:监控全局错误
    • class:FetchInterceptor:拦截 fetch 请求
    • class:Reporter:上报
    • class:Performance:监控性能数据
    • class:RrwebObserver:接入 rrweb 获取用户行为轨迹
    • class:SpaHandler:针对 SPA 应用做处理
    • util: DeviceUtil:设备信息获取辅助函数
    • event: 事件中心

    SDK 提供的事件

    对外暴露事件,_开头为框架内部事件

    export enum TrackerEvents {
      // 对外暴露事件
      performanceInfoReady = "performanceInfoReady",  // 页面性能数据获取完毕
      reqStart = "reqStart",  // 接口请求开始
      reqEnd = "reqEnd",   // 接口请求完成
      reqError = "reqError",  // 请求错误
      jsError = "jsError",  // 页面逻辑异常
      vuejsError = "vuejsError",  // vue 错误监控事件
      unHandleRejection = "unHandleRejection",  // 未处理 promise 异常
      resourceError = "resourceError",  // 资源加载错误
      batchErrors = "batchErrors",  // 错误合并上报事件,用户合并上报请求节省请求数量
      mouseTrack = "mouseTrack",  //  用户鼠标行为追踪
    }
    

    使用方式

    import { WebMonitor } from "femonitor-web";
    const monitor = Monitor.init();
    /* Listen single event */
    monitor.on([event], (emitData) => {});
    /* Or Listen all event */
    monitor.on("event", (eventName, emitData) => {})
    

    核心模块解析

    WebMonitor 、errorObserver 、ajaxInterceptor 、fetchInterceptor 、performance

    WebMonitor

    集成了框架的其他类,对传入配置和默认配置进行 deepmerge,根据配置进行初始化

    this.initOptions(options);
    
    this.getDeviceInfo();
    this.getNetworkType();
    this.getUserAgent();
    
    this.initGlobalData(); // 设置一些全局的数据,在所有事件中 globalData 中都会带上
    this.initInstances();
    this.initEventListeners();
    

    API

    支持链式操作

    • on:监听事件
    • off:移除事件
    • useVueErrorListener:使用 Vue 错误监控,获取更详细的组件数据
    • changeOptions: 修改配置
    • configData:设置全局数据

    errorObserver

    监听 window.onerror 和 window.onunhandledrejection,并且对 err.message 进行解析,获取想要 emit 的错误数据。

    window.onerror = function (...args) {
      // 调用原始方法
      if (oldOnError) {
        oldOnError(...args);
      }
    
      const [msg, url, line, column, error] = args;
    
      const stackTrace = error ? ErrorStackParser.parse(error) : [];
      const msgText = typeof msg === "string" ? msg : msg.type;
      const errorObj: IError = {};
    
      myEmitter.customEmit(TrackerEvents.jsError, errorObj);
    };
    
    window.onunhandledrejection = function (error: PromiseRejectionEvent) {
      if (oldUnHandleRejection) {
        oldUnHandleRejection.call(window, error);
      }
    
      const errorObj: IUnHandleRejectionError = {};
      myEmitter.customEmit(TrackerEvents.unHandleRejection, errorObj);
    };
    
    window.addEventListener(
      "error",
      function (event) {
        const target: any = event.target || event.srcElement;
        const isElementTarget =
          target instanceof HTMLScriptElement ||
          target instanceof HTMLLinkElement ||
          target instanceof HTMLImageElement;
        if (!isElementTarget) return false;
    
        const url = target.src || target.href;
    
        const errorObj: BaseError = {};
        myEmitter.customEmit(TrackerEvents.resourceError, errorObj);
      },
      true
    );
    
    

    ajaxInterceptor

    拦截 ajax 请求,并触发自定义的事件。对 XMLHttpRequest 的 open 和 send 方法进行重写

    XMLHttpRequest.prototype.open = function (method: string, url: string) {
      const reqStartRes: IAjaxReqStartRes = {
      };
    
      myEmitter.customEmit(TrackerEvents.reqStart, reqStartRes);
      return open.call(this, method, url, true);
    };
    
    XMLHttpRequest.prototype.send = function (...rest: any[]) {
      const body = rest[0];
      const requestData: string = body;
      const startTime = Date.now();
    
      this.addEventListener("readystatechange", function () {
        if (this.readyState === 4) {
          if (this.status >= 200 && this.status < 300) {
            const reqEndRes: IReqEndRes = {};
    
            myEmitter.customEmit(TrackerEvents.reqEnd, reqEndRes);
          } else {
            const reqErrorObj: IHttpReqErrorRes = {};
            
            myEmitter.customEmit(TrackerEvents.reqError, reqErrorObj);
          }
        }
      });
      return send.call(this, body);
    };
    

    fetchInterceptor

    对 fetch 进行拦截,并且触发自定义的事件。

    Object.defineProperty(window, "fetch", {
      configurable: true,
      enumerable: true,
      get() {
        return (url: string, options: any = {}) => {
          const reqStartRes: IFetchReqStartRes = {};
          myEmitter.customEmit(TrackerEvents.reqStart, reqStartRes);
    
          return originFetch(url, options)
            .then((res) => {
              const status = res.status;
              const reqEndRes: IReqEndRes = {};
    
              const reqErrorRes: IHttpReqErrorRes = {};
    
              if (status >= 200 && status < 300) {
                myEmitter.customEmit(TrackerEvents.reqEnd, reqEndRes);
              } else {
                if (this._url !== self._options.reportUrl) {
                  myEmitter.customEmit(TrackerEvents.reqError, reqErrorRes);
                }
              }
    
              return Promise.resolve(res);
            })
            .catch((e: Error) => {
              const reqErrorRes: IHttpReqErrorRes = {};
              myEmitter.customEmit(TrackerEvents.reqError, reqErrorRes);
            });
        };
      }
    });
    

    performance

    通过 Performance 获取页面性能,在性能数据完备后 emit 事件

    const {
      domainLookupEnd,
      domainLookupStart,
      connectEnd,
      connectStart,
      responseEnd,
      requestStart,
      domComplete,
      domInteractive,
      domContentLoadedEventEnd,
      loadEventEnd,
      navigationStart,
      responseStart,
      fetchStart
    } = this.timingInfo;
    
    const dnsLkTime = domainLookupEnd - domainLookupStart;
    const tcpConTime = connectEnd - connectStart;
    const reqTime = responseEnd - requestStart;
    const domParseTime = domComplete - domInteractive;
    const domReadyTime = domContentLoadedEventEnd - fetchStart;
    const loadTime = loadEventEnd - navigationStart;
    const fpTime = responseStart - fetchStart;
    const fcpTime = domComplete - fetchStart;
    
    const performanceInfo: IPerformanceInfo<number> = {
      dnsLkTime,
      tcpConTime,
      reqTime,
      domParseTime,
      domReadyTime,
      loadTime,
      fpTime,
      fcpTime
    };
    
    myEmitter.emit(TrackerEvents.performanceInfoReady, performanceInfo);
    

    完整 SDK 实现见下方 Github 仓库地址,欢迎 star 及 fork 。

    https://github.com/alex1504/femonitor-web

    4 条回复    2021-12-28 10:34:14 +08:00
    weimo383
        1
    weimo383  
       2021-05-05 17:13:23 +08:00
    为什么不用 proxy
    alex1504
        2
    alex1504  
    OP
       2021-05-05 18:03:39 +08:00 via iPhone
    @weimo383 也可以 需要 polyfill
    MichealXie
        3
    MichealXie  
       2021-05-06 07:47:49 +08:00 via Android
    学习了
    alex1504
        4
    alex1504  
    OP
       2021-12-28 10:34:14 +08:00
    @MichealXie 欢迎 issue
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1822 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 16:21 · PVG 00:21 · LAX 08:21 · JFK 11:21
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.