字节前端监控实践-前端监控 SDK 的一些技术要点原理分析
一个完整的前端监控平台包括三个部分:数据采集与上报、数据整理和存储、数据展示。
本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的大纲,大家可以先大致了解一下:


仅看理论知识是比较难以理解的,为此我结合本文要讲的技术要点写了一个简单的简控 SDK,可以用它来写一些简单的 DEMO,帮助加深理解。再结合本文一起阅读,效果更好。
性能数据采集
chrome 开发团队提出了一系列用于检测网页性能的指标:
- FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间
- FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间
- LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间
- CLS(layout-shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数
这四个性能指标都需要通过 PerformanceObserver 来获取(也可以通过
performance.getEntriesByName() 获取,但它不是在事件触发时通知的)。PerformanceObserver 是一个性能监测对象,用于监测性能度量事件。
简述
Slardar 前端监控自18年底开始建设以来,从仅仅作为 Sentry 的替代,经历了一系列迭代和发展,目前做为一个监控解决方案,已经应用到抖音、西瓜、今日头条等众多业务线。
据21年下旬统计,Slardar 前端监控(Web + Hybrd) 工作日晚间峰值 qps 300w+,日均处理数据超过千亿条。

本文,我将针对在这一系列的发展过程中,字节内部监控设计和迭代遇到的落地细节设计问题,管中窥豹,向大家介绍我们团队所思考和使用的解决方案。
他们主要围绕着前端监控体系建设的一些关键问题展开。也许大家对他们的原理早已身经百战见得多了,不过当实际要落地实现的时候,还是有许多细节可以再进一步琢磨的。

如何做好 JS 异常监控
JS 异常监控本质并不复杂,浏览器早已提供了全局捕获异常的方案。
javascript复制代码window.addEventListener('error', (err) => {
report(normalize(err))
});
window.addEventListener('unhandledrejection', (rejection) => {
report(normalize(rejection))
});
但捕获到错误仅仅只是相关工作的第一步。在我看来,JS 异常监控的目标是:
- 开发者迅速感知到 JS 异常发生
- 通过监控平台迅速定位问题
- 开发者能够高效的处理问题,并统计,追踪问题的处理进度
在异常捕获之外,还包括堆栈的反解与聚合,处理人分配和报警这几个方面。

堆栈反解: Sourcemap
大家都知道 Script 脚本在浏览器中都是以明文传输并执行,现代前端开发方案为了节省体积,减少网络请求数,不暴露业务逻辑,或从另一种语言编译成 JS。都会选择将代码进行混淆和压缩。在优化性能,提升用户体验的同时,也为异常的处理带来了麻烦。
在本地开发时,我们通常可以清楚的看到报错的源代码堆栈,从而快速定位到原始报错位置。而线上的代码经过压缩,可读性已经变得非常糟糕,上报的堆栈很难对应到原始的代码中。 Sourcemap 正是用来解决这个问题的。
简单来说,Sourcemap 维护了混淆后的代码行列到原代码行列的映射关系,我们输入混淆后的行列号,就能够获得对应的原始代码的行列号,结合源代码文件便可定位到真实的报错位置。
Sourcemap 的解析和反解析过程涉及到 VLQ 编码,它是一种将代码映射关系进一步压缩为类base64编码的优化手段。

在实际应用中,我们可以把它直接当成黑盒,因为业界已经为我们提供了方便的解析工具。下面是一个利用 mozila 的 sourcemap 库进行反解的例子。

以上代码执行后通常会得到这样的结果,实际上我们的在线反解服务也就是这样实现的

当然,我们不可能在每次异常发生后,才去生成 sourcemap,在本地或上传到线上进行反解。这样的效率太低,定位问题也太慢。另一个方案是利用 sourcemappingURL 来制定sourcemap 存放的位置,但这样等于将页面逻辑直接暴露给了网站使用者。对于具有一定规模和保密性的项目,这肯定是不能接受的。
ruby复制代码 //# sourceMappingURL=http://example.com/path/hello.js.map
为了解决这个问题,一个自然的方案便是利用各种打包插件或二进制工具,在构建过程中将生成的 sourcemap 直接上传到后端。Sentry 就提供了类似的工具,而字节内部也是使用相似的方案。


通过如上方案,我们能够让用户在发版构建时就可以完成 sourcemap 的上传工作,而异常发生后,错误可以自动完成解析。不需要用户再操心反解相关的工作了。
堆栈聚合策略
当代码被成功反解后,用户已经可以看到这一条错误的线上和原始代码了,但接下来遇到的问题则是,如果我们只是上报一条存一条,并且给用户展示一条错误,那么在平台侧,我们的异常错误列表会被大量的重复上报占满,
对于错误类型进行统计,后续的异常分配操作都无法正常进行。
在这种情况下,我们需要对堆栈进行分组和聚合。也就是,将具有相同特征的错误上报,归类为统一种异常,并且只对用户暴露这种聚合后的异常。

堆栈怎么聚合效果才好呢?我们首先可以观察我们的JS异常所携带的信息,一个异常通常包括以下部分

- name: 异常的 Type,例如 TypeError, SyntaxError, DOMError
- Message:异常的相关信息,通常是异常原因,例如 a.b is not defined.
- Stack (非标准)异常的上下文堆栈信息,通常为字符串
那么聚合的方案自然就出来了,利用某种方式,将 error 相关的信息利用提取为 fingerprint,每一次上报如果能够获得相同的 fingerprint,它们就可以归为一类。那么问题进一步细化为:如何利用 Error 来保证 fingerprint 的区分尽量准确呢?

如果跟随标准,我们只能利用 name + message 作为聚合依据。但在实践过程中,我们发现这是远远不够的。如上所示,可以看到这两个文件发生的位置是完全不同的,来自于不同的代码段,但由于我们只按照 name + message 聚合。它们被错误聚合到了一起,这样可能造成我们修复了其中一个错误后。误以为相关的所有异常都被解决。
因此,很明显我们需要利用非标准的 error.stack 的信息来帮我们解决问题了。在这里我们参考了 Sentry 的堆栈聚合策略:

除了常规的 name, message, 我们将反解后的 stacktrace 进一步拆分为一系列的 Frame,每一个 Frame 内我们重点关注其调用函数名,调用文件名以及当前执行的代码行(图中的context_line)。

Sentry 将每一个拆分出的部分都称为一个 GroupingComponent,当堆栈反解完毕后,我们首先自上而下的递归检测,并自下而上的生成一个个嵌套的 GroupingComponent。最后,在顶层调用 GroupingComponent.getHash() 方法, 得到一个最终的哈希值,这就是我们最终求得的 fingerprint。

相较于message+name, 利用 stacktrace 能够更细致的提取堆栈特征,规避了不同文件下触发相同 message 的问题。因此获得的聚合效果也更优秀。这个策略目前在字节内部的工作效果良好,基本上能够做到精确的区分各类异常而不会造成混淆和错误聚合。
处理人自动分配策略
异常已经成功定位后,如果我们可以直接将异常分配给这行代码的书写者或提交者,可以进一步提升问题解决的效率,这就是处理人自动分配所关心的,通常来说,分配处理人依赖 git blame 来实现。

一般的团队或公司都会使用 Gitlab / Github 作为代码的远端仓库。而这些平台都提供了丰富的 open-api 协助用户进行blame,
我们很自然的会联想到,当通过 sourcemap 解出原始堆栈路径后,如果可以结合调用 open-api,获得这段代码所在文件的blame历史, 我们就有机会直接在线上确定某一行的可能的 author / commitor 究竟是谁。从而将这个异常直接分配给他。
思路出来了,那么实际怎么落地呢?
我们需要几个信息
- 线上报错的项目对应的源代码仓库名,如 toutiao-fe/slardar
- 线上报错的代码发生的版本,以及与此版本关联的 git commit 信息,为什么需要这些信息呢?
默认用来 blame 的文件都是最新版本,但线上跑的不一定是最新版本的代码。不同版本的代码可能发生行的变动,从而影响实际代码的行号。如果我们无法将线上版本和用来 blame 的文件划分在统一范围内,则很有可能自动定位失败。
因此,我们必须找到一种方法,确定当前 blame 的文件和线上报错的文件处于同一版本。并且可以直接通过版本定位到关联的源代码 commit 起止位置。这样的操作在 Sentry 的官方工具 Sentry-Cli 中亦有提供。字节内部同样使用了这种方案。
通过 相关的 二进制工具,在代码发布前的脚本中提供当前将要发布的项目的版本和关联的代码仓库信息。同时在数据采集侧也携带相同的版本,线上异常发生后,我们就可以通过线上报错的版本找到原始文件对应的版本,从而精确定位到需要哪个时期的文件了。

异常报警
当异常已经成功反解和聚合后,当用户访问监控平台,已经可以观察并处理相关的错误,不过到目前为止,异常的发生还无法触及开发者,问题的解决依然依靠“走查”行为。这样的方案对严重的线上问题依然是不够用,因此我们还需要主动通知用户的手段,这就是异常报警。
在字节内部,报警可以分为宏观报警,即针对错误指标的数量/比率的报警,以及微观报警,即针对新增异常的报警。

宏观报警
宏观报警是数量/比率报警, 它只是统计某一类指标是否超出了限定的阈值,而不关心它具体是什么。因此默认情况下它并不会告诉你报警的原因。只有通过归因维度或者下文会提到的 微观(新增异常)报警 才能够知晓引发报警的具体原因
关于宏观报警,我们有几个关键概念
- 第一是样本量,用户数阈值: 在配置比率指标时。如果上报量过低,可能会造成比率的严重波动,例如错误率 > 20%, 的报警下,如果 JS 错误数从 0 涨到 1, 那就是比率上涨到无限大从而造成没有意义的误报。如果不希望被少量波动干扰,我们设置了针对错误上报量和用户数的最低阈值,例如只有当错误影响用户数 > 5 时,才针对错误率变化报警。

- 第二是归因维度: 对于数量,比率报警,仅仅获得一个异常指标值是没什么意义的,因为我们无法快速的定位问题是由什么因素引发的,因此我们提供了归因维度配置。例如,通过对 JS 异常报警配置错误信息归因,我们可以在报警时获得引发当前报警的 top3 关键错误和增长最快的 top3 错误信息。
- 第三是时间窗口,报警运行频率: 如上文所说,报警是数量,比率报警,而数量,比率一定有一个统计范围,这个就是通过 时间窗口 来确定的。而报警并不是时时刻刻盯着我们的业务数据的,可以理解为利用一个定时器来定期检查 时间窗口 内的数据是否超出了我们定义的阈值。而这个定时器的间隔时间,就是 报警运行频率。通过这种方式,我们可以做到类实时的监测异常数据的变化,但又没有带来过大的资源开销。
微观报警(新增异常)
相较于在意宏观数量变化的报警,新增异常在意每一个具体问题,只要此问题是此前没有出现过的,就会主动通知用户。
同时,宏观报警是针对数据的定时查找,存在运行频率和时间窗口的限制,实时性有限。微观报警是主动推送的,具有更高的实时性。
微观报警适用于发版,灰度等对新问题极其关注,并且不方便在此时专门配置相关数量报警的阶段。
如何判断“新增”?
我们在 异常自动分配章节讲到了,我们的业务代码都是可以关联一个版本概念的。实际上版本不仅和源代码有关,也可以关联到某一类错误上。
在这里我们同样也可以基于版本视角判断“新增错误”。
对于新增异常的判断,针对两种不同场景做了区分
- 对于指定版本、最新版本的新增异常报警,我们会分析该报警的 fingerprint 是否为该版本代码中首次出现。
- 而对于全体版本,我们则将"首次”的范围增加了时间限制,因为对于某个错误,如果在长期没有出现后又突然出现,他本身还是具有通知的意义的,如果不进行时间限制,这个错误就不会通知到用户,可能会出现信息遗漏的情况。
如何做好性能监控?
如果说异常处理是前端监控体系60分的分界线,那么性能度量则是监控体系能否达到90分的关键。一个响应迟钝,点哪儿卡哪儿的页面,不会比点开到处都是报错的页面更加吸引人。页面的卡顿可能会直接带来用户访问量的下降,进而影响背后承载的服务收入。因此,监控页面性能并提升页面性能也是非常重要的。针对性能监控,我们主要关注指标选取,品质度量 、瓶颈定位三个关键问题。
指标选取
指标选取依然不是我们今天文章分享的重点。网上关于 RUM 指标,Navigation 指标的介绍和采集方式已经足够清晰。通常分为两个思路:
- RUM (真实用户指标) -> 可以通过 Web Vitals (github.com/GoogleChrom…*
- 页面加载指标 -> NavigationTiming (ResourceTiming + DOM Processing + Load) 可以通过 MDN 相关介绍学习。这里都不多赘述。
瓶颈定位
收集到指标只是问题的第一步,接下来的关键问题便是,我们应该如何找出影响性能问题的根因,并且针对性的进行修复呢?
慢会话 + 性能时序分析
如果你对“数据洞察/可观测性”这个概念有所了解,那么你应该对 Kibana 或 Datadog 这类产品有所耳闻。在 kibana 或 Datadog 中都能够针对每一条上传的日志进行详细的追溯。和详细的上下文进行关联,让用户的体验可被观测,通过多种筛选找到需要用户的数据。
在字节前端的内部建设中,我们参考了这类数据洞察平台的消费思路。设计了数据探索能力。通过数据探索,我们可以针对用户上报的任意维度,对一类日志进行过滤,而不只是获得被聚合过的列表信息数据。这样的消费方式有什么好处呢?
- 我们可以直接定位到一条具体日志,找到一个现实的 data point 来分析问题
- 这种视图的状态是易于保存的,我们可以将找到的数据日志通过链接发送给其他人,其他用户可以直接还原现场。
对于性能瓶颈,在数据探索中,可以轻松通过针对某一类 PV 上报所关联的性能指标进行数值筛选。也可以按照某个固定时段进行筛选,从而直接获得响应的慢会话。这样的优势在于我们不用预先设定一个“慢会话阈值”,需要哪个范围的数据完全由我们自己说了算。例如,通过对 FCP > 3000ms 进行筛选,我们就能够获得一系列 FCP > 3s 的 PV 日志现场。

在每次 PV 上报后,我们会为数据采集的 SDK 设置一个全局状态,比如 view_id, 只要没有发生新的页面切换,当前的 view_id 就会保持不变。
而后续的一系列请求,异常,静态资源上报就可以通过 view_id 进行后端的时序串联。形成一张资源加载瀑布图。在瀑布图中我们可以观察到各类性能指标和静态资源加载,网络请求的关系。从而检测出是否是因为某些不必要的或者过大的资源,请求导致的页面性能瓶颈。这样的瀑布图都是一个个真实的用户上报形成的,相较于统计值产生的甘特图,更能帮助我们解决实际问题。

结合Longtask + 用户行为分析
通过指标过滤慢会话,并且结合性能时序瀑布图分析,我们能够判断出当前页面中是否存在由于网络或过大资源因素导致的页面加载迟缓问题
但页面的卡顿不一定全是由网络因素造成的。一个最简单的例子。当我在页面的 head 中插入一段非常耗时的同步脚本(例如 while N 次),则引发页面卡顿的原因就来自于代码执行而非资源加载。

针对这种情况,浏览器同样提供了 Longtask API 供我们收集这类占据主线程时间过长的任务。
同样的,我们将这类信息一并收集,并通过上文提到的 view_id 串联到一次页面访问中。用户就可以观察到某个性能指标是否受到了繁重的主线程加载的影响。若有,则可利用类似 lighthouse 的合成监控方案集中检查对应页面中是否存在相关逻辑了。

受限于浏览器所收集到的信息,目前的 longtask 我们仅仅只能获得它的执行时间相关信息。而无法像开发者面板中的 performance 工具一样准确获取这段逻辑是由那段代码引发的。如果我们能够在一定程度上收集到longtask触发的上下文,则可定位到具体的慢操作来源。
此外,页面的卡顿不一定仅仅发生在页面加载阶段,有时页面的卡顿会来自于页面的一次交互,如点击,滚动等等。这类行为造成的卡顿,仅仅依靠 RUM / navigation 指标是无法定位的。如果我们能够通过某种方式(在PPT中已经说明),对操作行为计时。并将操作计时范围内触发的请求,静态资源和longtask上报以同样的瀑布图方式收敛到一起。则可以进一步定位页面的“慢操作”,从而提升页面交互体验。
如下图所示,我们可以检查到,点击 slardar_web 这个按钮 / 标签带来了一系列的请求和 longtask,如果这次交互带来了一定的交互卡顿。我们便可以集中修复触发这个点击事件所涉及的逻辑来提升页面性能表现。

品质度量
当我们采集到一个性能指标后,针对这样一个数字,我们能做什么?
我们需要结论:好还是不好?
实际上我们通常是以单页面为维度来判定指标的,以整站视角来评判性能的优劣的置信度会受到诸多因素影响,比如一个站点中包含轻量的登陆页和功能丰富的中后台,两者的性能要求和用户的容忍度是不一致的,在实际状况下两者的绝对性能表现也是不一致的。而简单平均只会让我们观察不到重点,页面存在的问题数据也可能被其他的页面拉平。
其次,指标只是冷冰冰的数据,而数据想要发挥作用,一定需要参照系。比如,我仅仅提供 FMP = 4000ms,并不能说明这个页面的性能就一定需要重点关注,对于逻辑较重的PC页面,如数据平台,在线游戏等场景,它可能是符合业务要求的。而一个 FMP = 2000ms的页面则性能也不一定好,对于已经做了 SSR 等优化的回流页。这可能远远达不到我们的预期。
一个放之四海而皆准的指标定义是不现实的。不同的业务场景有不同的性能基准要求。我们可以把他们转化为具体的指标基准线。
通过对于现阶段线上指标的分布,我们可以可以自由定义当前站点场景下针对某个指标,怎样的数据是好的,怎样的数据是差的。
基准线应用后,我们便可以在具体的性能数据产出后,直观的观察到,在什么阶段,某些指标的表现是不佳的,并且可以集中针对这段时间的性能数据日志进行排查。

一个页面总是有多个性能指标的,现在我们已经知道了单个性能指标的优劣情况,如何整体的判断整个页面,乃至整个站点的性能状况,落实到消费侧则是,我们如何给一个页面的性能指标评分?
如果有关注过 lighthouse 的同学应该对这张图不陌生。

lighthouse 通过 google 采集到的大量线上页面的性能数据,针对每一个性能指标,通过对数正态分布将其指标值转化成 百分制分数。再通过给予每个指标一定的权重(随着 lighthouse 版本更迭), 计算出该页面性能模块的一个“整体分数”。在即将上线的“品质度量”能力中,我们针对 RUM 指标,异常指标,以及资源加载指标均采取了类似的方案。

我们通常可以给页面的整体性能分数再制定一个基准分数,当上文所述的性能得分超过分数线,才认为该页面的性能水平是“达标”的。而整站整体的达标水平,则可以利用整站达标的子页面数/全站页面数来计算,也就是达标率,通过达标率,我们可以非常直观的迅速找到需要优化的性能页面,让不熟悉相关技术的运营,产品同学也可以定期巡检相关页面的品质状况。
如何做好请求 / 静态资源监控?
除了 JS 异常和页面的性能表现以外,页面能否正常的响应用户的操作,信息能否正确的展示,也和 api 请求,静态资源息息相关。表现为 SLA,接口响应速度等指标。现在主流的监控方案通常是采用手动 hook相关 api 和利用 resource timing 来采集相关信息的。
手动打点通常用于请求耗时兜底以及记录请求状态和请求响应相关信息。
- 对于 XHR 请求: 通过 hook XHR 的 open 和 send 方法, 获取请求的参数,在 onreadystatechange 事件触发时打点记录请求耗时。
- javascript复制代码 // 记录 method hookObjectProperty(XMLHttpRequest.prototype, 'open', hookXHROpen); // hook onreadystateChange,调用前后打点计算 hookObjectProperty(XMLHttpRequest.prototype, 'send', hookXHRSend);
- 对于fetch请求,则通过 hook Fetch 实现
- csharp复制代码hookObjectProperty(global, 'fetch', hookFetch)
- 第二种则是 resourceTiming 采集方案
- 静态资源上报:
- pageLoad 前:通过 performance.getEntriesByType 获取 resource 信息
- pageLoad后:通过 PerformanceObserver 监控 entryType 为 resource 的资源
- javascript复制代码const callback = (val, i, arr, ob) => // ... 略 const observer = new PerformanceObserver((list, ob) => { if (list.getEntries) { list.getEntries().forEach((val, i, arr) => callback(val, i, arr, ob)) } else { onFail && onFail() } // ... }); observer.observe({ type: 'resource', buffered: false })
手动打点的优势在于无关兼容性,采集方便,而 Resource timing 则更精准,并且其记录中可以避开额外的事件队列处理耗时
如何理解和使用 resource timing 数据?
我们现在知道 ResourceTiming 是更能够反映实际资源加载状况的相关指标,而在工作中,我们经常遇到前端请求上报时间极长而后端对应接口日志却表现正常的情况。这通常就可能是由使用单纯的打点方案计算了太多非服务端因素导致的。影响一个请求在前端表现的因素除了服务端耗时以外,还包括网络,前端代码执行排队等因素。我们如何从 ResourceTiming 中分离出这些因素,从而更好的对齐后端口径呢?
第一种是 Chrome 方案(阿里的 ARMS 也采用的是这种方案):
它通过将线上采集的 ResoruceTiming 和 chrome timing 面板的指标进行类比还原出一个近似的各部分耗时值。他的简单计算方式如图所示。


不过 chrome 实际计算 timing 的方式不明,这种近似的方式不一定能够和 chrome 的面板数据对的上,可能会被用户质疑数据不一致。
第二种则是标准方案: 规范划分阶段,这种划分是符合 W3C 规范的格式,其优势便在于其通用性好,且数据一定是符合要求的而不是 chrome 方案那种“近似计算”。不过它的缺陷是阶段划分还是有点太粗了,比如用户无法判断出浏览器排队耗时,也无法完全区分网络下载和下载完成后的资源加载阶段。只是简单的划分成了 Request / Response 阶段,给用户理解和分析带来了一定成本
在字节内部,我们是以标准方案为主,chrome方案为辅的,用户可以针对自己喜好的那种统计方式来对齐指标。通常来说,和服务端对齐耗时阶段可以利用标准方案的request阶段减去severtiming中的cdn,网关部分耗时来确定。

接下来我们再谈谈采集 SDK 的设计。
SDK 如何降低侵入,减少用户性能损耗?体积控制和灵活使用可以兼得吗?
常需要尽早执行,其资源加载通常也会造成一定的性能影响。更大的资源加载可能会导致更慢的 Load,LCP,TTI 时间,影响用户体验。

为了进一步优化页面加载性能,我们采用了 JS Snippets 来实现异步加载 + 预收集。
- 异步加载主要逻辑
首先,如果通过 JS 代码创建 script 脚本并追加到页面中,新增的 script 脚本默认会携带 async 属性,这意味着这这部分代码将通过async方式延迟加载。下载阶段不会阻塞用户的页面加载逻辑。从而一定程度的提升用户的首屏性能表现。

- 预收集
试想一下我们通过 npm 或者 cdn 的方式直接引入监控代码,script必须置于业务逻辑最前端,这是因为若异常先于监控代码加载发生,当监控代码就位时,是没有办法捕获到历史上曾经发生过的异常的。但将script置于前端将不可避免的对用户页面造成一定阻塞,且用户的页面可能会因此受到我们监控 sdk 服务可用性的影响。
为了解决这个问题,我们可以同步的加载一段精简的代码,在其中启动 addEventListener 来采集先于监控主要逻辑发生的错误。并存储到一个全局队列中,这样,当监控代码就位,我们只需要读取全局队列中的缓存数据并上报,就不会出现漏报的情况了。

更进一步:事件驱动与插件化
方案1. 2在大部分情况下都已经比较够用了,但对于字节的某些特殊场景却还不够。由于字节存在大量的移动端页面,且这些页面对性能极为敏感。因而对于第三方库的首包体积要求非常苛刻,同时,也不希望第三方代码的执行占据主线程太长时间。
此外,公司内也有部分业务场景特殊,如 node 场景,小程序场景,electron,如果针对每一种场景,都完全重新开发一套新的监控 SDK,有很大的人力重复开发的损耗。
如果我们能够将 SDK 的框架逻辑做成平台无关的,而各个数据监控,收集方案都只是以插件形式存在,那么这个 SDK 完全是可插拔的,类似 Sentry 所使用的 integration 方案。用户甚至可以完全不使用任何官方插件,而是通过自己实现相关采集方案,来做到项目的定制化。
关于框架设计可以参见下图



- 我们把整个监控 SDK 看作一条流水线(Client),接受的是用户配置(config)(通过 ConfigManager),收集和产出的是具体事件(Event, 通过 Plugins)。流水线是平台无关的,它不关心处理的事件是什么,也不关心事件是从哪来的。它其实是将这一系列的组件交互都抽象为 client 上的事件,从而使得数据采集器能够介入数据流转的每个阶段
Client 通过 builder 包装事件后,转运给 Sender 负责批处理,Sender 最终调用 Transporter 上报。Transporter 是平台强相关的,例如 Web 使用 xhr 或 fetch,node 则使用 request 等。 同时,我们利用生命周期的概念设置了一系列的钩子,可以让用户可以在适当阶段处理流水线上的事件。例如利用 beforeSend 钩子去修改即将被发送的上报内容等。


当整体的框架结构设计完后,我们就可以把视角放到插件上了。由于我们将框架设置为平台无关的,它本身只是个数据流,有点像是一个精简版的 Rx.js。而应用在各个平台上,我们只需要根据各个平台的特性设计其对应的采集或数据处理插件。
插件方案某种意义上实现了 IOC,用户不需要关心事件怎么处理,传入的参数是哪里来的,只需要利用传入的参数去获取配置,启动自己的插件等。如下这段JS采集器代码,开发插件时,我们只需要关心插件自身相关的逻辑,并且利用传入 client 约定的相关属性和方法工作就可以了。不需要关心 client 是怎么来的,也不用关心 client 什么时候去执行它。

当我们写完了插件之后,它要怎么才能被应用在数据采集和处理中呢?为了达成降低首包大小的目标,我们将插件分为同步和异步两种加载方式。
- 可以预收集的监控代码都不需要出现在首包中,以异步插件方式接入
- 无法做到预收集的监控代码以同步形式和首包打在一起,在源码中将client传入,尽早启动,保证功能稳定。

3. 异步插件采用约定式加载,用户在使用层面是完全无感的。我们通过主包加载时向在全局初始化注册表和注册方法,在读取用户配置后,拉取远端插件加载并利用全局注册方法获取插件实例,最后传入我们的 client 实现代码执行。

经过插件化和一系列 SDK 的体积改造后,我们的sdk 首包体积降低到了从63kb 降低到了 34 kb。

FP
FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间。其实把 FP 理解成白屏时间也是没问题的。
测量代码如下:
js复制代码const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
observer.disconnect()
}
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
// buffered 属性表示是否观察缓存数据,也就是说观察代码添加时机比事情触发时机晚也没关系。
observer.observe({ type: 'paint', buffered: true })
通过以上代码可以得到 FP 的内容:
js复制代码{
duration: 0,
entryType: "paint",
name: "first-paint",
startTime: 359, // fp 时间
}
其中 startTime 就是我们要的绘制时间。
FCP
FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,"内容"指的是文本、图像(包括背景图像)、<svg>元素或非白色的<canvas>元素。

为了提供良好的用户体验,FCP 的分数应该控制在 1.8 秒以内。

测量代码:
js复制代码const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
observer.disconnect()
}
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })
通过以上代码可以得到 FCP 的内容:
js复制代码{
duration: 0,
entryType: "paint",
name: "first-contentful-paint",
startTime: 459, // fcp 时间
}
其中 startTime 就是我们要的绘制时间。
LCP
LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间。LCP 指标会根据页面首次开始加载的时间点来报告可视区域内可见的最大图像或文本块完成渲染的相对时间。
一个良好的 LCP 分数应该控制在 2.5 秒以内。

