Skip to content

从 rrweb 迁移到 rrweb Cloud ​

保留 rrweb 的事件格式,将摄取、存储和检索迁移到 rrweb Cloud。选择能保留你实际需要的行为的最小迁移方案。

场景推荐路径
标准录制器配置用浏览器客户端替换传输层
自定义录制器构建或自定义传输保留录制器,手动发送经过认证的事件

两条路径都使用公开写入密钥进行浏览器摄取。公开密钥可以追加事件和元数据,但不能读取录制。不要在浏览器代码中放入私有 API 密钥。

路径 1:用 Browser Client 替换标准传输 ​

如果你的集成使用受支持的选项调用 record(),且主要用于上传 emit 结果,请用浏览器客户端替换传输层;它会将 rrweb 选项转发给 record(),并负责管理录制 ID、WebSocket 投递、缓冲和 HTTP 回退。

javascript
import { start } from '@rrweb/browser-client';

start({
  publicApiKey: 'public_key_rr_your_key',
  blockSelector: '[data-private]',
  maskTextSelector: '[data-mask]',
  maskAllInputs: true,
  sampling: {
    mousemove: 100,
    scroll: 200,
  },
});

将隐私和录制选项原样迁移过来,然后分别比较两个集成产生的回放。请查看录制和隐私设置,因为浏览器客户端会为你未定义的选项提供 Cloud 默认值。

如果现有的 emit 回调执行本地工作,你可以保留它。浏览器客户端会调用该回调,同时也会通过其 Cloud 传输发送同一事件:

javascript
start({
  publicApiKey: 'public_key_rr_your_key',
  emit(event) {
    updateLocalDiagnostics(event);
  },
});

路径 2:保留自定义录制器或传输 ​

当你运行打过补丁的录制器、对事件流进行转换,或需要自定义传输生命周期时,请保留手动摄取;此时你需要自行负责 UUID、事件顺序、重试语义、认证、缓冲和卸载行为。

创建并保留录制 ID ​

Cloud 要求每条摄取路由中都包含一个 UUID;对于应在同源页面导航间延续的浏览器标签页,只生成一次 UUID 并将其存储在 sessionStorage 中。

一个录制 ID 必须标识一条有序的 rrweb 事件流。

javascript
const storageKey = 'my-app-rrweb-recording-id';
let recordingId = sessionStorage.getItem(storageKey);

if (!recordingId) {
  recordingId = crypto.randomUUID();
  sessionStorage.setItem(storageKey, recordingId);
}

不同的标签页拥有独立的 sessionStorage 上下文。在你的会话边界清除该键,即可创建新的录制。关于如何用元数据对多个录制 ID 进行分组,请参阅录制 ID。

认证 HTTP 采集 ​

对于严格的自定义传输,优先使用串行化的 HTTP POST,每个请求发送一个事件。仅有成功状态码还不够:JSON 响应会报告 successful_rows 和 quarantined_rows,2xx 响应仍可能包含被隔离的行。只有当当前响应报告恰好一行成功且零行被隔离时,才发送下一个事件。

该路由使用公开写入密钥作为 Authorization: Bearer 请求头。下面的示例会让队首事件保持排队,直到该验收检查通过;当投递被拒绝或结果不确定时,它会停止,而不是自动重试:

