前端監控SDK開發分享

子慕大詩人發表於2021-02-20

目錄

一、前言

隨著前端的發展和被重視,慢慢的行業內對於前端監控系統的重視程度也在增加。這裡不對為什麼需要監控再做解釋。那我們先直接說說需求。

對於中小型公司來說,可以直接使用三方的監控,比如自己搭建一套免費的sentry就可以捕獲異常和上報事件,或者使用阿里雲的ARMS,功能比較全面也並不會太貴。類似的開源系統或者付費系統還很多,都能滿足我們一定的需求。

假如這個公司逐漸成長,已經成為一箇中大型的公司,使用者量、業務服務、公司整體架構全部都在升級,這樣三方的監控系統可能就慢慢的出現一些不能滿足需求的問題。比如企業內部各種系統之間的關係太獨立和分散,不能使用內部的統一登陸、不能相互跳轉,想要增加一些欄位收集並不能很快得到支援等等。這些問題都會導致效率上不能滿足企業發展要求。一個內部可控並且能高速響應企業需求的前端監控系統就顯得很有必要。

我們在內部的前端監控系統上已經投入了一定的精力和時間,今天分享一下前端監控SDK部分的內容,主要三個方面:

  • 收集哪些資料
  • 客戶端SDK(探針)及原理
  • 編寫測試用例

二、收集哪些資料

前端監控系統最核心的首要是收集客戶端的相關資料,我們現在支援的客戶端探針有:web、微信小程式、andriodios。它們主要收集如圖以下資訊:

2.1 效能

收集頁面載入、靜態資源、ajax介面等效能資訊,指標有載入時間、http協議版本、響應體大小等,這是為業務整體質量提升提供資料支撐,解決慢查詢問題等。

2.2 錯誤

收集js報錯、靜態資源載入錯誤、ajax介面載入錯誤,這些常規錯誤收集都很好理解。下面主要說明一下"業務介面錯誤(bussiness)":

客戶端傳送ajax請求後端業務介面,介面都會返回json資料結構,而其中一般都會有errorcodemessage兩個欄位,errorcode為業務介面內部定義的狀態碼。正常的業務響應內部都會約定比如errorcode==0等,那如果不為0可能是一些異常問題或者可預見的異常問題,這種錯誤資料就是需要收集的。

由於不同團隊或者介面可能約定都不一樣,所以我們只會提供一個預設方法,預設方法會在ajax請求響應後呼叫,業務方自己根據約定和響應的json資料,在預設的方法中編寫判斷邏輯控制是否上報。像是下面這樣:

errcodeReport(res) {
  if (Object.prototype.toString.call(res) === '[object Object]' && res.hasOwnProperty('errcode') && res.errcode !== 0) {
    return { isReport: true, errMsg: res.errmsg,code: res.errcode };
  }
  return { isReport: false };
}

2.3 輔助資訊

除了上面兩類硬指標資料,我們還需要很多其它的資訊,比如:使用者的訪問軌跡、使用者點選行為、使用者ID、裝置版本、裝置型號、UV/UA標識、traceId等等。很多時候我們要解決的問題並不是那麼簡單直接就能排查出來,甚至我們需要前端監控和其它系統在某些情況下能夠關聯上,所以這些軟指標資訊同樣很重要。

在這裡專門解釋一下traceId:

現在的後端服務都會使用APM(應用效能管理)系統,APM工具會在一次完整請求呼叫之初生成唯一的id,通常叫做traceId,它會記錄整個請求過程服務端的鏈路細節。如果前端能夠獲取到它,就能通過它去後端APM系統中查詢某次請求的日誌資訊。只要後端做好相關的配置,後端介面在響應客戶端http請求時,可以把traceId返回給客戶端,SDK便可以去收集ajax請求的traceId,這樣前後端監控就能夠關聯上了。

2.4 小結

收集以上的資訊並開發一套管理臺,能夠達到監控前端效能和異常錯誤的目的。想象一個場景,當我們收到監控系統的告警或者相關同事的問題反饋時,我們能開啟管理臺,首先檢視到實時的錯誤,如果發現是js的程式碼導致的問題,我們能很快找到前端程式碼錯誤的地方。如果不是前端的錯誤,我們通過收集的業務介面錯誤發現是後端介面的問題,我們也能及時的通知後端同事,在什麼時間哪個介面報出errorcode為xx的錯誤,並且我們還能通過traceId直接查到這次ajax請求的後端鏈路監控資料。如果實在不是明顯就能排查到的問題,我們還能通過收集到的使用者軌跡、裝置資訊和網路請求等資料,多方面的分析還原使用者當時的場景,來輔助我們排查程式碼中的難以復現的bug或者相容問題。