测量代码:
js复制代码const entryHandler = (list) => {
if (observer) {
observer.disconnect()
}
for (const entry of list.getEntries()) {
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })
通过以上代码可以得到 LCP 的内容:
js复制代码{
duration: 0,
element: p,
entryType: "largest-contentful-paint",
id: "",
loadTime: 0,
name: "",
renderTime: 1021.299,
size: 37932,
startTime: 1021.299,
url: "",
}
其中 startTime 就是我们要的绘制时间。element 是指 LCP 绘制的 DOM 元素。
FCP 和 LCP 的区别是:FCP 只要任意内容绘制完成就触发,LCP 是最大内容渲染完成时触发。

LCP 考察的元素类型为:
- <img>元素
- 内嵌在<svg>元素内的<image>元素
- <video>元素(使用封面图像)
- 通过url()函数(而非使用CSS 渐变)加载的带有背景图像的元素
- 包含文本节点或其他行内级文本元素子元素的块级元素。
CLS
CLS(layout-shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数。
布局偏移分数的计算方式如下:
复制代码布局偏移分数 = 影响分数 * 距离分数
影响分数测量不稳定元素对两帧之间的可视区域产生的影响。
距离分数指的是任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。
CLS 就是把所有布局偏移分数加起来的总和。
当一个 DOM 在两个渲染帧之间产生了位移,就会触发 CLS(如图所示)。


上图中的矩形从左上角移动到了右边,这就算是一次布局偏移。同时,在 CLS 中,有一个叫会话窗口的术语:一个或多个快速连续发生的单次布局偏移,每次偏移相隔的时间少于 1 秒,且整个窗口的最大持续时长为 5 秒。

例如上图中的第二个会话窗口,它里面有四次布局偏移,每一次偏移之间的间隔必须少于 1 秒,并且第一个偏移和最后一个偏移之间的时间不能超过 5 秒,这样才能算是一次会话窗口。如果不符合这个条件,就算是一个新的会话窗口。可能有人会问,为什么要这样规定?其实这是 chrome 团队根据大量的实验和研究得出的分析结果 Evolving the CLS metric。
CLS 一共有三种计算方式:
- 累加
- 取所有会话窗口的平均数
- 取所有会话窗口中的最大值
累加
也就是把从页面加载开始的所有布局偏移分数加在一起。但是这种计算方式对生命周期长的页面不友好,页面存留时间越长,CLS 分数越高。
取所有会话窗口的平均数
这种计算方式不是按单个布局偏移为单位,而是以会话窗口为单位。将所有会话窗口的值相加再取平均值。但是这种计算方式也有缺点。

从上图可以看出来,第一个会话窗口产生了比较大的 CLS 分数,第二个会话窗口产生了比较小的 CLS 分数。如果取它们的平均值来当做 CLS 分数,则根本看不出来页面的运行状况。原来页面是早期偏移多,后期偏移少,现在的平均值无法反映出这种情况。
取所有会话窗口中的最大值
这种方式是目前最优的计算方式,每次只取所有会话窗口的最大值,用来反映页面布局偏移的最差情况。详情请看 Evolving the CLS metric。
下面是第三种计算方式的测量代码:
js复制代码let sessionValue = 0
let sessionEntries = []
const cls = {
subType: 'layout-shift',
name: 'layout-shift',
type: 'performance',
pageURL: getPageURL(),
value: 0,
}
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0]
const lastSessionEntry = sessionEntries[sessionEntries.length - 1]
// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (
sessionValue
&& entry.startTime - lastSessionEntry.startTime < 1000
&& entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value
sessionEntries.push(formatCLSEntry(entry))
} else {
sessionValue = entry.value
sessionEntries = [formatCLSEntry(entry)]
}
// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > cls.value) {
cls.value = sessionValue
cls.entries = sessionEntries
cls.startTime = performance.now()
lazyReportCache(deepCopy(cls))
}
}
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'layout-shift', buffered: true })
在看完上面的文字描述后,再看代码就好理解了。一次布局偏移的测量内容如下:
js复制代码{
duration: 0,
entryType: "layout-shift",
hadRecentInput: false,
lastInputTime: 0,
name: "",
sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
startTime: 1176.199999999255,
value: 0.000005752046026677329,
}
代码中的 value 字段就是布局偏移分数。
DOMContentLoaded、load 事件
当纯 HTML 被完全加载以及解析时,DOMContentLoaded 事件会被触发,不用等待 css、img、iframe 加载完。
当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发 load 事件。
虽然这两个性能指标比较旧了,但是它们仍然能反映页面的一些情况。对于它们进行监听仍然是必要的。
js复制代码import { lazyReportCache } from '../utils/report'
['load', 'DOMContentLoaded'].forEach(type => onEvent(type))
function onEvent(type) {
function callback() {
lazyReportCache({
type: 'performance',
subType: type.toLocaleLowerCase(),
startTime: performance.now(),
})
window.removeEventListener(type, callback, true)
}
window.addEventListener(type, callback, true)
}
首屏渲染时间
大多数情况下,首屏渲染时间可以通过 load 事件获取。除了一些特殊情况,例如异步加载的图片和 DOM。
html复制代码<script>
setTimeout(() => {
document.body.innerHTML = `
<div>
<!-- 省略一堆代码... -->
</div>
`
}, 3000)
</script>
像这种情况就无法通过 load 事件获取首屏渲染时间了。这时我们需要通过 MutationObserver 来获取首屏渲染时间。MutationObserver 在监听的 DOM 元素属性发生变化时会触发事件。
首屏渲染时间计算过程:
- 利用 MutationObserver 监听 document 对象,每当 DOM 元素属性发生变更时,触发事件。
- 判断该 DOM 元素是否在首屏内,如果在,则在 requestAnimationFrame() 回调函数中调用 performance.now() 获取当前时间,作为它的绘制时间。
- 将最后一个 DOM 元素的绘制时间和首屏中所有加载的图片时间作对比,将最大值作为首屏渲染时间。
监听 DOM
js复制代码const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']
observer = new MutationObserver(mutationList => {
const entry = {
children: [],
}
for (const mutation of mutationList) {
if (mutation.addedNodes.length && isInScreen(mutation.target)) {
// ...
}
}
if (entry.children.length) {
entries.push(entry)
next(() => {
entry.startTime = performance.now()
})
}
})
observer.observe(document, {
childList: true,
subtree: true,
})
上面的代码就是监听 DOM 变化的代码,同时需要过滤掉 style、script、link 等标签。
判断是否在首屏
一个页面的内容可能非常多,但用户最多只能看见一屏幕的内容。所以在统计首屏渲染时间的时候,需要限定范围,把渲染内容限定在当前屏幕内。
js复制代码const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// dom 对象是否在屏幕内
function isInScreen(dom) {
const rectInfo = dom.getBoundingClientRect()
if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
return true
}
return false
}
使用 requestAnimationFrame() 获取 DOM 绘制时间
当 DOM 变更触发 MutationObserver 事件时,只是代表 DOM 内容可以被读取到,并不代表该 DOM 被绘制到了屏幕上。

