SDK设计
需求分析
1. 多平台支持
前端的领域广阔,所以作为前端监控,也不只局限在浏览器环境,需要同时解决小程序、 Electron 、 Nodejs 等等其他环境的监控需求。不同环境之间差异巨大,从提供的配置项,到监控的功能、上报的方式都会不一样。
2. 方便的进行业务拓展和定制
业务功能总是会不断迭代的,SDK 也一样,所以说我们在设计SDK的时候就要考虑它的一个拓展性。
架构设计
我们的一个 SDK 项目,既要功能全,又满足体积小,又要支持多平台,那么怎么设计这个 SDK 可以让它既支持多平台,但是在启用某个平台的时候不会引入无用的代码呢?这本身互相矛盾,只要兼容其他环境,打包进来的代码会导致体积变大,因此设计之初的目标就是同一套设计组装成不同的 SDK 。此设计的第一要务是要逻辑解耦。
逻辑解耦
虽然多环境下差异很大,但要做的事情是一样的,比如配置、采集数据、组装数据、上报数据。
我们设计了五个角色,每个角色只需要实现约定的接口即可。这样就保证了不同的环境下,各个角色合作的方式是相同的,在实现了一套内核模版后,不同的监控 SDK 就可以快速搭建出来。
- Monitor:收集器 ,主动或被动地采集特定环境下的原始数据,组装为平台无关事件。
Monitor 有若干个,每一个 Monitor 对应一个功能,比如关于 JS 错误的监控是一个 Monitor ,关于请求的监控又是另一个 Monitor 。
- Builder:组装器,负责将收集器上报的平台无关事件转换为特定平台的上报格式。
主要负责包装特定环境下的上下文信息。在浏览器环境下,上下文信息包括页面地址、网络状态、当前时间等等,再结合收到的 Monitor 的数据,完成上报格式的组装。
- Sender:发送器,负责发送逻辑,比如批量,重试等功能。
监控 SDK 的 Sender 都是 BatchSender ,它会负责维护一个缓存队列,按照一定的队列长度或者缓存时间间隔来聚合上报数据,会开放一些方法自定义缓存队列长度和缓存间隔时间,也支持立即上报和清空队列等操作。
特定环境下的 Sender 也需要负责处理一些边缘 case ,比如浏览器环境下的 Sender 在页面关闭时,需要使用 sendBeacon 立即上报所有队列数据,以免漏报。
TIP
在实际实践中,我们对 Sender 进行了进一步抽象, Sender 不会内置发送的能力,关于如何发送数据,不同环境依赖的 API 不同,因此会由 Client 在创建 Sender 时将具体的发送能力传入 Sender 中。
- ConfigManager:配置管理器,负责配置逻辑,比如合并初始配置和用户配置、拉取远端配置等功能。
一般需要传入默认配置,支持用户手动配置,当配置完成时, ConfigManager 会变更 ready 状态,所以它也支持被订阅,以便当 ready 时或者配置变更时通知到订阅方。
- Client:实例主体,负责串联配置管理器、收集器、组装器和发送器,串通整个流程,同时提供生命周期监听以供扩展 SDK 功能。
丰富的生命周期
监控做的事情就像一条单纯的流水线:初始化 => 采集数据 => 组装数据 => 上报数据,我们希望能在不同阶段执行各种操作,但又不希望直接将逻辑耦合在代码,这样不利于后期的迭代维护,也会导致体积一步步增加,走向重构的必然结果。
于是我们决定让内核模版提供规范的生命周期,所有的功能都借助生命周期的监听来实现,这样不仅解决了体积不断膨胀的问题,也让 SDK 易于扩展。
基于监控 SDK 的各个阶段,我们明确了六个主要的生命周期,命名也比较贴切,从上到下分别是:
初始化 => 开启上报 => Monitor 监控到数据,传递给 Client => 包装数据 => 发送数据 => 销毁实例。
基于这些生命周期,我们提供了十个生命周期钩子,主要分为两类:
- 回调类:只执行回调,不影响流程继续执行,比如 init / start / beforeConfig / config 等等。
- 处理类:执行并返回修改后的有效值,如果返回无效值,将不再往下执行,终止上报,比如 report / beforeBuild / build / beforeSend 等等。
插件化架构
为支持多平台、可拓展、可插拔的特点,整体SDK的架构设计是 内核+插件 的插件式设计,良好的生命周期是插件化的基础, 基于这些生命周期我们就能实现各种各样的插件。 每个 SDK 首先实现Core约定好的接口。然后在自身SDK中,初始化内核实例和插件。
内核里是SDK内的公共逻辑或者基础逻辑,比如数据格式化和数据上报是底下插件都要用到的公共逻辑,而配置初始化是SDK运行的一个基础逻辑。 插件里是SDK的上层拓展业务,比如说监听js错误、监听promise错误,每一个小功能都是一个插件。内核和插件一起组成了 SDK实例 Instance,最后暴露给客户端使用。
举个例子,我们需要为 Monitor 采集到的数据包装事件发生时的上下文,可以通过这种方式:监听 report ,劫持到数据,重新包装,再传递给 Client 。
// 一个包装上下文的插件
export const InjectEnvPlugin = (client: WebClient) => {
client('on', 'report', (ev: WebReportEvent) => {
return addEnvToSendEvent(ev)
})
}
// 应用此插件
InjectEnvPlugin(client)
这样子进行 SDK 的设计有很多好处:
- 每个平台分开打包,每个包的体积会大大缩小
- 代码的逻辑更加清晰自恰
最后打包上线时,我们通过修改 build 的脚本,对 packages 文件夹下的每个平台都单独打一个包,并且分开上传到 npm 平台。
SDK异常隔离以及上报
首先,我们引入监控系统的原因之一就是为了避免页面产生错误,而如果因为监控SDK报错,导致整个应用主业务流程被中断,这是我们不能够接收的。
实际上,我们无法保证我们的 SDK 不出现错误,那么假如万一SDK本身报错了,我们就需要它不会去影响主业务流程的运行。最简单粗暴的方法就是把整个 SDK 都用 try catch 包裹起来,那么这样子即使出现了错误,也会被拦截在我们的 catch 里面。
但是我们回过头来想一想,这样简单粗暴的包裹,会带来哪些问题:
- 我们只能获取到一个报错的信息,但是我们无法得知报错的位置、插件
- 我们没有将其上报,我们无法感知到 SDK 产生了错误
- 我们没法获取 SDK 出错的一个环境数据
那么,我们就需要一个相对优雅的一个异常隔离+上报机制,回想我们上文的架构:内核+插件的形式。我们对每一个插件模块,都单独的用trycatch包裹起来,然后当抛出错误的时候,进行数据的封装、上报。
这样子,就完成了一个异常隔离机制:
它实现了:当SDK产生异常时不会影响主业务的流程
- 当SDK产生异常时进行数据的封装、上报
- 出现异常后,中止 SDK 的运行,并移除所有的监听
采用什么样的上报策略
对于上报方面来说,SDK的数据上报可不是随随便便就上报上去了,里面有涉及到数据上报的方式取舍以及上报时机的选择等等,还有一些可以让数据上报更加优雅的优化点.
首先,日志上报并不是应用的主要功能逻辑,日志上报行为不应该影响业务逻辑,不应该占用业务计算资源.我们先来了解一下目前通用的几个上报方式:
- 信标(Beacon API)
- Ajax(XMLHttpRequest 和 fetch)
- Image(GIF、PNG)
首先 Beacon API 是一个较新的 API
- 它可以将数据以 POST 方法将少量数据发送到服务端
- 它保证页面卸载之前启动信标请求
- 并允许运行完成且不会阻塞请求或阻塞处理用户交互事件的任务
然后 Ajax 请求方式就不用我多说了,大家应该平常用的最多的异步请求就是 Ajax。
最后来说一下 Image 上报方式:我们可以以向服务端请求图片资源的形式,像服务端传输少量数据,这种方式不会造成跨域。
我们最终采用 sendBeacon + xmlHttpRequest 降级上报的方式,当浏览器不支持 sendBeacon 或者 传输的数据量超过了 sendBeacon 的限制,我们就降级采用 xmlHttpRequest 进行上报数据。
优先选用 Beacon API 的理由上文已经有提到:它可以保证页面卸载之前启动信标请求,是一种数据可靠,传输异步并且不会影响下一页面的加载 的传输方式。
而降级使用 XMLHttpRequest 的原因是, Beacon API 现在并不是所有的浏览器都完全支持,我们需要一个保险方案兜底,并且 sendbeacon 不能传输大数据量的信息,这个时候还是得回到 Ajax 来。
看到了这里,有的同学可能会问:为什么不用 Image 呀?那跨域怎么办呀?原因也很简单:
- Image 是以GET方式请求图片资源的方式,将上报数据附在 URL 上携带到服务端,而URL地址的长度是有一定限制的。规范对 URL 长度并没有要求,但是浏览器、服务器、代理服务器都对 URL 长度有要求。有的浏览器要求URL中path部分不超过 2048,这就导致有些请求会发送不完全。
- 至于跨域问题,作为接受数据上报的服务端,允许跨域是理所应当的。
如何按需加载
为了方便使用,默认情况下,我们会集成所有的监控功能。但这并不是所有业务都需要的,有的业务只关心 JS 错误,其他的功能都不想要,这应该怎么解决呢?
为此 SDK 导出了一个最小的实例,这个实例只引入通用的插件,但是不引入数据采集类的插件,而具体要采集哪些功能由用户在 integrations 上按需配置。
import { createMinimalBrowserClient } from '@owl-js/web'
import { jsErrorPlugin } from '@owl-js/integrations'
// 创建一个最小的实例
const client = createMinimalBrowserClient()
client('init',{
...
// 按需引入需要采集的监控功能
integrations: [jsErrorPlugin()],
...
})
如何尽早的开始监听
监听不遗漏的前提是事件发生在开始监控之后。但是一些超高优的事件,比如 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 = []
当然如果使用npm包接入的话,依然会有预收集的逻辑,因为npm包不会挂全局变量,所以逻辑稍微有一些不同,同时受限于引入的顺序,执行的时机会稍晚一些。
如何减少对业务的影响
绝大部分的业务都是使用监控 SDK 来自动上报性能数据以此来监控业务的性能,这也隐含着对监控 SDK 最基本的要求:不能带来性能问题。
最重要的就是不能影响业务的首屏渲染,为此我们把 Monitor 类的插件分为两类,一是需要立即监听的,先加载。二是不需要的立即监听的,延后加载。比如路由变化的监听、请求的监听,如果延后会导致数据遗漏,就属于第一类。像静态资源性能监控这样晚一点执行也并不会遗漏的,就属于第二类。
除此之外, SDK 本身的性能评估也非常重要。单个插件的执行耗时多少,插件带来的副作用的耗时又是多少,这些都是基本的评估点。
如何保证SDK的质量
SDK 有完善的单元测试,每一个插件,每一个方法,都会单独编写测试用例。以及完善的自动化测试,对于整个 SDK 的所有默认行为以及各个配置项对应的行为有完整的用例覆盖。每次变动都需要补充对应的相关用例,且每次 MR 都要测试通过才能合入预发布分支,这样才能做到心中不慌。此外,会有预发布验证环节,验证改动的预期效果。如果改动的地方比较敏感,会找站点合作方灰度一段时间后发布正式版本。发布后的一段时间内我们也会密切的关注整体的流量情况,确认是否存在异常上涨和下降,是否有新增的 SDK 相关异常。
由此, SDK 的质量得以保证。
错误上报去重
首先,我们需要理清一个概念,我们可以认为:
- 在用户的一次会话中,如果产生了同一个错误,那么将这同一个错误上报多次是没有意义的
- 在用户的不同会话中,如果产生了同一个错误,那么将不同会话中产生的错误进行上报是有意义的
为什么有上面的结论呢?理由很简单:
- 在用户的同一次会话中,如果点击一个按钮出现了错误,那么再次点击同一个按钮,必定会出现同一个错误,而这出现的多次错误,影响的是同一个用户、同一次访问,所以将其全部上报是没有意义的
- 而在同一个用户的不同会话中,如果出现了同一个错误,那么这不同会话里的错误进行上报就显得有意义了
如何实现服务端时间的校对
看到这里,可能有的同学并不明白,进行服务端时间的校对是什么意思。我们首先要明白,我们通过 JS 调用 new Date() 获取的时间,是我们的机器时间。也就是说:这个时间是一个随时都有可能不准确的时间。
那么既然时间是不准确的,假如有一个对时间精准度要求比较敏感的功能:比如说 API全链路监控。最后整体绘制出来的全链路图直接客户端的访问时间点变成了未来的时间点,直接时间穿梭那可不行。