目錄
一、前言
隨着前端的發展和被重視,慢慢的行業內對於前端監控系統的重視程度也在增加。這里不對為什么需要監控再做解釋。那我們先直接說說需求。
對於中小型公司來說,可以直接使用三方的監控,比如自己搭建一套免費的sentry
就可以捕獲異常和上報事件,或者使用阿里雲的ARMS
,功能比較全面也並不會太貴。類似的開源系統或者付費系統還很多,都能滿足我們一定的需求。
假如這個公司逐漸成長,已經成為一個中大型的公司,用戶量、業務服務、公司整體架構全部都在升級,這樣三方的監控系統可能就慢慢的出現一些不能滿足需求的問題。比如企業內部各種系統之間的關系太獨立和分散,不能使用內部的統一登陸、不能相互跳轉,想要增加一些字段收集並不能很快得到支持等等。這些問題都會導致效率上不能滿足企業發展要求。一個內部可控並且能高速響應企業需求的前端監控系統就顯得很有必要。
我們在內部的前端監控系統上已經投入了一定的精力和時間,今天分享一下前端監控SDK
部分的內容,主要三個方面:
- 收集哪些數據
- 客戶端SDK(探針)及原理
- 編寫測試用例
二、收集哪些數據
前端監控系統最核心的首要是收集客戶端的相關數據,我們現在支持的客戶端探針有:web
、微信小程序、andriod
和ios
。它們主要收集如圖以下信息:
2.1 性能
收集頁面加載、靜態資源、ajax
接口等性能信息,指標有加載時間、http
協議版本、響應體大小等,這是為業務整體質量提升提供數據支撐,解決慢查詢問題等。
2.2 錯誤
收集js
報錯、靜態資源加載錯誤、ajax
接口加載錯誤,這些常規錯誤收集都很好理解。下面主要說明一下"業務接口錯誤(bussiness)":
客戶端發送ajax
請求后端業務接口,接口都會返回json
數據結構,而其中一般都會有errorcode
和message
兩個字段,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
可以拿到頁面首次加載的性能數據,dns
、tcp
、白屏時間等,而在最新的標准中performance.timing
已經被廢棄,因此我們也改造為使用performance.getEntriesByType('navigation')
。這里的白屏時間可能和實際真正的用戶感官的白屏時間是有差異的,僅供參考。
通過new PerformanceObserver
監聽器,我們可以監聽所有資源(css
,script
,img
,ajax
等)加載的性能數據:加載時間,響應大小,http
協議版本(http1.1
/http2
)等。而后我們需要通過一個數組去管理資源性能數據,在完成數據上報后,清空數組。
3.1.2 fetch/xmlHttpRequest
由於瀏覽器並沒有提供一個統一的API
使我們能夠收集到ajax
請求和響應數據,並且不管我們是用axois
還是使用其他的http
請求庫,他們都是基於fetch
和xmlHttpRequest
實現的。因此只能通過重寫fetch
和xmlHttpRequest
,並在對應的函數和邏輯中插入自定義代碼,來達到收集的目的。相關的文章很多,這里就不再細說了。
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
不同,我們沒有辦法獲取到web
中performance
能獲取到的頁面和資源加載信息(后來發現小程序已經在v2.11.0 (2020-04-24)版本中,新增 API 提供performance性能對象指標,以后可以使用了)。下圖是SDK
主要使用的API
3.2.1 App和Component
通過重寫全局的App
函數,綁定onError
方法監聽錯誤,重寫它的onShow
方法執行小程序啟動時SDK
需要的邏輯。通過重寫Component
的onShow
方法,可以在頁面組件切換時執行我們的路徑收集和執行上報等邏輯。
// 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 單元測試
單元測試主要是對一些有明顯輸入輸出的通用方法,比如SDK
的utils
中的常用方法,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-phantomjs
和phantomjs
,以下是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
。於是使用mocha
在html
中寫測試用例,如果希望全程使用終端跑測試,還可以配合使用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
代碼中,直接讓執行環境支持WebSocket
: const WebSocket = require('WebSocket')
。這樣我們就不需要在瀏覽器或者無頭瀏覽器環境下運行了。
下面就具體拿我們的監控SDK
中的fetch
舉例,是如何模擬流程測試的,總的來說要支持下面3個內容,
- 啟動一個httpserver服務提供接口服務
- 引入三方庫,讓node支持fetch
- node中手動模擬部分performance API
首先說明一下SDK
中fetch
的正常流程,當我們的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/getData
的fetch
的請求、響應和性能信息,並且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 -