从上图可以看出,当触发 MutationObserver 事件时,可以读取到 document.body 上已经有内容了,但实际上左边的屏幕并没有绘制任何内容。所以要调用 requestAnimationFrame() 在浏览器绘制成功后再获取当前时间作为 DOM 绘制时间。
和首屏内的所有图片加载时间作对比
js复制代码function getRenderTime() {
let startTime = 0
entries.forEach(entry => {
if (entry.startTime > startTime) {
startTime = entry.startTime
}
})
// 需要和当前页面所有加载图片的时间做对比,取最大值
// 图片请求时间要小于 startTime,响应结束时间要大于 startTime
performance.getEntriesByType('resource').forEach(item => {
if (
item.initiatorType === 'img'
&& item.fetchStart < startTime
&& item.responseEnd > startTime
) {
startTime = item.responseEnd
}
})
return startTime
}
优化
现在的代码还没优化完,主要有两点注意事项:
- 什么时候上报渲染时间?
- 如果兼容异步添加 DOM 的情况?
第一点,必须要在 DOM 不再变化后再上报渲染时间,一般 load 事件触发后,DOM 就不再变化了。所以我们可以在这个时间点进行上报。
第二点,可以在 LCP 事件触发后再进行上报。不管是同步还是异步加载的 DOM,它都需要进行绘制,所以可以监听 LCP 事件,在该事件触发后才允许进行上报。
将以上两点方案结合在一起,就有了以下代码:
js复制代码let isOnLoaded = false
executeAfterLoad(() => {
isOnLoaded = true
})
let timer
let observer
function checkDOMChange() {
clearTimeout(timer)
timer = setTimeout(() => {
// 等 load、lcp 事件触发后并且 DOM 树不再变化时,计算首屏渲染时间
if (isOnLoaded && isLCPDone()) {
observer && observer.disconnect()
lazyReportCache({
type: 'performance',
subType: 'first-screen-paint',
startTime: getRenderTime(),
pageURL: getPageURL(),
})
entries = null
} else {
checkDOMChange()
}
}, 500)
}
checkDOMChange() 代码每次在触发 MutationObserver 事件时进行调用,需要用防抖函数进行处理。
接口请求耗时
接口请求耗时需要对 XMLHttpRequest 和 fetch 进行监听。
监听 XMLHttpRequest
js复制代码originalProto.open = function newOpen(...args) {
this.url = args[1]
this.method = args[0]
originalOpen.apply(this, args)
}
originalProto.send = function newSend(...args) {
this.startTime = Date.now()
const onLoadend = () => {
this.endTime = Date.now()
this.duration = this.endTime - this.startTime
const { status, duration, startTime, endTime, url, method } = this
const reportData = {
status,
duration,
startTime,
endTime,
url,
method: (method || 'GET').toUpperCase(),
success: status >= 200 && status < 300,
subType: 'xhr',
type: 'performance',
}
lazyReportCache(reportData)
this.removeEventListener('loadend', onLoadend, true)
}
this.addEventListener('loadend', onLoadend, true)
originalSend.apply(this, args)
}
如何判断 XML 请求是否成功?可以根据他的状态码是否在 200~299 之间。如果在,那就是成功,否则失败。
监听 fetch
js复制代码const originalFetch = window.fetch
function overwriteFetch() {
window.fetch = function newFetch(url, config) {
const startTime = Date.now()
const reportData = {
startTime,
url,
method: (config?.method || 'GET').toUpperCase(),
subType: 'fetch',
type: 'performance',
}
return originalFetch(url, config)
.then(res => {
reportData.endTime = Date.now()
reportData.duration = reportData.endTime - reportData.startTime
const data = res.clone()
reportData.status = data.status
reportData.success = data.ok
lazyReportCache(reportData)
return res
})
.catch(err => {
reportData.endTime = Date.now()
reportData.duration = reportData.endTime - reportData.startTime
reportData.status = 0
reportData.success = false
lazyReportCache(reportData)
throw err
})
}
}
对于 fetch,可以根据返回数据中的的 ok 字段判断请求是否成功,如果为 true 则请求成功,否则失败。
注意,监听到的接口请求时间和 chrome devtool 上检测到的时间可能不一样。这是因为 chrome devtool 上检测到的是 HTTP 请求发送和接口整个过程的时间。但是 xhr 和 fetch 是异步请求,接口请求成功后需要调用回调函数。事件触发时会把回调函数放到消息队列,然后浏览器再处理,这中间也有一个等待过程。
资源加载时间、缓存命中率
通过 PerformanceObserver 可以监听 resource 和 navigation 事件,如果浏览器不支持 PerformanceObserver,还可以通过
performance.getEntriesByType(entryType) 来进行降级处理。
当 resource 事件触发时,可以获取到对应的资源列表,每个资源对象包含以下一些字段:

