阅读 1446

天眼探针基于rrweb实现前端异常视频录制与回放功能

前言

日常开发过程中,面对线上问题,常见的两个痛点,一个是用户操作在自己的机器上,开发者无法还原用户触发异常的场景,另一个,由于混淆和压缩代码,导致线上报错在控制台打印的堆栈信息,无法将异常定位到代码源码.这次我们来讨论下异常视频回放的解决方案.

核心主角快照生成与播放库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 部分):

// record
const snapshot = $('body').clone();
// replay
$('body').replaceWith(snapshot);
复制代码

序列化

我们通过将 DOM 对象整体保存在内存中实现了快照。

但是这个对象本身并不是可序列化的,因此我们不能将其保存为特定的文本格式(例如 JSON)进行传输,也就无法做到远程录制,所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 parse5 的原因包含两个方面.

  1. 我们需要实现一个“非标准”的序列化方法,下文会详细展开。
  2. 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。

序列化中的特殊处理

之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:

  1. 去脚本化。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 script 标签改为 noscript 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录。
  2. 记录没有反映在 HTML 中的视图状态。例如 输入后的值不会反映在其 HTML 中,而是通过 value 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成
  3. 相对路径转换为绝对路径。回放时我们会将被录制的页面放置在一个 中,此时的页面 URL为重放页面的地址,如果被录制页面中有一些相对路径就会产生错误,所以在录制时就要将相对路径进行转换,同样的 CSS 样式表中的相对路径也需要转换。
  4. 尽量记录 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
}
复制代码

这个序列化的结果中有两点需要注意:

  1. 我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
  2. 我们给每一个 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 的设计原则是尽量少的在录制端进行处理,最大程度减少对被录制页面的影响,因此在回放端需要做一些特殊的处理。

高精度计时器

在回放时我们会一次性拿到完整的快照链,如果将所有快照依次同步执行我们可以直接获取被录制页面最后的状态,但是我们需要的是同步初始化第一个全量快照,再异步地按照正确的时间间隔依次重放每一个增量快照,这就需要一个高精度的计时器。

之所以强调高精度,是因为原生的 setTimeout 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。

对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 requestAnimationFrame 来实现一个不断校准的定时器,确保绝大部分情况下增量快照的重放延迟不超过一帧。

同时自定义的计时器也是我们实现“快进”功能的基础。

补全缺失节点

在[增量快照设计](## 增量快照)中提到了 rrweb 使用 MutationObserver 时的延迟序列化策略,这一策略可能导致以下场景中我们不能记录完整的增量快照:

parent
  child2
  child1
复制代码
  1. parent 节点插入子节点 child1
  2. parent 节点在 child1 之前插入子节点 child2

按照实际执行顺序 child1 会被 rrweb 先序列化,但是在序列化新增节点时我们除了记录父节点之外还需要记录相邻节点,从而保证回放时可以把新增节点放置在正确的位置。但是此时 child 1 相邻节点 child2 已经存在但是还未被序列化,我们会将其记录为 id: -1(不存在相邻节点时 id 为 null)。

重放时当我们处理到新增 child1 的增量快照时,我们可以通过其相邻节点 id 为 -1 这一特征知道帮助它定位的节点还未生成,然后将它临时放入”缺失节点池“中暂不插入 DOM 树中。

之后在处理到新增 child2 的增量快照时,我们正常处理并插入 child2,完成重放之后检查 child2 的相邻节点 id 是否指向缺失节点池中的某个待添加节点,如果吻合则将其从池中取出插入对应位置。

模拟 Hover

在许多前端页面中都会存在 :hover 选择器对应的 CSS 样式,但是我们并不能通过 JavaScript 触发 hover 事件。因此回放时我们需要模拟 hover 事件让样式正确显示。

具体方式包括两部分:

  1. 遍历 CSS 样式表,对于 :hover 选择器相关 CSS 规则增加一条完全一致的规则,但是选择器为一个特殊的 class,例如 .:hover
  2. 当回放 mouse up 鼠标交互事件时,为事件目标及其所有祖先节点都添加 .:hover 类名,mouse down 时再对应移除。

从任意时间点开始播放

除了基础的回放功能之外,我们还希望 rrweb-player 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。

实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。

沙盒

在序列化设计中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中我们将所有 script 标签改写为 noscript 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 script 标签中的,例如 HTML 中的 inline script、表单提交等。

脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。

iframe sandbox

我们在重建快照时将被录制的 DOM 重建在一个 iframe 元素中,通过设置它的 sandbox 属性,我们可以禁止以下行为:

  • 表单提交
  • window.open 等弹出窗
  • JS 脚本(包含 inline event handler 和 <URL>

这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。

避免链接跳转

当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。

通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 event.preventDefault() 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。

重新查看我们回放交互事件增量快照的实现,我们会发现其实 click 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。

不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。

iframe 样式设置

由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 noscript 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下:

const injectStyleRules: string[] = [
  'iframe { background: #f1f3f5 }',
  'noscript { display: none !important; }',
];

const styleEl = document.createElement('style');
const { documentElement, head } = this.iframe.contentDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
  (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
复制代码

需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 id -> Node 的映射将出现错误。

探针集成rrweb实现异常录制实现方案

异步加载rrweb视频录制库

image

基于性能考虑,探针希望减少本身体积,用户未开启异常视频录制时,探针源文件不会将异常录制相关代码打包进压缩的探针文件中.

探针开启回放配置参数后,在探针初始化之后,将会根据探针版本,向当前项目插入于探针版本一致的rrweb-reocrd插件.并开始记录页面中dom的变化,

采集策略

image image

快照文件缓存

基于性能考虑,探针内部维护了俩个快照缓存数组.分为oldSnap于newSnap,rrweb生成的快照对象将会首先放置在newSnap数组中.

在首次初始化时、页面dom变化或者用户操作时,rrweb将会生成快照对象,并推入newSnap数组中,除了初始化时,rrweb会生成一次全亮快照,之后每生成200个增量快照时,会重新生成一次全量快照(一次全量快照对应两个快照对象 {type=4 event}, {type=2 event}).

类似V8垃圾回收机制.每次全量快照生成,探针将会把newSnap数组转移到oldSnap数组中,覆盖原oldSnap中的数据,并把newSnap清空,新产生的快照对象重新填入到newSnap数组中.确保探针中缓存的快照数据保持在一定范围,不至于在回放时视频时常过少,也不至于快照过多,导致内存占用过多.

快照文件生成

image 当探针监听到异常时,如在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 image

两个队列存放的文件数有所限制,待上报文件将先推入队列中,等待消耗,在极端条件下,如果文件消耗不及时,根据先入先出的规则,部分文件将会被抛弃,防止内存溢出.

平台实现异常回放功能

image

用户集成探针后,用户线上发生异常根据上报日志中的replay_id可以在ali-oss上获取到上报的快照文件,并通过rrweb-player播放器将回溯视频播放 image

image

待优化项

在项目上线后,整体体验尚佳,但是还是暴露了一些问题以待后续修复

  1. 播放回放视频时,原页面的样式文件可能随着项目迭代丢失或者更改,导致样式错位问题.(这块可能需要后端增加定时任务,将上报快照中的样式文件下载存放在oss,并更改对应的样式文件引入地址,保证视频回溯完整)
  2. 视频播放器体验优化,左侧事件列表与视屏回溯的联动优化.
  3. 丰富视频录制数据,将请求(耗时)、console数据与视频播放对应.

参考资料

rrweb:打开 web 页面录制与回放的黑盒子

rrweb github

文章分类
前端
文章标签