在以上這個場景中,我們能夠提高前端排查問題的能力,甚至能輔助後端同學。在大部分時候,出現bug,很可能第一時間首先是找到前端做反饋,前端是排查問題的先頭部隊。當我們有這樣的前端監控系統之後,不至於每次遇到問題手足無措,解決問題的時間也會快許多。

【具體欄位一覽】

確定好了要收集哪些資訊,接下來就需要去實現客戶端SDK,它能夠在業務專案中自動收集資料上報給服務端。

三、客戶端SDK(探針)相關原理和API

所謂探針,是因為我們的SDK要依託於監控的前端專案的執行環境,在其執行環境的底層API中加入探針函式來收集資訊,下面分享WEB和微信小程式SDK實現的主要原理和使用的API

3.1 WEB

下圖是SDK主要使用的Web API,通過這幾個API我們就能分別獲取到:頁面效能資訊、資源效能資訊、ajax資訊、錯誤資訊。

3.1.1 Performance

通過performance.timing可以拿到頁面首次載入的效能資料,dnstcp、白屏時間等,而在最新的標準中performance.timing已經被廢棄,因此我們也改造為使用performance.getEntriesByType('navigation')。這裡的白屏時間可能和實際真正的使用者感官的白屏時間是有差異的,僅供參考。

通過new PerformanceObserver監聽器,我們可以監聽所有資源(css,script,img,ajax等)載入的效能資料:載入時間,響應大小,http協議版本(http1.1/http2)等。而後我們需要通過一個陣列去管理資源效能資料,在完成資料上報後,清空陣列。

3.1.2 fetch/xmlHttpRequest

由於瀏覽器並沒有提供一個統一的API使我們能夠收集到ajax請求和響應資料,並且不管我們是用axois還是使用其他的http請求庫,他們都是基於fetchxmlHttpRequest實現的。因此只能通過重寫fetchxmlHttpRequest,並在對應的函式和邏輯中插入自定義程式碼,來達到收集的目的。相關的文章很多,這裡就不再細說了。

let _fetch = fetch;
window.fetch = function () {
  // custom code
  return _fetch
    .apply(this, arguments)
    .then((res) => {
      // custom code
      return res;
    })
};

3.1.3 window.onerror | unhandledrejection | console.error | 以及框架自帶的監聽函式

最後這幾個API都是收集js相關錯誤資訊的。需要注意兩個問題:

一是onerror會獲取不到跨域的script錯誤,解決方案也很簡單:為跨域的script標籤設定crossorigin屬性,並且需要靜態伺服器為當前資源設定CORS響應頭。

二是程式碼壓縮後的報錯資訊需要通過sourceMap檔案解析出原始碼對應的行列和錯誤資訊,sourceMap本身是一種資料結構,儲存了原始碼和壓縮程式碼的關係資料,通過解析庫能夠很輕鬆轉換它們。但如何自動化管理和操作sourceMap檔案才是前端監控系統核心需要解決的問題。這裡就需要結合企業內部的靜態資源釋出系統和前端監控系統,來解決低效率的手動打包上傳問題。

3.2 微信小程式

微信小程式底層使用js實現,有著它自己的一套生命週期,也提供了全域性的API。通過重寫它的部分全域性函式和相關API我們能獲取到:網路請求、錯誤資訊、裝置和版本資訊等。由於微信小程式的載入流程是由微信APP控制的,js等資源也被微信內部託管,因此和web不同,我們沒有辦法獲取到webperformance能獲取到的頁面和資源載入資訊。下圖是SDK主要使用的API

3.2.1 App和Component

通過重寫全域性的App函式,繫結onError方法監聽錯誤,重寫它的onShow方法執行小程式啟動時SDK需要的邏輯。通過重寫ComponentonShow方法,可以在頁面元件切換時執行我們的路徑收集和執行上報等邏輯。

// SDK初始化函式
init(){
    this.appMethod = App;
    this.componentMethod = Component;
    const ctx = this;
    //重寫微信小程式Component
    Component = (opts) => {
      overrideComponent(opts, ctx);
      ctx.componentMethod(opts);
    };
    //重寫微信小程式App
    App = (app) => {
      overrideApp(app, ctx);
      ctx.appMethod(app);
    };
}  

