基于typescript開發(fā)前端錯誤及性能監(jiān)控SDK

前端的錯誤監(jiān)控票编、性能數(shù)據(jù)往往對業(yè)務(wù)的穩(wěn)定性有很重要的影響,即使我們在開發(fā)階段十分小心,也難免線上會出現(xiàn)異常蛾茉,并且線上環(huán)境的異常我們往往后知后覺。而頁面的性能數(shù)據(jù)則關(guān)系到用戶體驗撩鹿,因此采集頁面的性能數(shù)據(jù)也十分的重要臀稚。

現(xiàn)在第三方完整解決方案國外有sentry,國內(nèi)有fundebug三痰、frontjs吧寺,他們提供前端接入的SDK和數(shù)據(jù)服務(wù),然后有一定的免費額度散劫,超出就需要使用付費方案稚机。前端的SDK用戶監(jiān)控用戶端異常和性能,后端服務(wù)用戶可以創(chuàng)建應(yīng)用获搏,每個應(yīng)用分配一個APPKEY赖条,然后SDK完成自動上報。

本文不考慮數(shù)據(jù)服務(wù)常熙,只對前端監(jiān)控進(jìn)行分析纬乍,講下web如何進(jìn)行監(jiān)控和采集這些數(shù)據(jù),并且通過TS集成這些功能做出一套前端監(jiān)控SDK裸卫。

既然需要采集數(shù)據(jù)仿贬,我們要明確下可能需要哪些數(shù)據(jù),目前來看有如下一些數(shù)據(jù):

  • 頁面錯誤數(shù)據(jù)
  • 頁面資源加載情況
  • 頁面性能數(shù)據(jù)
  • 接口數(shù)據(jù)
  • 手機墓贿、瀏覽器數(shù)據(jù)
  • 頁面訪問數(shù)據(jù)
  • 用戶行為數(shù)據(jù)
  • ...

下面分析一下這些數(shù)據(jù)如何獲燃肜帷:

頁面錯誤數(shù)據(jù)

  • window.onerror AOP捕獲異常能力無論是異步還是非異步錯誤,onerror 都能捕獲到運行時錯誤聋袋。
  • window.onerror不能捕獲頁面資源的加載錯誤队伟,但資源加載錯誤能被window.addEventListener在捕獲階段捕獲。由于addEventListener也能夠捕獲js錯誤幽勒,因此需要過濾避免重復(fù)觸發(fā)事件鉤子
  • window.onerror無法捕獲Promise任務(wù)中未被處理的異常嗜侮,通過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中,我們應(yīng)該通過Vue.config.errorHandler = function(err, vm, info) {};進(jìn)行異常捕獲,這樣可以獲取到更多的上下文信息锈颗。

對于React缠借,React 16 提供了一個內(nèi)置函數(shù) componentDidCatch,使用它可以非常簡單的獲取到 react 下的錯誤信息

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

頁面性能數(shù)據(jù)

通常我們會關(guān)注以下性能指標(biāo):

  • 白屏?xí)r間:從瀏覽器輸入地址并回車后到頁面開始有內(nèi)容的時間宜猜;
  • 首屏?xí)r間:從瀏覽器輸入地址并回車后到首屏內(nèi)容渲染完畢的時間泼返;
  • 用戶可操作時間節(jié)點:domready觸發(fā)節(jié)點,點擊事件有反應(yīng)姨拥;
  • 總下載時間:window.onload的觸發(fā)節(jié)點绅喉。

白屏?xí)r間

白屏?xí)r間節(jié)點指的是從用戶進(jìn)入網(wǎng)站(輸入url、刷新叫乌、跳轉(zhuǎn)等方式)的時刻開始計算柴罐,一直到頁面有內(nèi)容展示出來的時間節(jié)點。
這個過程包括dns查詢憨奸、建立tcp連接革屠、發(fā)送首個http請求(如果使用https還要介入TLS的驗證時間)、返回html文檔排宰、html文檔head解析完畢似芝。