javascript
function createHttpTransport({ recordingId, publicApiKey, fetchImpl = fetch }) {
  const eventBuffer = [];
  const encoder = new TextEncoder();
  let flushInFlight = null;
  let haltedError = null;

  function enqueue(event) {
    eventBuffer.push(event);
  }

  async function sendHead() {
    if (haltedError) throw haltedError;
    if (eventBuffer.length === 0) return;

    const body = JSON.stringify(eventBuffer[0]);

    try {
      const response = await fetchImpl(
        `https://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/events`,
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${publicApiKey}`,
            'Content-Type': 'application/json',
          },
          body,
          keepalive: encoder.encode(body).byteLength < 64_000,
        },
      );
      if (!response.ok) {
        throw new Error(`rrweb Cloud returned ${response.status}`);
      }

      let result;
      try {
        result = await response.json();
      } catch {
        throw new Error('rrweb Cloud returned an unreadable ingest result');
      }

      if (result?.successful_rows !== 1 || result?.quarantined_rows !== 0) {
        throw new Error(
          `rrweb Cloud did not accept exactly one event ` +
            `(successful_rows=${String(result?.successful_rows)}, ` +
            `quarantined_rows=${String(result?.quarantined_rows)})`,
        );
      }

      eventBuffer.shift();
    } catch (error) {
      haltedError =
        error instanceof Error ? error : new Error('Unknown ingest failure');
      throw haltedError;
    }
  }

  async function drain() {
    while (eventBuffer.length > 0) await sendHead();
  }

  function flush() {
    if (flushInFlight) return flushInFlight;
    flushInFlight = drain().finally(() => {
      flushInFlight = null;
    });
    return flushInFlight;
  }

  function state() {
    return { halted: haltedError !== null, pending: eventBuffer.length };
  }

  function resolveHalt({ removeHead = false } = {}) {
    if (flushInFlight) throw new Error('Wait for the current flush to settle');
    if (!haltedError) return;
    if (removeHead) eventBuffer.shift();
    haltedError = null;
  }

  return { enqueue, flush, state, resolveHalt };
}

将该传输用作录制器的 emit 回调,并安排一条 flush 路径:

javascript
import { record } from '@rrweb/record';

const transport = createHttpTransport({
  recordingId,
  publicApiKey: 'public_key_rr_your_key',
});

const stopRecording = record({
  emit: transport.enqueue,
  blockSelector: '[data-private]',
  maskAllInputs: true,
});

const flushTimer = setInterval(() => {
  if (transport.state().halted) return;
  void transport.flush().catch((error) => {
    console.error('Ingestion halted with the head event retained', error);
  });
}, 5_000);

document.addEventListener('visibilitychange', () => {
  if (document.hidden && !transport.state().halted) {
    void transport.flush().catch(console.error);
  }
});

进行中的 promise 可防止 POST 重叠;drain() 通过先等待每个事件的验收结果、再发送下一个事件来保持事件顺序。任何失败都会让队首事件保留在原位,并阻止后续发送。应对该停止状态设置告警,并在采取明确的恢复操作之前,检查响应、应用日志和录制内容:

  • 如果你确认某个结果不确定的请求已被接受,或者有意丢弃某个被隔离的事件,请在再次 flush 之前调用 resolveHalt({ removeHead: true })。
  • 如果你确认事件未被接受且重试是安全的,请调用 resolveHalt() 并再次 flush。被隔离的事件通常需要修正,而不是原样重试。

摄取路由对事件写入没有幂等键。如果 Cloud 在客户端丢失响应之前已提交事件,那么在超时、连接中断或响应不可读之后重试,可能会产生重复事件。该队列也仅存在于内存中。如果页面终止、离线会话或崩溃绝不允许丢失事件,请在捕获前持久化队列及其已确认位置,或者改用浏览器客户端,而不是把 keepalive 当作投递保证。

等效的命令行请求为:

bash
curl --request POST \
  --url https://api.rrweb.com/recordings/550e8400-e29b-41d4-a716-446655440000/events \
  --header 'Authorization: Bearer public_key_rr_your_key' \
  --header 'Content-Type: application/json' \
  --data '{"type":4,"timestamp":1760000000000,"data":{"href":"https://example.test/","width":1280,"height":720}}'

认证 WebSocket 采集 ​

浏览器无法为握手添加 Authorization 请求头,因此请将公开写入密钥放在 token 查询参数中。

WebSocket 是一种高级的自定义传输选择,而不是开箱即用的可靠性层。帧大小应以字节而非 JavaScript 字符串长度来衡量:

javascript
const publicApiKey = 'public_key_rr_your_key';
const wsUrl = new URL(
  `wss://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/events/ws`,
);
wsUrl.searchParams.set('token', publicApiKey);

const socket = new WebSocket(wsUrl);
const encoder = new TextEncoder();

function sendFrame(event) {
  const frame = JSON.stringify(event);
  if (encoder.encode(frame).byteLength >= 1_000_000) {
    throw new Error('Frame is too large; use the HTTP transport');
  }
  if (socket.readyState !== WebSocket.OPEN) {
    throw new Error('Socket is not open; retain the event before retrying');
  }
  socket.send(frame);
}

WebSocket.send() 只是把帧交给浏览器;它并不是 Cloud 已持久接受该事件的应用层确认。断连之后,客户端无法推断哪些在途帧已被接受。通过另一条传输重发这些不确定的帧,可能会造成重复或乱序。

对于自定义 WebSocket 传输,请在投入生产之前为你的应用设计好确认、序列检查点和恢复行为。否则,请使用上面的串行化 HTTP 传输或浏览器客户端——其生命周期随 SDK 一同维护。

迁移期间附加元数据 ​

使用相同的录制 ID 和公开写入密钥,将元数据 POST 到录制的元数据端点,优先使用稳定的不透明标识符,而不是姓名或电子邮件地址。

javascript
async function addRecordingMetadata(metadata) {
  const response = await fetch(
    `https://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/metadata`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${publicApiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(metadata),
    },
  );

  if (!response.ok) {
    throw new Error(`Metadata write failed with ${response.status}`);
  }
}

await addRecordingMetadata({
  user_id: 'user-123',
  environment: 'migration-test',
  ingestion_version: 'cloud-v1',
});

关于命名、更新和服务端补充,请参阅应用元数据。

切换前的验证 ​

  1. 使用非敏感的测试页面和全新的录制 ID。
  2. 确认初始的 Meta 事件和 FullSnapshot(全量快照)事件先于增量事件到达。
  3. 在仪表板中找到该录制,回放导航、点击、滚动和脱敏输入。
  4. 确认元数据筛选能返回该测试录制。
  5. 演练 WebSocket 失败、POST 重试、页面导航和你的会话边界。
  6. 与现有管道比较事件数量和回放时长。

通过双写逐步上线 ​

将 Cloud 投递放在功能开关之后,并从内部流量开始。双写期间,将每个 rrweb 事件同时发送到旧目标和 Cloud 目标,且不要在两次发送之间修改事件。使用专门的 Cloud 录制 ID 映射,避免重试意外并入无关会话。

只有当仪表板、错误、已接受的批次、已存储的元数据和实际回放都一致时,才增加流量。除了请求成功率之外,还要监控存储量:传输成功并不能证明录制是有用的。

回滚 ​

在观察窗口结束之前,保持旧传输及其配置处于可部署状态。要回滚,请关闭 Cloud 功能开关,停止创建新的 Cloud 录制 ID,并继续使用现有目标。事故期间不要删除双写的录制;保留它们以便对比,事后按你的正常保留流程处理。

稳定之后,移除双写代码,并记录哪个系统负责录制 ID、元数据、重试队列和运维告警。