//注意ctx是sdk的this
overrideComponent(opts, ctx) => {
  const compOnShow = opts.methods.onShow;
  opts.methods.onShow = function(){
    // do something
    //注意這裡的this是實際呼叫方
    compOnShow.apply(this, arguments)
  }
})

overrideApp(app, ctx) => {
  const _onError = app.onError || function () {};
  const _onShow = app.onShow || function () {};
  app.onError = function (err) {
    reportError(err, ctx);
    return _onError.apply(this, arguments);
  };
  app.onShow = function () {
    //do something
    return _onShow.apply(this, arguments);
  };
})

3.2.2 重寫wx.request

這裡也是因為和 fetch/xmlHttpRequest 一樣,並沒有一個全域性的API能讓我們捕獲到請求資訊,因此只能通過重寫wx.request來達到監聽收集的功能。

const originRequest = wx.request;
const ctx = this;
//重寫wx.request,增加中間邏輯
Object.defineProperty(wx, 'request', {
  value: function () {
      // sdk code
      const _complete = config.complete || function (data) {};
      config.complete = function (data) {
        // sdk code
        return _complete.apply(this, arguments);
      };

    return originRequest.apply(this, arguments);
  }
})

當我們已經實現了SDK之後或者說在實現的過程中,就需要編寫測試程式碼了,下面說說編寫測試用例。

四、編寫測試用例

SDK屬於一個需要長期維護和更新的獨立庫,它被使用在很多業務專案中,要求更加穩定,當出現問題的時候,它的更新成本很高。需要經歷:更新程式碼->釋出新版本->業務方更新依賴版本,等流程,而如果在這個流程中,假如SDK又改出其它問題,那將會再啟上述迴圈,業務同事肯定會被麻煩死。隨著接入監控的系統增多,在迭代過程中改動任何的程式碼已經讓人開始發慌,因為存在很多流程性的關聯邏輯,害怕改出問題。在一次程式碼的重構和優化過程中,決心完善單元測試和流程測試。

4.1 單元測試

單元測試主要是對一些有明顯輸入輸出的通用方法,比如SDKutils中的常用方法,SDK的引數配置方法等。而對於監控SDK來說,更多的測試程式碼主要集中在流程測試,對於單元測試這裡就不具體說明了。

4.2 流程測試

監控SDK在業務專案中初始化之後,主要是通過加入探針監聽業務專案的執行狀態而收集資訊並進行上傳的,它在大部分情況下並不是業務方呼叫什麼就執行什麼。比如我們頁面初次載入,SDK在合適的時機會執行首次載入相關資訊的收集並上傳,那我們需要通過測試程式碼來模擬這個流程,保障上報的資料是預期的。

我們的SDK執行在瀏覽器環境中,在node環境下是不支援Web相關API的。因此我們需要讓我們的測試程式碼在瀏覽器中執行,或者提供相關API的支援。下面我們將會介紹兩種不同的方式,來支援我們的測試程式碼正常執行。

4.2.1 提供Web環境的方式

假如我們使用mocha或者jest作為測試框架,可以通過mocha自帶的mocha.run方法在html中編寫和執行我們的測試程式碼,並在瀏覽器中開啟執行;jest-lite也可以支援讓jest執行在瀏覽器中。

但有時候我們不想讓它開啟瀏覽器,希望在終端中就能完成測試程式碼執行,可以使用無頭瀏覽器,在node中載入瀏覽器環境,比如phontomjs或者puppeteer。他們提供了相關的工具,比如mocha-phantomjs就能直接在終端中執行html執行測試流程。

基於寫好的html測試檔案,再使用mocha-phantomjsphantomjs,以下是package.json的命令配置。

scripts:{
    test: mocha-phantomjs -p ./node_modules/.bin/phantomjs /test/unit/index.html
}

phontomjs已經被廢棄了,不被推薦使用。推薦puppeteer,相關的功能和類似工具都有支援。

舉例說明:

以前有在WebSocket的程式碼庫中使用過這種方式。因為依賴Web Api: WebSocket。需要通過new WebSocket(),來完成測試流程,而node環境下沒有此API。於是使用mochahtml中寫測試用例,如果希望全程使用終端跑測試,還可以配合使用mocha-phantomjs讓測試的html檔案可以在終端中執行而不用開啟本地的網頁執行。

當然其實完全可以直接在瀏覽器中開啟html檢視測試執行結果,而且phantomjs相關的依賴包非常大、安裝也比較慢。但當時我們使用了持續繼承服務travis,當我們的程式碼更新到遠端倉庫以後,travis將會啟動多個獨立容器並在終端中執行我們的測試檔案,如果不使用mocha-phantomjs在終端中跑測試沒有辦法在travis中成功通過。