从这些字段中我们可以提取到一些有用的信息:
js复制代码{
name: entry.name, // 资源名称
subType: entryType,
type: 'performance',
sourceType: entry.initiatorType, // 资源类型
duration: entry.duration, // 资源加载耗时
dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗时
tcp: entry.connectEnd - entry.connectStart, // 建立 tcp 连接耗时
redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗时
ttfb: entry.responseStart, // 首字节时间
protocol: entry.nextHopProtocol, // 请求协议
responseBodySize: entry.encodedBodySize, // 响应内容大小
responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 响应头部大小
resourceSize: entry.decodedBodySize, // 资源解压后的大小
isCache: isCache(entry), // 是否命中缓存
startTime: performance.now(),
}
判断该资源是否命中缓存
在这些资源对象中有一个 transferSize 字段,它表示获取资源的大小,包括响应头字段和响应数据的大小。如果这个值为 0,说明是从缓存中直接读取的(强制缓存)。如果这个值不为 0,但是 encodedBodySize 字段为 0,说明它走的是协商缓存(encodedBodySize 表示请求响应数据 body 的大小)。
js复制代码function isCache(entry) {
// 直接从缓存读取或 304
return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0)
}
不符合以上条件的,说明未命中缓存。然后将所有命中缓存的数据/总数据就能得出缓存命中率。
浏览器往返缓存 BFC(back/forward cache)
bfcache 是一种内存缓存,它会将整个页面保存在内存中。当用户返回时可以马上看到整个页面,而不用再次刷新。据该文章 bfcache 介绍,firfox 和 safari 一直支持 bfc,chrome 只有在高版本的移动端浏览器支持。但我试了一下,只有 safari 浏览器支持,可能我的 firfox 版本不对。
但是 bfc 也是有缺点的,当用户返回并从 bfc 中恢复页面时,原来页面的代码不会再次执行。为此,浏览器提供了一个 pageshow 事件,可以把需要再次执行的代码放在里面。
js复制代码window.addEventListener('pageshow', function(event) {
// 如果该属性为 true,表示是从 bfc 中恢复的页面
if (event.persisted) {
console.log('This page was restored from the bfcache.');
} else {
console.log('This page was loaded normally.');
}
});
从 bfc 中恢复的页面,我们也需要收集他们的 FP、FCP、LCP 等各种时间。
js复制代码onBFCacheRestore(event => {
requestAnimationFrame(() => {
['first-paint', 'first-contentful-paint'].forEach(type => {
lazyReportCache({
startTime: performance.now() - event.timeStamp,
name: type,
subType: type,
type: 'performance',
pageURL: getPageURL(),
bfc: true,
})
})
})
})
上面的代码很好理解,在 pageshow 事件触发后,用当前时间减去事件触发时间,这个时间差值就是性能指标的绘制时间。注意,从 bfc 中恢复的页面的这些性能指标,值一般都很小,一般在 10 ms 左右。所以要给它们加个标识字段 bfc: true。这样在做性能统计时可以对它们进行忽略。
FPS
利用 requestAnimationFrame() 我们可以计算当前页面的 FPS。
js复制代码const next = window.requestAnimationFrame
? requestAnimationFrame : (callback) => { setTimeout(callback, 1000 / 60) }
const frames = []
export default function fps() {
let frame = 0
let lastSecond = Date.now()
function calculateFPS() {
frame++
const now = Date.now()
if (lastSecond + 1000 <= now) {
// 由于 now - lastSecond 的单位是毫秒,所以 frame 要 * 1000
const fps = Math.round((frame * 1000) / (now - lastSecond))
frames.push(fps)
frame = 0
lastSecond = now
}
// 避免上报太快,缓存一定数量再上报
if (frames.length >= 60) {
report(deepCopy({
frames,
type: 'performace',
subType: 'fps',
}))
frames.length = 0
}
next(calculateFPS)
}
calculateFPS()
}
代码逻辑如下:
- 先记录一个初始时间,然后每次触发 requestAnimationFrame() 时,就将帧数加 1。过去一秒后用帧数/流逝的时间就能得到当前帧率。
当连续三个低于 20 的 FPS 出现时,我们可以断定页面出现了卡顿,详情请看 如何监控网页的卡顿。
js复制代码export function isBlocking(fpsList, below = 20, last = 3) {
let count = 0
for (let i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++
} else {
count = 0
}
if (count >= last) {
return true
}
}
return false
}
Vue 路由变更渲染时间
首屏渲染时间我们已经知道如何计算了,但是如何计算 SPA 应用的页面路由切换导致的页面渲染时间呢?本文用 Vue 作为示例,讲一下我的思路。
js复制代码export default function onVueRouter(Vue, router) {
let isFirst = true
let startTime
router.beforeEach((to, from, next) => {
// 首次进入页面已经有其他统计的渲染时间可用
if (isFirst) {
isFirst = false
return next()
}
// 给 router 新增一个字段,表示是否要计算渲染时间
// 只有路由跳转才需要计算
router.needCalculateRenderTime = true
startTime = performance.now()
next()
})
let timer
Vue.mixin({
mounted() {
if (!router.needCalculateRenderTime) return
this.$nextTick(() => {
// 仅在整个视图都被渲染之后才会运行的代码
const now = performance.now()
clearTimeout(timer)
timer = setTimeout(() => {
router.needCalculateRenderTime = false
lazyReportCache({
type: 'performance',
subType: 'vue-router-change-paint',
duration: now - startTime,
startTime: now,
pageURL: getPageURL(),
})
}, 1000)
})
},
})
}
代码逻辑如下:
- 监听路由钩子,在路由切换时会触发 router.beforeEach() 钩子,在该钩子的回调函数里将当前时间记为渲染开始时间。
- 利用 Vue.mixin() 对所有组件的 mounted() 注入一个函数。每个函数都执行一个防抖函数。
- 当最后一个组件的 mounted() 触发时,就代表该路由下的所有组件已经挂载完毕。可以在 this.$nextTick() 回调函数中获取渲染时间。
同时,还要考虑到一个情况。不切换路由时,也会有变更组件的情况,这时不应该在这些组件的 mounted() 里进行渲染时间计算。所以需要添加一个 needCalculateRenderTime 字段,当切换路由时将它设为 true,代表可以计算渲染时间了。
错误数据采集
资源加载错误
使用 addEventListener() 监听 error 事件,可以捕获到资源加载失败错误。
js复制代码// 捕获资源加载失败错误 js css img...
window.addEventListener('error', e => {
const target = e.target
if (!target) return
if (target.src || target.href) {
const url = target.src || target.href
lazyReportCache({
url,
type: 'error',
subType: 'resource',
startTime: e.timeStamp,
html: target.outerHTML,
resourceType: target.tagName,
paths: e.path.map(item => item.tagName).filter(Boolean),
pageURL: getPageURL(),
})
}
}, true)
js 错误
使用 window.onerror 可以监听 js 错误。
js复制代码// 监听 js 错误
window.onerror = (msg, url, line, column, error) => {
lazyReportCache({
msg,
line,
column,
error: error.stack,
subType: 'js',
pageURL: url,
type: 'error',
startTime: performance.now(),
})
}
promise 错误
使用 addEventListener() 监听 unhandledrejection 事件,可以捕获到未处理的 promise 错误。
js复制代码// 监听 promise 错误 缺点是获取不到列数据
window.addEventListener('unhandledrejection', e => {
lazyReportCache({
reason: e.reason?.stack,
subType: 'promise',
type: 'error',
startTime: e.timeStamp,
pageURL: getPageURL(),
})
})
sourcemap
一般生产环境的代码都是经过压缩的,并且生产环境不会把 sourcemap 文件上传。所以生产环境上的代码报错信息是很难读的。因此,我们可以利用 source-map 来对这些压缩过的代码报错信息进行还原。
当代码报错时,我们可以获取到对应的文件名、行数、列数:
js复制代码{
line: 1,
column: 17,
file: 'https:/www.xxx.com/bundlejs',
}
然后调用下面的代码进行还原:
js复制代码async function parse(error) {
const mapObj = JSON.parse(getMapFileContent(error.url))
const consumer = await new sourceMap.SourceMapConsumer(mapObj)
// 将 webpack://source-map-demo/./src/index.js 文件中的 ./ 去掉
const sources = mapObj.sources.map(item => format(item))
// 根据压缩后的报错信息得出未压缩前的报错行列数和源码文件
const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
// sourcesContent 中包含了各个文件的未压缩前的源码,根据文件名找出对应的源码
const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
return {
file: originalInfo.source,
content: originalFileContent,
line: originalInfo.line,
column: originalInfo.column,
msg: error.msg,
error: error.error
}
}
function format(item) {
return item.replace(/(\.\/)*/g, '')
}
function getMapFileContent(url) {
return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')
}
每次项目打包时,如果开启了 sourcemap,那么每一个 js 文件都会有一个对应的 map 文件。
arduino复制代码bundle.js
bundle.js.map
这时 js 文件放在静态服务器上供用户访问,map 文件存储在服务器,用于还原错误信息。source-map 库可以根据压缩过的代码报错信息还原出未压缩前的代码报错信息。例如压缩后报错位置为 1 行 47 列,还原后真正的位置可能为 4 行 10 列。除了位置信息,还可以获取到源码原文。

