腾讯3面:说说前端监控平台/监控SDK的架构设计和难点亮点?
优采云 发布时间: 2022-06-28 05:55腾讯3面:说说前端监控平台/监控SDK的架构设计和难点亮点?
前言
事情是这样的,上周,我的一位两年前端经验的发小,在 腾讯三轮面试 的时候被问了一个问题:说说你们公司前端监控项目的架构设计和亮点设计 ;
而说回我这位发小,因为做过他们公司监控项目的可视化报表界面,所以简历上有写着前端监控项目的项目经验;但是不幸的是,他虽然前端基础相当不错,但并没有实际参与监控SDK的设计开发(只负责写监控的可视化分析界面),所以被问到这个问题,直接就一个懵了;结果也很正常,面试没过;
那么这篇文章,我就来介绍一下对于前端监控项目的 整体架构 和 可以做的亮点优化 ;前文几篇文章有介绍具体的前端监控实现,感兴趣的小伙伴可以点击链接跳转过去阅读; 传送门就在下面。
传送门
这篇文章的标题原拟定是:一文摸清前端监控实践要点(四)架构设计;但是我的发小面试刚好碰上了这么一个问题,于是我便将标题改为了这个。
一文摸清前端监控实践要点(一)性能监控[1]
一文摸清前端监控实践要点(二)行为监控[2]
一文摸清前端监控实践要点(三)错误监控[3]
腾讯三面:说说前端监控告警分析平台的架构设计和难点亮点?[4]
整体 架构设计
image.png
直接上图,我们在应用层SDK上报的数据,在接入层经过 削峰限流 和 数据加工 后,将原始日志存储于 ES 中,再经过 数据清洗 、数据聚合 后,将 issue(聚合的数据) 持久化存储 于 MySQL ,最后提供 RESTful API 提供给监控平台调用;
SDK 架构设计
为支持多平台、可拓展、可插拔的特点,整体SDK的架构设计是 内核+插件 的插件式设计;每个 SDK 首先继承于平台无关的 Core 层代码。然后在自身SDK中,初始化内核实例和插件;
image.png
image.png值得一谈的点
下面将主要谈谈这些内容:前端监控项目除了正常的数据采集、数据报表分析以外;会碰上哪些难点可以去突破,或者说可以做出哪些亮点的内容?
SDK 如何设计成多平台支持?
首先我们先来了解一下,在前端监控的领域里,我们可能不仅仅只是监控一个 web环境 下的数据,包括 Nodejs、微信小程序、Electron 等各种其余的环境都是有监控的业务需求在的;
那么我们就要思考一个点,我们的一个 SDK 项目,既然功能全,又要支持多平台,那么怎么设计这个 SDK 可以让它既支持多平台,但是在启用某个平台的时候不会引入无用的代码呢?
最简单的办法:将每个平台单独放一个仓库,单独维护 ;但是这种办法的问题也很严重:人力资源浪费严重;会导致一些重复的代码很多;维护非常困难;
而较好一点的解决方案:我们可以通过插件化对代码进行组织:见下图
image.png
这样子进行 SDK 的设计有很多好处:
最后打包上线时,我们通过修改 build 的脚本,对 packages 文件夹下的每个平台都单独打一个包,并且分开上传到 npm 平台;
SDK 如何方便的进行业务拓展和定制?
业务功能总是会不断迭代的,SDK 也一样,所以说我们在设计SDK的时候就要考虑它的一个拓展性;我们来看下图:
image.png
上图是 SDK 内部的一个架构设计 :内核+插件 的设计;
而看了上图已经上文的解释,可拓展这个问题的答案已经很清晰了,我们需要拓展业务,只需要在内核的基础上,不断的往上叠加 Monitor 插件的数量就可以了;
至于说定制化,插件里的功能,都是使用与否不影响整个SDK运行的,所以我们可以自由的让用户对插件里的功能进行定制化,决定哪个监控功能启用、哪个监控功能不启用等等....
我这边举个代码例子,大家可以参考着看看就行:
// 服务于 Web 的SDK,继承了 Core 上的与平台无关方法;<br />class WebSdk extends Core {<br /> // 性能监控实例,实例里每个插件实现一个性能监控功能;<br /> public performanceInstance: WebVitals;<br /><br /> // 行为监控实例,实例里每个插件实现一个行为监控功能;<br /> public userInstance: UserVitals;<br /><br /> // 错误监控实例,实例里每个插件实现一个错误监控功能;<br /> public errorInstance: ErrorVitals;<br /><br /> // 上报实例,这里面封装上报方法<br /> public transportInstance: TransportInstance;<br /><br /> // 数据格式化实例<br /> public builderInstance: BuilderInstance;<br /><br /> // 维度实例,用以初始化 uid、sid等信息<br /> public dimensionInstance: DimensionInstance;<br /><br /> // 参数初始化实例<br /> public configInstance: ConfigInstance;<br /><br /> private options: initOptions;<br /><br /> constructor(options: initOptions) {<br /> super();<br /> this.configInstance = new ConfigInstance(this, options);<br /> // 各种初始化......<br /> }<br />}<br /><br />export default WebSdk;<br />
看上面的代码,我在初始化每个插件的时候,都将 this 传入进去,那么每个插件里面都可以访问内核里的方法;
SDK 在拓展新业务的时候,如何保证原有业务的正确性?
在上述的 内核+插件 设计下,我们开发新业务对原功能的影响基本上可以忽略不计,但是难免有意外,所以在 SDK 项目的层面上,需要有 单元测试 的来保证业务的稳定性;
我们可以引入单元测试,并对 每一个插件,每一个内核方法,都单独编写测试用例,在覆盖率达标的情况下,只要每次代码上传都测试通过,就可以保证原有业务的一个稳定性;
SDK 如何实现异常隔离以及上报?
首先,我们引入监控系统的原因之一就是为了避免页面产生错误,而如果因为监控SDK报错,导致整个应用主业务流程被中断,这是我们不能够接收的;
实际上,我们无法保证我们的 SDK 不出现错误,那么假如万一SDK本身报错了,我们就需要它不会去影响主业务流程的运行;最简单粗暴的方法就是把整个 SDK 都用 try catch 包裹起来,那么这样子即使出现了错误,也会被拦截在我们的 catch 里面;
但是我们回过头来想一想,这样简单粗暴的包裹,会带来哪些问题:
那么,我们就需要一个相对优雅的一个异常隔离+上报机制,回想我们上文的架构:内核+插件的形式;我们对每一个插件模块,都单独的用trycatch包裹起来,然后当抛出错误的时候,进行数据的封装、上报;
这样子,就完成了一个异常隔离机制:
SDK 如何实现服务端时间的校对?
看到这里,可能有的同学并不明白,进行服务端时间的校对是什么意思;我们首先要明白,我们通过 JS 调用 new Date() 获取的时间,是我们的机器时间;也就是说:这个时间是一个随时都有可能不准确的时间;
那么既然时间是不准确的,假如有一个对时间精准度要求比较敏感的功能:比如说 API全链路监控;最后整体绘制出来的全链路图直接客户端的访问时间点变成了未来的时间点,直接时间穿梭那可不行;
image.png
如上图,我们先要了解的是,http响应头 上有一个字段 Date;它的值是服务端发送资源时的服务器时间,我们可以在初始化SDK的时候,发送一个简单的请求给上报服务器,获取返回的 Date 值后计算 Diff差值 存在本地;
这样子就可以提供一个 公共API,来提供一个时间校对的服务,让本地的时间 比较趋近于 服务端的真实时间;(只是比较趋近的原因是:还会有一个单程传输耗时的误差)
let diff = 0;<br />export const diffTime = (date: string) => {<br /> const serverDate = new Date(date);<br /> const inDiff = Date.now() - serverDate.getTime();<br /> if (diff === 0 || diff > inDiff) {<br /> diff = inDiff;<br /> }<br />};<br /><br />export const getTime = () => {<br /> return new Date(Date.now() - diff);<br />};<br />
当然,这里还可以做的更精确一点,我们可以让后端服务在返回的时候,带上 API 请求在后端服务执行完毕所消耗的时间 server-timing,放在响应头里;我们取到数据后,将 ttfb 耗时 减去返回的 server-timing 再除以 2;就是单程传输的耗时;那这样我们上文的计算中差的 单程传输耗时的误差 就可以补上了;
SDK 如何实现会话级别的错误上报去重?
首先,我们需要理清一个概念,我们可以认为:
为什么有上面的结论呢?理由很简单:
所以说我们在第三篇文章《一文摸清前端监控实践要点(三)错误监控》[5]中有一个生成 错误mid 的操作,这是一个唯一id,但是它的唯一规则是针对于不同错误的唯一;
// 对每一个错误详情,生成一串编码<br />export const getErrorUid = (input: string) => {<br /> return window.btoa(unescape(encodeURIComponent(input)));<br />};<br />
所以说我们传入的参数,是 错误信息、错误行号、错误列号、错误文件等可能的关键信息的一个集合,这样保证了产生在同一个地方的错误,生成的 错误mid 都是相等的;这样子,我们才能在错误上报的入口函数里,做上报去重;
// 封装错误的上报入口,上报前,判断错误是否已经发生过<br />errorSendHandler = (data: ExceptionMetrics) => {<br /> // 统一加上 用户行为追踪 和 页面基本信息<br /> const submitParams = {<br /> ...data,<br /> breadcrumbs: this.engineInstance.userInstance.breadcrumbs.get(),<br /> pageInformation: this.engineInstance.userInstance.metrics.get('page-information'),<br /> } as ExceptionMetrics;<br /> // 判断同一个错误在本次页面访问中是否已经发生过;<br /> const hasSubmitStatus = this.submitErrorUids.includes(submitParams.errorUid);<br /> // 检查一下错误在本次页面访问中,是否已经产生过<br /> if (hasSubmitStatus) return;<br /> this.submitErrorUids.push(submitParams.errorUid);<br /> // 记录后清除 breadcrumbs<br /> this.engineInstance.userInstance.breadcrumbs.clear();<br /> // 一般来说,有报错就立刻上报;<br /> this.engineInstance.transportInstance.kernelTransportHandler(<br /> this.engineInstance.transportInstance.formatTransportData(transportCategory.ERROR, submitParams),<br /> );<br />};<br />
SDK 采用什么样的上报策略?
对于上报方面来说,SDK的数据上报可不是随随便便就上报上去了,里面有涉及到数据上报的方式取舍以及上报时机的选择等等,还有一些可以让数据上报更加优雅的优化点;
首先,日志上报并不是应用的主要功能逻辑,日志上报行为不应该影响业务逻辑,不应该占用业务计算资源;那么在往下阅读之前,我们先来了解一下目前通用的几个上报方式:
我们来简单讲一下上述的几个上报方式
首先 Beacon API[6] 是一个较新的 API
然后 Ajax 请求方式就不用我多说了,大家应该平常用的最多的异步请求就是 Ajax;
最后来说一下 Image 上报方式:我们可以以向服务端请求图片资源的形式,像服务端传输少量数据,这种方式不会造成跨域;
上报方式
看了上面的三种上报方式,我们最终采用 sendBeacon + xmlHttpRequest 降级上报的方式,当浏览器不支持 sendBeacon 或者 传输的数据量超过了 sendBeacon 的限制,我们就降级采用 xmlHttpRequest 进行上报数据;
优先选用 Beacon API 的理由上文已经有提到:它可以保证页面卸载之前启动信标请求,是一种数据可靠,传输异步并且不会影响下一页面的加载 的传输方式。
而降级使用 XMLHttpRequest 的原因是, Beacon API 现在并不是所有的浏览器都完全支持,我们需要一个保险方案兜底,并且 sendbeacon 不能传输大数据量的信息,这个时候还是得回到 Ajax 来;
看到了这里,有的同学可能会问:为什么不用 Image 呀?那跨域怎么办呀?原因也很简单:
我们将其简单封装一下:
export enum transportCategory {<br /> // PV访问数据<br /> PV = 'pv',<br /> // 性能数据<br /> PERF = 'perf',<br /> // api 请求数据<br /> API = 'api',<br /> // 报错数据<br /> ERROR = 'error',<br /> // 自定义行为<br /> CUS = 'custom',<br />}<br /><br />export interface DimensionStructure {<br /> // 用户id,存储于cookie<br /> uid: string;<br /> // 会话id,存储于cookiestorage<br /> sid: string;<br /> // 应用id,使用方传入<br /> pid: string;<br /> // 应用版本号<br /> release: string;<br /> // 应用环境<br /> environment: string;<br />}<br /><br />export interface TransportStructure {<br /> // 上报类别<br /> category: transportCategory;<br /> // 上报的维度信息<br /> dimension: DimensionStructure;<br /> // 上报对象(正文)<br /> context?: Object;<br /> // 上报对象数组<br /> contexts?: Array;<br /> // 捕获的sdk版本信息,版本号等...<br /> sdk: Object;<br />}<br /><br />export default class TransportInstance {<br /> private engineInstance: EngineInstance;<br /><br /> public kernelTransportHandler: Function;<br /><br /> private options: TransportParams;<br /><br /> constructor(engineInstance: EngineInstance, options: TransportParams) {<br /> this.engineInstance = engineInstance;<br /> this.options = options;<br /> this.kernelTransportHandler = this.initTransportHandler();<br /> }<br /><br /> // 格式化数据,传入部分为 category 和 context \ contexts<br /> formatTransportData = (category: transportCategory, data: Object | Array): TransportStructure => {<br /> const transportStructure = {<br /> category,<br /> dimension: this.engineInstance.dimensionInstance.getDimension(),<br /> sdk: getSdkVersion(),<br /> } as TransportStructure;<br /> if (data instanceof Array) {<br /> transportStructure.contexts = data;<br /> } else {<br /> transportStructure.context = data;<br /> }<br /> return transportStructure;<br /> };<br /><br /> // 初始化上报方法<br /> initTransportHandler = () => {<br /> return typeof navigator.sendBeacon === 'function' ? this.beaconTransport() : this.xmlTransport();<br /> };<br /><br /> // beacon 形式上报<br /> beaconTransport = (): Function => {<br /> const handler = (data: TransportStructure) => {<br /> const status = window.navigator.sendBeacon(this.options.transportUrl, JSON.stringify(data));<br /> // 如果数据量过大,则本次大数据量用 XMLHttpRequest 上报<br /> if (!status) this.xmlTransport().apply(this, data);<br /> };<br /> return handler;<br /> };<br /><br /> // XMLHttpRequest 形式上报<br /> xmlTransport = (): Function => {<br /> const handler = (data: TransportStructure) => {<br /> const xhr = new (window as any).oXMLHttpRequest();<br /> xhr.open('POST', this.options.transportUrl, true);<br /> xhr.send(JSON.stringify(data));<br /> };<br /> return handler;<br /> };<br />}<br />
上报时机
上报时机这里,一般来说:
上报优化
或许,我们想把我们的数据上报做的再优雅一点,那么我们还有什么可以优化的点呢?还是有的:
平台数据如何进行 削峰限流?
假设说,有某一个时间点,突然间流量爆炸,无数的数据向服务器访问过来,这时如果没有一个削峰限流的策略,很可能会导致机器Down掉,
所以说我们有必要去做一个削峰限流,从概率学的角度上讲,在大数据量的基础上我们对于整体数据做一个百分比的截断,并不会影响整体的一个数据比例。
简单方案-随机丢弃策略进行限流
前端做削峰限流最简单的方法是什么?没错,就是 Math.random() ,我们让用户传入一个采样率,
<p>if(Math.random()