4.2.2 Mock Web API的方式

在這次完善監控SDK測試的過程中,嘗試了另一種方式,全程使用Mock的方式。

上面的Web環境執行方式需要提供瀏覽器或者無頭瀏覽器。但實際我們需要測試的程式碼並不是Web API,我們只是使用了它們。我們假定它們是穩定的,我們只需要在乎它的輸入輸出,如果它們內部出bug了,我們也是不能控制的,那是瀏覽器開發商的事情。因此我要做的事情僅僅是在node環境中模擬相關的Web API

拿前面說到的WebSocket舉例,因為node中不支援WebSocket,我們沒有辦法new WebSocket。那假如有完全模擬WebSocket的三方node庫,我們就可以在node程式碼中,直接讓執行環境支援WebSocketconst WebSocket = require('WebSocket')。這樣我們就不需要在瀏覽器或者無頭瀏覽器環境下執行了。

下面就具體拿我們的監控SDK中的fetch舉例,是如何模擬流程測試的,總的來說要支援下面3個內容,

  1. 啟動一個httpserver服務提供介面服務
  2. 引入三方庫,讓node支援fetch
  3. node中手動模擬部分performance API

首先說明一下SDKfetch的正常流程,當我們的SDK在業務專案中初始化了之後,SDK會重寫fetch,於是業務專案中真正使用fetch做業務介面請求的時候,SDK就能通過之前重寫的邏輯獲取到http請求和響應資訊,同時也會通過performance獲取到fetch請求的效能資訊,並進行上報。我們要寫的測試程式碼,就是驗證這個流程能夠順利完成。

(1)http server

因為是驗證fetch完整流程,我們需要啟動一個httpserver服務,提供介面來接收和響應這次fetch請求。

(2)mock fetch

node環境中支援fetch的話,我們可以直接使用三方庫node-fetch,在執行環境的頂部,我們就可以提前定義fetch

/** MockFetch.js */
import fetch from 'node-fetch';
window = {};
window.fetch = fetch;
global.fetch = fetch;
(3)mock performance

performance就比較特殊一點,沒有一個三方的庫能夠支援。對於fetch流程來說,我們如果要模擬performance,只需要模擬我們使用的PerformanceObserver,甚至一些入參和返回我們也可以只模擬我們需要的。下面的程式碼是PerformanceObserver的使用例子。在SDK中,我們主要也是使用這一段程式碼。

/** PerformanceObserver 使用例項 */
var observer = new PerformanceObserver(function(list, obj) {
  var entries = list.getEntriesByType('resource');
  for (var i=0; i < entries.length; i++) {
    // Process "resource" events
  }
});
observer.observe({entryTypes: ['resource']});

在瀏覽器內部performance底層會自動去監聽資源請求,我們只是通過它提供PerformanceObserver去收集它的資料。本質上來說,主動收集的行為探針在performance內部實現。

下面我們模擬PerformanceObserver一部分功能,來支援我們需要的測試流程。定義window.PerformanceObserver為建構函式,把傳入方法引數fn加入到陣列中。mockPerformanceEntriesAdd 是我們需要手動呼叫的方法,當我們發起一次fetch,我們就手動呼叫一下此方法,把mock資料傳入給註冊的監聽函式,這樣就能使PerformanceObserver的例項接收到我們的mock資料,以此來模擬瀏覽器中performance內部的行為。

/** MockPerformance.js */
let observerCallbacks = [];
//模擬PerformanceObserver物件,新增資源監聽佇列
window.PerformanceObserver = function (fn) {
  this.observe = function () {};
  observerCallbacks.push(fn);
};

//手動觸發模擬performance資源佇列
window.mockPerformanceEntriesAdd = (resource) => {
  observerCallbacks.forEach((cb) => {
    cb({
      getEntriesByType() {
        return [resource];
      },
    });
  });
};

通俗點舉例來說,十號公司要給打工人銀行卡發工資的,打工人的工資銀行卡第二天就會被扣房貸。打工人最關心的保障正常扣房貸否則影響徵信。本來打工人只需要關注銀行是否成功完成扣款,但是打工人最近丟工作了公司不會打款到工資卡,所以只能拿積蓄卡給自己的扣貸銀行卡轉錢,讓後續銀行可以扣錢還房貸。公司就是瀏覽器performance底層,打工人給自己轉錢就是mockPerformanceEntriesAdd,把公司發工資到銀行卡替換為自己轉錢進去,從被動接收變為主動執行。細品,你細品~

