前端監控系列1| 位元組的前端監控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 '@apmplus/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 '@apmplus/web'
import CustomPlugin from 'xxx'
client('init', {
如何按需載入
為了方便使用,默認情況下,我們會集成所有的監控功能。但這並不是所有業務都需要的,有的業務只關心 JS 錯誤,其他的功能都不想要,這應該怎麼解決呢?
為此 SDK 導出了一個最小的實例,這個實例只引入通用的插件,但是不引入數據採集類的插件,而具體要採集哪些功能由用戶在 integrations 上按需配置。
import { createMinimalBrowserClient } from '@apmplus/web'
import { jsErrorPlugin } from '@apmplus/integrations'
// 創建一個最小的實例
const client = createMinimalBrowserClient()
client('init',{
如何保證原有業務的正確性
接入監控 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 本身的性能評估也非常重要。單個插件的執行耗時多少,插件帶來的副作用的耗時又是多少,這些都是基本的評估點。基於位元組內部提供的性能工具,我們編寫了完善的 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 = []
如何保證SDK的品質
SDK 作為服務於位元組唯一的前端監控產品,上報數據的流量近千萬 QPS ,需要有嚴格的品質把控。
SDK 有完善的**單元測試,每一個插件,每一個方法,都會單獨編寫測試用例。以及完善的自動化測試**,對於整個 SDK 的所有默認行為以及各個配置項對應的行為有完整的用例覆蓋。每次變動都需要補充對應的相關用例,且每次 MR 都要測試通過才能合入預發布分支,這樣才能做到心中不慌。此外,會有預發布驗證環節,驗證改動的預期效果。如果改動的地方比較敏感,會找站點合作方灰度一段時間後發布正式版本。發布後的一段時間內我們也會密切的關注整體的流量情況,確認是否存在異常上漲和下降,是否有新增的 SDK 相關異常。
由此, SDK 的品質得以保證。
歡迎使用
現在這套前端監控服務已經同步在了火山引擎上,接入即可對 Web 端真實數據進行實時監控、報警歸因、聚類分析和細節定位,解決白屏、性能瓶頸、慢查詢等關鍵問題,歡迎體驗。