位元組的前端監控 SDK 是怎樣設計的

語言: CN / TW / HK

作者|彭莉,火山引擎 APM 研發工程師,2020 年加入位元組,負責前端監控 SDK 的開發維護、平臺數據消費的探索和落地

摘要

公司內部監控環境多樣( Web 應用、小程式、Electron 應用、跨端應用等等), SDK 如何保證底層邏輯的複用、上層邏輯的解耦。

在業務龐雜、監控需求多樣的背景下, SDK 如何做到足夠靈活,如何實現外掛化,並且支援業務自行擴充套件的。

大型 C 端業務非常注重業務自身的正確性和效能,監控 SDK 如何保證原有業務的正確性;如何保持 SDK 自身的效能,減少對業務的影響。

接入業務眾多,上報量級近千萬 QPS ,在日常需求迭代中, SDK 是如何確保自身穩定性的。

邏輯解耦

前端的領域廣闊,所以作為前端監控,也不只侷限在瀏覽器環境,需要同時解決小程式、 Electron 、 Nodejs 等等其他環境的監控需求。不同環境之間差異巨大,從提供的配置項,到監控的功能、上報的方式都會不一樣。

一個 SDK 不可能既支援多環境,又滿足體積小、功能全面的要求,這本身互相矛盾。只要相容其他環境,打包進來的程式碼會導致體積變大,因此設計之初的目標就是同一套設計組裝成不同的 SDK 。此設計的第一要務是要邏輯解耦。雖然多環境下差異很大,但要做的事情是一樣的,比如配置、採集資料、組裝資料、上報資料。

我們設計了五個角色,每個角色只需要實現約定的介面即可。這樣就保證了不同的環境下,各個角色合作的方式是相同的,在實現了一套核心模版後,不同的監控 SDK 就可以快速搭建出來。

Monitor

收集器,主動或被動地採集特定環境下的原始資料,組裝為平臺無關事件。

Monitor 有若干個,每一個 Monitor 對應一個功能,比如關於 JS 錯誤的監控是一個 Monitor ,關於請求的監控又是另一個 Monitor 。

Builder

組裝器,負責將收集器上報的平臺無關事件轉換為特定平臺的上報格式。

主要負責包裝特定環境下的上下文資訊。在瀏覽器環境下,上下文資訊包括頁面地址、網路狀態、當前時間等等,再結合收到的 Monitor 的資料,完成上報格式的組裝。

Sender

傳送器,負責傳送邏輯,比如批量,重試等功能。

監控 SDK 的 Sender 都是 BatchSender ,它會負責維護一個快取佇列,按照一定的佇列長度或者快取時間間隔來聚合上報資料,會開放一些方法自定義快取佇列長度和快取間隔時間,也支援立即上報和清空佇列等操作。

特定環境下的 Sender 也需要負責處理一些邊緣 case ,比如瀏覽器環境下的 Sender 在頁面關閉時,需要使用 sendBeacon 立即上報所有佇列資料,以免漏報。

在實際實踐中,我們對 Sender 進行了進一步抽象, Sender 不會內建傳送的能力,關於如何傳送資料,不同環境依賴的 API 不同,因此會由 Client 在建立 Sender 時將具體的傳送能力傳入 Sender 中。

ConfigManager

配置管理器,負責配置邏輯,比如合併初始配置和使用者配置、拉取遠端配置等功能。

一般需要傳入預設配置,支援使用者手動配置,當配置完成時, ConfigManager 會變更 ready 狀態,所以它也支援被訂閱,以便當 ready 時或者配置變更時通知到訂閱方。

export interface ConfigManager<Config> {
  setConfig: (c: Partial<Config>) => Config
  getConfig: () => Config
  onChange: (fn: () => void) => void
  onReady: (fn: () => void) => void
}

Client

例項主體,負責串聯配置管理器、收集器、組裝器和傳送器,串通整個流程,同時提供生命週期監聽以供擴充套件 SDK 功能。

下面是一段方便理解串聯過程的虛擬碼,僅作參考。

export const createClient = ({ configManager, builder, sender }) => {
    let inited = false
    let started = false
    let preStartQueue = []
    const client = {
        init: (config) => {
            configManager.setConfig(config)
            configManager.onReady(() => {
                preStartQueue.forEach((e) => { this.report(e) })
                started = true
            })
            inited = true
        }
        report: (data) => {
            if (!started) {
                preStartQueue.push(data)
            } else {
                const builderData = builder.build(data)
                builderData && sender.send(builderData)
            }
        }
    }
    return client
}

const client = createClient({ configManager, builder, sender })
monitors.forEach((e) => { e(client) })

