騰訊二面:現在要你實現一個埋點監控SDK,你會怎么設計?
大家好,我是年年!
這是小伙伴上周被問到的一個綜合性設計題,如果是沒有用過埋點監控系統,或者沒有深入了解,基本就涼涼。
這篇文章會講清楚:
- 埋點監控系統負責處理哪些問題,需要怎么設計api?
- 為什么用img的src做請求的發送,sendBeacon又是什么?
- 在react、vue的錯誤邊界中要怎么處理?
什么是埋點監控SDK
舉個例子,公司開發上線了一個網站,但開發人員不可能預測,用戶實際使用時會發生什么:用戶瀏覽過哪幾個頁面?幾成用戶會點擊某個彈窗的確認按鈕,幾成會點擊取消?有沒有出現頁面崩潰?
所以我們需要一個埋點監控SDK去做數據的收集,后續再統計分析。有了分析數據,才能有針對性對網站進行優化:PV特別少的頁面就不要浪費大量人力;有bug的頁面趕緊修復,不然要325了。
比較有名的埋點監控有Google Analytics,除了web端,還有iOS、安卓的SDK。
埋點監控的職能范圍
因為業務需要的不同,大部分公司都會自己開發一套埋點監控系統,但基本上都會涵蓋這三類功能:
用戶行為監控
負責統計PV(頁面訪問次數)、UV(頁面訪問人數)以及用戶的點擊操作等行為。
這類統計是用的最多的,有了這些數據才能量化我們的工作成果。
頁面性能監控
開發和測試人員固然在上線之前會對這些數據做評估,但用戶的環境和我們不一樣,也許是3G網,也許是很老的機型,我們需要知道在實際使用場景中的性能數據,比如頁面加載時間、白屏時間等。
錯誤報警監控
獲取錯誤數據,及時處理才能避免大量用戶受到影響。除了全局捕獲到的錯誤信息,還有在代碼內部被catch住的錯誤告警,這些都需要被收集到。
下面會從api的設計出發,對上述三種類型進一步展開。
SDK的設計
在開始設計之前,先看一下SDK怎么使用。
import StatisticSDK from 'StatisticSDK';
// 全局初始化一次
window.insSDK = new StatisticSDK('uuid-12345');
<button onClick={()=>{
window.insSDK.event('click','confirm');
...// 其他業務代碼
}}>確認</button>
首先把SDK實例掛載到全局,之后在業務代碼中調用,這里的新建實例時需要傳入一個id,因為這個埋點監控系統往往是給多個業務去使用的,通過id去區分不同的數據來源。
首先實現實例化部分:
class StatisticSDK {
constructor(productID){
this.productID = productID;
}
}
數據發送
數據發送是一個最基礎的api,后面的功能都要基于此進行。通常這種前后端分離的場景會使用AJAX的方式發送數據,但是這里使用圖片的src屬性。原因有兩點:
- 沒有跨域的限制,像srcipt標簽、img標簽都可以直接發送跨域的GET請求,不用做特殊處理。
- 兼容性好,一些靜態頁面可能禁用了腳本,這時script標簽就不能使用了。
但要注意,這個圖片不是用來展示的,我們的目的是去「傳遞數據」,只是借助img標簽的的src屬性,在其url后面拼接上參數,服務端收到再去解析。
class StatisticSDK {
constructor(productID){
this.productID = productID;
}
send(baseURL,query={}){
query.productID = this.productID;
let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
let img = new Image();
img.src = `${baseURL}?${queryStr}`
}
}
img標簽的優點是不需要將其append到文檔,只需設置src屬性便能成功發起請求。
通常請求的這個url會是一張1X1px的GIF圖片,網上的文章對于這里為什么返回圖片的是一張GIF都是含糊帶過,這里查閱了一些資料并測試了:
1.同樣大小,不同格式的的圖片中GIF大小是最小的,所以選擇返回一張GIF,這樣對性能的損耗更小。
2.如果返回204,會走到img的onerror事件,并拋出一個全局錯誤;如果返回200和一個空對象會有一個CORB的告警。
3.當然如果不在意這個報錯可以采取返回空對象,事實上也有一些工具是這樣做的。
有一些埋點需要真實的加到頁面上,比如垃圾郵件的發送者會添加這樣一個隱藏標志來驗證郵件是否被打開,如果返回204或者是200空對象會導致一個明顯圖片占位符。
<img src="http://www.example.com/logger?event_id=1234">
更優雅的web beacon
這種打點標記的方式被稱web beacon(網絡信標)。除了gif圖片,從2014年開始,瀏覽器逐漸實現專門的API,來更優雅的完成這件事:Navigator.sendBeacon。
使用很簡單。
Navigator.sendBeacon(url,data)
相較于圖片的src,這種方式的更有優勢:
- 不會和主要業務代碼搶占資源,而是在瀏覽器空閑時去做發送。
- 并且在頁面卸載時也能保證請求成功發送,不阻塞頁面刷新和跳轉。
現在的埋點監控工具通常會優先使用sendBeacon,但由于瀏覽器兼容性,還是需要用圖片的src兜底。
用戶行為監控
上面實現了數據發送的api,現在可以基于它去實現用戶行為監控的api。
class StatisticSDK {
constructor(productID){
this.productID = productID;
}
// 數據發送
send(baseURL,query={}){
query.productID = this.productID;
let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
let img = new Image();
img.src = `${baseURL}?${queryStr}`
}
// 自定義事件
event(key, val={}) {
let eventURL = 'http://demo/'
this.send(eventURL,{event:key,...val})
}
// pv曝光
pv() {
this.event('pv')
}
}
用戶行為包括自定義事件和pv曝光,也可以把pv曝光看作是一種特殊的自定義行為事件。
頁面性能監控
頁面的性能數據可以通過performance.timing這個API獲取到,獲取的數據是單位為毫秒的時間戳。
上面的不需要全部了解,但比較關鍵的數據有下面幾個,根據它們可以計算出FP/DCL/Load等關鍵事件的時間點:
- 頁面首次渲染時間:FP(firstPaint)=domLoading-navigationStart。
- DOM加載完成:DCL(DOMContentEventLoad)=domContentLoadedEventEnd-navigationStart。
- 圖片、樣式等外鏈資源加載完成:L(Load)=loadEventEnd-navigationStart。
上面的數值可以跟performance面板里的結果對應。
回到SDK,我們只用實現一個上傳所有性能數據的api就可以了:
class StatisticSDK {
constructor(productID){
this.productID = productID;
// 初始化自動調用性能上報
this.initPerformance()
}
// 數據發送
send(baseURL,query={}){
query.productID = this.productID;
let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
let img = new Image();
img.src = `${baseURL}?${queryStr}`
}
// 性能上報
initPerformance(){
let performanceURL = 'http://performance/'
this.send(performanceURL,performance.timing)
}
}
并且,在構造函數里自動調用,因為性能數據是必須要上傳的,就不需要用戶每次都手動調用了。
錯誤告警監控
錯誤報警監控分為JS原生錯誤和React/Vue的組件錯誤的處理。
JS原生錯誤
除了try catch中捕獲住的錯誤,我們還需要上報沒有被捕獲住的錯誤——通過error事件和unhandledrejection事件去監聽。
error
error事件是用來監聽DOM操作錯誤DOMException和JS錯誤告警的,具體來說,JS錯誤分為下面8類:
- InternalError: 內部錯誤,比如如遞歸爆棧。
- ?RangeError: 范圍錯誤,比如new Array(-1)。
- EvalError: 使用eval()時錯誤。
- ReferenceError: 引用錯誤,比如使用未定義變量。
- SyntaxError: 語法錯誤,比如var a = 。
- TypeError: 類型錯誤,比如[1,2].split('.')。
- URIError: 給 encodeURI或 decodeURl()傳遞的參數無效,比如decodeURI('%2')。
- Error: 上面7種錯誤的基類,通常是開發者拋出。
也就是說,代碼運行時發生的上述8類錯誤,都可以被檢測到。
unhandledrejection
Promise內部拋出的錯誤是無法被error捕獲到的,這時需要用unhandledrejection事件。
回到SDK的實現,處理錯誤報警的代碼如下:
class StatisticSDK {
constructor(productID){
this.productID = productID;
// 初始化錯誤監控
this.initError()
}
// 數據發送
send(baseURL,query={}){
query.productID = this.productID;
let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
let img = new Image();
img.src = `${baseURL}?${queryStr}`
}
// 自定義錯誤上報
error(err, etraInfo={}) {
const errorURL = 'http://error/'
const { message, stack } = err;
this.send(errorURL, { message, stack, ...etraInfo})
}
// 初始化錯誤監控
initError(){
window.addEventListener('error', event=>{
this.error(error);
})
window.addEventListener('unhandledrejection', event=>{
this.error(new Error(event.reason), { type: 'unhandledrejection'})
})
}
}
和初始化性能監控一樣,初始化錯誤監控也是一定要做的,所以需要在構造函數中調用。后續開發人員只用在業務代碼的try catch中調用error方法即可。
React/Vue組件錯誤
成熟的框架庫都會有錯誤處理機制,React和Vue也不例外。
React的錯誤邊界
錯誤邊界是希望當應用內部發生渲染錯誤時,不會整個頁面崩潰。我們提前給它設置一個兜底組件,并且可以細化粒度,只有發生錯誤的部分被替換成這個「兜底組件」,不至于整個頁面都不能正常工作。
它的使用很簡單,就是一個帶有特殊生命周期的類組件,用它把業務組件包裹起來。
這兩個生命周期是getDerivedStateFromError和componentDidCatch。
代碼如下:
// 定義錯誤邊界
class ErrorBoundary extends React.Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch(error, errorInfo) {
// 調用我們實現的SDK實例
insSDK.error(error, errorInfo)
}
render() {
if (this.state.error) {
return <h2>Something went wrong.</h2>
}
return this.props.children
}
}
...
<ErrorBoundary>
<BuggyCounter />
</ErrorBoundary>
回到SDK的整合上,在生產環境下,被錯誤邊界包裹的組件,如果內部拋出錯誤,全局的error事件是無法監聽到的,因為這個錯誤邊界本身就相當于一個try catch。所以需要在錯誤邊界這個組件內部去做上報處理。也就是上面代碼中的componentDidCatch生命周期。
Vue的錯誤邊界
vue也有一個類似的生命周期來做這件事,不再贅述:errorCaptured。
Vue.component('ErrorBoundary', {
data: () => ({ error: null }),
errorCaptured (err, vm, info) {
this.error = `${err.stack}\n\nfound in ${info} of component`
// 調用我們的SDK,上報錯誤信息
insSDK.error(err,info)
return false
},
render (h) {
if (this.error) {
return h('pre', { style: { color: 'red' }}, this.error)
}
return this.$slots.default[0]
}
})
...
<error-boundary>
<buggy-counter />
</error-boundary>
現在我們已經實現了一個完整的SDK的骨架,并且處理了在實際開發時,react/vue項目應該怎么接入。
實際生產使用的SDK會更健壯,但思路也不外乎,感興趣的可以去讀一讀源碼。
結語
文章比較長,但想答好這個問題,這些知識儲備都是必須的。
我們要設計SDK,首先要清楚它的基本使用方法,才知道后面的代碼框架要怎么搭;然后是明確SDK的職能范圍:需要能處理用戶行為、頁面性能以及錯誤報警三類監控;最后是react、vue的項目,通常會做錯誤邊界處理,要怎么接入我們自己的SDK。