首屏?xí)r間

首屏?xí)r間的統(tǒng)計比較復(fù)雜,因為涉及圖片等多種元素及異步渲染等方式板甘。觀察加載視圖可發(fā)現(xiàn)党瓮,影響首屏的主要因素的圖片的加載。通過統(tǒng)計首屏內(nèi)圖片的加載時間便可以獲取首屏渲染完成的時間盐类。

  • 頁面存在 iframe 的情況下也需要判斷加載時間
  • gif 圖片在 IE 上可能重復(fù)觸發(fā) load 事件需排除
  • 異步渲染的情況下應(yīng)在異步獲取數(shù)據(jù)插入之后再計算首屏
  • css 重要背景圖片可以通過 JS 請求圖片 url 來統(tǒng)計(瀏覽器不會重復(fù)加載)
  • 沒有圖片則以統(tǒng)計 JS 執(zhí)行時間為首屏寞奸,即認(rèn)為文字出現(xiàn)時間

用戶可操作時間

DOM解析完畢時間,可統(tǒng)計DomReady時間在跳,因為通常會在這個時間點綁定事件

對于web端獲取性能數(shù)據(jù)方法很簡單枪萄,只需要使用瀏覽器自帶的Performance接口

頁面性能數(shù)據(jù)采集

Performance 接口可以獲取到當(dāng)前頁面中與性能相關(guān)的信息,它是 High Resolution Time API 的一部分猫妙,同時也融合了 Performance Timeline API瓷翻、Navigation Timing API、 User Timing API 和 Resource Timing API吐咳。

image.png

從圖中可以看到很多指標(biāo)都是成對出現(xiàn)逻悠,這里我們直接求差值,就可以求出對應(yīng)頁面加載過程中關(guān)鍵節(jié)點的耗時韭脊,這里我們介紹幾個比較常用的,比如:

const timingInfo = window.performance.timing;

// DNS解析单旁,DNS查詢耗時
timingInfo.domainLookupEnd - timingInfo.domainLookupStart;

// TCP連接耗時
timingInfo.connectEnd - timingInfo.connectStart;

// 獲得首字節(jié)耗費時間沪羔,也叫TTFB
timingInfo.responseStart - timingInfo.navigationStart;

// *: domReady時間(與DomContentLoad事件對應(yīng))
timingInfo.domContentLoadedEventStart - timingInfo.navigationStart;

// DOM資源下載
timingInfo.responseEnd - timingInfo.responseStart;

// 準(zhǔn)備新頁面時間耗時
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;

// *: 白屏?xí)r間
timingInfo.responseStart - timingInfo.fetchStart;

// *: 首屏?xí)r間
timingInfo.domComplete - timingInfo.fetchStart;

接口數(shù)據(jù)

接口數(shù)據(jù)主要包括接口耗時、接口請求異常,耗時可以通過對XmlHttpRequest 和 fetch請求的攔截過程中進(jìn)行時間統(tǒng)計蔫饰,異常通過xhr的readyState和status屬性判斷琅豆。

XmlHttpRequest 攔截:修改XMLHttpRequest的原型,在發(fā)送請求時開啟事件監(jiān)聽篓吁,注入SDK鉤子
XMLHttpRequest.readyState的五種就緒狀態(tài):

  • 0:請求未初始化(還沒有調(diào)用 open())茫因。
  • 1:請求已經(jīng)建立,但是還沒有發(fā)送(還沒有調(diào)用 send())杖剪。
  • 2:請求已發(fā)送冻押,正在處理中(通常現(xiàn)在可以從響應(yīng)中獲取內(nèi)容頭)盛嘿。
  • 3:請求在處理中洛巢;通常響應(yīng)中已有部分?jǐn)?shù)據(jù)可用了,但是服務(wù)器還沒有完成響應(yīng)的生成次兆。
  • 4:響應(yīng)已完成稿茉;您可以獲取并使用服務(wù)器的響應(yīng)了。
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) => {
            // ...
        })
    };
  }
});