角色之間足夠抽象,互相獨立、各司其職。比如 Monitor 只負責收集,並不知道最終上報的具體格式;Builder 只做組裝,組裝完成後交給例項主體 Client ,由 Client 交給 Sender ;Sender 不知道收到的具體事件格式,只負責完成傳送。

開放豐富的生命週期

監控做的事情就像一條單純的流水線:初始化 => 採集資料 => 組裝資料 => 上報資料,我們希望能在不同階段執行各種操作,但又不希望直接將邏輯耦合在程式碼,這樣不利於後期的迭代維護,也會導致體積一步步增加,走向重構的必然結果。

於是我們決定讓核心模版提供規範的生命週期,所有的功能都藉助生命週期的監聽來實現,這樣不僅解決了體積不斷膨脹的問題,也讓 SDK 易於擴充套件。

基於監控 SDK 的各個階段,我們明確了六個主要的生命週期,命名也比較貼切,從上到下分別是:初始化 => 開啟上報 =>  Monitor 監控到資料,傳遞給 Client  => 包裝資料 => 傳送資料 => 銷燬例項

基於這些生命週期,我們提供了十個生命週期鉤子,主要分為兩類:

  • 回撥類:只執行回撥,不影響流程繼續執行,比如 init / start / beforeConfig / config 等等。
  • 處理類:執行並返回修改後的有效值,如果返回無效值,將不再往下執行,終止上報,比如 report / beforeBuild / build / beforeSend 等等。

如何實現外掛化

良好的生命週期是外掛化的基礎, 基於這些生命週期我們就能實現各種各樣的外掛。

舉個例子,我們需要為 Monitor 採集到的資料包裝事件發生時的上下文,可以通過這種方式:監聽 report ,劫持到資料,重新包裝,再傳遞給 Client 。

// 一個包裝上下文的外掛
export const InjectEnvPlugin = (client: WebClient) => {
  client('on', 'report', (ev: WebReportEvent) => {
    return addEnvToSendEvent(ev)
  })
}

// 應用此外掛
InjectEnvPlugin(client)

再舉個例子,我們需要新監控一類資料,可以通過這種方式:監聽例項主體 Client 當前的狀態,在 Client ready 的時候(使用者配置完成時),開始收集資料。在收集到資料時,將資料傳回 Client 即可。

// 一個監聽資料的外掛
export const MonitorXXPlugin = (client: WebClient) => {
  client('on', 'init', () => {
     const data = listenXX();
     client('report', data)
  })
}

在 SDK 內,  基本都是外掛,常規的資料採集是一個個外掛,其他的比如取樣、包裝上下文、非同步載入等功能,也都是各自獨立的外掛。

業務如何自行擴充套件

簡單的擴充套件,一般可以靠生命週期鉤子函式來完成,常見的需求就是在資料傳送前做一些手動的過濾、安全脫敏等等。

舉個例子,我們想要在頁面地址包含  '/test'  時不上報任何資料,可以通過下面的程式碼來實現。

import client from '@slardar/web'

client('on', 'beforeSend', (ev) => {
  if (ev.common.url.includes('/test')) {
    return false
  }
  return ev
})

但如果有高階的需求,比如想寫一個外掛能提供給團隊的其他人用,上面的方式就不再適用。如果外掛太複雜,其他人需要複製一大段程式碼,用起來不太優雅。

基於這個需求, SDK 設計了一個自定義外掛的傳遞協議,可以在初始化時將自定義外掛傳遞給 Client , Client 將會在初始化時執行傳入的 setup 方法,在例項銷燬時執行傳入的 tearDown 方法來銷燬副作用。

export interface Integration<T extends AnyClient> {
  name: string
  setup: (client: T) => void
  tearDown?: () => void
}

可以注意到,介面約定的例項型別是 AnyClient ,這個協議並不在意是什麼型別的 Client ,實際的 Client 型別由 SDK 來定義,比如 Web SDK 拿到的是 WebClient , Electron SDK 拿到的是 ElectronClient 。

業務可以自行釋出一個外掛包,外掛的實現可以是直接返回一個物件,或一個方法。允許使用者傳入一些配置,返回一個物件,只要這個物件滿足上面的 Integration 型別即可。

import client from '@slardar/web'
import CustomPlugin from 'xxx'

client('init', {
  ...
  integrations: [CustomPlugin({ config: {} })]
  ...
})

如何按需載入

為了方便使用,預設情況下,我們會整合所有的監控功能。但這並不是所有業務都需要的,有的業務只關心 JS 錯誤,其他的功能都不想要,這應該怎麼解決呢?

為此 SDK 匯出了一個最小的例項,這個例項只引入通用的外掛,但是不引入資料採集類的外掛,而具體要採集哪些功能由使用者在 integrations 上按需配置。

import { createMinimalBrowserClient } from '@slardar/web'
import { jsErrorPlugin } from '@slardar/integrations/dist/jsError'

