摘要
公司內部監(jiān)控環(huán)境多樣( Web 應用、小程序、Electron 應用、跨端應用等等), SDK 如何保證底層邏輯的復用、上層邏輯的解耦。
在業(yè)務龐雜、監(jiān)控需求多樣的背景下, SDK 如何做到足夠靈活,如何實現(xiàn)插件化,并且支持業(yè)務自行擴展的。
大型 C 端業(yè)務非常注重業(yè)務自身的正確性和性能,監(jiān)控 SDK 如何保證原有業(yè)務的正確性;如何保持 SDK 自身的性能,減少對業(yè)務的影響。
接入業(yè)務眾多,上報量級近千萬 QPS ,在日常需求迭代中, SDK 是如何確保自身穩(wěn)定性的。
邏輯解耦
前端的領域廣闊,所以作為前端監(jiān)控,也不只局限在瀏覽器環(huán)境,需要同時解決小程序、 Electron 、 Nodejs 等等其他環(huán)境的監(jiān)控需求。不同環(huán)境之間差異巨大,從提供的配置項,到監(jiān)控的功能、上報的方式都會不一樣。
一個 SDK 不可能既支持多環(huán)境,又滿足體積小、功能全面的要求,這本身互相矛盾。只要兼容其他環(huán)境,打包進來的代碼會導致體積變大,因此設計之初的目標就是同一套設計組裝成不同的 SDK 。此設計的第一要務是要邏輯解耦。雖然多環(huán)境下差異很大,但要做的事情是一樣的,比如配置、采集數(shù)據、組裝數(shù)據、上報數(shù)據。
我們設計了五個角色,每個角色只需要實現(xiàn)約定的接口即可。這樣就保證了不同的環(huán)境下,各個角色合作的方式是相同的,在實現(xiàn)了一套內核模版后,不同的監(jiān)控 SDK 就可以快速搭建出來。
Monitor
收集器,主動或被動地采集特定環(huán)境下的原始數(shù)據,組裝為平臺無關事件。
Monitor 有若干個,每一個 Monitor 對應一個功能,比如關于 JS 錯誤的監(jiān)控是一個 Monitor ,關于請求的監(jiān)控又是另一個 Monitor 。
Builder
組裝器,負責將收集器上報的平臺無關事件轉換為特定平臺的上報格式。
主要負責包裝特定環(huán)境下的上下文信息。在瀏覽器環(huán)境下,上下文信息包括頁面地址、網絡狀態(tài)、當前時間等等,再結合收到的 Monitor 的數(shù)據,完成上報格式的組裝。
Sender
發(fā)送器,負責發(fā)送邏輯,比如批量,重試等功能。
監(jiān)控 SDK 的 Sender 都是 BatchSender ,它會負責維護一個緩存隊列,按照一定的隊列長度或者緩存時間間隔來聚合上報數(shù)據,會開放一些方法自定義緩存隊列長度和緩存間隔時間,也支持立即上報和清空隊列等操作。
特定環(huán)境下的 Sender 也需要負責處理一些邊緣 case ,比如瀏覽器環(huán)境下的 Sender 在頁面關閉時,需要使用 sendBeacon 立即上報所有隊列數(shù)據,以免漏報。
在實際實踐中,我們對 Sender 進行了進一步抽象, Sender 不會內置發(fā)送的能力,關于如何發(fā)送數(shù)據,不同環(huán)境依賴的 API 不同,因此會由 Client 在創(chuàng)建 Sender 時將具體的發(fā)送能力傳入 Sender 中。
ConfigManager
配置管理器,負責配置邏輯,比如合并初始配置和用戶配置、拉取遠端配置等功能。
一般需要傳入默認配置,支持用戶手動配置,當配置完成時, ConfigManager 會變更 ready 狀態(tài),所以它也支持被訂閱,以便當 ready 時或者配置變更時通知到訂閱方。
export interface ConfigManager<Config> {
setConfig: (c: Partial<Config>) => Config
getConfig: () => Config
onChange: (fn: () => void) => void
onReady: (fn: () => void) => void
}
Client
實例主體,負責串聯(lián)配置管理器、收集器、組裝器和發(fā)送器,串通整個流程,同時提供生命周期監(jiān)聽以供擴展 SDK 功能。
下面是一段方便理解串聯(lián)過程的偽代碼,僅作參考。
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 不知道收到的具體事件格式,只負責完成發(fā)送。
開放豐富的生命周期
監(jiān)控做的事情就像一條單純的流水線:初始化 => 采集數(shù)據 => 組裝數(shù)據 => 上報數(shù)據,我們希望能在不同階段執(zhí)行各種操作,但又不希望直接將邏輯耦合在代碼,這樣不利于后期的迭代維護,也會導致體積一步步增加,走向重構的必然結果。
于是我們決定讓內核模版提供規(guī)范的生命周期,所有的功能都借助生命周期的監(jiān)聽來實現(xiàn),這樣不僅解決了體積不斷膨脹的問題,也讓 SDK 易于擴展。
基于監(jiān)控 SDK 的各個階段,我們明確了六個主要的生命周期,命名也比較貼切,從上到下分別是:初始化 => 開啟上報 => Monitor 監(jiān)控到數(shù)據,傳遞給 Client => 包裝數(shù)據 => 發(fā)送數(shù)據 => 銷毀實例
基于這些生命周期,我們提供了十個生命周期鉤子,主要分為兩類:
- 回調類:只執(zhí)行回調,不影響流程繼續(xù)執(zhí)行,比如 init / start / beforeConfig / config 等等。
- 處理類:執(zhí)行并返回修改后的有效值,如果返回無效值,將不再往下執(zhí)行,終止上報,比如 report / beforeBuild / build / beforeSend 等等。
如何實現(xiàn)插件化
良好的生命周期是插件化的基礎, 基于這些生命周期我們就能實現(xiàn)各種各樣的插件。
舉個例子,我們需要為 Monitor 采集到的數(shù)據包裝事件發(fā)生時的上下文,可以通過這種方式:監(jiān)聽 report ,劫持到數(shù)據,重新包裝,再傳遞給 Client 。
// 一個包裝上下文的插件
export const InjectEnvPlugin = (client: WebClient) => {
client('on', 'report', (ev: WebReportEvent) => {
return addEnvToSendEvent(ev)
})
}
// 應用此插件
InjectEnvPlugin(client)
再舉個例子,我們需要新監(jiān)控一類數(shù)據,可以通過這種方式:監(jiān)聽實例主體 Client 當前的狀態(tài),在 Client ready 的時候(用戶配置完成時),開始收集數(shù)據。在收集到數(shù)據時,將數(shù)據傳回 Client 即可。
// 一個監(jiān)聽數(shù)據的插件
export const MonitorXXPlugin = (client: WebClient) => {
client('on', 'init', () => {
const data = listenXX();
client('report', data)
})
}
在 SDK 內, 基本都是插件,常規(guī)的數(shù)據采集是一個個插件,其他的比如采樣、包裝上下文、異步加載等功能,也都是各自獨立的插件。
業(yè)務如何自行擴展
簡單的擴展,一般可以靠生命周期鉤子函數(shù)來完成,常見的需求就是在數(shù)據發(fā)送前做一些手動的過濾、安全脫敏等等。
舉個例子,我們想要在頁面地址包含 '/test' 時不上報任何數(shù)據,可以通過下面的代碼來實現(xiàn)。
import client from '@slardar/web'
client('on', 'beforeSend', (ev) => {
if (ev.common.url.includes('/test')) {
return false
}
return ev
})
但如果有高階的需求,比如想寫一個插件能提供給團隊的其他人用,上面的方式就不再適用。如果插件太復雜,其他人需要復制一大段代碼,用起來不太優(yōu)雅。
基于這個需求, SDK 設計了一個自定義插件的傳遞協(xié)議,可以在初始化時將自定義插件傳遞給 Client , Client 將會在初始化時執(zhí)行傳入的 setup 方法,在實例銷毀時執(zhí)行傳入的 tearDown 方法來銷毀副作用。
export interface Integration<T extends AnyClient> {
name: string
setup: (client: T) => void
tearDown?: () => void
}
可以注意到,接口約定的實例類型是 AnyClient ,這個協(xié)議并不在意是什么類型的 Client ,實際的 Client 類型由 SDK 來定義,比如 Web SDK 拿到的是 WebClient , Electron SDK 拿到的是 ElectronClient 。
業(yè)務可以自行發(fā)布一個插件包,插件的實現(xiàn)可以是直接返回一個對象,或一個方法。允許用戶傳入一些配置,返回一個對象,只要這個對象滿足上面的 Integration 類型即可。
import client from '@slardar/web'
import CustomPlugin from 'xxx'
client('init', {
...
integrations: [CustomPlugin({ config: {} })]
...
})
如何按需加載
為了方便使用,默認情況下,我們會集成所有的監(jiān)控功能。但這并不是所有業(yè)務都需要的,有的業(yè)務只關心 JS 錯誤,其他的功能都不想要,這應該怎么解決呢?
為此 SDK 導出了一個最小的實例,這個實例只引入通用的插件,但是不引入數(shù)據采集類的插件,而具體要采集哪些功能由用戶在 integrations 上按需配置。
import { createMinimalBrowserClient } from '@slardar/web'
import { jsErrorPlugin } from '@slardar/integrations/dist/jsError'
// 創(chuàng)建一個最小的實例
const client = createMinimalBrowserClient()
client('init',{
...
// 按需引入需要采集的監(jiān)控功能
integrations: [jsErrorPlugin()],
...
})
如何保證原有業(yè)務的正確性
接入監(jiān)控 SDK 的目的是為了發(fā)現(xiàn)問題,如果監(jiān)控 SDK 的問題導致業(yè)務受到了影響,不免本末倒置。加上絕大部分前端業(yè)務都接入了這個 SDK ,如果出現(xiàn)問題,影響范圍和損失都很巨大。因此保證原有業(yè)務的正確性遠遠比監(jiān)控本身更重要。
SDK 會首先將對業(yè)務有影響的 敏感代碼 使用 try catch 包裹起來,確保即使發(fā)生了錯誤也不影響業(yè)務,比如 hook 類的操作, hook XHR 和 Fetch 等等。這個操作要膽大心細,同時 try catch 的范圍能小則小。
其次是監(jiān)控 SDK 自身的錯誤。我們也會將 SDK 自身的 關鍵代碼 包裹 try catch ,確保一個錯誤不會影響整個監(jiān)控流程。單純的 try catch 將錯誤吞掉解決不了問題,這些錯誤可能導致某些監(jiān)控數(shù)據沒有收集完全,影響監(jiān)控的完整性。因此 SDK 實現(xiàn)了一個 ObserveSelfErrorPlugin ,用于收集 SDK 自身的錯誤并上報。
同時,我們會針對上報所有的上報數(shù)據進行清洗,帶有 SDK 自身堆棧的數(shù)據會統(tǒng)一消費一份到另一處,便于從宏觀上觀察 SDK 的出錯情況,及時發(fā)現(xiàn)問題。
這樣既確保了業(yè)務的正確性,也確保了監(jiān)控 SDK 的正確性。
如何減少對業(yè)務的影響
絕大部分的業(yè)務都是使用監(jiān)控 SDK 來自動上報性能數(shù)據以此來監(jiān)控業(yè)務的性能,這也隱含著對監(jiān)控 SDK 最基本的要求:不能帶來性能問題。
最重要的就是不能影響業(yè)務的首屏渲染,為此我們把 Monitor 類的插件分為兩類,一是需要立即監(jiān)聽的,先加載;二是不需要的立即監(jiān)聽的,延后加載。比如路由變化的監(jiān)聽、請求的監(jiān)聽,如果延后會導致數(shù)據遺漏,就屬于第一類;像靜態(tài)資源性能監(jiān)控這樣晚一點執(zhí)行也并不會遺漏的,就屬于第二類。
除此之外, SDK 本身的性能評估也非常重要。單個插件的執(zhí)行耗時多少,插件帶來的副作用的耗時又是多少,這些都是基本的評估點?;贛aiev,我們編寫了完善的 Benchmark 性能測試,在代碼 MR 的時候會觸發(fā)相應的測試任務,另外也有固定周期來定時執(zhí)行測試任務,任務異常時不能發(fā)版, SDK 的性能由此保證。
當然盡可能縮小 SDK 的體積也能直接減少對業(yè)務的影響,這塊內容涉及較廣,留作后續(xù)分說。
如何盡早開始監(jiān)聽
監(jiān)聽不遺漏的前提是事件發(fā)生在開始監(jiān)控之后。但是一些超高優(yōu)的事件,比如 JS 錯誤,發(fā)生時機可能超級靠前,等不到監(jiān)控腳本加載完成。所以監(jiān)控 SDK 針對 script 的接入方式會提供一個簡短的腳本,讓用戶內聯(lián)在頁面中。它的作用是提前開始監(jiān)聽,保證高優(yōu)的事件不被遺漏。
它還有另一個巧用:緩存調用命令。
監(jiān)控腳本是異步加載的,因此會先掛載一個空函數(shù),確保調用不報錯;同時把對實例主體 Client 的調用命令緩存下來,記錄下調用的時間和頁面地址,確保能正確組裝數(shù)據;等到監(jiān)控腳本加載完成時再順序執(zhí)行,以此確保調用不遺漏。示例如下:
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包不會掛全局變量,所以邏輯稍微有一些不同,同時受限于引入的順序,執(zhí)行的時機會稍晚一些。
如何保證 SDK 的質量
Slardar Web SDK 為絕大部分公司前端業(yè)務提供監(jiān)控能力,上報數(shù)據的流量近千萬 QPS ,需要有嚴格的質量把控。
SDK 有完善的單元測試,每一個插件,每一個方法,都會單獨編寫測試用例。以及完善的自動化測試,對于整個 SDK 的所有默認行為以及各個配置項對應的行為有完整的用例覆蓋。每次變動都需要補充對應的相關用例,且每次 MR 都要測試通過才能合入預發(fā)布分支,這樣才能做到心中不慌。此外,會有預發(fā)布驗證環(huán)節(jié),驗證改動的預期效果。如果改動的地方比較敏感,會找站點合作方灰度一段時間后發(fā)布正式版本。發(fā)布后的一段時間內我們也會密切的關注整體的流量情況,確認是否存在異常上漲和下降,是否有新增的 SDK 相關異常。
由此, SDK 的質量得以保證。