手機芥炭、瀏覽器數(shù)據(jù)

通過navigatorAPI獲取在進(jìn)行解析漓库,使用第三方包mobile-detect幫助我們獲取解析

頁面訪問數(shù)據(jù)

全局?jǐn)?shù)據(jù)增加url、頁面標(biāo)題园蝠、用戶標(biāo)識米苹,SDK可以自動為網(wǎng)頁session分配一個隨機用戶label作為標(biāo)識,以此標(biāo)識單個用戶

用戶行為數(shù)據(jù)

主要包含用戶點擊頁面元素砰琢、控制臺信息蘸嘶、用戶鼠標(biāo)移動軌跡。

  • 用戶點擊元素:window事件代理
  • 控制臺信息:重寫console
  • 用戶鼠標(biāo)移動軌跡:第三方庫rrweb

下面是針對這些數(shù)據(jù)進(jìn)行統(tǒng)一的監(jiān)控SDK設(shè)計

SDK開發(fā)

為更好的解耦模塊陪汽,我決定使用基于事件訂閱的方式训唱,整個SDK分成幾個核心的模塊,由于使用ts開發(fā)并且代碼會保持良好的命名規(guī)范和語義化挚冤,只有在關(guān)鍵的地方才會有注釋况增,完整的代碼實現(xiàn)見文末Github倉庫。

  • class: WebMonitor:核心監(jiān)控類
  • class:AjaxInterceptor:攔截ajax請求
  • class:ErrorObserver:監(jiān)控全局錯誤
  • class:FetchInterceptor:攔截fetch請求
  • class:Reporter:上報
  • class:Performance:監(jiān)控性能數(shù)據(jù)
  • class:RrwebObserver:接入rrweb獲取用戶行為軌跡
  • class:SpaHandler:針對SPA應(yīng)用做處理
  • util: DeviceUtil:設(shè)備信息獲取輔助函數(shù)
  • event: 事件中心

SDK提供的事件

對外暴露事件训挡,_開頭為框架內(nèi)部事件

export enum TrackerEvents {
  // 對外暴露事件
  performanceInfoReady = "performanceInfoReady",  // 頁面性能數(shù)據(jù)獲取完畢
  reqStart = "reqStart",  // 接口請求開始
  reqEnd = "reqEnd",   // 接口請求完成
  reqError = "reqError",  // 請求錯誤
  jsError = "jsError",  // 頁面邏輯異常
  vuejsError = "vuejsError",  // vue錯誤監(jiān)控事件
  unHandleRejection = "unHandleRejection",  // 未處理promise異常
  resourceError = "resourceError",  // 資源加載錯誤
  batchErrors = "batchErrors",  // 錯誤合并上報事件澳骤,用戶合并上報請求節(jié)省請求數(shù)量
  mouseTrack = "mouseTrack",  //  用戶鼠標(biāo)行為追蹤
}

使用方式

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

集成了框架的其他類肤京,對傳入配置和默認(rèn)配置進(jìn)行deepmerge颊艳,根據(jù)配置進(jìn)行初始化

this.initOptions(options);

this.getDeviceInfo();
this.getNetworkType();
this.getUserAgent();

this.initGlobalData(); // 設(shè)置一些全局的數(shù)據(jù),在所有事件中g(shù)lobalData中都會帶上
this.initInstances();
this.initEventListeners();

API

支持鏈?zhǔn)讲僮?/p>

  • on:監(jiān)聽事件
  • off:移除事件
  • useVueErrorListener:使用Vue錯誤監(jiān)控,獲取更詳細(xì)的組件數(shù)據(jù)
  • changeOptions: 修改配置
  • configData:設(shè)置全局?jǐn)?shù)據(jù)