// 建立一個最小的例項
const client = createMinimalBrowserClient()

client('init',{
  ...
  // 按需引入需要採集的監控功能
  integrations: [jsErrorPlugin()],
  ...
})

如何保證原有業務的正確性

接入監控 SDK 的目的是為了發現問題,如果監控 SDK 的問題導致業務受到了影響,不免本末倒置。加上絕大部分前端業務都接入了這個 SDK ,如果出現問題,影響範圍和損失都很巨大。因此保證原有業務的正確性遠遠比監控本身更重要。

SDK 會首先將對業務有影響的 敏感程式碼 使用 try catch 包裹起來,確保即使發生了錯誤也不影響業務,比如 hook 類的操作, hook XHR 和 Fetch 等等。這個操作要膽大心細,同時 try catch 的範圍能小則小。

其次是監控 SDK 自身的錯誤。我們也會將 SDK 自身的 關鍵程式碼 包裹 try catch ,確保一個錯誤不會影響整個監控流程。單純的 try catch 將錯誤吞掉解決不了問題,這些錯誤可能導致某些監控資料沒有收集完全,影響監控的完整性。因此 SDK 實現了一個 ObserveSelfErrorPlugin ,用於收集 SDK 自身的錯誤並上報。

同時,我們會針對上報所有的上報資料進行清洗,帶有 SDK 自身堆疊的資料會統一消費一份到另一處,便於從巨集觀上觀察 SDK 的出錯情況,及時發現問題。

這樣既確保了業務的正確性,也確保了監控 SDK 的正確性。

如何減少對業務的影響

絕大部分的業務都是使用監控 SDK 來自動上報效能資料以此來監控業務的效能,這也隱含著對監控 SDK 最基本的要求:不能帶來效能問題。

最重要的就是不能影響業務的首屏渲染,為此我們把 Monitor 類的外掛分為兩類,一是需要立即監聽的,先載入;二是不需要的立即監聽的,延後載入。比如路由變化的監聽、請求的監聽,如果延後會導致資料遺漏,就屬於第一類;像靜態資源效能監控這樣晚一點執行也並不會遺漏的,就屬於第二類。

除此之外, SDK 本身的效能評估也非常重要。單個外掛的執行耗時多少,外掛帶來的副作用的耗時又是多少,這些都是基本的評估點。基於Maiev,我們編寫了完善的 Benchmark 效能測試,在程式碼 MR 的時候會觸發相應的測試任務,另外也有固定週期來定時執行測試任務,任務異常時不能發版, SDK 的效能由此保證。

當然儘可能縮小 SDK 的體積也能直接減少對業務的影響,這塊內容涉及較廣,留作後續分說。

如何儘早開始監聽

監聽不遺漏的前提是事件發生在開始監控之後。但是一些超高優的事件,比如 JS 錯誤,發生時機可能超級靠前,等不到監控指令碼載入完成。所以監控 SDK 針對 script 的接入方式會提供一個簡短的指令碼,讓使用者內聯在頁面中。它的作用是提前開始監聽,保證高優的事件不被遺漏。

它還有另一個巧用:快取呼叫命令。

監控指令碼是非同步載入的,因此會先掛載一個空函式,確保呼叫不報錯;同時把對例項主體 Client 的呼叫命令快取下來,記錄下呼叫的時間和頁面地址,確保能正確組裝資料;等到監控指令碼載入完成時再順序執行,以此確保呼叫不遺漏。示例如下:

window[globalName] = function (m) {
    const onceArguments = [].slice.call(arguments)
    onceArguments.push(Date.now(), location.href)
    ;window[globalName].precolletArguments.push(onceArguments)
  }
  
  window[globalName].precolletArguments = []

當然如果使用npm包接入的話,依然會有預收集的邏輯,因為npm包不會掛全域性變數,所以邏輯稍微有一些不同,同時受限於引入的順序,執行的時機會稍晚一些。

如何保證 SDK 的質量

Slardar Web SDK 為絕大部分公司前端業務提供監控能力,上報資料的流量近千萬 QPS ,需要有嚴格的質量把控。

SDK 有完善的單元測試,每一個外掛,每一個方法,都會單獨編寫測試用例。以及完善的自動化測試,對於整個 SDK 的所有預設行為以及各個配置項對應的行為有完整的用例覆蓋。每次變動都需要補充對應的相關用例,且每次 MR 都要測試通過才能合入預釋出分支,這樣才能做到心中不慌。此外,會有預釋出驗證環節,驗證改動的預期效果。如果改動的地方比較敏感,會找站點合作方灰度一段時間後釋出正式版本。釋出後的一段時間內我們也會密切的關注整體的流量情況,確認是否存在異常上漲和下降,是否有新增的 SDK 相關異常。

由此, SDK 的質量得以保證。