性能文章>前端监控系列1| 字节的前端监控SDK是怎样设计的>

前端监控系列1| 字节的前端监控SDK是怎样设计的原创

4724010

作者:彭莉,火山引擎 APM 研发工程师,2020年加入字节,负责前端监控 SDK 的开发维护、平台数据消费的探索和落地。

摘要

  • 字节内部应用环境多样( Web 应用、小程序、Electron 应用、跨端应用等等), SDK 如何保证底层逻辑的复用、上层逻辑的解耦。

  • 在业务庞杂、监控需求多样的背景下, SDK 如何做到足够灵活,如何实现插件化,并且支持业务自行扩展的。

  • 大型 C 端业务非常注重业务自身的正确性和性能,监控 SDK 如何保证原有业务的正确性;如何保持 SDK 自身的性能,减少对业务的影响。

  • 接入业务众多,上报量级近千万 QPS ,在日常需求迭代中, SDK 是如何确保自身稳定性的。

逻辑解耦

前端的领域广阔,所以作为前端监控,也不只局限在浏览器环境,需要同时解决小程序、 Electron 、 Nodejs 等等其他环境的监控需求。不同环境之间差异巨大,从提供的配置项,到监控的功能、上报的方式都会不一样。

一个 SDK 不可能既支持多环境,又满足体积小、功能全面的要求,这本身互相矛盾。只要兼容其他环境,打包进来的代码会导致体积变大,因此设计之初的目标就是同一套设计组装成不同的 SDK 。此设计的第一要务是要逻辑解耦。虽然多环境下差异很大,但要做的事情是一样的,比如配置、采集数据、组装数据、上报数据。

我们设计了五个角色,每个角色只需要实现约定的接口即可。这样就保证了不同的环境下,各个角色合作的方式是相同的,在实现了一套内核模版后,不同的监控 SDK 就可以快速搭建出来。

image.png

Monitor

收集器 ,主动或被动地采集特定环境下的原始数据,组装为平台无关事件。

Monitor 有若干个,每一个 Monitor 对应一个功能,比如关于 JS 错误的监控是一个 Monitor ,关于请求的监控又是另一个 Monitor 。

Builder

组装器,负责将收集器上报的平台无关事件转换为特定平台的上报格式。

主要负责包装特定环境下的上下文信息。在浏览器环境下,上下文信息包括页面地址、网络状态、当前时间等等,再结合收到的 Monitor 的数据,完成上报格式的组装。

Sender

发送器,负责发送逻辑,比如批量,重试等功能。

监控 SDK 的 Sender 都是 BatchSender ,它会负责维护一个缓存队列,按照一定的队列长度或者缓存时间间隔来聚合上报数据,会开放一些方法自定义缓存队列长度和缓存间隔时间,也支持立即上报和清空队列等操作。

特定环境下的 Sender 也需要负责处理一些边缘 case ,比如浏览器环境下的 Sender 在页面关闭时,需要使用 sendBeacon 立即上报所有队列数据,以免漏报。

在实际实践中,我们对 Sender 进行了进一步抽象, Sender 不会内置发送的能力,关于如何发送数据,不同环境依赖的 API 不同,因此会由 Client 在创建 Sender 时将具体的发送能力传入 Sender 中。

ConfigManager

配置管理器,负责配置逻辑,比如合并初始配置和用户配置、拉取远端配置等功能。

一般需要传入默认配置,支持用户手动配置,当配置完成时, ConfigManager 会变更 ready 状态,所以它也支持被订阅,以便当 ready 时或者配置变更时通知到订阅方。

export interface ConfigManager<Config> {
  setConfig: (c: Partial<Config>) => Config
  getConfig: () => Config
  onChange: (fn: () => void) => void
  onReady: (fn: () => void) => void
}

Client

实例主体,负责串联配置管理器、收集器、组装器和发送器,串通整个流程,同时提供生命周期监听以供扩展 SDK 功能。

下面是一段方便理解串联过程的伪代码,仅作参考。

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 不知道收到的具体事件格式,只负责完成发送。

开放丰富的生命周期