errorObserver

監(jiān)聽window.onerror和window.onunhandledrejection棋枕,并且對err.message進(jìn)行解析白修,獲取想要emit的錯誤數(shù)據(jù)。

window.onerror = function (...args) {
  // 調(diào)用原始方法
  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請求重斑,并觸發(fā)自定義的事件兵睛。對XMLHttpRequest的open和send方法進(jìn)行重寫

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進(jìn)行攔截,并且觸發(fā)自定義的事件窥浪。

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獲取頁面性能祖很,在性能數(shù)據(jù)完備后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實現(xiàn)見下方Github倉庫地址,歡迎star寒矿、fork突琳、issue。

web前端監(jiān)控SDK:https://github.com/alex1504/femonitor-web

如果本文對你有幫助符相,歡迎點贊拆融、收藏及轉(zhuǎn)發(fā),也歡迎在下方評論區(qū)一起交流啊终,你的支持是我前進(jìn)的動力镜豹。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市蓝牲,隨后出現(xiàn)的幾起案子趟脂,更是在濱河造成了極大的恐慌,老刑警劉巖例衍,帶你破解...
    沈念sama閱讀 212,383評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件昔期,死亡現(xiàn)場離奇詭異,居然都是意外死亡佛玄,警方通過查閱死者的電腦和手機硼一,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,522評論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來梦抢,“玉大人般贼,你說我怎么就攤上這事“路裕” “怎么了哼蛆?”我有些...
    開封第一講書人閱讀 157,852評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長霞赫。 經(jīng)常有香客問我腮介,道長,這世上最難降的妖魔是什么绩脆? 我笑而不...
    開封第一講書人閱讀 56,621評論 1 284
  • 正文 為了忘掉前任萤厅,我火速辦了婚禮橄抹,結(jié)果婚禮上靴迫,老公的妹妹穿的比我還像新娘惕味。我一直安慰自己,他們只是感情好玉锌,可當(dāng)我...
    茶點故事閱讀 65,741評論 6 386
  • 文/花漫 我一把揭開白布名挥。 她就那樣靜靜地躺著,像睡著了一般主守。 火紅的嫁衣襯著肌膚如雪禀倔。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,929評論 1 290
  • 那天参淫,我揣著相機與錄音救湖,去河邊找鬼。 笑死涎才,一個胖子當(dāng)著我的面吹牛鞋既,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播耍铜,決...
    沈念sama閱讀 39,076評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼邑闺,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了棕兼?” 一聲冷哼從身側(cè)響起陡舅,我...
    開封第一講書人閱讀 37,803評論 0 268
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎伴挚,沒想到半個月后靶衍,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,265評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡茎芋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,582評論 2 327
  • 正文 我和宋清朗相戀三年颅眶,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片败徊。...
    茶點故事閱讀 38,716評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡帚呼,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出皱蹦,到底是詐尸還是另有隱情煤杀,我是刑警寧澤,帶...
    沈念sama閱讀 34,395評論 4 333
  • 正文 年R本政府宣布沪哺,位于F島的核電站沈自,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏辜妓。R本人自食惡果不足惜枯途,卻給世界環(huán)境...
    茶點故事閱讀 40,039評論 3 316
  • 文/蒙蒙 一忌怎、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧酪夷,春花似錦榴啸、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,798評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至坦报,卻和暖如春库说,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背片择。 一陣腳步聲響...
    開封第一講書人閱讀 32,027評論 1 266
  • 我被黑心中介騙來泰國打工潜的, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人字管。 一個月前我還...
    沈念sama閱讀 46,488評論 2 361
  • 正文 我出身青樓啰挪,卻偏偏與公主長得像,于是被迫代替她去往敵國和親纤掸。 傳聞我的和親對象是個殘疾皇子脐供,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,612評論 2 350

推薦閱讀更多精彩內(nèi)容