diff --git a/.changeset/browser-client-sequence-id.md b/.changeset/browser-client-sequence-id.md
new file mode 100644
index 0000000000..95775976cf
--- /dev/null
+++ b/.changeset/browser-client-sequence-id.md
@@ -0,0 +1,5 @@
+---
+"@rrweb/browser-client": patch
+---
+
+Add browser-client `sequenceId` assignment for Cloud-bound events.
diff --git a/.changeset/sequential-id-record-start-id.md b/.changeset/sequential-id-record-start-id.md
new file mode 100644
index 0000000000..304f68e6c3
--- /dev/null
+++ b/.changeset/sequential-id-record-start-id.md
@@ -0,0 +1,5 @@
+---
+"@rrweb/rrweb-plugin-sequential-id-record": patch
+---
+
+Allow the sequential-id record plugin to resume from a provided starting ID.
diff --git a/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md b/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md
new file mode 100644
index 0000000000..65594143f3
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md
@@ -0,0 +1,1246 @@
+# Browser Client Sequence ID Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add stable top-level `sequenceId` values to every event sent by `@rrweb/browser-client`, continuing per recording across same-tab page navigations.
+
+**Architecture:** Browser-client owns the final outbound sequence state and persists it per `recordingId` in `sessionStorage`. The existing sequential-id record plugin is extended with `startId` and `preserveExisting` so browser-client can include it in the rrweb pipeline without duplicating or overwriting valid IDs.
+
+**Tech Stack:** TypeScript, Yarn workspaces, Vite, Vitest, happy-dom, Puppeteer integration tests, rrweb record plugins.
+
+---
+
+## File Structure
+
+- Modify `packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts`: add `startId` and `preserveExisting` options with backward-compatible defaults.
+- Create `packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts`: plugin unit tests for default behavior, `startId`, invalid `startId`, and `preserveExisting`.
+- Create `packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts`: match the existing console-record plugin test config.
+- Modify `packages/plugins/rrweb-plugin-sequential-id-record/package.json`: add `test` and `test:watch` scripts and Vitest dev dependency.
+- Modify `packages/browser-client/package.json`: add `@rrweb/rrweb-plugin-sequential-id-record` dependency.
+- Modify `packages/browser-client/tsconfig.json`: add a project reference to the sequential-id record plugin package.
+- Modify `packages/browser-client/src/index.ts`: add sequence storage/state helpers, normalize every serialized event, construct a fresh browser-client sequential-id plugin before each `record()` call, and clear sequence state on permanent stop.
+- Create `packages/browser-client/test/sequence-id.test.ts`: focused happy-dom tests for metadata, rrweb events, custom events, close events, resume, malformed storage, delayed start, freeze restart, and plugin options.
+- Modify `packages/browser-client/test/integration.test.ts`: assert returned Cloud/server events have numeric increasing `sequenceId` before normalizing for event comparison.
+- Create `.changeset/browser-client-sequence-id.md`: patch release note for browser-client and sequential-id plugin.
+
+---
+
+### Task 1: Add Sequential ID Plugin Tests
+
+**Files:**
+
+- Create: `packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts`
+- Create: `packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts`
+- Modify: `packages/plugins/rrweb-plugin-sequential-id-record/package.json`
+
+- [ ] **Step 1: Add test scripts and Vitest dependency**
+
+Edit `packages/plugins/rrweb-plugin-sequential-id-record/package.json` so `scripts` includes `test` and `test:watch`, and `devDependencies` includes `vitest`.
+
+```json
+{
+ "scripts": {
+ "dev": "vite build --watch",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "build": "yarn turbo run prepublish",
+ "check-types": "tsc -noEmit",
+ "prepublish": "tsc -noEmit && vite build"
+ },
+ "devDependencies": {
+ "rrweb": "^2.0.0",
+ "typescript": "^5.4.5",
+ "vite": "^6.0.1",
+ "vite-plugin-dts": "^3.9.1",
+ "vitest": "^1.4.0"
+ }
+}
+```
+
+- [ ] **Step 2: Add Vitest config**
+
+Create `packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts`.
+
+```ts
+///
+import { defineProject, mergeConfig } from 'vitest/config';
+import configShared from '../../../vitest.config.ts';
+
+export default mergeConfig(configShared, defineProject({}));
+```
+
+- [ ] **Step 3: Write failing plugin tests**
+
+Create `packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts`.
+
+```ts
+import { describe, expect, it } from 'vitest';
+import type { eventWithTime } from '@rrweb/types';
+import { EventType } from '@rrweb/types';
+import { getRecordSequentialIdPlugin } from '../src/index';
+
+function event(sequenceId?: unknown): eventWithTime & { sequenceId?: unknown } {
+ const e: eventWithTime & { sequenceId?: unknown } = {
+ timestamp: 1,
+ type: EventType.Custom,
+ data: {
+ tag: 'test',
+ payload: {},
+ },
+ };
+ if (sequenceId !== undefined) {
+ e.sequenceId = sequenceId;
+ }
+ return e;
+}
+
+describe('getRecordSequentialIdPlugin', () => {
+ it('keeps default behavior of assigning 1 then 2', () => {
+ const plugin = getRecordSequentialIdPlugin({ key: 'sequenceId' });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 1 });
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 2 });
+ });
+
+ it('starts generated ids after startId', () => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId: 10,
+ });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 11 });
+ });
+
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5])(
+ 'treats invalid startId %s as 0',
+ (startId) => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId,
+ });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({
+ sequenceId: 1,
+ });
+ },
+ );
+
+ it('overwrites an existing key by default', () => {
+ const plugin = getRecordSequentialIdPlugin({ key: 'sequenceId' });
+
+ expect(plugin.eventProcessor?.(event(50))).toMatchObject({
+ sequenceId: 1,
+ });
+ });
+
+ it('preserves existing positive integer ids and advances the counter', () => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ preserveExisting: true,
+ });
+
+ expect(plugin.eventProcessor?.(event(50))).toMatchObject({
+ sequenceId: 50,
+ });
+ expect(plugin.eventProcessor?.(event())).toMatchObject({
+ sequenceId: 51,
+ });
+ });
+
+ it.each([0, -1, 1.5, Number.NaN, '2'])(
+ 'replaces invalid existing id %s when preserving existing ids',
+ (sequenceId) => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ preserveExisting: true,
+ });
+
+ expect(plugin.eventProcessor?.(event(sequenceId))).toMatchObject({
+ sequenceId: 1,
+ });
+ },
+ );
+});
+```
+
+- [ ] **Step 4: Run plugin tests and verify they fail**
+
+Run:
+
+```sh
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record test
+```
+
+Expected: tests fail with TypeScript/runtime errors because `startId` and `preserveExisting` do not exist yet and the plugin always starts from `0` internally.
+
+- [ ] **Step 5: Commit failing tests**
+
+```sh
+git add packages/plugins/rrweb-plugin-sequential-id-record/package.json packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts
+git commit -m "test: cover sequential id plugin options"
+```
+
+---
+
+### Task 2: Implement Sequential ID Plugin Options
+
+**Files:**
+
+- Modify: `packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts`
+- Test: `packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts`
+
+- [ ] **Step 1: Implement option normalization and preservation**
+
+Replace `packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts` with this implementation.
+
+```ts
+import type { RecordPlugin } from '@rrweb/types';
+
+export type SequentialIdOptions = {
+ key: string;
+ startId?: number;
+ preserveExisting?: boolean;
+};
+
+const defaultOptions: SequentialIdOptions = {
+ key: '_sid',
+ startId: 0,
+ preserveExisting: false,
+};
+
+export const PLUGIN_NAME = 'rrweb/sequential-id@1';
+
+function isValidSequenceId(value: unknown): value is number {
+ return Number.isInteger(value) && value > 0;
+}
+
+function normalizeStartId(value: unknown): number {
+ return Number.isInteger(value) && value >= 0 ? value : 0;
+}
+
+export const getRecordSequentialIdPlugin: (
+ options?: Partial,
+) => RecordPlugin = (options) => {
+ const _options = Object.assign({}, defaultOptions, options);
+ let id = normalizeStartId(_options.startId);
+
+ return {
+ name: PLUGIN_NAME,
+ eventProcessor(event) {
+ if (
+ _options.preserveExisting &&
+ isValidSequenceId((event as Record)[_options.key])
+ ) {
+ id = Math.max(id, (event as Record)[_options.key]);
+ return event;
+ }
+
+ Object.assign(event, {
+ [_options.key]: ++id,
+ });
+ return event;
+ },
+ options: _options,
+ };
+};
+```
+
+- [ ] **Step 2: Run plugin tests and typecheck**
+
+Run:
+
+```sh
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record test
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record check-types
+```
+
+Expected: both commands pass.
+
+- [ ] **Step 3: Commit plugin implementation**
+
+```sh
+git add packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts
+git commit -m "feat: add sequential id plugin start options"
+```
+
+---
+
+### Task 3: Add Browser Client Sequence Tests
+
+**Files:**
+
+- Create: `packages/browser-client/test/sequence-id.test.ts`
+
+- [ ] **Step 1: Write failing happy-dom tests**
+
+Create `packages/browser-client/test/sequence-id.test.ts`.
+
+```ts
+// @vitest-environment happy-dom
+import { EventType } from '@rrweb/types';
+import type { RecordPlugin } from '@rrweb/types';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+type QueueLike = {
+ items: string[];
+ add(value: string): void;
+ clear(): void;
+ length(): number;
+ read(): string | undefined;
+};
+
+type WebsocketLike = {
+ send: ReturnType;
+ close: ReturnType;
+ addEventListener: ReturnType;
+};
+
+type MockState = {
+ buffers: QueueLike[];
+ recordCalls: Array>;
+ websockets: WebsocketLike[];
+ hidden: boolean;
+ pluginOptions: Array>;
+};
+
+const mockState = vi.hoisted(
+ (): MockState => ({
+ buffers: [],
+ recordCalls: [],
+ websockets: [],
+ hidden: false,
+ pluginOptions: [],
+ }),
+);
+
+vi.mock('@rrweb/rrweb-plugin-sequential-id-record', () => {
+ function isPositiveInteger(value: unknown): value is number {
+ return Number.isInteger(value) && value > 0;
+ }
+
+ return {
+ getRecordSequentialIdPlugin: vi.fn((options: Record) => {
+ mockState.pluginOptions.push(options);
+ let id = Number(options.startId) || 0;
+ const plugin: RecordPlugin = {
+ name: 'rrweb/sequential-id@1',
+ options,
+ eventProcessor(event) {
+ const current = (event as Record)[
+ String(options.key)
+ ];
+ if (options.preserveExisting && isPositiveInteger(current)) {
+ id = Math.max(id, current);
+ return event;
+ }
+ Object.assign(event, { [String(options.key)]: ++id });
+ return event;
+ },
+ };
+ return plugin;
+ }),
+ };
+});
+
+vi.mock('@rrweb/record', () => {
+ const record = vi.fn((options: Record) => {
+ mockState.recordCalls.push(options);
+ const event = {
+ timestamp: 1,
+ type: EventType.Meta,
+ data: {
+ href: document.location.href,
+ width: 1024,
+ height: 768,
+ },
+ };
+ const plugins = (options.plugins || []) as RecordPlugin[];
+ const processed = plugins.reduce(
+ (acc, plugin) => plugin.eventProcessor?.(acc) || acc,
+ event,
+ );
+ (options.emit as (event: unknown) => void)?.(processed);
+ return vi.fn();
+ });
+ record.addCustomEvent = vi.fn();
+ record.freezePage = vi.fn();
+ return { record };
+});
+
+vi.mock('websocket-ts', () => {
+ class ArrayQueue {
+ items: string[] = [];
+ add(value: string) {
+ this.items.push(value);
+ }
+ clear() {
+ this.items = [];
+ }
+ length() {
+ return this.items.length;
+ }
+ read() {
+ return this.items.shift();
+ }
+ }
+
+ class ExponentialBackoff {
+ constructor(
+ public readonly initial: number,
+ public readonly exponent: number,
+ ) {}
+ }
+
+ class Websocket {
+ send = vi.fn();
+ close = vi.fn();
+ addEventListener = vi.fn();
+ }
+
+ class WebsocketBuilder {
+ constructor(private readonly url: string) {}
+ withBuffer(buffer: QueueLike) {
+ mockState.buffers.push(buffer);
+ return this;
+ }
+ withBackoff() {
+ return this;
+ }
+ build() {
+ const ws = new Websocket();
+ mockState.websockets.push(ws);
+ return ws;
+ }
+ }
+
+ return {
+ ArrayQueue,
+ ExponentialBackoff,
+ Websocket,
+ WebsocketBuilder,
+ WebsocketEvent: {
+ open: 'open',
+ message: 'message',
+ close: 'close',
+ },
+ };
+});
+
+function setDocumentHidden(value: boolean) {
+ mockState.hidden = value;
+ Object.defineProperty(document, 'hidden', {
+ configurable: true,
+ get: () => mockState.hidden,
+ });
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => (mockState.hidden ? 'hidden' : 'visible'),
+ });
+}
+
+async function importFreshClient() {
+ vi.resetModules();
+ mockState.buffers = [];
+ mockState.recordCalls = [];
+ mockState.websockets = [];
+ mockState.pluginOptions = [];
+ return await import('../src/index');
+}
+
+function recordingId(): string {
+ const value = sessionStorage.getItem('rrweb-browser-client-recording-id');
+ expect(value).toBeTruthy();
+ return value as string;
+}
+
+function sequenceKey() {
+ return `rrweb-browser-client-sequence-id:${recordingId()}`;
+}
+
+function bufferEvents() {
+ return mockState.buffers.flatMap((buffer) =>
+ buffer.items.map((item) => JSON.parse(item) as Record),
+ );
+}
+
+function sentEvents() {
+ return mockState.websockets.flatMap((ws) =>
+ ws.send.mock.calls.map(
+ ([payload]) => JSON.parse(String(payload)) as Record,
+ ),
+ );
+}
+
+beforeEach(() => {
+ sessionStorage.clear();
+ document.body.innerHTML = '';
+ setDocumentHidden(false);
+ window.history.replaceState({}, '', 'http://localhost/');
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('@rrweb/browser-client sequenceId', () => {
+ it('assigns recording-meta before constructing the rrweb plugin', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 1 });
+ expect(mockState.pluginOptions[0]).toMatchObject({
+ key: 'sequenceId',
+ startId: 1,
+ preserveExisting: true,
+ });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 2 });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('2');
+ });
+
+ it('continues from stored sequence state on resume', async () => {
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+ sessionStorage.setItem(
+ 'rrweb-browser-client-sequence-id:recording-1',
+ '41',
+ );
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 42 });
+ expect(mockState.pluginOptions[0]).toMatchObject({ startId: 42 });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 43 });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('43');
+ });
+
+ it('recovers malformed stored sequence state by assigning 1', async () => {
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+ sessionStorage.setItem(
+ 'rrweb-browser-client-sequence-id:recording-1',
+ 'bad',
+ );
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 1 });
+ });
+
+ it('assigns ids to fallback custom events before recording is active', async () => {
+ const client = await importFreshClient();
+
+ client.addCustomEvent('pre-start', { ok: true });
+
+ expect(bufferEvents()[0]).toMatchObject({
+ type: EventType.Custom,
+ sequenceId: 1,
+ data: {
+ tag: 'pre-start',
+ },
+ });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('1');
+ });
+
+ it('assigns ids to close events and clears state on permanent stop', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ const key = sequenceKey();
+
+ client.stop(true);
+
+ expect(sentEvents().at(-1)).toMatchObject({
+ sequenceId: 3,
+ data: {
+ tag: 'close-permanent',
+ },
+ });
+ expect(sessionStorage.getItem(key)).toBeNull();
+ });
+
+ it('builds the plugin from latest state after delayed visible start', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ client.addCustomEvent('queued-while-hidden', {});
+
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(bufferEvents().map((event) => event.sequenceId)).toEqual([1, 2]);
+ expect(mockState.pluginOptions[0]).toMatchObject({ startId: 2 });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 3 });
+ });
+
+ it('builds a fresh plugin after freeze restart without accumulating browser-client plugins', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ document.dispatchEvent(new Event('freeze'));
+ client.addCustomEvent('between-records', {});
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockState.pluginOptions).toHaveLength(2);
+ expect(mockState.pluginOptions[1]).toMatchObject({ startId: 3 });
+ for (const call of mockState.recordCalls) {
+ const plugins = call.plugins as RecordPlugin[];
+ expect(
+ plugins.filter((plugin) => plugin.name === 'rrweb/sequential-id@1'),
+ ).toHaveLength(1);
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run the focused browser-client tests and verify they fail**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client retest test/sequence-id.test.ts
+```
+
+Expected: tests fail because browser-client does not import the plugin, assign `sequenceId`, persist sequence state, or clear the sequence key.
+
+- [ ] **Step 3: Commit failing browser-client tests**
+
+```sh
+git add packages/browser-client/test/sequence-id.test.ts
+git commit -m "test: cover browser client sequence ids"
+```
+
+---
+
+### Task 4: Implement Browser Client Sequence State
+
+**Files:**
+
+- Modify: `packages/browser-client/package.json`
+- Modify: `packages/browser-client/tsconfig.json`
+- Modify: `packages/browser-client/src/index.ts`
+- Test: `packages/browser-client/test/sequence-id.test.ts`
+
+- [ ] **Step 1: Add browser-client dependency and TypeScript reference**
+
+In `packages/browser-client/package.json`, add the dependency.
+
+```json
+{
+ "dependencies": {
+ "@rrweb/record": "^2.0.0",
+ "@rrweb/rrweb-plugin-sequential-id-record": "^2.0.0",
+ "@rrweb/types": "^2.0.0",
+ "@rrweb/utils": "^2.0.0",
+ "rrweb": "^2.0.0",
+ "websocket-ts": "^2.2.1"
+ }
+}
+```
+
+In `packages/browser-client/tsconfig.json`, add the plugin reference after `../record`.
+
+```json
+{
+ "references": [
+ {
+ "path": "../record"
+ },
+ {
+ "path": "../plugins/rrweb-plugin-sequential-id-record"
+ },
+ {
+ "path": "../types"
+ },
+ {
+ "path": "../utils"
+ },
+ {
+ "path": "../rrweb"
+ }
+ ]
+}
+```
+
+- [ ] **Step 2: Import the plugin and add sequence types/constants**
+
+In `packages/browser-client/src/index.ts`, add the import and constants near existing imports/constants.
+
+```ts
+import { record } from '@rrweb/record';
+import { getRecordSequentialIdPlugin } from '@rrweb/rrweb-plugin-sequential-id-record';
+
+import { EventType } from '@rrweb/types';
+```
+
+Add these types/constants near `sessionStorageName`.
+
+```ts
+type eventWithSequenceId = eventWithTime & {
+ sequenceId?: unknown;
+};
+
+type SequenceState = {
+ recordingId: string;
+ sequenceId: number;
+};
+
+const sessionStorageName = 'rrweb-browser-client-recording-id';
+const sequenceStoragePrefix = 'rrweb-browser-client-sequence-id:';
+let sequenceState: SequenceState | undefined;
+```
+
+- [ ] **Step 3: Add storage and sequence helper functions**
+
+Add these helpers after `getSetRecordingId()`.
+
+```ts
+function sequenceStorageName(recordingId: string): string {
+ return `${sequenceStoragePrefix}${recordingId}`;
+}
+
+function isValidSequenceId(value: unknown): value is number {
+ return Number.isInteger(value) && value > 0;
+}
+
+function readSequenceId(recordingId: string): number {
+ try {
+ const value = Number(
+ sessionStorage.getItem(sequenceStorageName(recordingId)),
+ );
+ return Number.isInteger(value) && value >= 0 ? value : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function persistSequenceId(state: SequenceState): void {
+ try {
+ sessionStorage.setItem(
+ sequenceStorageName(state.recordingId),
+ String(state.sequenceId),
+ );
+ } catch {
+ // Best-effort only; keep in-memory sequencing for this page.
+ }
+}
+
+function getCurrentRecordingId(): string | null {
+ try {
+ return sessionStorage.getItem(sessionStorageName);
+ } catch {
+ return null;
+ }
+}
+
+function getSequenceState(recordingId: string): SequenceState {
+ if (sequenceState?.recordingId === recordingId) {
+ return sequenceState;
+ }
+ sequenceState = {
+ recordingId,
+ sequenceId: readSequenceId(recordingId),
+ };
+ return sequenceState;
+}
+
+function removeSequenceId(recordingId: string): void {
+ try {
+ sessionStorage.removeItem(sequenceStorageName(recordingId));
+ } catch {
+ // Best-effort cleanup only.
+ }
+ if (sequenceState?.recordingId === recordingId) {
+ sequenceState = undefined;
+ }
+}
+
+function ensureSequenceId(
+ event: eventWithTime,
+ state: SequenceState,
+): eventWithTime & { sequenceId: number } {
+ const eventWithSequence = event as eventWithSequenceId;
+ if (isValidSequenceId(eventWithSequence.sequenceId)) {
+ state.sequenceId = Math.max(state.sequenceId, eventWithSequence.sequenceId);
+ } else {
+ state.sequenceId += 1;
+ eventWithSequence.sequenceId = state.sequenceId;
+ }
+ persistSequenceId(state);
+ return eventWithSequence as eventWithTime & { sequenceId: number };
+}
+
+function getSetSequenceState(): SequenceState | undefined {
+ const recordingId = getSetRecordingId();
+ return recordingId ? getSequenceState(recordingId) : undefined;
+}
+
+function buildRecordOptionsWithSequencePlugin(
+ recordOptions: Omit,
+ state: SequenceState,
+): Omit {
+ return {
+ ...recordOptions,
+ plugins: [
+ ...(recordOptions.plugins || []),
+ getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId: state.sequenceId,
+ preserveExisting: true,
+ }),
+ ],
+ };
+}
+```
+
+- [ ] **Step 4: Normalize metadata before buffering**
+
+In `start()`, after `recordingId` is known, initialize sequence state.
+
+```ts
+const recordingId = getSetRecordingId();
+if (!recordingId) {
+ console.error(
+ '@rrweb/browser-client: Unable to start(); sessionStorage unavailable',
+ );
+ return;
+}
+const activeSequenceState = getSequenceState(recordingId);
+```
+
+When adding `metaEvent` to `buffer`, wrap it.
+
+```ts
+// metadata event should be the first seen server side
+buffer.add(JSON.stringify(ensureSequenceId(metaEvent, activeSequenceState)));
+```
+
+- [ ] **Step 5: Normalize rrweb events before user callbacks and serialization**
+
+At the top of `recordOptions.emit`, normalize the event once.
+
+```ts
+recordOptions.emit = (event: eventWithTime) => {
+ const sequencedEvent = ensureSequenceId(event, activeSequenceState);
+ if (!ws) {
+ ws = connect(serverUrl, postUrl, publicApiKey, handleMessage);
+
+ ws.addEventListener(WebsocketEvent.close, () => {
+ wsConnectionPaused = true;
+ });
+ }
+ if (configEmit !== undefined) {
+ if (typeof configEmit === 'function') {
+ configEmit(sequencedEvent);
+ } else if (
+ typeof (window as unknown as Record)[configEmit] ===
+ 'function'
+ ) {
+ const emit = (window as unknown as Record)[
+ configEmit
+ ] as (event: eventWithTime) => void;
+ emit(sequencedEvent);
+ } else {
+ console.error('Could not understand emit config option:', configEmit);
+ }
+ }
+ if (sequencedEvent.type === EventType.Meta && includePii) {
+ const metaData = sequencedEvent.data as typeof sequencedEvent.data & {
+ title?: string;
+ referrer?: string;
+ };
+ metaData.title = document.title.substring(0, 500);
+ metaData.referrer = document.referrer;
+ }
+
+ const eventStr = JSON.stringify(sequencedEvent);
+ if (eventStr.length > wsLimit) {
+ void postData(postUrl, publicApiKey, eventStr);
+ } else if (ws && !wsConnectionPaused) {
+ ws.send(eventStr);
+ } else {
+ buffer.add(eventStr);
+ }
+};
+```
+
+- [ ] **Step 6: Build fresh record options for each `record()` call**
+
+Replace each `record(recordOptions as recordOptions)` call in `start()` with a helper that appends exactly one browser-client plugin from the latest sequence state.
+
+```ts
+const startRecording = () => {
+ rrwebStopFn = record(
+ buildRecordOptionsWithSequencePlugin(
+ recordOptions,
+ activeSequenceState,
+ ) as recordOptions,
+ );
+};
+
+let startWhenVisible = false;
+if (!document.hidden) {
+ startRecording();
+} else {
+ startWhenVisible = true;
+}
+```
+
+In the `visibilitychange` listener, use `startRecording()` instead of calling `record()` directly.
+
+```ts
+if (!document.hidden && startWhenVisible) {
+ startRecording();
+ startWhenVisible = false;
+ return;
+}
+```
+
+- [ ] **Step 7: Run focused browser-client tests**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client retest test/sequence-id.test.ts
+```
+
+Expected: tests pass.
+
+- [ ] **Step 8: Commit browser-client sequence implementation**
+
+```sh
+git add packages/browser-client/package.json packages/browser-client/tsconfig.json packages/browser-client/src/index.ts
+git commit -m "feat: add browser client sequence ids"
+```
+
+---
+
+### Task 5: Cover Custom Events, Close Events, and Reset Semantics
+
+**Files:**
+
+- Modify: `packages/browser-client/src/index.ts`
+- Test: `packages/browser-client/test/sequence-id.test.ts`
+
+- [ ] **Step 1: Ensure custom fallback events are sequenced**
+
+In `addCustomEvent()`, normalize fallback custom events before buffering. Replace the fallback branch with:
+
+```ts
+} else {
+ const customEvent: customEventWithTime = {
+ timestamp: nowTimestamp(),
+ type: EventType.Custom,
+ data: {
+ tag,
+ payload,
+ },
+ };
+ const state = getSetSequenceState();
+ if (!state) {
+ console.error(
+ '@rrweb/browser-client: Unable to addCustomEvent(); sessionStorage unavailable',
+ );
+ return;
+ }
+ buffer.add(JSON.stringify(ensureSequenceId(customEvent, state)));
+}
+```
+
+- [ ] **Step 2: Ensure close events are sequenced and reset clears the key**
+
+At the start of `stop(resetRecordingId)`, capture the current recording ID without creating a new one.
+
+```ts
+export function stop(resetRecordingId: boolean) {
+ const recordingId = getCurrentRecordingId();
+ const state = recordingId ? getSequenceState(recordingId) : undefined;
+```
+
+When sending `closeEvent`, normalize it if `state` exists.
+
+```ts
+const eventStr = state
+ ? JSON.stringify(ensureSequenceId(closeEvent, state))
+ : JSON.stringify(closeEvent);
+buffer.clear();
+ws.send(eventStr);
+```
+
+When resetting, remove the sequence ID before removing the recording ID.
+
+```ts
+if (resetRecordingId) {
+ if (recordingId) {
+ removeSequenceId(recordingId);
+ }
+ removeRecordingId();
+}
+```
+
+- [ ] **Step 3: Run focused browser-client tests**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client retest test/sequence-id.test.ts
+```
+
+Expected: tests pass, including `addCustomEvent()` fallback and `stop(true)` reset coverage.
+
+- [ ] **Step 4: Run browser-client typecheck**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client check-types
+```
+
+Expected: typecheck passes.
+
+- [ ] **Step 5: Commit event path completion**
+
+```sh
+git add packages/browser-client/src/index.ts packages/browser-client/test/sequence-id.test.ts
+git commit -m "fix: sequence browser client custom events"
+```
+
+---
+
+### Task 6: Update Integration Assertions
+
+**Files:**
+
+- Modify: `packages/browser-client/test/integration.test.ts`
+
+- [ ] **Step 1: Add a helper for increasing sequence IDs**
+
+Near `pollUntil()` in `packages/browser-client/test/integration.test.ts`, add:
+
+```ts
+function expectIncreasingSequenceIds(events: Array>) {
+ let previous = 0;
+ for (const event of events) {
+ expect(event.sequenceId).toEqual(expect.any(Number));
+ expect(Number.isInteger(event.sequenceId)).toBe(true);
+ expect(event.sequenceId as number).toBeGreaterThan(previous);
+ previous = event.sequenceId as number;
+ }
+}
+```
+
+- [ ] **Step 2: Assert sequence IDs before server-event normalization**
+
+In the `can roundtrip events` test, after `expect(serverEvents.length).toBeGreaterThan(1);`, add:
+
+```ts
+expectIncreasingSequenceIds(serverEvents as Array>);
+```
+
+Keep the existing deletion of `sequenceId` before comparing `snapshots` to `serverEvents`.
+
+- [ ] **Step 3: Assert sequence IDs across stop/restart replay results**
+
+In the `can clear recordingId using stop()` test, before collecting custom events, add:
+
+```ts
+for (const id of recordingIds) {
+ const eventsForRecording = serverEvents.filter(
+ (event) => event.recordingId === id,
+ ) as Array>;
+ expectIncreasingSequenceIds(eventsForRecording);
+}
+```
+
+If this cannot be placed before `recordingIds` is populated, split collection into two passes:
+
+```ts
+const recordingIds = new Set();
+serverEvents.forEach((e) => {
+ if ('recordingId' in e && typeof e.recordingId === 'string') {
+ recordingIds.add(e.recordingId);
+ }
+});
+for (const id of recordingIds) {
+ const eventsForRecording = serverEvents.filter(
+ (event) => event.recordingId === id,
+ ) as Array>;
+ expectIncreasingSequenceIds(eventsForRecording);
+}
+const customEvents = [];
+serverEvents.forEach((e) => {
+ if (e.type === EventType.Custom) {
+ customEvents.push(e);
+ }
+});
+```
+
+- [ ] **Step 4: Run browser-client tests without external Cloud env**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client retest test/sequence-id.test.ts test/load-diagnostics.test.ts
+```
+
+Expected: local happy-dom tests pass. Integration tests remain skipped unless `VITE_TEST_API_KEY` is set.
+
+- [ ] **Step 5: Commit integration assertions**
+
+```sh
+git add packages/browser-client/test/integration.test.ts
+git commit -m "test: assert browser client sequence ids"
+```
+
+---
+
+### Task 7: Add Release Metadata and Documentation
+
+**Files:**
+
+- Create: `.changeset/browser-client-sequence-id.md`
+- Modify: `packages/browser-client/README.md`
+- Modify: `packages/plugins/rrweb-plugin-sequential-id-record/README.md`
+
+- [ ] **Step 1: Add changeset**
+
+Create `.changeset/browser-client-sequence-id.md`.
+
+```md
+---
+'@rrweb/browser-client': patch
+'@rrweb/rrweb-plugin-sequential-id-record': patch
+---
+
+Add browser-client `sequenceId` assignment for Cloud-bound events and allow the sequential-id record plugin to resume from a provided starting ID.
+```
+
+- [ ] **Step 2: Document browser-client sequencing behavior**
+
+In `packages/browser-client/README.md`, add this paragraph under `Recording Helpers` after the `stop(resetRecordingId)` bullet.
+
+```md
+Browser-client events sent to rrweb Cloud include a top-level `sequenceId`. The client stores the latest assigned sequence ID in `sessionStorage` per recording ID, so same-tab page navigations continue ordering from the previous page. Sequence IDs are persisted when events are queued or sent, so gaps can appear if a page exits before an event reaches the server.
+```
+
+- [ ] **Step 3: Document plugin options**
+
+In `packages/plugins/rrweb-plugin-sequential-id-record/README.md`, update the usage snippet to include the new options.
+
+```js
+record({
+ emit: function emit(event) {
+ // send events to server
+ },
+ plugins: [
+ getRecordSequentialIdPlugin({
+ key: '_sid', // default value
+ startId: 0, // next generated id will be startId + 1
+ preserveExisting: false, // keep current overwrite behavior by default
+ }),
+ ],
+});
+```
+
+Add this paragraph after the snippet:
+
+```md
+Use `startId` when recording resumes from a known last ID. Set `preserveExisting` to `true` if another event processor may already provide a valid positive integer at the configured key.
+```
+
+- [ ] **Step 4: Commit release metadata and docs**
+
+```sh
+git add .changeset/browser-client-sequence-id.md packages/browser-client/README.md packages/plugins/rrweb-plugin-sequential-id-record/README.md
+git commit -m "docs: document browser client sequence ids"
+```
+
+---
+
+### Task 8: Final Verification
+
+**Files:**
+
+- Verify all touched implementation, test, package, docs, and changeset files.
+
+- [ ] **Step 1: Run plugin verification**
+
+Run:
+
+```sh
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record test
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record check-types
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record build
+```
+
+Expected: all commands pass.
+
+- [ ] **Step 2: Run browser-client focused verification**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client check-types
+yarn workspace @rrweb/browser-client build
+yarn workspace @rrweb/browser-client retest test/sequence-id.test.ts test/load-diagnostics.test.ts
+```
+
+Expected: all commands pass.
+
+- [ ] **Step 3: Run browser-client integration command**
+
+Run:
+
+```sh
+yarn workspace @rrweb/browser-client retest test/integration.test.ts
+```
+
+Expected without `VITE_TEST_API_KEY`: suite is skipped by `describe.skip`. Expected with env configured: integration tests pass and assert increasing sequence IDs.
+
+- [ ] **Step 4: Inspect final git state**
+
+Run:
+
+```sh
+git status --short
+git log --oneline -8
+```
+
+Expected: only known unrelated user changes remain unstaged, especially `packages/rrweb-player/.svelte-kit/ambient.d.ts` if it is still present. Recent commits should show the task commits from this plan.
diff --git a/docs/superpowers/specs/2026-06-02-browser-client-sequence-id-design.md b/docs/superpowers/specs/2026-06-02-browser-client-sequence-id-design.md
new file mode 100644
index 0000000000..46b97407cd
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-02-browser-client-sequence-id-design.md
@@ -0,0 +1,159 @@
+# Browser Client Sequence ID Design
+
+## Context
+
+`@rrweb/browser-client` records rrweb events and sends them to an rrweb Cloud-compatible API over WebSocket, with HTTP POST fallback for buffered or oversized events. It stores the active recording ID in `sessionStorage` using `rrweb-browser-client-recording-id`, so a recording can continue across same-tab page navigations.
+
+rrweb already has `@rrweb/rrweb-plugin-sequential-id-record`, but the current record plugin always starts at `1` and only processes events emitted through rrweb's `record()` pipeline. The browser client also creates events itself, including `recording-meta`, queued custom events, and close events. Those events need the same top-level ordering field if Cloud ingestion and cursor pagination are going to rely on it.
+
+## Goals
+
+- Include `@rrweb/rrweb-plugin-sequential-id-record` in the browser-client bundle.
+- Send every browser-client event to Cloud with a top-level numeric `sequenceId`.
+- Continue `sequenceId` across same-tab page navigations for the same `recordingId`.
+- Avoid reusing browser-client-assigned IDs even when transport failures, page freezes, or navigation interrupts happen.
+- Preserve existing sequential-id plugin behavior for callers that do not use the new options.
+- Keep the change focused on browser-client sequencing and the plugin options needed to support it.
+
+## Non-Goals
+
+- Do not add server-side sequencing or reconciliation.
+- Do not guarantee gap-free sequences. Gaps are acceptable and useful for diagnosing capture or transport issues.
+- Do not change recording ID ownership or allow caller-supplied recording IDs.
+- Do not refactor browser-client transport behavior beyond routing events through the sequence helper before serialization.
+
+## Proposed Approach
+
+Use browser-client-owned outbound sequencing, with the existing record plugin included in the rrweb recording pipeline.
+
+1. Add `@rrweb/rrweb-plugin-sequential-id-record` as a browser-client dependency.
+2. Extend the record plugin with `startId?: number` and `preserveExisting?: boolean` options. The plugin's internal counter starts from `startId`, then pre-increments as it does today. Existing default behavior still starts emitted IDs at `1` and overwrites the configured key.
+3. In browser-client, initialize sequence state for the active `recordingId` before serializing browser-client-created events.
+4. Add a browser-client `ensureSequenceId(event)` helper that guarantees every outbound event has a valid top-level `sequenceId`.
+5. Apply `ensureSequenceId()` before every browser-client `JSON.stringify()` event path.
+6. Build a fresh sequential-id plugin immediately before each `record()` call, using the current browser-client sequence state: `getRecordSequentialIdPlugin({ key: 'sequenceId', startId: currentSequenceId, preserveExisting: true })`.
+7. Persist the latest assigned or observed sequence ID immediately when the event is queued or sent, not after server acknowledgement.
+8. Clear the persisted sequence ID when `stop(true)` clears the active recording ID.
+
+This approach intentionally permits gaps. If an event receives a sequence ID and the browser exits before the event reaches Cloud, the next page continues after that assigned ID instead of reusing it.
+
+## Architecture
+
+### Sequential ID Plugin
+
+`SequentialIdOptions` becomes:
+
+```ts
+export type SequentialIdOptions = {
+ key: string;
+ startId?: number;
+ preserveExisting?: boolean;
+};
+```
+
+The plugin normalizes `startId` to `0` unless it is a finite non-negative integer. With `startId: 5`, the next processed rrweb event receives `6`. Fractional, negative, missing, or non-finite `startId` values behave like `0`.
+
+`preserveExisting` defaults to `false`, which preserves the plugin's current overwrite behavior. When `preserveExisting: true`, the plugin keeps an existing finite positive integer at the configured key and advances its internal counter to at least that value. Invalid existing values are replaced with the next plugin-generated ID.
+
+### Browser Client Sequence State
+
+Browser-client adds helpers around a storage key derived from the active recording ID, for example:
+
+```ts
+rrweb-browser-client-sequence-id:${recordingId}
+```
+
+Reads tolerate missing, malformed, negative, non-finite, or unavailable storage values by falling back to `0`. Writes are best-effort. Because `start()` already returns early when it cannot get a recording ID, sequence storage can fail without stopping the current page's recording.
+
+The in-memory sequence state is module-level and keyed by the active `recordingId`:
+
+1. `recordingId = getSetRecordingId()`
+2. `sequenceState = getSequenceState(recordingId)`
+3. `sequenceState` initializes from `readStoredSequenceId(recordingId)`
+4. `ensureSequenceId(event)` uses the current `sequenceState`
+
+`addCustomEvent()` fallback paths that run before active recording must also obtain or create the active recording ID and sequence state before serializing the event. If session storage cannot provide a recording ID, `start()` cannot send events anyway, so no Cloud-bound event is expected from that page.
+
+### Event Normalization
+
+`ensureSequenceId(event)` mutates the event before serialization:
+
+- If `event.sequenceId` is a finite positive integer, keep it and advance local state to at least that value.
+- If `event.sequenceId` is missing or invalid, assign `currentSequenceId + 1`.
+- Persist the updated latest ID immediately.
+
+The helper should be used for:
+
+- the initial `recording-meta` event,
+- rrweb events emitted by `recordOptions.emit`,
+- queued events created by `addCustomEvent()` before recording is active,
+- close events created by `stop()`,
+- oversized events sent directly through POST,
+- buffered WebSocket and fallback POST events.
+
+## Data Flow
+
+When a new recording starts, no sequence key exists for its new recording ID, so the first browser-client event receives `sequenceId: 1`.
+
+When `start()` creates the initial `recording-meta` event, browser-client assigns and persists its `sequenceId` before constructing the rrweb plugin. Browser-client then builds the plugin from the updated current sequence state. This prevents the first rrweb-emitted event from reusing the metadata event's ID.
+
+When a same-tab navigation resumes an existing recording ID, browser-client reads the last stored sequence ID, assigns any browser-client-created events from that state, and builds the rrweb plugin immediately before `record()` starts. The next outbound event receives `last + 1`.
+
+If recording is delayed until the page becomes visible, or restarted after a page freeze, browser-client must not reuse an old plugin instance. It builds a fresh plugin at the moment it calls `record()`, after any queued browser-client-created events have advanced sequence state.
+
+If caller-provided rrweb plugins exist, browser-client appends the sequential-id plugin after them with `preserveExisting: true`. This keeps user event processors first while avoiding overwriting a valid caller-supplied `sequenceId`. If a user plugin creates an invalid `sequenceId`, the sequential-id plugin replaces it with the next generated value.
+
+## Error Handling
+
+Malformed stored sequence values reset to `0`. Storage write failures are ignored after the current in-memory state has advanced.
+
+Transport failures do not roll back sequence IDs. This avoids duplicate IDs across navigation or retries. Failed sends can therefore create gaps, and those gaps are expected.
+
+If a user plugin creates an invalid `sequenceId`, browser-client replaces it with the next valid number. If it creates a valid value lower than the current state, browser-client preserves the event value but does not move the stored state backward.
+
+## Testing
+
+Add focused browser-client tests in the existing happy-dom test area or a new adjacent test file:
+
+- `start()` assigns `sequenceId` to `recording-meta` before constructing the sequential-id plugin.
+- `start()` appends a sequential-id plugin with `key: 'sequenceId'`, `startId` from current sequence state, and `preserveExisting: true`.
+- rrweb-emitted events get top-level `sequenceId`.
+- browser-client-created `recording-meta`, `addCustomEvent()` fallback events, and `stop()` close events get top-level `sequenceId`.
+- sequence state persists immediately on enqueue/send.
+- same-recording resume starts from the stored sequence ID.
+- `stop(true)` clears the persisted sequence key for the active recording ID.
+- malformed stored sequence values recover by assigning `1`.
+- delayed recording start and recording restart build the plugin from the latest browser-client sequence state, so the first rrweb event does not duplicate an earlier browser-client-created event.
+
+Add plugin-level tests for `@rrweb/rrweb-plugin-sequential-id-record`:
+
+- default behavior still assigns `1`, then `2`,
+- `startId` makes the next processed event start at `startId + 1`,
+- invalid `startId` values behave like `0`,
+- `preserveExisting: true` keeps a valid existing key, advances the internal counter, and replaces invalid existing values.
+
+Update the browser-client integration expectations so returned server events explicitly assert numeric, increasing `sequenceId` values instead of only deleting the field before comparison. If Cloud returns extra fields that still need to be ignored for deep event comparison, keep that normalization after the explicit `sequenceId` assertion.
+
+## Verification
+
+Run focused verification after implementation:
+
+```sh
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record check-types
+yarn workspace @rrweb/rrweb-plugin-sequential-id-record build
+yarn workspace @rrweb/browser-client check-types
+yarn workspace @rrweb/browser-client build
+yarn workspace @rrweb/browser-client retest
+```
+
+If a plugin test script is added, run that package's test command as well.
+
+## Risks
+
+The browser-client must normalize every serialization path. Missing one path would reintroduce null or absent `sequenceId` values.
+
+The plugin and browser-client helper both touch `sequenceId` for rrweb-emitted events. They must share the same starting state so they do not diverge during normal recording.
+
+Preserving valid user-supplied `sequenceId` values means a user plugin can still create duplicates if it intentionally or accidentally emits lower values. Browser-client prevents reuse for IDs it assigns itself.
+
+Persisting on enqueue/send can create gaps when events are lost before reaching Cloud. This is intentional, but tests should verify no duplicate IDs are created during resume.
diff --git a/packages/browser-client/README.md b/packages/browser-client/README.md
index 65144a55bd..1b18bde8fc 100644
--- a/packages/browser-client/README.md
+++ b/packages/browser-client/README.md
@@ -65,6 +65,8 @@ rrwebBrowserClient.start({
- `addCustomEvent(tag, payload)`: queues a custom rrweb event.
- `stop(resetRecordingId)`: stops rrweb recording and closes the WebSocket. Pass `true` to clear the stored recording id before a future `start()`.
+Browser-client events sent to rrweb Cloud include a top-level `sequenceId`. The client stores the latest assigned sequence ID in `sessionStorage` per recording ID, so same-tab page navigations continue ordering from the previous page. Sequence IDs are persisted when events are queued or sent, so gaps can appear if a page exits before an event reaches the server.
+
## Further Reading
- [JavaScript SDK guide](https://rrweb.com/docs/cloud/javascript-sdk)
diff --git a/packages/browser-client/package.json b/packages/browser-client/package.json
index cfcf41f0f1..2b50baefc3 100644
--- a/packages/browser-client/package.json
+++ b/packages/browser-client/package.json
@@ -65,6 +65,7 @@
},
"dependencies": {
"@rrweb/record": "^2.0.1",
+ "@rrweb/rrweb-plugin-sequential-id-record": "^2.0.1",
"@rrweb/types": "^2.0.1",
"@rrweb/utils": "^2.0.1",
"rrweb": "^2.0.1",
diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts
index dc953fa1bc..99f689ba71 100644
--- a/packages/browser-client/src/index.ts
+++ b/packages/browser-client/src/index.ts
@@ -1,4 +1,5 @@
import { record } from '@rrweb/record';
+import { getRecordSequentialIdPlugin } from '@rrweb/rrweb-plugin-sequential-id-record';
import { EventType } from '@rrweb/types';
import type { customEvent, eventWithTime, listenerHandler } from '@rrweb/types';
@@ -46,6 +47,15 @@ export type customEventWithTime = customEvent & {
timestamp: number;
};
+type eventWithSequenceId = eventWithTime & {
+ sequenceId?: unknown;
+};
+
+type SequenceState = {
+ recordingId: string;
+ sequenceId: number;
+};
+
const defaultServerUrl =
'https://api.rrweb.com/recordings/{recordingId}/events/ws';
let defaultClientConfig: clientConfig = {
@@ -55,12 +65,16 @@ let defaultClientConfig: clientConfig = {
includePii: false,
};
const sessionStorageName = 'rrweb-browser-client-recording-id';
+const sequenceStoragePrefix = 'rrweb-browser-client-sequence-id:';
+const browserClientStartTokenPrefix = '__rrweb_browser_client_start_token__:';
const wsLimit = 10e5; // this is approximate and depends on the browser, also on how unicode is encoded (we are comparing against the length of a javascript string)
+let sequenceState: SequenceState | undefined;
let ws: Websocket | undefined;
let wsConnectionPaused = false;
const buffer: ArrayQueue = new ArrayQueue();
+const activeStartTokenNames = new Set();
let rrwebStopFn: listenerHandler | undefined;
function isServerMessage(value: unknown): value is ServerMessage {
@@ -86,12 +100,19 @@ function scriptSourceFromElement(
}
export function stop(resetRecordingId: boolean) {
+ if (resetRecordingId) {
+ invalidateActiveStart();
+ }
// reset all state so that start() can start afresh
if (rrwebStopFn !== undefined) {
rrwebStopFn();
rrwebStopFn = undefined;
}
if (ws) {
+ const recordingId = getCurrentRecordingId();
+ const activeSequenceState = recordingId
+ ? getSequenceState(recordingId)
+ : undefined;
const closeEvent: customEventWithTime = {
timestamp: nowTimestamp(),
type: EventType.Custom,
@@ -103,7 +124,13 @@ export function stop(resetRecordingId: boolean) {
// Clear old events before send, so websocket-ts can buffer the close event
// for the HTTP fallback when the socket is not currently open.
buffer.clear();
- ws.send(JSON.stringify(closeEvent));
+ ws.send(
+ JSON.stringify(
+ activeSequenceState
+ ? ensureSequenceId(closeEvent, activeSequenceState)
+ : closeEvent,
+ ),
+ );
ws.close();
wsConnectionPaused = false;
ws = undefined; // so `emit` can restart it again if page is unfrozen
@@ -111,12 +138,35 @@ export function stop(resetRecordingId: boolean) {
buffer.clear();
}
if (resetRecordingId) {
+ const recordingId = getCurrentRecordingId();
+ if (recordingId) {
+ removeSequenceId(recordingId);
+ }
removeRecordingId();
}
}
function removeRecordingId(): void {
- sessionStorage.removeItem(sessionStorageName);
+ try {
+ sessionStorage.removeItem(sessionStorageName);
+ } catch {
+ // Best-effort cleanup only.
+ }
+}
+
+function invalidateActiveStart(): void {
+ const recordingId = getCurrentRecordingId();
+ const windowState = window as unknown as Record;
+ if (recordingId) {
+ const tokenName = browserClientStartTokenName(recordingId);
+ delete windowState[tokenName];
+ activeStartTokenNames.delete(tokenName);
+ return;
+ }
+ activeStartTokenNames.forEach((tokenName) => {
+ delete windowState[tokenName];
+ activeStartTokenNames.delete(tokenName);
+ });
}
function getSetVisitorId() {
@@ -164,6 +214,111 @@ function getSetRecordingId(): string | null {
return value;
}
+function sequenceStorageName(recordingId: string): string {
+ return `${sequenceStoragePrefix}${recordingId}`;
+}
+
+function browserClientStartTokenName(recordingId: string): string {
+ return `${browserClientStartTokenPrefix}${recordingId}`;
+}
+
+function isValidSequenceId(value: unknown): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
+}
+
+function readSequenceId(recordingId: string): number {
+ try {
+ const value = Number(
+ sessionStorage.getItem(sequenceStorageName(recordingId)),
+ );
+ return Number.isInteger(value) && value >= 0 ? value : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function persistSequenceId(state: SequenceState): void {
+ try {
+ sessionStorage.setItem(
+ sequenceStorageName(state.recordingId),
+ String(state.sequenceId),
+ );
+ } catch {
+ // Best-effort only; keep in-memory sequencing for this page.
+ }
+}
+
+function getCurrentRecordingId(): string | null {
+ try {
+ return sessionStorage.getItem(sessionStorageName);
+ } catch {
+ return null;
+ }
+}
+
+function getSequenceState(recordingId: string): SequenceState {
+ if (sequenceState?.recordingId === recordingId) {
+ return sequenceState;
+ }
+ sequenceState = {
+ recordingId,
+ sequenceId: readSequenceId(recordingId),
+ };
+ return sequenceState;
+}
+
+function removeSequenceId(recordingId: string): void {
+ try {
+ sessionStorage.removeItem(sequenceStorageName(recordingId));
+ } catch {
+ // Best-effort cleanup only.
+ }
+ if (sequenceState?.recordingId === recordingId) {
+ sequenceState = undefined;
+ }
+}
+
+function ensureSequenceId(
+ event: eventWithTime,
+ state: SequenceState,
+): eventWithTime & { sequenceId: number } {
+ const eventWithSequence = event as eventWithSequenceId;
+ if (
+ isValidSequenceId(eventWithSequence.sequenceId) &&
+ eventWithSequence.sequenceId > state.sequenceId
+ ) {
+ state.sequenceId = eventWithSequence.sequenceId;
+ persistSequenceId(state);
+ return eventWithSequence as eventWithTime & { sequenceId: number };
+ }
+ state.sequenceId += 1;
+ eventWithSequence.sequenceId = state.sequenceId;
+ persistSequenceId(state);
+ return eventWithSequence as eventWithTime & { sequenceId: number };
+}
+
+function getSetSequenceState(): SequenceState | undefined {
+ const recordingId = getSetRecordingId();
+ return recordingId ? getSequenceState(recordingId) : undefined;
+}
+
+function buildRecordOptionsWithSequencePlugin(
+ recordOptions: Omit,
+ state: SequenceState,
+): Omit {
+ return {
+ ...recordOptions,
+ plugins: [
+ ...(recordOptions.plugins || []),
+ getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId: state.sequenceId,
+ preserveExisting: true,
+ }),
+ ],
+ };
+}
+
// if API client requests the recording ID prior to start,
// set it immediately so that it will be the eventual id used
export const getRecordingId = getSetRecordingId;
@@ -320,6 +475,14 @@ export function start(
);
return;
}
+ const activeSequenceState = getSequenceState(recordingId);
+ const startToken = Symbol('rrweb-browser-client-start');
+ const windowState = window as unknown as Record;
+ const startTokenName = browserClientStartTokenName(recordingId);
+ windowState[startTokenName] = startToken;
+ activeStartTokenNames.add(startTokenName);
+
+ const isActiveStart = () => windowState[startTokenName] === startToken;
const initialPayload: nameValues = {
domain: document.location.hostname || document.location.href.split('?')[0], // latter is for debugging (e.g. a file:// url)
@@ -390,9 +553,10 @@ export function start(
},
};
// metadata event should be the first seen server side
- buffer.add(JSON.stringify(metaEvent));
+ buffer.add(JSON.stringify(ensureSequenceId(metaEvent, activeSequenceState)));
recordOptions.emit = (event: eventWithTime) => {
+ const sequencedEvent = ensureSequenceId(event, activeSequenceState);
if (!ws) {
// don't make a connection until rrweb starts (looks at document.readyState and waits for DOMContentLoaded or load)
ws = connect(serverUrl, postUrl, publicApiKey, handleMessage);
@@ -404,7 +568,7 @@ export function start(
}
if (configEmit !== undefined) {
if (typeof configEmit === 'function') {
- configEmit(event);
+ configEmit(sequencedEvent);
} else if (
typeof (window as unknown as Record)[configEmit] ===
'function'
@@ -412,13 +576,13 @@ export function start(
const emit = (window as unknown as Record)[
configEmit
] as (event: eventWithTime) => void;
- emit(event);
+ emit(sequencedEvent);
} else {
console.error('Could not understand emit config option:', configEmit);
}
}
- if (event.type === EventType.Meta && includePii) {
- const metaData = event.data as typeof event.data & {
+ if (sequencedEvent.type === EventType.Meta && includePii) {
+ const metaData = sequencedEvent.data as typeof sequencedEvent.data & {
title?: string;
referrer?: string;
};
@@ -426,7 +590,7 @@ export function start(
metaData.referrer = document.referrer; // could potentially contain PII
}
- const eventStr = JSON.stringify(event);
+ const eventStr = JSON.stringify(sequencedEvent);
// TODO: add browser native compression
if (eventStr.length > wsLimit) {
// Assuming wsLimit is a defined constant, and eventStr.length is intended.
@@ -438,9 +602,18 @@ export function start(
}
};
+ const startRecording = () => {
+ rrwebStopFn = record(
+ buildRecordOptionsWithSequencePlugin(
+ recordOptions,
+ activeSequenceState,
+ ) as recordOptions,
+ );
+ };
+
let startWhenVisible = false;
if (!document.hidden) {
- rrwebStopFn = record(recordOptions as recordOptions);
+ startRecording();
} else {
startWhenVisible = true;
}
@@ -448,8 +621,9 @@ export function start(
document.addEventListener(
'visibilitychange',
() => {
+ if (!isActiveStart()) return;
if (!document.hidden && startWhenVisible) {
- rrwebStopFn = record(recordOptions as recordOptions);
+ startRecording();
startWhenVisible = false;
return;
}
@@ -481,6 +655,7 @@ export function start(
);
}
document.addEventListener('freeze', () => {
+ if (!isActiveStart()) return;
// https://developer.chrome.com/docs/web-platform/page-lifecycle-api
// "In particular, it's important that you ... close any open Web Socket connections
if (ws) {
@@ -490,6 +665,7 @@ export function start(
}
if (rrwebStopFn !== undefined) {
rrwebStopFn();
+ rrwebStopFn = undefined;
startWhenVisible = true;
}
});
@@ -508,7 +684,14 @@ export const addCustomEvent = (tag: string, payload: T) => {
},
};
// let websocket buffer handle it
- buffer.add(JSON.stringify(customEvent));
+ const state = getSetSequenceState();
+ if (!state) {
+ console.error(
+ '@rrweb/browser-client: Unable to addCustomEvent(); sessionStorage unavailable',
+ );
+ return;
+ }
+ buffer.add(JSON.stringify(ensureSequenceId(customEvent, state)));
}
};
diff --git a/packages/browser-client/test/integration.test.ts b/packages/browser-client/test/integration.test.ts
index 403fc86f35..528402e623 100644
--- a/packages/browser-client/test/integration.test.ts
+++ b/packages/browser-client/test/integration.test.ts
@@ -91,6 +91,16 @@ async function pollUntil(
throw new Error(`Timed out after ${timeout}ms`);
}
+function expectIncreasingSequenceIds(events: Array>) {
+ let previous = 0;
+ for (const event of events) {
+ expect(event.sequenceId).toEqual(expect.any(Number));
+ expect(Number.isInteger(event.sequenceId)).toBe(true);
+ expect(event.sequenceId as number).toBeGreaterThan(previous);
+ previous = event.sequenceId as number;
+ }
+}
+
const describeWithApi = TEST_API_KEY ? describe : describe.skip;
describeWithApi(
@@ -237,6 +247,9 @@ ${JSON.stringify(defaultOptions(options))}
);
expect(serverEvents.length).toBeGreaterThan(1);
+ expectIncreasingSequenceIds(
+ serverEvents as Array>,
+ );
serverEvents.forEach((e) => {
// TODO: these should probably not be returned in the first place
@@ -380,11 +393,19 @@ ${JSON.stringify(defaultOptions(options))}
expect(serverEvents.length).toBeGreaterThan(eventsFromFirst);
const customEvents = [];
- const recordingIds = new Set();
+ const recordingIds = new Set();
serverEvents.forEach((e) => {
- if ('recordingId' in e) {
+ if ('recordingId' in e && typeof e.recordingId === 'string') {
recordingIds.add(e.recordingId);
}
+ });
+ for (const id of recordingIds) {
+ const eventsForRecording = serverEvents.filter(
+ (event) => event.recordingId === id,
+ ) as Array>;
+ expectIncreasingSequenceIds(eventsForRecording);
+ }
+ serverEvents.forEach((e) => {
if (e.type === EventType.Custom) {
customEvents.push(e);
}
diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts
new file mode 100644
index 0000000000..9d05c4dfec
--- /dev/null
+++ b/packages/browser-client/test/sequence-id.test.ts
@@ -0,0 +1,590 @@
+// @vitest-environment happy-dom
+import { EventType } from '@rrweb/types';
+import type { RecordPlugin } from '@rrweb/types';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+type QueueLike = {
+ items: string[];
+ add(value: string): void;
+ clear(): void;
+ length(): number;
+ read(): string | undefined;
+};
+
+type WebsocketLike = {
+ url?: string;
+ send: ReturnType;
+ close: ReturnType;
+ addEventListener: ReturnType;
+};
+
+type MockState = {
+ buffers: QueueLike[];
+ recordCalls: Array>;
+ websockets: WebsocketLike[];
+ hidden: boolean;
+ pluginOptions: Array>;
+ operations: string[];
+};
+
+const mockState = vi.hoisted(
+ (): MockState => ({
+ buffers: [],
+ recordCalls: [],
+ websockets: [],
+ hidden: false,
+ pluginOptions: [],
+ operations: [],
+ }),
+);
+
+vi.mock('@rrweb/rrweb-plugin-sequential-id-record', () => {
+ function isPositiveInteger(value: unknown): value is number {
+ return Number.isInteger(value) && value > 0;
+ }
+
+ return {
+ getRecordSequentialIdPlugin: vi.fn((options: Record) => {
+ mockState.operations.push('plugin:create');
+ mockState.pluginOptions.push(options);
+ let id = Number(options.startId) || 0;
+ const plugin: RecordPlugin = {
+ name: 'rrweb/sequential-id@1',
+ options,
+ eventProcessor(event) {
+ const current = (event as Record)[
+ String(options.key)
+ ];
+ if (options.preserveExisting && isPositiveInteger(current)) {
+ id = Math.max(id, current);
+ return event;
+ }
+ Object.assign(event, { [String(options.key)]: ++id });
+ return event;
+ },
+ };
+ return plugin;
+ }),
+ };
+});
+
+vi.mock('@rrweb/record', () => {
+ const record = vi.fn((options: Record) => {
+ mockState.recordCalls.push(options);
+ const event = {
+ timestamp: 1,
+ type: EventType.Meta,
+ data: {
+ href: document.location.href,
+ width: 1024,
+ height: 768,
+ },
+ };
+ const plugins = (options.plugins || []) as RecordPlugin[];
+ const processed = plugins.reduce(
+ (acc, plugin) => plugin.eventProcessor?.(acc) || acc,
+ event,
+ );
+ (options.emit as (event: unknown) => void)?.(processed);
+ return vi.fn();
+ });
+ record.addCustomEvent = vi.fn();
+ record.freezePage = vi.fn();
+ return { record };
+});
+
+vi.mock('websocket-ts', () => {
+ class ArrayQueue {
+ items: string[] = [];
+ constructor() {
+ mockState.buffers.push(this);
+ }
+ add(value: string) {
+ mockState.operations.push('buffer:add');
+ this.items.push(value);
+ }
+ clear() {
+ this.items = [];
+ }
+ length() {
+ return this.items.length;
+ }
+ read() {
+ return this.items.shift();
+ }
+ }
+
+ class ExponentialBackoff {
+ constructor(
+ public readonly initial: number,
+ public readonly exponent: number,
+ ) {}
+ }
+
+ class Websocket {
+ send = vi.fn();
+ close = vi.fn();
+ addEventListener = vi.fn();
+ }
+
+ class WebsocketBuilder {
+ constructor(private readonly url: string) {}
+ withBuffer(buffer: QueueLike) {
+ if (!mockState.buffers.includes(buffer)) {
+ mockState.buffers.push(buffer);
+ }
+ return this;
+ }
+ withBackoff() {
+ return this;
+ }
+ build() {
+ const ws = new Websocket();
+ ws.url = this.url;
+ mockState.websockets.push(ws);
+ return ws;
+ }
+ }
+
+ return {
+ ArrayQueue,
+ ExponentialBackoff,
+ Websocket,
+ WebsocketBuilder,
+ WebsocketEvent: {
+ open: 'open',
+ message: 'message',
+ close: 'close',
+ },
+ };
+});
+
+function setDocumentHidden(value: boolean) {
+ mockState.hidden = value;
+ Object.defineProperty(document, 'hidden', {
+ configurable: true,
+ get: () => mockState.hidden,
+ });
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => (mockState.hidden ? 'hidden' : 'visible'),
+ });
+}
+
+async function importFreshClient() {
+ vi.resetModules();
+ mockState.buffers = [];
+ mockState.recordCalls = [];
+ mockState.websockets = [];
+ mockState.pluginOptions = [];
+ mockState.operations = [];
+ return await import('../src/index');
+}
+
+function recordingId(): string {
+ const value = sessionStorage.getItem('rrweb-browser-client-recording-id');
+ expect(value).toBeTruthy();
+ return value as string;
+}
+
+function sequenceKey() {
+ return `rrweb-browser-client-sequence-id:${recordingId()}`;
+}
+
+function startTokenKey(id: string) {
+ return `__rrweb_browser_client_start_token__:${id}`;
+}
+
+function clearStartTokens() {
+ for (const key of Object.getOwnPropertyNames(window)) {
+ if (key.startsWith('__rrweb_browser_client_start_token__:')) {
+ delete (window as unknown as Record)[key];
+ }
+ }
+}
+
+function bufferEvents() {
+ return mockState.buffers.flatMap((buffer) =>
+ buffer.items.map((item) => JSON.parse(item) as Record),
+ );
+}
+
+function sentEvents() {
+ return mockState.websockets.flatMap((ws) =>
+ ws.send.mock.calls.map(
+ ([payload]) => JSON.parse(String(payload)) as Record,
+ ),
+ );
+}
+
+beforeEach(() => {
+ sessionStorage.clear();
+ document.body.innerHTML = '';
+ clearStartTokens();
+ setDocumentHidden(false);
+ window.history.replaceState({}, '', 'http://localhost/');
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('@rrweb/browser-client sequenceId', () => {
+ it('assigns recording-meta before constructing the rrweb plugin', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({
+ type: EventType.Custom,
+ data: { tag: 'recording-meta' },
+ sequenceId: 1,
+ });
+ expect(mockState.operations.slice(0, 2)).toEqual([
+ 'buffer:add',
+ 'plugin:create',
+ ]);
+ expect(mockState.pluginOptions[0]).toMatchObject({
+ key: 'sequenceId',
+ startId: 1,
+ preserveExisting: true,
+ });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 2 });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('2');
+ });
+
+ it('overwrites stale user plugin sequence ids during final normalization', async () => {
+ const client = await importFreshClient();
+ const emittedEvents: Array> = [];
+ const staleSequencePlugin: RecordPlugin = {
+ name: 'test/stale-sequence-id',
+ eventProcessor(event) {
+ Object.assign(event, { sequenceId: 1 });
+ return event;
+ },
+ };
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: (event) => emittedEvents.push(event as Record),
+ plugins: [staleSequencePlugin],
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 1 });
+ expect(emittedEvents[0]).toMatchObject({ sequenceId: 2 });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 2 });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('2');
+ });
+
+ it('continues from stored sequence state on resume', async () => {
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+ sessionStorage.setItem(
+ 'rrweb-browser-client-sequence-id:recording-1',
+ '41',
+ );
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 42 });
+ expect(mockState.pluginOptions[0]).toMatchObject({ startId: 42 });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 43 });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('43');
+ });
+
+ it('recovers malformed stored sequence state by assigning 1', async () => {
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+ sessionStorage.setItem(
+ 'rrweb-browser-client-sequence-id:recording-1',
+ 'bad',
+ );
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(bufferEvents()[0]).toMatchObject({ sequenceId: 1 });
+ });
+
+ it('assigns ids to fallback custom events before recording is active', async () => {
+ const client = await importFreshClient();
+
+ client.addCustomEvent('pre-start', { ok: true });
+
+ expect(bufferEvents()[0]).toMatchObject({
+ type: EventType.Custom,
+ sequenceId: 1,
+ data: {
+ tag: 'pre-start',
+ },
+ });
+ expect(sessionStorage.getItem(sequenceKey())).toBe('1');
+ });
+
+ it('does not buffer fallback custom events without sequence state', async () => {
+ const client = await importFreshClient();
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ const getItem = vi
+ .spyOn(Storage.prototype, 'getItem')
+ .mockImplementation(() => {
+ throw new Error('sessionStorage unavailable');
+ });
+
+ client.addCustomEvent('pre-start', { ok: true });
+
+ expect(bufferEvents()).toEqual([]);
+ expect(consoleError).toHaveBeenCalledWith(
+ '@rrweb/browser-client: Unable to addCustomEvent(); sessionStorage unavailable',
+ );
+
+ getItem.mockRestore();
+ consoleError.mockRestore();
+ });
+
+ it('assigns ids to close events and clears state on permanent stop', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ const key = sequenceKey();
+
+ client.stop(true);
+
+ expect(sentEvents().at(-1)).toMatchObject({
+ sequenceId: 3,
+ data: {
+ tag: 'close-permanent',
+ },
+ });
+ expect(sessionStorage.getItem(key)).toBeNull();
+ });
+
+ it('does not throw on permanent stop when recording id cleanup storage fails', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ const removeItem = vi
+ .spyOn(Storage.prototype, 'removeItem')
+ .mockImplementation(() => {
+ throw new Error('sessionStorage unavailable');
+ });
+
+ expect(() => client.stop(true)).not.toThrow();
+
+ removeItem.mockRestore();
+ });
+
+ it('does not record after permanent stop cancels a hidden delayed start', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ client.stop(true);
+
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockState.recordCalls).toHaveLength(0);
+ });
+
+ it('stores delayed start tokens under recording-scoped window keys', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ expect(
+ Object.prototype.hasOwnProperty.call(
+ window,
+ startTokenKey('recording-1'),
+ ),
+ ).toBe(true);
+ expect(
+ Object.prototype.hasOwnProperty.call(
+ window,
+ '__rrweb_browser_client_start_token__',
+ ),
+ ).toBe(false);
+ });
+
+ it('does not record after permanent stop cancels a hidden delayed start when recording id lookup fails', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ const getItem = vi
+ .spyOn(Storage.prototype, 'getItem')
+ .mockImplementation(() => {
+ throw new Error('sessionStorage unavailable');
+ });
+
+ try {
+ client.stop(true);
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+ } finally {
+ getItem.mockRestore();
+ }
+
+ expect(mockState.recordCalls).toHaveLength(0);
+ });
+
+ it('invalidates delayed starts only for the matching recording id', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-1');
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ sessionStorage.setItem('rrweb-browser-client-recording-id', 'recording-2');
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+
+ client.stop(true);
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(
+ Object.prototype.hasOwnProperty.call(
+ window,
+ startTokenKey('recording-1'),
+ ),
+ ).toBe(true);
+ expect(
+ Object.prototype.hasOwnProperty.call(
+ window,
+ startTokenKey('recording-2'),
+ ),
+ ).toBe(false);
+ expect(mockState.recordCalls).toHaveLength(1);
+ expect(mockState.pluginOptions[0]).toMatchObject({ startId: 1 });
+ expect(mockState.websockets[0].url).toContain('/recordings/recording-1/');
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 2 });
+ });
+
+ it('does not restart recording after permanent stop cancels a frozen start', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ document.dispatchEvent(new Event('freeze'));
+ client.stop(true);
+
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockState.recordCalls).toHaveLength(1);
+ });
+
+ it('builds the plugin from latest state after delayed visible start', async () => {
+ setDocumentHidden(true);
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ client.addCustomEvent('queued-while-hidden', {});
+
+ setDocumentHidden(false);
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(bufferEvents().map((event) => event.sequenceId)).toEqual([1, 2]);
+ expect(mockState.pluginOptions[0]).toMatchObject({ startId: 2 });
+ expect(sentEvents()[0]).toMatchObject({ sequenceId: 3 });
+ });
+
+ it('builds a fresh plugin after freeze restart without accumulating browser-client plugins', async () => {
+ const client = await importFreshClient();
+
+ client.start({
+ serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws',
+ publicApiKey: 'public_key_rr_test',
+ includePii: false,
+ autostart: false,
+ emit: () => undefined,
+ });
+ document.dispatchEvent(new Event('freeze'));
+ client.addCustomEvent('between-records', {});
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockState.pluginOptions).toHaveLength(2);
+ expect(mockState.pluginOptions[1]).toMatchObject({ startId: 3 });
+ for (const call of mockState.recordCalls) {
+ const plugins = call.plugins as RecordPlugin[];
+ expect(
+ plugins.filter((plugin) => plugin.name === 'rrweb/sequential-id@1'),
+ ).toHaveLength(1);
+ }
+ });
+});
diff --git a/packages/browser-client/tsconfig.json b/packages/browser-client/tsconfig.json
index 772371dc7d..7787ba7e79 100644
--- a/packages/browser-client/tsconfig.json
+++ b/packages/browser-client/tsconfig.json
@@ -1,8 +1,6 @@
{
"extends": "../../tsconfig.base.json",
- "include": [
- "src"
- ],
+ "include": ["src"],
"compilerOptions": {
"rootDir": "src",
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
@@ -11,6 +9,9 @@
{
"path": "../record"
},
+ {
+ "path": "../plugins/rrweb-plugin-sequential-id-record"
+ },
{
"path": "../types"
},
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/README.md b/packages/plugins/rrweb-plugin-sequential-id-record/README.md
index e258a76e3a..31a4c4d922 100644
--- a/packages/plugins/rrweb-plugin-sequential-id-record/README.md
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/README.md
@@ -22,11 +22,15 @@ record({
plugins: [
getRecordSequentialIdPlugin({
key: '_sid', // default value
+ startId: 0, // next generated id will be startId + 1
+ preserveExisting: false, // keep current overwrite behavior by default
}),
],
});
```
+Use `startId` when recording resumes from a known last ID. Set `preserveExisting` to `true` if another event processor may already provide a valid positive integer at the configured key.
+
## Sponsors
[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site.
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/package.json b/packages/plugins/rrweb-plugin-sequential-id-record/package.json
index 746748f39a..d4f78da467 100644
--- a/packages/plugins/rrweb-plugin-sequential-id-record/package.json
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/package.json
@@ -26,6 +26,8 @@
],
"scripts": {
"dev": "vite build --watch",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
"build": "yarn turbo run prepublish",
"check-types": "tsc -noEmit",
"prepublish": "tsc -noEmit && vite build"
@@ -44,10 +46,12 @@
},
"homepage": "https://github.com/rrweb-io/rrweb#readme",
"devDependencies": {
+ "@rrweb/types": "^2.0.1",
"rrweb": "^2.0.1",
"typescript": "^5.4.5",
"vite": "^6.0.1",
- "vite-plugin-dts": "^3.9.1"
+ "vite-plugin-dts": "^3.9.1",
+ "vitest": "^1.4.0"
},
"peerDependencies": {
"rrweb": "^2.0.1"
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts b/packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts
index 140b9c7003..f5d3b5fccf 100644
--- a/packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/src/index.ts
@@ -2,25 +2,45 @@ import type { RecordPlugin } from '@rrweb/types';
export type SequentialIdOptions = {
key: string;
+ startId?: number;
+ preserveExisting?: boolean;
};
const defaultOptions: SequentialIdOptions = {
key: '_sid',
+ startId: 0,
+ preserveExisting: false,
};
export const PLUGIN_NAME = 'rrweb/sequential-id@1';
+function isValidSequenceId(value: unknown): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
+}
+
+function normalizeStartId(value: unknown): number {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0
+ ? value
+ : 0;
+}
+
export const getRecordSequentialIdPlugin: (
options?: Partial,
) => RecordPlugin = (options) => {
- const _options = options
- ? Object.assign({}, defaultOptions, options)
- : defaultOptions;
- let id = 0;
+ const _options = Object.assign({}, defaultOptions, options);
+ let id = normalizeStartId(_options.startId);
return {
name: PLUGIN_NAME,
eventProcessor(event) {
+ if (
+ _options.preserveExisting &&
+ isValidSequenceId((event as Record)[_options.key])
+ ) {
+ id = Math.max(id, (event as Record)[_options.key]);
+ return event;
+ }
+
Object.assign(event, {
[_options.key]: ++id,
});
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts b/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts
new file mode 100644
index 0000000000..6c0c512817
--- /dev/null
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import type { eventWithTime } from '@rrweb/types';
+import { EventType } from '@rrweb/types';
+import { getRecordSequentialIdPlugin } from '../src/index';
+
+function event(sequenceId?: unknown): eventWithTime & { sequenceId?: unknown } {
+ const e: eventWithTime & { sequenceId?: unknown } = {
+ timestamp: 1,
+ type: EventType.Custom,
+ data: {
+ tag: 'test',
+ payload: {},
+ },
+ };
+ if (sequenceId !== undefined) {
+ e.sequenceId = sequenceId;
+ }
+ return e;
+}
+
+describe('getRecordSequentialIdPlugin', () => {
+ it('keeps default behavior of assigning _sid 1 then 2', () => {
+ const plugin = getRecordSequentialIdPlugin();
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ _sid: 1 });
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ _sid: 2 });
+ });
+
+ it('keeps custom key behavior of assigning 1 then 2', () => {
+ const plugin = getRecordSequentialIdPlugin({ key: 'sequenceId' });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 1 });
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 2 });
+ });
+
+ it('starts generated ids after startId', () => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId: 10,
+ });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({ sequenceId: 11 });
+ });
+
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5])(
+ 'treats invalid startId %s as 0',
+ (startId) => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ startId,
+ });
+
+ expect(plugin.eventProcessor?.(event())).toMatchObject({
+ sequenceId: 1,
+ });
+ },
+ );
+
+ it('overwrites an existing key by default', () => {
+ const plugin = getRecordSequentialIdPlugin({ key: 'sequenceId' });
+
+ expect(plugin.eventProcessor?.(event(50))).toMatchObject({
+ sequenceId: 1,
+ });
+ });
+
+ it('preserves existing positive integer ids and advances the counter', () => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ preserveExisting: true,
+ });
+
+ expect(plugin.eventProcessor?.(event(50))).toMatchObject({
+ sequenceId: 50,
+ });
+ expect(plugin.eventProcessor?.(event())).toMatchObject({
+ sequenceId: 51,
+ });
+ });
+
+ it.each([0, -1, 1.5, Number.NaN, '2'])(
+ 'replaces invalid existing id %s when preserving existing ids',
+ (sequenceId) => {
+ const plugin = getRecordSequentialIdPlugin({
+ key: 'sequenceId',
+ preserveExisting: true,
+ });
+
+ expect(plugin.eventProcessor?.(event(sequenceId))).toMatchObject({
+ sequenceId: 1,
+ });
+ },
+ );
+});
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/tsconfig.json b/packages/plugins/rrweb-plugin-sequential-id-record/tsconfig.json
index 8ffb27ccca..f23a6fab64 100644
--- a/packages/plugins/rrweb-plugin-sequential-id-record/tsconfig.json
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/tsconfig.json
@@ -1,12 +1,19 @@
{
"extends": "../../../tsconfig.base.json",
- "include": ["src"],
- "exclude": ["vite.config.ts"],
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "vite.config.ts"
+ ],
"compilerOptions": {
"rootDir": "src",
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
},
"references": [
+ {
+ "path": "../../types"
+ },
{
"path": "../../rrweb"
}
diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts b/packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts
new file mode 100644
index 0000000000..a4d73c9821
--- /dev/null
+++ b/packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts
@@ -0,0 +1,5 @@
+///
+import { defineProject, mergeConfig } from 'vitest/config';
+import configShared from '../../../vitest.config.ts';
+
+export default mergeConfig(configShared, defineProject({}));