从 rrweb 迁移到 rrweb Cloud
保留 rrweb 的事件格式,将摄取、存储和检索迁移到 rrweb Cloud。选择能保留你实际需要的行为的最小迁移方案。
| 场景 | 推荐路径 |
|---|---|
| 标准录制器配置 | 用浏览器客户端替换传输层 |
| 自定义录制器构建或自定义传输 | 保留录制器,手动发送经过认证的事件 |
两条路径都使用公开写入密钥进行浏览器摄取。公开密钥可以追加事件和元数据,但不能读取录制。不要在浏览器代码中放入私有 API 密钥。
路径 1:用 Browser Client 替换标准传输
如果你的集成使用受支持的选项调用 record(),且主要用于上传 emit 结果,请用浏览器客户端替换传输层;它会将 rrweb 选项转发给 record(),并负责管理录制 ID、WebSocket 投递、缓冲和 HTTP 回退。
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 传输发送同一事件:
start({
publicApiKey: 'public_key_rr_your_key',
emit(event) {
updateLocalDiagnostics(event);
},
});路径 2:保留自定义录制器或传输
当你运行打过补丁的录制器、对事件流进行转换,或需要自定义传输生命周期时,请保留手动摄取;此时你需要自行负责 UUID、事件顺序、重试语义、认证、缓冲和卸载行为。
创建并保留录制 ID
Cloud 要求每条摄取路由中都包含一个 UUID;对于应在同源页面导航间延续的浏览器标签页,只生成一次 UUID 并将其存储在 sessionStorage 中。
一个录制 ID 必须标识一条有序的 rrweb 事件流。
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 请求头。下面的示例会让队首事件保持排队,直到该验收检查通过;当投递被拒绝或结果不确定时,它会停止,而不是自动重试:
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 路径:
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 当作投递保证。
等效的命令行请求为:
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 字符串长度来衡量:
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 到录制的元数据端点,优先使用稳定的不透明标识符,而不是姓名或电子邮件地址。
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',
});关于命名、更新和服务端补充,请参阅应用元数据。
切换前的验证
- 使用非敏感的测试页面和全新的录制 ID。
- 确认初始的 Meta 事件和 FullSnapshot(全量快照)事件先于增量事件到达。
- 在仪表板中找到该录制,回放导航、点击、滚动和脱敏输入。
- 确认元数据筛选能返回该测试录制。
- 演练 WebSocket 失败、POST 重试、页面导航和你的会话边界。
- 与现有管道比较事件数量和回放时长。
通过双写逐步上线
将 Cloud 投递放在功能开关之后,并从内部流量开始。双写期间,将每个 rrweb 事件同时发送到旧目标和 Cloud 目标,且不要在两次发送之间修改事件。使用专门的 Cloud 录制 ID 映射,避免重试意外并入无关会话。
只有当仪表板、错误、已接受的批次、已存储的元数据和实际回放都一致时,才增加流量。除了请求成功率之外,还要监控存储量:传输成功并不能证明录制是有用的。
回滚
在观察窗口结束之前,保持旧传输及其配置处于可部署状态。要回滚,请关闭 Cloud 功能开关,停止创建新的 Cloud 录制 ID,并继续使用现有目标。事故期间不要删除双写的录制;保留它们以便对比,事后按你的正常保留流程处理。
稳定之后,移除双写代码,并记录哪个系统负责录制 ID、元数据、重试队列和运维告警。