背景
公司内部有比较完善的服务端监控,但是对客户端的监控却很少。再加上使用的外部产品很融入到内部的整个监控系统体系之内。因此我们自研了一套前端(客户端)监控系统。它不仅能捕捉到错误,还能用记录数据还原整个错误场景,帮助用户快速定位和解决问题。对各种页面性能统计和分析,也能帮助用户提前发现问题,找到性能瓶颈。和其他的监控系统一样,前端监控也分为三部分,它们各司其职,又相辅相成:
- 链路 Tracing
- 指标 Metrics
- 日志 Logging
开端
设计一个监控系统,先从收集数据开始,那应该收集哪些数据呢?
- 页面性能相关数据
- 错误相关数据
- 客户端数据
性能相关数据
Google凭借chrome浏览器的广泛使用,主导了web性能标准 Web指标[1],其中核心指标,侧重于用户体验的三个方面--加载性能、交互性和视觉稳定性。
- Largest Contentful Paint (LCP) :最大内容绘制,测量加载性能,
- First Input Delay (FID) :首次输入延迟,测量交互性,
- Cumulative Layout Shift (CLS) :累积布局偏移,测量视觉稳定性。
当然还需要其他指标帮助诊断特殊问题。例如:Time to First Byte 首字节时间 (TTFB) 和 First Contentful Paint 首次内容绘制 (FCP) 指标都是加载体验的重要方面,并且在诊断LCP问题方面(分别为服务器响应时间过长或阻塞渲染资源)都十分有用。
错误数据
浏览器中错误主要分为两种:Error[2] 和 DOMException[3]。最常见就是 Error,当代码运行的发出错误,会创建新的Error对象,并将其抛出。
按照捕获方式又可以分为以下几类:
- 脚本错误。可以使用 window.onerror,
- 资源加载错误。可以使用 object.onerror 和 performance 接口,
- Promise错误。可以监听 unhandledrejection 事件。
客户端数据
浏览器的相关信息和用户相关信息。其中有浏览器名称和版本、操作系统版本、webview 容器名称和版本、和网络等信息。此外还可以收集用户的名称和标识等相关信息。对这类数据的收集,主要还是用于帮助还原整个错误场景,更好的定位问题。因为涉及用户,这块数据尤其需要注意数据安全,依照最小可用原则来进行规范地收集信息。
整体设计
收集的数据都已经明确,那就开始整体设计。其中包括三个重要的环节:采集器、传输与存储和查询与分析。
采集器
前端页面交互都是基于事件机制设计的,因此采集器就是一个事件监听器,可以监听 onerror 和 unhandledrejection 事件,一旦页面发生了错误,就可以捕获它,然后发送给后端服务器。整个流程很简单,就是监听与发送错误。
直接发送错误会有什么问题呢?
如果遇到了陷入了死循环的错误,一下子抛出很多一样的错误,然后发送网络请求,之后就会触发浏览器的 TCP 并发数限制,6个 TCP 连接数很快被占用,同时还会有大量的网络任务堆积,占用大量内存。那有什么好办法呢?
- 用数组建立一个简单的队列,进行流量控制,并设置数组的长度上限,
- 对一段时间内的相同错误去重,丢弃掉多余的错误信息。
现在收集到了错误数据,有错误类型,名称,还有错误堆栈。错误堆栈格式每个浏览器还不太一样,可以参考 Tracekit[4],但是在转换完错误堆栈格式会发现,这些数据都是类似 a,b,f 这样的压缩数据,很难真正地帮忙大家排查出问题。
都是压缩数据,看不懂那怎么办呢?
那不得不提 SourceMap[5],简单来说,SourceMap 就是一个信息文件,里面存储了代码打包转换后的位置信息,实质是一个 json 描述文件,维护了打包前后的代码映射关系。因此需要如下步骤:
- 打包时通过 webpack 插件上传 SourceMap 文件,
- 存储错误数据的时候,再根据 SourceMap 文件和报错堆栈还原出源文件的报错信息。
那错误数据收集完,该收集页面性能相关数据了。同样是监听页面事件,当页面加载的时候,在客户端计算好各个性能指标。页面加载的过程中还有很多事件,比如资源的加载、页面的渲染、网络请求、和交互事件等。这些都是属于 tracing 范畴的数据,可以做成 Timeline 时间线,更好地帮助用户发现整个时序内,什么时刻发生了什么操作,各个操作之间的父子关系等,在这时序内的一系列操作就相当于一个事务,所有需要发送整个事务。
那事务什么时候开始,什么结束呢?
可以从页面加载或者页面跳转开始启动事务,但是结束时间就很难确定了。比如:
使用 onload 作为结束事件,那至少有两个问题:
- 如果有个图片加载很慢,用户就直接跳转了,那这个事务并没有结束,就只能被抛弃了,
- 很多页面都是 SPA 单页面,页面的跳转是通过 history 实现的,并不会触发 onload事件。
那改成使用 beforeunload 事件,也会有问题:
- 用户加载了页面,然后就放着不管了,
- 用户浏览完页面,用户切换到其他应用,并没有关闭,那就一直触发不了 beforeunload 事件。
那是不是一定得用这基于事件的设计呢?
可以设计一个心跳检测的机制,最好让用户能根据自身业务灵活配置。事务开始开始后,启动心跳,期间会不断有事件触发,操作进入事务,当在两个心跳期间并没有任何新增事件,将会触发事务结束。心跳可以开放给用户配置。同时为了防止事务体积过大,还可以设置事务的最长有效时间。
传输与存储
数据已经采集完毕,现在需要将数据发送到服务器,就改考虑如何传输了。
目前数据发送主要有这两种方式:
- img 请求上报,创建一个图片,在 URL 上带上参数。比如:img.src="http://www.google-analytics.com/__utm.gif?utmwv=4&utmn=769876874&..."
- Ajax 的 POST 方式,数据直接放在 body 里。
使用那种方式更合适呢?
图片 image 本质是一个 GET 请求,对上报数据量有一定的限制,不同浏览器标准不一样,一般为2~8kb。这在事务比较大的情况下,很容易超出,更适合简单数据的收集场景。如果数据量偶尔超出,可以考虑数据分割,但是这又增加了系统的复杂度。
Ajax 的 POST 方式,直接使用 JSON 数据发送,数据量没有了限制,但是发现每次会多发一个请求。因为使用了 CORS[6] 去解决跨域问题,那浏览器每次用 OPTIONS 方法对服务器发起一个预检请求。那如何避免这个预检请求呢?那就需要将这个请求改成 CORS 简单请求,设置 Content-Type: text/plain。
数据可以发送了,但是体积可以优化吗?
既然都需要转成 Text,那为什么要一定用 JSON 呢?完全可以选择性能更好,体积更小的通信协议。比如使用 Protobuf[7],客户端和服务端共同维护一份 proto 文件,也方便后期的升级管理。Proto 数据字段的具体设计需要根据采集的数据确定,其中需要注意的服务器接受的数据除了来自于浏览器,还可能是其他客户端,比如:APP。Proto 输出的二进制数据,正如上面提到的 CORS 问题,需要将二进制转成 text,比如使用 Base64,而 APP 并不会存在这个问题。
已经进入了传输的过程一定就安全了吗?比如:浏览器正使用 Ajax 的 POST 方式发送数据,但是就在这个时候页面跳转,刷新或者被关闭了。数据不是就直接丢失了。
发送数据的时候,页面关闭了怎么办?
提起页面关闭时候的网络请求问题,那是不是就准备要上 navigation.sendBeacon?其实还真没有必要,Ajax 的方式可以选用 fetch 做为数据的发送方法,fetch 接口有个选项:keepalive[8] 就是专门用来解决这个问题的。
最后数据存到哪里呢?
数据都是一条条记录,并没有多少对象间的关系,完全可以选择文档型数据库或者面向日志的数据库。比如,选用阿里云 SLS 作为数据存储和分析。如果考虑到接入的客户端比较多,请求量很大,可能还需要接入一个消息队列系统,比如:Kafka。
查询与分析
用户需要查看页面的性能,报错信息,Api 请求,客户端信息等等。如果存储的时候都是在一张表里,将很难进行多维分析。因此要设计成多个表,比如拆分成:transaction 事务表,exception 错误表,span 操作表等等。
- 指标分析利用 SLS 强大的查询和分析能力,可以统计出每个 transaction 的指标,能及时发现性能有问题的页面。然后在众多的相似数据中找到具有代表性的一个事务,查看它的详情。
- 事务分析查找出这个事务中的所有操作,并按照时间顺序进行整合,然后绘制出一个时间线。这个时间线里可以查看到各种操作,比如资源加载,渲染长任务,网络请求等等。比如:可以根据基准线查看1秒的页面状态,找出性能瓶颈。
- 错误分析及时发现页面的报错,特别对跨端的 H5 页面很有帮助。在特定的浏览器或者webview 容器内的报错,能更快更好的还原现场,查看报错信息。
终章
至此,整个监控系统设计完毕,我们一起再来回顾一遍,看一下监控三要素。
- 链路 Tracing:transaction 的整个事务的收集过程,集合了各种 span,满足了 tracing 的链路与关联,
- 指标 Metrics: 页面指标里 web-vitals 的收集,正对应了指标的聚合与统计,
- 日志 Logging: exception 的错误信息,客户端信息等的收集,这不就是用日志来还原现场。
监控系统梳理完毕,当然还有很多设计需要优化,很多细节问题还欠考虑。生产环境中建议基于一些成熟的库进行开发,比如我们就是基于 sentry 核心库进行的二次开发。
参考资料
[1]
Web指标: https://web.dev/i18n/zh/vitals/
[2]
Error: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error
[3]
DOMException: https://developer.mozilla.org/zh-CN/docs/Web/API/DOMException
[4]
Tracekit: https://github.com/csnover/TraceKit
[5]
SourceMap: https://developer.chrome.com/blog/sourcemaps/
[6]
CORS: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS
[7]
Protobuf: https://protobuf.dev/
[8]
keepalive: https://developer.mozilla.org/en-US/docs/Web/API/fetch#keepalive
关于作者
陈华嗣,来自技术平台部
来源:微信公众号:SQB Blog
出处:https://mp.weixin.qq.com/s/6rup2xv3x3LInjTFLqaI_w
本文暂时没有评论,来添加一个吧(●'◡'●)