騰訊二面:現在要你實現一個埋點監控SDK,你會怎麼設計?

前端私教年年發表於2022-04-15

大家好,我是年年!

這是小夥伴上週被問到的一個綜合性設計題,如果是沒有用過埋點監控系統,或者沒有深入瞭解,基本就涼涼。

原文首發在我的公眾號:前端私教年年

這篇文章會講清楚:

  1. 埋點監控系統負責處理哪些問題,需要怎麼設計api?
  2. 為什麼用img的src做請求的傳送,sendBeacon又是什麼?
  3. 在react、vue的錯誤邊界中要怎麼處理?

什麼是埋點監控SDK

舉個例子,公司開發上線了一個網站,但開發人員不可能預測,使用者實際使用時會發生什麼:使用者瀏覽過哪幾個頁面?幾成使用者會點選某個彈窗的確認按鈕,幾成會點選取消?有沒有出現頁面崩潰?

所以我們需要一個埋點監控SDK去做資料的收集,後續再統計分析。有了分析資料,才能有針對性對網站進行優化:PV特別少的頁面就不要浪費大量人力;有bug的頁面趕緊修復,不然要325了。

比較有名的埋點監控有Google Analytics,除了web端,還有iOS、安卓的SDK。

公眾號後臺回覆「ReactSDK」可獲取react版本的github

埋點監控的職能範圍

因為業務需要的不同,大部分公司都會自己開發一套埋點監控系統,但基本上都會涵蓋這三類功能:

使用者行為監控

負責統計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屬性。原因有兩點:

  1. 沒有跨域的限制,像srcipt標籤、img標籤都可以直接傳送跨域的GET請求,不用做特殊處理;
  2. 相容性好,一些靜態頁面可能禁用了指令碼,這時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的告警;

當然如果不在意這個報錯可以採取返回空物件,事實上也有一些工具是這樣做的
  1. 有一些埋點需要真實的加到頁面上,比如垃圾郵件的傳送者會新增這樣一個隱藏標誌來驗證郵件是否被開啟,如果返回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,這種方式的更有優勢:

  1. 不會和主要業務程式碼搶佔資源,而是在瀏覽器空閒時去做傳送;
  2. 並且在頁面解除安裝時也能保證請求成功傳送,不阻塞頁面重新整理和跳轉;

現在的埋點監控工具通常會優先使用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等關鍵事件的時間點:

  1. 頁面首次渲染時間:FP(firstPaint)=domLoading-navigationStart
  2. DOM載入完成:DCL(DOMContentEventLoad)=domContentLoadedEventEnd-navigationStart
  3. 圖片、樣式等外鏈資源載入完成: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類:

  1. InternalError: 內部錯誤,比如如遞迴爆棧;
  2. RangeError: 範圍錯誤,比如new Array(-1);
  3. EvalError: 使用eval()時錯誤;
  4. ReferenceError: 引用錯誤,比如使用未定義變數;
  5. SyntaxError: 語法錯誤,比如var a = ;
  6. TypeError: 型別錯誤,比如[1,2].split('.');
  7. URIError: 給 encodeURI或 decodeURl()傳遞的引數無效,比如decodeURI('%2')
  8. 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的錯誤邊界

錯誤邊界是希望當應用內部發生渲染錯誤時,不會整個頁面崩潰。我們提前給它設定一個兜底元件,並且可以細化粒度,只有發生錯誤的部分被替換成這個「兜底元件」,不至於整個頁面都不能正常工作。

它的使用很簡單,就是一個帶有特殊生命週期的類元件,用它把業務元件包裹起來。

這兩個生命週期是getDerivedStateFromErrorcomponentDidCatch

程式碼如下:

// 定義錯誤邊界
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>
建了一個線上sandbox可以體驗,公眾號後臺回覆「錯誤邊界demo」獲取地址

回到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。

如果覺得這篇文章對你有用,點贊關注是對我最大的鼓勵!

你的支援是我創作的動力!

相關文章