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

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

现在第三方完整解决方案国外有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。

image.png

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

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、issue。

web前端监控SDK:https://github.com/alex1504/femonitor-web

如果本文对你有帮助,欢迎点赞、收藏及转发,也欢迎在下方评论区一起交流,你的支持是我前进的动力。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容