近期主要工作内容是进校开放平台(简称开平)相关业务,开平简单来说就是一个可为第三方应用提供接入主端(例如微信、飞书)应用能力的平台,为了让第三方应用稳定可靠地接入开平,需要为其提供一些底层的基础能力,其中应用监控就是其中不可或缺的一环。目前如何在进校开平中做三方应用的监控管理还在初步预研阶段,为此了解了一下前端监控相关背景知识。鉴于我司已有一套非常完善的 APM 平台,因此下文诸多理论和源码参考自我司 APM Web SDK 源码。
1 . 数据采集:明确需要采集哪些指标以及采集的方式。
2 . 数据上报:将上一步采集的数据以一定的策略进行上报。
3 . 数据清洗、存储:服务端在接收到上报数据后需要对数据进行清洗和存储。
4 . 数据消费:数据最终会在类似 Slardar Web 这样的监控平台以图、表等形式分类别地进行可视化展示,并提供诸如监控报警等消费能力。
上述流程看似不复杂但每个环节的技术细节都非常多,本文主要关注前端视角下的数据采集和上报环节。
做好前端监控的第一步要明确哪些数据是值得我们采集的,前端环境下监控数据从大的维度上可划分成环境信息、异常数据和性能数据:
采集的监控数据一般都会设置一些通用的环境信息,这些环境信息可以提供更多的维度以帮助用户发现问题和解决问题。下图列举了一些常见的环境信息:
先抛开如何采集 JS 异常信息不谈,在采集之前如果连报错信息都不全那么即使采集到了这样的数据也是无效的。巧的是,确实存在这样一个场景:当页面加载自不同域的脚本(例如页面的 JS 托管在 CDN)中发生语法错误时,浏览器基于安全机制考虑,不会给出语法错误的细节,而是简单的 Script error.
。
因此,如果你希望自己页面的详细报错信息被监控 SDK 捕获你需要为页面中的脚本 script 添加 crossorigin= anonymous
属性,且脚本所在的服务设置 CORS 响应头 Access-Control-Allow-Origin: *
,这是 JS 异常监控的第一项准备工作。
常见的 JS 错误可分为编译时错误和运行时错误,其中编译时错误在 IDE 层面就会给出提示,一般不会流入到线上,因此编译时错误不在监控范围。
有的同学说在 Slardar 上时常看到 SyntaxError
的字样,这种情况一般都是 JSON.parse
解析出错或浏览器兼容性问题导致,属于运行时错误并非编译时错误。
对于异常监控我们主要关注 JS 运行时错误,多数场景下的处理手段如下:
错误场景 | 如何上报 |
---|---|
场景一:自行感知的同步运行时异常 | try-catch 后进行错误上报 |
场景二:没有手动 catch 的运行时异常(包括异步但不包括 promise 异常) | 通过 window.onerror 进行监听 |
场景三:自行感知的 promise 异常 | promise catch 进行捕获后进行错误上报 |
场景四:没有手动 catch 的 promise 异常 | 监听 window 对象的 unhandledrejection 事件 |
整体来看,监控 SDK 会在全局帮助用户去捕获他们没有自行感知的异常并上报,对于自行捕获的异常一般会提供手动上报接口进行上报。
假设现在已经采集到页面目前存在的 JS 异常并做了上报,最终消费时你我们当然希望看到的是错误的初始来源和调用堆栈,但实际发生报错的 JS 代码都经过各种转换混淆压缩,早已面目全非了,因此这里需要借助打包阶段生成的 SourceMap 做一个反向解析得到原始报错信息的上下文。
以 Sentry (Slardar 也有用到 Sentry)为例大致流程如下:
1 . 采集侧收集错误信息发送到监控平台服务端。
2 . 接入的业务方自行上传 SourceMap 文件到监控平台服务端,上传完成后删除本地的 SourceMap文件,且打包后的 js 文件末尾不需要 SourceMap URL,最大程度避免 SourceMap 泄漏。
3 . 服务端通过 source-map 工具结合 SourceMap 和原始错误信息定位到源码具体位置。
静态资源加载异常的捕获存在两种方式:
1 . 在出现静态资源加载异常的元素的 onerror
方法中处理。
2 . 资源加载异常触发的error
事件不会冒泡,因此使window.addEventListener('error', cb,
true
)
在事件捕获阶段进行捕获。
第一种方式侵入性太强,不够优雅,目前主流方案均采用第二种方式进行监控:
捕获静态资源加载异常
APM 平台一般会有所有静态资源加载的明细,其原理是通过 PerformanceResourceTiming API 来采集静态资源加载的基本情况,这里不做展开。
业务中的 AJAX 请求或者 Fetch 请求在不同的网络环境或者客户端环境会有不稳定的表现,这些不稳定的情况我们很难通过本地测试的途径进行测试或者感知得到,所以我们需要对 HTTP 请求进行线上监控,通过将 HTTP 请求异常上报的方式对错误日志进行采集,然后进行一系列的分析和监控。
请求异常通常泛指 HTTP 请求失败或者 HTTP 请求返回的状态码非 20X。
那么请求异常监控怎么做呢?普遍采用的方式是对原生的 XMLHttpRequest
对象和 fetch
方法进行重写,从而在代理对象中实现状态码的监听和错误上报:
重写 XMLHttpRequest 对象
重写 fetch 方法
当然了,重写上述方法后除了异常请求可以被监控到之外,正常响应的请求状态自然也能被采集到,比如 Slardar 会将对所有上报请求的持续时间进行分析从而得出慢请求的占比:
PS:如果通过 XHR 或 fetch 来上报监控数据的话,上报请求也会被被拦截,可以有选择地做一层过滤处理。
卡顿指的是显示器刷新时下一帧的画面还没有准备好,导致连续多次展示同样的画面,从而让用户感觉到页面不流畅,也就是所谓的掉帧,衡量一个页面是否卡顿的指标就是我们熟知的 FPS。
Chrome DevTool 中有一栏 Rendering 中包含 FPS 指标,但目前浏览器标准中暂时没有提供相应 API ,只能手动实现。这里需要借助 requestAnimationFrame
方法模拟实现,浏览器会在下一次重绘之前执行 rAF
的回调,因此可以通过计算每秒内 rAF
的执行次数来计算当前页面的 FPS。
通过 rAF 计算 FPS
从技术角度看 FPS 低于 60 即视为卡顿,但在真实环境中用户很多行为都可能造成 FPS 的波动,并不能无脑地把 FPS 低于 60 以下的 case 全部上报,会造成非常多无效数据,因此需要结合实际的用户体验重新定义“真正的卡顿”,这里贴一下司内 APM 平台的上报策略:
1 . 页面 FPS 持续低于预期:当前页面连续 3s FPS 低于 20。
2 . 用户操作带来的卡顿:当用户进行交互行为后,渲染新的一帧的时间超过 16ms + 100ms。
Web 页面崩溃指在网页运行过程页面完全无响应的现象,通常有两种情况会造成页面崩溃:
1 . JS 主线程出现无限循环,触发浏览器的保护策略,结束当前页面的进程。
2 . 内存不足
发生崩溃时主线程被阻塞,因此对崩溃的监控只能在独立于 JS 主线程的 Worker 线程中进行,我们可以采用 Web Worker 心跳检测的方式来对主线程进行不断的探测,如果主线程崩溃,就不会有任何响应,那就可以在 Worker 线程中进行崩溃异常的上报。这里继续贴一下 Slardar 的检测策略:
JS 主线程:
固定时间间隔(2s)向 Web Worker 发送心跳
Web Worker:
定期(2s)检查是否收到心跳。
超过一定时间(6s)未收到心跳,则认为页面崩溃。
检测到崩溃后,通过 http 请求进行异常上报。
崩溃检测
性能监控并不只是简单的监控“页面速度有多快”,需要从用户体验的角度全面衡量性能指标。(就是所谓的 RUM 指标)目前业界主流标准是 Google 最新定义的 Core Web Vitals:
可以看到最新标准中,以往熟知的 FP、FCP、FMP、TTI 等指标都被移除了,个人认为这些指标还是具备一定的参考价值,因此下文还是会将这些指标进行相关介绍。(谷歌的话不听不听)
和 Loading 相关的指标有 FP
、FCP
、FMP
和 LCP
,首先来看一下我们相对熟悉的几个指标:
一张流传已久的图
这两个指标都通过 PerformancePaintTiming API 获取:
通过 PerformancePaintTiming 获取 FP 和 FCP
下面再来看 FMP 的定义和获取方式:
FMP 的计算相对复杂,因为浏览器并未提供相应的 API,在此之前我们先看一组图:
从图中可以发现页面渲染过程中的一些规律:
1 . 在 1.577 秒,页面渲染了一个搜索框,此时已经有 60 个布局对象被添加到了布局树中。
2 . 在 1.760 秒,页面头部整体渲染完成,此时布局对象总数是 103 个。
3 . 在 1.907 秒,页面主体内容已经绘制完成,此时有 261 个布局对象被添加到布局树中从用户体验的角度看,此时的时间点就是是 FMP。
可以看到布局对象的数量与页面完成度高度相关。业界目前比较认可的一个计算 FMP 的方式就是——「页面在加载和渲染过程中最大布局变动之后的那个绘制时间即为当前页面的 FMP 」
实现原理则需要通过 MutationObserver 监听 document 整体的 DOM 变化,在回调计算出当前 DOM 树的分数,分数变化最剧烈的时刻,即为 FMP 的时间点。
至于如何计算当前页面 DOM 的分数,LightHouse 的源码中会根据当前节点深度作为变量做一个权重的计算,具体实现可以参考 LightHouse 源码。
const curNodeScore = 1 + 0.5 * depth;
const domScore = 所有子节点分数求和
上述计算方式性能开销大且未必准确,LightHouse 6.0 已明确废弃了 FMP 打分项,建议在具体业务场景中根据实际情况手动埋点来确定 FMP 具体的值,更准确也更高效。
没错,LCP (Largest Contentful Paint) 是就是用来代替 FMP 的一个性能指标 ,用于度量视口中最大的内容元素何时可见,可以用来确定页面的主要内容何时在屏幕上完成渲染。
使用 Largest Contentful Paint API 和 PerformanceObserver 即可获取 LCP 指标的值:
获取 LCP
TTI(Time To Interactive) 表示从页面加载开始到页面处于完全可交互状态所花费的时间, TTI 值越小,代表用户可以更早地操作页面,用户体验就更好。
这里定义一下什么是完全可交互状态的页面:
1 . 页面已经显示有用内容。
2 . 页面上的可见元素关联的事件响应函数已经完成注册。
3 . 事件响应函数可以在事件发生后的 50ms 内开始执行(主线程无 Long Task)。
TTI 的算法略有些复杂,结合下图看一下具体步骤:
TTI 示意图
Long Task: 阻塞主线程达 50 毫秒或以上的任务。
1 . 从 FCP 时间开始,向前搜索一个不小于 5s 的静默窗口期。(静默窗口期定义:窗口所对应的时间内没有 Long Task,且进行中的网络请求数不超过 2 个)
2 . 找到静默窗口期后,从静默窗口期向后搜索到最近的一个 Long Task,Long Task 的结束时间即为 TTI。
3 . 如果一直找到 FCP 时刻仍然没有找到 Long Task,以 FCP 时间作为 TTI。
其实现需要支持 Long Tasks API 和 Resource Timing API,具体实现感兴趣的同学可以按照上述流程尝试手动实现。
FID(First Input Delay) 用于度量用户第一次与页面交互的延迟时间,是用户第一次与页面交互到浏览器真正能够开始处理事件处理程序以响应该交互的时间。
其实现使用简洁的 PerformanceEventTiming API 即可,回调的触发时机是用户首次与页面发生交互并得到浏览器响应(点击链接、输入文字等)。
获取 FID
至于为何新的标准中采用 FID 而非 TTI,可能存在以下几个因素:
CLS(Cumulative Layout Shift) 是对在页面的整个生命周期中发生的每一次意外布局变化的最大布局变化得分的度量,布局变化得分越小证明你的页面越稳定。
听起来有点复杂,这里做一个简单的解释:
举个例子,一个占据页面高度 50% 的元素,向下偏移了 25%,那么其得分为 0.75 * 0.25,大于标准定义的 0.1 分,该页面就视为视觉上没那么稳定的页面。
使用 Layout Instability API 和 PerformanceObserver 来获取 CLS:
获取 CLS
一点感受:在翻阅诸多参考资料后,私以为性能监控是一件长期实践、以实际业务为导向的事情,业内主流标准日新月异,到底监控什么指标是最贴合用户体验的我们不得而知,对于 FMP、FPS 这类浏览器未提供 API 获取方式的指标花费大量力气去探索实现是否有足够的收益也存在一定的疑问,但毋容置疑的是从自身页面的业务属性出发,结合一些用户反馈再进行相关手段的优化可能是更好的选择。(更推荐深入了解浏览器渲染原理,写出性能极佳的页面,让 APM 同学失业
得到所有错误、性能、用户行为以及相应的环境信息后就要考虑如何进行数据上报,理论上正常使用ajax 即可,但有一些数据上报可能出现在页面关闭 (unload) 的时刻,这些请求会被浏览器的策略 cancel 掉,因此出现了以下几种解决方案:
1 . 优先使用 Navigator.sendBeacon,这个 API 就是为了解决上述问题而诞生,它通过 HTTP POST 将数据异步传输到服务器且不会影响页面卸载。
2 . 如果不支持上述 API,动态创建一个 <img / > 标签将数据通过 url 拼接的方式传递。
3 . 使用同步 XHR 进行上报以延迟页面卸载,不过现在很多浏览器禁止了该行为。
Slardar 采取了第一种方式,不支持 sendBeacon 则使用 XHR,偶尔丢日志的原因找到了。
由于监控数据通常量级都十分庞大,因此不能简单地采集一个就上报一个,需要一些优化手段:
batch 请求
的请求体:本文旨在提供一个相对体系的前端监控视图,帮助各位了解前端监控领域我们能做什么、需要做什么。此外,如果能对页面性能和异常处理有着更深入的认知,无论是在开发应用时的自我管理(减少 bug、有意识地书写高性能代码),还是自研监控 SDK 都有所裨益。
如何设计监控 SDK 不是本文的重点,部分监控指标的定义和实现细节也可能存在其他解法,实现一个完善且健壮的前端监控 SDK 还有很多技术细节,例如每个指标可以提供哪些配置项、如何设计上报的维度、如何做好兼容性等等,这些都需要在真实的业务场景中不断打磨和优化才能趋于成熟。
Google Developer
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8