监控做的事情就像一条单纯的流水线:初始化 => 采集数据 => 组装数据 => 上报数据,我们希望能在不同阶段执行各种操作,但又不希望直接将逻辑耦合在代码,这样不利于后期的迭代维护,也会导致体积一步步增加,走向重构的必然结果。

于是我们决定让内核模版提供规范的生命周期,所有的功能都借助生命周期的监听来实现,这样不仅解决了体积不断膨胀的问题,也让 SDK 易于扩展。

基于监控 SDK 的各个阶段,我们明确了六个主要的生命周期,命名也比较贴切,从上到下分别是:

初始化 => 开启上报 => Monitor 监控到数据,传递给 Client => 包装数据 => 发送数据 => 销毁实例

image.png

基于这些生命周期,我们提供了十个生命周期钩子,主要分为两类:

  • 回调类:只执行回调,不影响流程继续执行,比如 init / start / beforeConfig / config 等等。

  • 处理类:执行并返回修改后的有效值,如果返回无效值,将不再往下执行,终止上报,比如 report / beforeBuild / build / beforeSend 等等。

如何实现插件化

良好的生命周期是插件化的基础, 基于这些生命周期我们就能实现各种各样的插件。

举个例子,我们需要为 Monitor 采集到的数据包装事件发生时的上下文,可以通过这种方式:监听 report ,劫持到数据,重新包装,再传递给 Client 。

// 一个包装上下文的插件
export const InjectEnvPlugin = (client: WebClient) => {
  client('on', 'report', (ev: WebReportEvent) => {
    return addEnvToSendEvent(ev)
  })
}

// 应用此插件
InjectEnvPlugin(client)

再举个例子,我们需要新监控一类数据,可以通过这种方式:监听实例主体 Client 当前的状态,在 Client ready 的时候(用户配置完成时),开始收集数据。在收集到数据时,将数据传回 Client 即可。

// 一个监听数据的插件
export const MonitorXXPlugin = (client: WebClient) => {
  client('on', 'init', () => {
     const data = listenXX();
     client('report', data)
  })
}

在 SDK 内, 基本都是插件,常规的数据采集是一个个插件,其他的比如采样、包装上下文、异步加载等功能,也都是各自独立的插件。

业务如何自行扩展

简单的扩展,一般可以靠生命周期钩子函数来完成,常见的需求就是在数据发送前做一些手动的过滤、安全脱敏等等。

举个例子,我们想要在页面地址包含 ‘/test’ 时不上报任何数据,可以通过下面的代码来实现。

import client from '@apmplus/web'

client('on', 'beforeSend', (ev) => {
  if (ev.common.url.includes('/test')) {
    return false
  }
  return ev
})

但如果有高阶的需求,比如想写一个插件能提供给团队的其他人用,上面的方式就不再适用。如果插件太复杂,其他人需要复制一大段代码,用起来不太优雅。

基于这个需求, SDK 设计了一个自定义插件的传递协议,可以在初始化时将自定义插件传递给 Client , Client 将会在初始化时执行传入的 setup 方法,在实例销毁时执行传入的 tearDown 方法来销毁副作用。

export interface Integration<T extends AnyClient> {
  name: string
  setup: (client: T) => void
  tearDown?: () => void
}

可以注意到,接口约定的实例类型是 AnyClient ,这个协议并不在意是什么类型的 Client ,实际的 Client 类型由 SDK 来定义,比如 Web SDK 拿到的是 WebClient , Electron SDK 拿到的是 ElectronClient 。

业务可以自行发布一个插件包,插件的实现可以是直接返回一个对象,或一个方法。允许用户传入一些配置,返回一个对象,只要这个对象满足上面的 Integration 类型即可。

import client from '@apmplus/web'
import CustomPlugin from 'xxx'

client('init', {
  ...
  integrations: [CustomPlugin({ config: {} })]
  ...
})

如何按需加载

为了方便使用,默认情况下,我们会集成所有的监控功能。但这并不是所有业务都需要的,有的业务只关心 JS 错误,其他的功能都不想要,这应该怎么解决呢?

为此 SDK 导出了一个最小的实例,这个实例只引入通用的插件,但是不引入数据采集类的插件,而具体要采集哪些功能由用户在 integrations 上按需配置。

