当前位置: 首页 > 科技观察

字节的前端监控 SDK 是怎样设计的

时间:2023-03-15 22:26:33 科技观察

字节的前端监控SDK是如何设计的?多种多样(Web应用、小程序、Electron应用、跨端应用等),SDK如何保证底层逻辑的复用和上层逻辑的解耦。在业务复杂、监控需求多样的背景下,如何让SDK足够灵活,如何实现插件化,支持业务的自扩展。大型C端业务非常重视业务本身的正确性和性能,监控SDK如何保证原有业务的正确性;如何保持SDK本身的性能,减少对业务的影响。接入业务众多,上报量级近千万QPS。SDK在日常需求的迭代中如何保证自身的稳定性。前端逻辑解耦领域广阔,作为前端监控,不局限于浏览器环境,需要同时解决小程序、Electron、Nodejs等其他环境的监控需求时间。不同环境之间存在巨大差异,从提供的配置项到监控功能和报告方式。一个SDK不可能同时支持多种环境,同时满足体积小、功能全面的要求,这本身就是矛盾的。只要兼容其他环境,打包后的代码体积会变大,所以设计之初的目标是将同一套设计组装成不同的SDK。这种设计的首要任务是逻辑解耦。虽然在多个环境中差异很大,但是要做的事情都是一样的,比如配置、数据收集、数据组装、数据上报等。我们设计了五个角色,每个角色只需要实现约定的接口即可。这就保证了在不同的环境中,各个角色的合作方式是相同的。实现一套内核模板后,可以快速构建不同的监控SDK。Monitor收集器主动或被动地收集特定环境中的原始数据,并将其组装成与平台无关的事件。有几个Monitor,每个Monitor对应一个function。比如有一个监控JS错误的Monitor,还有一个监控请求的Monitor。Builder组装器负责将收集器报告的与平台无关的事件转换为特定平台的报告格式。它主要负责在特定环境下包装上下文信息。在浏览器环境下,上下文信息包括页面地址、网络状态、当前时间等,结合接收到的监控数据,完成上报格式的组装。Sender负责发送逻辑,比如批量、重试等功能。监控SDK的Sender是BatchSender,会负责维护一个缓存队列,按照一定的队列长度或者缓存间隔聚合上报数据,同时会开启一些自定义缓存队列长度和缓存间隔的方法,同时也会支持即时上报和清空队列等操作。特定环境中的Sender还需要负责处理一些边缘情况。比如浏览器环境下的Sender关闭页面时,需要使用sendBeacon立即上报所有队列数据,避免漏报。在实际操作中,我们对Sender进行了进一步的抽象。发件人没有内置的发送功能。关于如何发送数据,不同的环境依赖于不同的API。因此,客户端在创建Sender时,会将具体的发送能力传递给Sender。.ConfigManager配置管理器负责配置逻辑,比如合并初始配置和用户配置,拉取远程配置等功能。一般需要传入默认配置,支持用户手动配置。当配置完成后,ConfigManager会改变就绪状态,所以它也支持被订阅,在准备就绪或配置发生变化时通知订阅者。exportinterfaceConfigManager{setConfig:(c:Partial)=>ConfiggetConfig:()=>ConfigonChange:(fn:()=>void)=>voidonReady:(fn:()=>void)=>void}Client实例体,负责串联配置管理器、收集器、组装器、发送器,整理整个流程,为扩展SDK功能提供生命周期监控。下面是一段伪代码,方便理解拼接过程,仅供参考。exportconstcreateClient=({configManager,builder,sender})=>{letinited=falseletstarted=falseletpreStartQueue=[]constclient={init:(config)=>{configManager.setConfig(config)configManager.onReady(()=>{preStartQueue.forEach((e)=>{this.report(e)})started=true})inited=true}report:(data)=>{if(!started){preStartQueue.push(数据)}else{constbuilderData=builder.build(data)builderData&&sender.send(builderData)}}}returnclient}constclient=createClient({configManager,builder,sender})monitors.forEach((e)=>{e(client)})角色足够抽象,相互独立,各司其职。比如Monitor只负责收集,并不知道最终报告的具体格式;Builder只做组装,组装完成后交给client,实例主体,client交给Sender;Sender不知道收到的事件的具体格式,只负责完成发送。开放丰富的生命周期监控做的事情就像一个简单的流水线:初始化=>收集数据=>组装数据=>上报数据,我们希望在不同的阶段进行各种操作,但又不想直接在代码中耦合逻辑,这是不利于后期的迭代维护,还会导致体积逐级增大,这是重构的必然结果。所以我们决定让内核模板提供一个标准化的生命周期,所有的功能都是借助生命周期监控来实现的,这样既解决了体量不断扩大的问题,也让SDK易于扩展。基于监控SDK的各个阶段,我们定义了六个主要的生命周期,这个命名比较贴切。从上到下分别是:初始化=>开启上报=>Monitor监听数据传递给客户端=>打包数据=>发送数据=>销毁实例基于这几个生命周期,我们提供了十个生命周期钩子,主要分为两类:回调类:只执行回调,不影响流程的继续,比如init/start/beforeConfig/config等。处理类:执行并返回修改后的有效值。如果返回无效值,则不再执行,终止报表,如report/beforeBuild/build/beforeSend等插件如何实现良好的生命周期是插件的基础,基于这些生命周期我们可以实现各种插件。比如我们需要对Monitor采集到的数据进行事件上下文的封装,可以这样进行:监听上报,劫持数据,重新封装,然后传给Client。//包装上下文的插件exportconstInjectEnvPlugin=(client:WebClient)=>{client('on','report',(ev:WebReportEvent)=>{returnaddEnvToSendEvent(ev)})}//Applythis插件InjectEnvPlugin(client)是另一个例子。我们需要监控一种新的数据类型,可以这样实现:监控实例主体Client的当前状态,当Client准备就绪时(用户配置完成时)开始采集数据。收集数据后,只需将数据传回给Client即可。//监控数据导出的插件constMonitorXXPlugin=(client:WebClient)=>{client('on','init',()=>{constdata=listenXX();client('report',data)})}在SDK中,它们基本上都是插件。常规数据采集是一个插件,其他的采样、打包上下文、异步加载等功能也是独立的插件。如何自行扩展业务简单的扩展一般可以通过生命周期钩子函数来完成。常见的需求是在发送数据前做一些人工过滤,安全脱敏等。比如我们想在页面地址包含'/test'时不上报任何数据,可以通过如下代码实现。从'@slardar/web'client('on','beforeSend',(ev)=>{if(ev.common.url.includes('/test')){returnfalse}returnev})导入客户端但是如果有更高层次的需求,比如写一个插件可以给团队其他成员使用,上面的方法就不再适用了。如果插件太复杂,别人需要复制一大段代码,使用起来不是很优雅。基于这个需求,SDK设计了自定义插件的传递协议,可以在初始化时将自定义插件传递给Client,Client会在初始化时执行传入的setup方法,初始化时执行传入的tearDown方法该实例被销毁以消除副作用。exportinterfaceIntegration{name:stringsetup:(client:T)=>voidtearDown?:()=>void}可以注意到接口约定的实例类型是AnyClient,这个协议做不管是什么TypeofClient,实际的Client类型是由SDK定义的,比如WebSDK获取WebClient,ElectronSDK获取ElectronClient。业务可以自己发布一个插件包,插件的实现可以直接返回一个对象或者方法。允许用户传入一些配置,返回一个对象,只要对象满足上面的Integration类型即可。importclientfrom'@slardar/web'importCustomPluginfrom'xxx'client('init',{...integrations:[CustomPlugin({config:{}})]...})如何按需加载方便,我们会默认集成所有的监控功能。但这并不是所有企业都需要的。有些业务只关心JS错误,不需要其他功能。这应该如何解决?为此,SDK导出了一个极简实例,只引入通用插件,不引入数据采集类插件,具体采集的功能由用户根据需要在集成时配置.import{createMinimalBrowserClient}from'@slardar/web'import{jsErrorPlugin}from'@slardar/integrations/dist/jsError'//创建一个最小实例constclient=createMinimalBrowserClient()client('init',{...//根据需要引入需要采集的监控功能集成:[jsErrorPlugin()],...})如何保证原有业务的正确性接入监控SDK的目的是发现问题。如果监控SDK的问题导致业务受到影响,本末倒置在所难免。另外,大部分前端业务都接入了这个SDK。一旦出现问题,影响和损失的范围将是巨大的。因此,保证原始业务的正确性远比监控本身重要。SDK会先把影响业务的敏感代码用trycatch包裹起来,保证即使出错也不会影响业务,比如hook操作,hookXHR和Fetch等,这个操作要大胆谨慎,trycatch的范围可大可小。二是监控SDK本身的错误。我们也会将SDK本身的关键代码用trycatch包裹起来,保证一个错误不会影响整个监控过程。一个简单的trycatch并不能通过吞噬错误来解决问题。这些错误可能会导致部分监控数据采集不全,影响监控的完整性。因此,SDK实现了一个ObserveSelfErrorPlugin来收集和报告SDK自身的错误。同时,我们会清洗所有上报的数据,SDK自带栈的数据会一份一份的消费到另一份,以便我们从宏观上观察SDK的错误,及时发现问题。这样既保证了业务的正确性,又保证了监控SDK的正确性。如何降低对业务的影响大部分业务都是通过监控SDK自动上报性能数据来监控业务的性能,这也隐含了对监控SDK最基本的要求:不能造成性能问题。最重要的是不要影响业务的首屏渲染。为此,我们将监控插件分为两类。一种是需要立即监控就立即加载;另一种是如果不需要立即监控则延迟加载。比如路由变化和请求的监听,如果延迟会导致数据遗漏,属于第一类;比如静态资源性能监控,后面执行,不会遗漏,属于第二类。另外,SDK本身的性能评估也很重要。单个插件的执行耗时多少,插件带来的副作用有多长,这些都是基本的评测点。基于Maiev,我们编写了完整的Benchmark性能测试。当代码为MR时,会触发相应的测试任务。此外,还有一个固定的周期定期执行测试任务。任务异常时,无法发布版本,保证SDK的性能。当然,尽可能减小SDK的体积也可以直接减少对业务的影响。本篇内容涉及较多,留待后续讨论。如何尽早开始监控不漏掉监控的前提是事件发生在监控开始之后。但是,一些超高质量的事件,比如JS错误,可能会出现在超早的时间,在监控脚本加载之前。因此,监控SDK会提供脚本访问方式的短脚本,让用户在页面中内联。其作用是提前启动监控,确保优质事件不被遗漏。它还有另一个巧妙的用途:缓存调用命令。监控脚本是异步加载的,所以会先挂载一个空函数,保证调用不报错;同时缓存对实例主体Client的调用命令,记录调用时间和页面地址,保证数据能够正确组装;脚本加载时,会依次执行,保证不漏调用。一个例子如下:push(onceArguments)}window[globalName].precolletArguments=[]当然如果使用npm包访问,还是会有预采集逻辑,因为npm包不会挂全局变量,所以逻辑略不同,且受导入指令限制,执行时间会稍晚。如何保证SDK的质量SlardarWebSDK为公司大部分前端业务提供监控能力,上报数据流量近千万QPS,需要严格的质量控制。SDK有完整的单元测试,每个插件和每个方法都会有一个单独的测试用例。以及完善的自动化测试,对整个SDK的所有默认行为以及每个配置项对应的行为都有完整的用例覆盖。每一次变更都需要补充相应的相关用例,并且每个MR都必须通过测试才能合并到预发布分支,以免出现panic。此外,还将有一个预发布验证会话来验证更改的预期效果。如果改动比较敏感,我们会找本站合作伙伴灰度,过一段时间后发布正式版。发布后的一段时间内,我们也会密切关注整体流量情况,确认是否有异常涨跌,是否有新增SDK相关异常。从而保证了SDK的质量。