前言
日常开发过程中,面对线上问题,常见的两个痛点,一个是用户操作在自己的机器上,开发者无法还原用户触发异常的场景,另一个,由于混淆和压缩代码,导致线上报错在控制台打印的堆栈信息,无法将异常定位到代码源码.这次我们来讨论下异常视频回放的解决方案.
核心主角快照生成与播放库rrweb
下面我要介绍的就是今天的主角rrweb框架,全称record and replay the web。它由三个库组成:
rrweb-snapshot
将页面中的dom转化为可序列化的数据结构
rrweb
提供录屏和重放的api
rrweb-player
提供播放的ui页面,支持快进、全屏、拖拽等操作 每次刷新页面时,rrweb会将页面中的dom元素全部转换成文档数据,并给每个dom元素分配一个唯一id。后面当页面发生变化时,只对变化的dom元素进行序列化。当重放页面时,会将数据反序列化并插入到页面中,而原先增量的dom变化,如属性或者文本变化,则根据id找到对应dom元素修改;而子节点的增加或减少,根据父元素id进行dom变更。
简述rrweb实现快照的生成
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存。例如通过以下的代码实现(使用 jQuery 简化示例,仅保存 body 部分):
// recordconst snapshot = $('body').clone();// replay$('body').replaceWith(snapshot);复制代码
序列化
我们通过将 DOM 对象整体保存在内存中实现了快照。
但是这个对象本身并不是可序列化的,因此我们不能将其保存为特定的文本格式(例如 JSON)进行传输,也就无法做到远程录制,所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 parse5 的原因包含两个方面.
- 我们需要实现一个“非标准”的序列化方法,下文会详细展开。
- 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。
序列化中的特殊处理
之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:
- 去脚本化。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 script 标签改为 noscript 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录。
- 记录没有反映在 HTML 中的视图状态。例如输入后的值不会反映在其 HTML 中,而是通过 value 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成。
- 相对路径转换为绝对路径。回放时我们会将被录制的页面放置在一个 中,此时的页面 URL为重放页面的地址,如果被录制页面中有一些相对路径就会产生错误,所以在录制时就要将相对路径进行转换,同样的 CSS 样式表中的相对路径也需要转换。
- 尽量记录 CSS 样式表的内容。如果被录制页面加载了一些同源的 样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让一些内网环境(如 localhost)的录制也有比较好的效果。
唯一标识
同时,我们的序列化还应该包含全量和增量两种类型,全量序列化可以将一个 DOM 树转化为对应的树状数据结构。
例如以下的 DOM 树:
<html> <body><header></header> </body></html>复制代码
会被序列化成类似这样的数据结构:
{ "type": "Document", "childNodes": [ { "type": "Element", "tagName": "html", "attributes": {}, "childNodes": [ { "type": "Element", "tagName": "head", "attributes": {}, "childNodes": [], "id": 3}, { "type": "Element", "tagName": "body", "attributes": {}, "childNodes": [ { "type": "Text", "textContent": "\n ", "id": 5}, { "type": "Element", "tagName": "header", "attributes": {}, "childNodes": [ { "type": "Text", "textContent": "\n ", "id": 7} ], "id": 6} ], "id": 4} ], "id": 2} ], "id": 1}复制代码
这个序列化的结果中有两点需要注意:
- 我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
- 我们给每一个 Node 都添加了唯一标识 id,这是为之后的增量快照做准备。
想象一下如果我们在同页面中记录一次点击按钮的操作并回放,我们可以用以下格式记录该操作(也就是我们所说的一次增量快照):
type clickSnapshot = { source: 'MouseInteraction'; type: 'Click'; node: HTMLButtonElement; }复制代码
再通过 snapshot.node.click() 就能将操作再执行一次。
但是在实际场景中,虽然我们已经重建出了完整的 DOM,但是却没有办法将增量快照中被交互的 DOM 节点和已存在的 DOM 关联在一起。
这就是唯一标识 id 的作用,我们在录制端和回放端维护随时间变化完全一致的 id -> Node 映射,并随着 DOM 节点的创建和销毁进行同样的更新,保证我们在增量快照中只需要记录 id 就可以在回放时找到对应的 DOM 节点。
上述示例中的数据结构相应的变为:
type clickSnapshot = { source: 'MouseInteraction'; type: 'Click'; id: Number; }复制代码
增量快照
在完成一次全量快照之后,我们就需要基于当前视图状态观察所有可能对视图造成改动的事件,在 rrweb 中我们已经观察了以下事件(将不断增加):
- DOM 变动
- 节点创建、销毁
- 节点属性变化
- 文本变化
- 鼠标移动
- 鼠标交互
- mouse up、mouse down
- click、double click、context menu
- focus、blur
- touch start、touch move、touch end
- 页面或元素滚动
- 视窗大小改变
- 输入
探针集成rrweb实现异常录制
异步加载rrweb视频录制库
基于性能考虑,探针希望减少本身体积,用户未开启异常视频录制时,探针源文件不会将异常录制相关代码打包进压缩的探针文件中.
探针开启回放配置参数后,在探针初始化之后,将会根据探针版本,向当前项目插入于探针版本一致的rrweb-reocrd插件.并开始记录页面中dom的变化,
采集策略
快照文件缓存
基于性能考虑,探针内部维护了俩个快照缓存数组.分为oldSnap于newSnap,rrweb生成的快照对象将会首先放置在newSnap数组中.
在首次初始化时、页面dom变化或者用户操作时,rrweb将会生成快照对象,并推入newSnap数组中,除了初始化时,rrweb会生成一次全亮快照,之后每生成200个增量快照时,会重新生成一次全量快照(一次全量快照对应两个快照对象 {type=4 event}, {type=2 event}).
类似V8垃圾回收机制.每次全量快照生成,探针将会把newSnap数组转移到oldSnap数组中,覆盖原oldSnap中的数据,并把newSnap清空,新产生的快照对象重新填入到newSnap数组中.确保探针中缓存的快照数据保持在一定范围,不至于在回放时视频时常过少,也不至于快照过多,导致内存占用过多.
快照文件生成
当探针监听到异常时,如在E1时发生异常那么将会上报全量快照fs1文件 与 增量快照 E1文件,并将全量快照文件名记录下来
① 全量快照1文件:fs1: [...全量快照] ② E1: { host: '文件域名', full: '/{product_code}/{app_code}/error_fullsnapshot_{session_id}_{uuid}.xxx', history: [], incre: [...当前回放的增量快照] }复制代码
当探针监听到异常时,如在E2时发生异常,首先查询当前分段对应的全量快照文件是否已经上传,如果已经上传,则只上报E1-E2之间的快照数据,并将E1的文件名存入history中,这样上报的数据量将会减少
③ E2: { full: fs1, history: [E1], incre: [{E1 ~ E2之间的增量快照}] }复制代码
如果多个异常发生时间间隔在500ms以内,那么不会将500ms以内的异常放入history中,在异常回溯时,减少对history内文件的读取
④ E3: { full: fs1, history: [E1], incre: [{E1 ~ E3之间的增量快照}] } ⑤ E4: { full: fs1, histroy: [E1], incre: [{E1 ~ E4之间的增量快照}] }复制代码
为了保证视频数据足够,在newSnap数组的长度为100以内时,其全量快照文件对应的是oldSnap数组的全量快照文件
⑥ E5: { full: fs1, histroy: [E1, E4], incre: [{E4 ~ E5之间的增量快照}] } ⑦ 全量快照2文件:fs2 ⑧ E6: { full: fs2, histroy: [], incre: [{fs2 ~ E6之间的增量快照}] } ⑨ E7: { full: fs2, histroy: [E6], incre: [{E6 ~ E7之间的增量快照}] }复制代码
数据上报
异常回放对应的快照文件,存放在ali-oss仓库中,由于ali-oss仓库需要校验权限,而且权限具有时效性,为了防止获取权限期间,上报的视频数据丢失,探针内部还维护了两个上报数据队列,分别存放全量快照文件fs 与增量快照文件 E$n
两个队列存放的文件数有所限制,待上报文件将先推入队列中,等待消耗,在极端条件下,如果文件消耗不及时,根据先入先出的规则,部分文件将会被抛弃,防止内存溢出.
平台实现异常回放功能
用户集成探针后,用户线上发生异常根据上报日志中的replay_id可以在ali-oss上获取到上报的快照文件,并通过rrweb-player播放器将回溯视频播放
待优化项
在项目上线后,整体体验尚佳,但是还是暴露了一些问题以待后续修复
- 播放回放视频时,原页面的样式文件可能随着项目迭代丢失或者更改,导致样式错位问题.(这块可能需要后端增加定时任务,将上报快照中的样式文件下载存放在oss,并更改对应的样式文件引入地址,保证视频回溯完整)
- 视频播放器体验优化,左侧事件列表与视屏回溯的联动优化.
- 丰富视频录制数据,将请求(耗时)、console数据与视频播放对应.