import { createMinimalBrowserClient } from '@apmplus/web'
import { jsErrorPlugin } from '@apmplus/integrations'

// 创建一个最小的实例
const client = createMinimalBrowserClient()

client('init',{
  ...
  // 按需引入需要采集的监控功能
  integrations: [jsErrorPlugin()],
  ...
})

如何保证原有业务的正确性

接入监控 SDK 的目的是为了发现问题,如果监控 SDK 的问题导致业务受到了影响,不免本末倒置。加上绝大部分的字节前端业务都接入了这个 SDK ,如果出现问题,影响范围和损失都很巨大。因此保证原有业务的正确性远远比监控本身更重要。

SDK 会首先将对业务有影响的 敏感代码 使用 try catch 包裹起来,确保即使发生了错误也不影响业务。比如 hook 类的操作, hook XHR 和 Fetch 等等。这个操作,要做到胆大心细,同时 try catch 的范围能小则小。

其次是监控 SDK 自身的错误。我们会将 SDK 自身的 关键代码 包裹 try catch ,确保一个错误不会影响整个监控流程。单纯的 try catch 将错误吞掉解决不了问题,这些错误可能导致某些监控数据没有收集完全,影响监控的完整性。因此 SDK 实现了一个 ObserveSelfErrorPlugin ,用于收集 SDK 自身的错误并上报。

同时在字节内部,我们会针对上报所有的上报数据进行清洗,带有 SDK 自身堆栈的数据会统一消费一份到另一处,便于从宏观上观察 SDK 的出错情况,及时发现问题。

这样既确保了业务的正确性,也确保了监控 SDK 的正确性。

如何减少对业务的影响

绝大部分的业务都是使用监控 SDK 来自动上报性能数据以此来监控业务的性能,这也隐含着对监控 SDK 最基本的要求:不能带来性能问题。

最重要的就是不能影响业务的首屏渲染,为此我们把 Monitor 类的插件分为两类,一是需要立即监听的,先加载;二是不需要的立即监听的,延后加载。比如路由变化的监听、请求的监听,如果延后会导致数据遗漏,就属于第一类;像静态资源性能监控这样晚一点执行也并不会遗漏的,就属于第二类。

除此之外, SDK 本身的性能评估也非常重要。单个插件的执行耗时多少,插件带来的副作用的耗时又是多少,这些都是基本的评估点。基于字节内部提供的性能工具,我们编写了完善的 Benchmark 性能测试,在代码 MR 的时候会触发相应的测试任务,另外也有固定周期来定时执行测试任务,任务异常时不能发版, SDK 的性能由此保证。

当然尽可能缩小 SDK 的体积也能直接减少对业务的影响,这块内容涉及较广,留作后续分说。

如何尽早开始监听

监听不遗漏的前提是事件发生在开始监控之后。但是一些超高优的事件,比如 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 作为服务于字节唯一的前端监控产品,上报数据的流量近千万 QPS ,需要有严格的质量把控。

SDK 有完善的单元测试,每一个插件,每一个方法,都会单独编写测试用例。以及完善的自动化测试,对于整个 SDK 的所有默认行为以及各个配置项对应的行为有完整的用例覆盖。每次变动都需要补充对应的相关用例,且每次 MR 都要测试通过才能合入预发布分支,这样才能做到心中不慌。

此外,会有预发布验证环节,验证改动的预期效果。如果改动的地方比较敏感,会找站点合作方灰度一段时间后发布正式版本。发布后的一段时间内我们也会密切的关注整体的流量情况,确认是否存在异常上涨和下降,是否有新增的 SDK 相关异常。

由此, SDK 的质量得以保证。

欢迎使用

现在这套前端监控服务已经同步在了火山引擎上,接入即可对 Web 端真实数据进行实时监控、报警归因、聚类分析和细节定位,解决白屏、性能瓶颈、慢查询等关键问题,欢迎体验。

扫描下方二维码,立即申请免费使用⬇️

image.png

:::

点赞收藏
火山引擎开发者服务

火山引擎应用性能监控全链路版,经字节内部众多APP实践验证、提供APP、Web、小程序、服务端、PC、OS端在内的APM服务,通过先进的数据采集技术,为用户优化应用性能助力。

请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
10
0