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