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