上图就是一个代码报错还原后的示例。鉴于这部分内容不属于 SDK 的范围,所以我另开了一个 仓库 来做这个事,有兴趣可以看看。
Vue 错误
利用 window.onerror 是捕获不到 Vue 错误的,它需要使用 Vue 提供的 API 进行监听。
js复制代码Vue.config.errorHandler = (err, vm, info) => {
// 将报错信息打印到控制台
console.error(err)
lazyReportCache({
info,
error: err.stack,
subType: 'vue',
type: 'error',
startTime: performance.now(),
pageURL: getPageURL(),
})
}
行为数据采集
PV、UV
PV(page view) 是页面浏览量,UV(Unique visitor)用户访问量。PV 只要访问一次页面就算一次,UV 同一天内多次访问只算一次。
对于前端来说,只要每次进入页面上报一次 PV 就行,UV 的统计放在服务端来做,主要是分析上报的数据来统计得出 UV。
js复制代码export default function pv() {
lazyReportCache({
type: 'behavior',
subType: 'pv',
startTime: performance.now(),
pageURL: getPageURL(),
referrer: document.referrer,
uuid: getUUID(),
})
}
页面停留时长
用户进入页面记录一个初始时间,用户离开页面时用当前时间减去初始时间,就是用户停留时长。这个计算逻辑可以放在 beforeunload 事件里做。
js复制代码export default function pageAccessDuration() {
onBeforeunload(() => {
report({
type: 'behavior',
subType: 'page-access-duration',
startTime: performance.now(),
pageURL: getPageURL(),
uuid: getUUID(),
}, true)
})
}
页面访问深度
记录页面访问深度是很有用的,例如不同的活动页面 a 和 b。a 平均访问深度只有 50%,b 平均访问深度有 80%,说明 b 更受用户喜欢,根据这一点可以有针对性的修改 a 活动页面。
除此之外还可以利用访问深度以及停留时长来鉴别电商刷单。例如有人进来页面后一下就把页面拉到底部然后等待一段时间后购买,有人是慢慢的往下滚动页面,最后再购买。虽然他们在页面的停留时间一样,但明显第一个人更像是刷单的。
页面访问深度计算过程稍微复杂一点:
- 用户进入页面时,记录当前时间、scrollTop 值、页面可视高度、页面总高度。
- 用户滚动页面的那一刻,会触发 scroll 事件,在回调函数中用第一点得到的数据算出页面访问深度和停留时长。
- 当用户滚动页面到某一点时,停下继续观看页面。这时记录当前时间、scrollTop 值、页面可视高度、页面总高度。
- 重复第二点...
具体代码请看:
js复制代码let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0
export default function pageAccessHeight() {
window.addEventListener('scroll', onScroll)
onBeforeunload(() => {
const now = performance.now()
report({
startTime: now,
duration: now - startTime,
type: 'behavior',
subType: 'page-access-height',
pageURL: getPageURL(),
value: toPercent((scrollTop + viewportHeight) / pageHeight),
uuid: getUUID(),
}, true)
})
// 页面加载完成后初始化记录当前访问高度、时间
executeAfterLoad(() => {
startTime = performance.now()
pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
scrollTop = document.documentElement.scrollTop || document.body.scrollTop
viewportHeight = window.innerHeight
})
}
function onScroll() {
clearTimeout(timer)
const now = performance.now()
if (!hasReport) {
hasReport = true
lazyReportCache({
startTime: now,
duration: now - startTime,
type: 'behavior',
subType: 'page-access-height',
pageURL: getPageURL(),
value: toPercent((scrollTop + viewportHeight) / pageHeight),
uuid: getUUID(),
})
}
timer = setTimeout(() => {
hasReport = false
startTime = now
pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
scrollTop = document.documentElement.scrollTop || document.body.scrollTop
viewportHeight = window.innerHeight
}, 500)
}
function toPercent(val) {
if (val >= 1) return '100%'
return (val * 100).toFixed(2) + '%'
}
用户点击
利用 addEventListener() 监听 mousedown、touchstart 事件,我们可以收集用户每一次点击区域的大小,点击坐标在整个页面中的具体位置,点击元素的内容等信息。
js复制代码export default function onClick() {
['mousedown', 'touchstart'].forEach(eventType => {
let timer
window.addEventListener(eventType, event => {
clearTimeout(timer)
timer = setTimeout(() => {
const target = event.target
const { top, left } = target.getBoundingClientRect()
lazyReportCache({
top,
left,
eventType,
pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
type: 'behavior',
subType: 'click',
target: target.tagName,
paths: event.path?.map(item => item.tagName).filter(Boolean),
startTime: event.timeStamp,
pageURL: getPageURL(),
outerHTML: target.outerHTML,
innerHTML: target.innerHTML,
width: target.offsetWidth,
height: target.offsetHeight,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
uuid: getUUID(),
})
}, 500)
})
})
}
页面跳转
利用 addEventListener() 监听 popstate、hashchange 页面跳转事件。需要注意的是调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back()或者history.forward()方法)。同理,hashchange 也一样。
js复制代码export default function pageChange() {
let from = ''
window.addEventListener('popstate', () => {
const to = getPageURL()
lazyReportCache({
from,
to,
type: 'behavior',
subType: 'popstate',
startTime: performance.now(),
uuid: getUUID(),
})
from = to
}, true)
let oldURL = ''
window.addEventListener('hashchange', event => {
const newURL = event.newURL
lazyReportCache({
from: oldURL,
to: newURL,
type: 'behavior',
subType: 'hashchange',
startTime: performance.now(),
uuid: getUUID(),
})
oldURL = newURL
}, true)
}
Vue 路由变更
Vue 可以利用 router.beforeEach 钩子进行路由变更的监听。
js复制代码export default function onVueRouter(router) {
router.beforeEach((to, from, next) => {
// 首次加载页面不用统计
if (!from.name) {
return next()
}
const data = {
params: to.params,
query: to.query,
}
lazyReportCache({
data,
name: to.name || to.path,
type: 'behavior',
subType: ['vue-router-change', 'pv'],
startTime: performance.now(),
from: from.fullPath,
to: to.fullPath,
uuid: getUUID(),
})
next()
})
}
数据上报
上报方法
数据上报可以使用以下几种方式:
- sendBeacon
- XMLHttpRequest
- image
我写的简易 SDK 采用的是第一、第二种方式相结合的方式进行上报。利用 sendBeacon 来进行上报的优势非常明显。
使用 sendBeacon() 方法会使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题:数据可靠,传输异步并且不会影响下一页面的加载。
在不支持 sendBeacon 的浏览器下我们可以使用 XMLHttpRequest 来进行上报。一个 HTTP 请求包含发送和接收两个步骤。其实对于上报来说,我们只要确保能发出去就可以了。也就是发送成功了就行,接不接收响应无所谓。为此,我做了个实验,在 beforeunload 用 XMLHttpRequest 传送了 30kb 的数据(一般的待上报数据很少会有这么大),换了不同的浏览器,都可以成功发出去。当然,这和硬件性能、网络状态也是有关联的。
上报时机
上报时机有三种:
- 采用 requestIdleCallback/setTimeout 延时上报。
- 在 beforeunload 回调函数里上报。
- 缓存上报数据,达到一定数量后再上报。
建议将三种方式结合一起上报:
- 先缓存上报数据,缓存到一定数量后,利用 requestIdleCallback/setTimeout 延时上报。
- 在页面离开时统一将未上报的数据进行上报。
总结
本文主要从 JS 异常监控,性能监控和请求,静态资源监控几个细节点讲述了 Slardar 在前端监控方向所面临关键问题的探索和实践,希望能够对大家在前端监控领域或者将来的工作中产生帮助。其实前端监控还有许多方面可以深挖,例如如何利用拨测,线下实验室数据采集来进一步追溯问题,如何捕获白屏等类崩溃异常,如何结合研发流程来实现用户无感知的接入等等。