mockPerformanceEntriesAdd就是模擬瀏覽器的主動行為,入參是效能資訊,我們可以直接寫死(下方mockData)。
看看測試程式碼

/** test/fetch.js */
import 'MockFetch.js';
import 'MockPerformance.js';
import webReportSdk from '../dist/monitorSDK';
//初始化監控sdk,sdk內部會重寫fetch
const monitor = webReportSdk({
  appId: 'appid_test',
});
const mockData = {
    name: 'http://localhost:xx/api/getData',
    entryType: 'resource',
    startTime: 90427.23999964073,
    duration: 272.06500014290214,
    initiatorType: 'fetch',
    nextHopProtocol: 'h2',
    ...
}
test('web api: fetch', () => {
  //GET
  const requestAddress = mockData.name;
  fetch(requestAddress, {
    method: 'GET',
  });

  //傳送請求後,需要模擬瀏覽器performace資料監聽
  window.mockPerformanceEntriesAdd(mockData);
})

mockPerformanceEntriesAdd執行的時候,SDK內部的PerformanceObserver便能收集到mock的效能資訊了。( 這裡注意,我們還需要啟動一個httpserver的服務,服務提供http://localhost:xx/api/getData介面 )

當上面的測試程式碼執行的時候,SDK能夠獲取地址為http://localhost:xx/api/getDatafetch的請求、響應和效能資訊,並且SDK也會傳送一次fetch請求把收集的資料上報給後端服務。我們可以再次重寫window.fetch,來攔截SDK的上報請求,就可以獲取到請求內容,用請求內容來做預期測試判斷

//再次重寫fetch,攔截請求並跳過上報
const monitorFetch = window.fetch;
let reportData;
window.fetch = function () {
  //sdk上報的資料我們會做一個type標記,避免SDK收集它自己發出的請求資訊
  if (arguments[1] && arguments[1].type === 'report-data') {
    //獲取請求內容
    reportData = JSON.parse(arguments[1].body);
    return Promise.resolve();
  }
  return monitorFetch.apply(this, arguments);
};

//省略中間程式碼

expect(reportData.resourceList[0].name).toEqual(mockData.name);


合併後的測試程式碼

/** test/fetch.js */
import 'MockFetch.js';
import 'MockPerformance.js';
import webReportSdk from '../dist/monitorSDK';
//初始化監控sdk,sdk內部會重寫fetch
const monitor = webReportSdk({
  appId: 'appid_test',
});

//再次重寫fetch,攔截請求並跳過上報
const monitorFetch = window.fetch;
let reportData;
window.fetch = function () {
  //sdk上報的資料我們會做一個type標記,避免SDK收集它自己發出的請求資訊
  if (arguments[1] && arguments[1].type === 'report-data') {
    //獲取請求內容
    reportData = JSON.parse(arguments[1].body);
    return Promise.resolve();
  }
  return monitorFetch.apply(this, arguments);
};

const mockData = {
    name: 'xxx.com/api/getData',
    entryType: 'resource',
    startTime: 90427.23999964073,
    duration: 272.06500014290214,
    initiatorType: 'fetch',
    nextHopProtocol: 'h2',
    ...
}
test('web api: fetch', (done) => {
  //GET
  const requestAddress = mockData.name;
  fetch(requestAddress, {
    method: 'GET',
  });

  //傳送請求後,需要模擬瀏覽器performace資料監聽
  window.mockPerformanceEntriesAdd(mockData);
  
  //需要一定延遲
  setTimeout(()=>{
  	expect(reportData.resourceList[0].name).toEqual(mockData.name);
  	//more expect...
    done()
  },3000)
})

如上圖所示,我們主要是以這樣的模式進行SDK的流程測試和程式碼編寫。有了測試程式碼後,能夠在很大程度上保障程式碼維護迭代過程中的穩定性可控性,也能省去很多後期測試成本。

五、結語

以上分享是我們在做監控SDK時比較核心的這三個方面,還有很多其它的細節和實現,比如:如何節流、上報時機、資料合併、初始化配置等。開發迭代過程中,要避免客戶端SDK或者後端服務因為迭代造成的相容性問題。還比較重要的是要考慮後期資料庫查詢和儲存方面的需求,收集、儲存和查詢才能完整的構成這套前端監控系統。

- End -

相關文章