前端監控 SDK 開發分享

語言: CN / TW / HK

一、前言

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

對於中小型公司來說,可以直接使用三方的監控,比如自己搭建一套免費的 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 或者後端服務因為迭代造成的相容性問題。還比較重要的是要考慮後期資料庫查詢和儲存方面的需求,收集、儲存和查詢才能完整的構成這套前端監控系統。

- EOF -

覺得本文對你有幫助?請分享給更多人

關注「大前端技術之路」加星標,提升前端技能

點贊和在看就是最大的支援 :heart: