From 151d28342f6356dd1ffdbea1682a2224aef8615c Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 2 Jun 2026 21:35:17 +0200 Subject: [PATCH 01/21] docs: add browser client sequence id design --- ...06-02-browser-client-sequence-id-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-browser-client-sequence-id-design.md 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..987a3c0247 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-browser-client-sequence-id-design.md @@ -0,0 +1,145 @@ +# 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, Tinybird chunk manifests, 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 resume option. +- Keep the change focused on browser-client sequencing and the plugin option 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 a `startId?: number` option. The plugin's internal counter starts from `startId`, then pre-increments as it does today. Existing default behavior still starts emitted IDs at `1`. +3. In browser-client `start()`, read the stored last sequence ID for the active `recordingId`. +4. Append `getRecordSequentialIdPlugin({ key: 'sequenceId', startId: lastSequenceId })` to `recordOptions.plugins`. +5. Add a browser-client `ensureSequenceId(event)` helper that guarantees every outbound event has a valid top-level `sequenceId`. +6. Apply `ensureSequenceId()` before every browser-client `JSON.stringify()` event path. +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; +}; +``` + +The plugin normalizes `startId` to `0` when it is missing. With `startId: 5`, the next processed rrweb event receives `6`. + +### 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 initialized once per `start()` call: + +1. `recordingId = getSetRecordingId()` +2. `lastSequenceId = readStoredSequenceId(recordingId)` +3. `ensureSequenceId` closes over `recordingId` and `lastSequenceId` + +### Event Normalization + +`ensureSequenceId(event)` mutates the event before serialization: + +- If `event.sequenceId` is a finite non-negative integer, keep it and advance local state to at least that value. +- If `event.sequenceId` is missing or invalid, assign `lastSequenceId + 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 a same-tab navigation resumes an existing recording ID, browser-client reads the last stored sequence ID, initializes the rrweb plugin from that value, and uses the same local state for browser-client-created events. The next outbound event receives `last + 1`. + +If caller-provided rrweb plugins exist, browser-client appends the sequential-id plugin after them. This keeps user event processors first and assigns the final Cloud ordering field at the end of rrweb's plugin pipeline. If a user plugin already creates a valid `sequenceId`, the browser-client helper preserves it and advances stored state. + +## 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()` appends a sequential-id plugin with `key: 'sequenceId'` and `startId` from storage. +- 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`. + +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`. + +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. From 218f10b71528a6df482e7c4675133f7c2305aaca Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 2 Jun 2026 21:40:09 +0200 Subject: [PATCH 02/21] docs: fix browser client sequence id design --- ...06-02-browser-client-sequence-id-design.md | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) 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 index 987a3c0247..53da2e9a65 100644 --- 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 @@ -12,8 +12,8 @@ rrweb already has `@rrweb/rrweb-plugin-sequential-id-record`, but the current re - 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 resume option. -- Keep the change focused on browser-client sequencing and the plugin option needed to support it. +- 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 @@ -27,11 +27,11 @@ rrweb already has `@rrweb/rrweb-plugin-sequential-id-record`, but the current re 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 a `startId?: number` option. The plugin's internal counter starts from `startId`, then pre-increments as it does today. Existing default behavior still starts emitted IDs at `1`. -3. In browser-client `start()`, read the stored last sequence ID for the active `recordingId`. -4. Append `getRecordSequentialIdPlugin({ key: 'sequenceId', startId: lastSequenceId })` to `recordOptions.plugins`. -5. Add a browser-client `ensureSequenceId(event)` helper that guarantees every outbound event has a valid top-level `sequenceId`. -6. Apply `ensureSequenceId()` before every browser-client `JSON.stringify()` event path. +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. @@ -47,10 +47,13 @@ This approach intentionally permits gaps. If an event receives a sequence ID and export type SequentialIdOptions = { key: string; startId?: number; + preserveExisting?: boolean; }; ``` -The plugin normalizes `startId` to `0` when it is missing. With `startId: 5`, the next processed rrweb event receives `6`. +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 @@ -62,18 +65,21 @@ 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 initialized once per `start()` call: +The in-memory sequence state is module-level and keyed by the active `recordingId`: 1. `recordingId = getSetRecordingId()` -2. `lastSequenceId = readStoredSequenceId(recordingId)` -3. `ensureSequenceId` closes over `recordingId` and `lastSequenceId` +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 non-negative integer, keep it and advance local state to at least that value. -- If `event.sequenceId` is missing or invalid, assign `lastSequenceId + 1`. +- 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: @@ -89,9 +95,13 @@ The helper should be used for: When a new recording starts, no sequence key exists for its new recording ID, so the first browser-client event receives `sequenceId: 1`. -When a same-tab navigation resumes an existing recording ID, browser-client reads the last stored sequence ID, initializes the rrweb plugin from that value, and uses the same local state for browser-client-created events. The next outbound event receives `last + 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. This keeps user event processors first and assigns the final Cloud ordering field at the end of rrweb's plugin pipeline. If a user plugin already creates a valid `sequenceId`, the browser-client helper preserves it and advances stored 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 @@ -105,18 +115,22 @@ If a user plugin creates an invalid `sequenceId`, browser-client replaces it wit Add focused browser-client tests in the existing happy-dom test area or a new adjacent test file: -- `start()` appends a sequential-id plugin with `key: 'sequenceId'` and `startId` from storage. +- `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`. +- `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. From 3ce7dfa6c5617e173e917411c786670a3c9016af Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 2 Jun 2026 21:45:32 +0200 Subject: [PATCH 03/21] docs: remove tinybird reference from sequence id design --- .../specs/2026-06-02-browser-client-sequence-id-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 53da2e9a65..46b97407cd 100644 --- 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 @@ -4,7 +4,7 @@ `@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, Tinybird chunk manifests, and cursor pagination are going to rely on it. +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 From 5e4c1565cfb632dd6bf9ddae8b50f847587bf63e Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 2 Jun 2026 21:50:36 +0200 Subject: [PATCH 04/21] docs: add browser client sequence id plan --- .../2026-06-02-browser-client-sequence-id.md | 1236 +++++++++++++++++ 1 file changed, 1236 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md 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..0ee8788bd7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md @@ -0,0 +1,1236 @@ +# 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. From ae37b753a818332bbf0a812e2fce45cbbff3b222 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 06:48:23 +0200 Subject: [PATCH 05/21] test: cover sequential id plugin options --- .../package.json | 6 +- .../test/index.test.ts | 86 +++++++++++++++++++ .../vitest.config.ts | 5 ++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts create mode 100644 packages/plugins/rrweb-plugin-sequential-id-record/vitest.config.ts diff --git a/packages/plugins/rrweb-plugin-sequential-id-record/package.json b/packages/plugins/rrweb-plugin-sequential-id-record/package.json index 3446a9407c..0046691055 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.0", "rrweb": "^2.0.0", "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.0" 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..c61ae1eb53 --- /dev/null +++ b/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import type { eventWithTime } from '@rrweb/types'; +import { getRecordSequentialIdPlugin } from '../src/index'; + +function event(sequenceId?: unknown): eventWithTime & { sequenceId?: unknown } { + const e: eventWithTime & { sequenceId?: unknown } = { + timestamp: 1, + type: 5, + 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, + }); + }, + ); +}); 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({})); From 15aff983603dacaf7018495b0bebc774ba428b8d Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 06:52:01 +0200 Subject: [PATCH 06/21] test: cover default sequential id key --- .../test/index.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index c61ae1eb53..6c0c512817 100644 --- a/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-sequential-id-record/test/index.test.ts @@ -1,11 +1,12 @@ 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: 5, + type: EventType.Custom, data: { tag: 'test', payload: {}, @@ -18,7 +19,14 @@ function event(sequenceId?: unknown): eventWithTime & { sequenceId?: unknown } { } describe('getRecordSequentialIdPlugin', () => { - it('keeps default behavior of assigning 1 then 2', () => { + 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 }); From 795923bf6911ff8a95b5a945e42e92c386481f63 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 06:53:42 +0200 Subject: [PATCH 07/21] feat: add sequential id plugin start options --- .../src/index.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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, }); From 68bb27658f89cd82ce6d3484d938ed1f95113d88 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 06:58:33 +0200 Subject: [PATCH 08/21] test: cover browser client sequence ids --- .../browser-client/test/sequence-id.test.ts | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 packages/browser-client/test/sequence-id.test.ts 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..42c2d025e9 --- /dev/null +++ b/packages/browser-client/test/sequence-id.test.ts @@ -0,0 +1,352 @@ +// @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); + } + }); +}); From dcc867f48836fcecc56d415b0b6bd56c3e7629a5 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 06:59:54 +0200 Subject: [PATCH 09/21] chore: update sequential id plugin references --- .../rrweb-plugin-sequential-id-record/tsconfig.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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" } From 8d2a85f1486d208850885875c8fabc31da2a8cb5 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:01:22 +0200 Subject: [PATCH 10/21] test: assert browser client sequence order --- packages/browser-client/test/sequence-id.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index 42c2d025e9..716caf02eb 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -23,6 +23,7 @@ type MockState = { websockets: WebsocketLike[]; hidden: boolean; pluginOptions: Array>; + operations: string[]; }; const mockState = vi.hoisted( @@ -32,6 +33,7 @@ const mockState = vi.hoisted( websockets: [], hidden: false, pluginOptions: [], + operations: [], }), ); @@ -42,6 +44,7 @@ vi.mock('@rrweb/rrweb-plugin-sequential-id-record', () => { return { getRecordSequentialIdPlugin: vi.fn((options: Record) => { + mockState.operations.push('plugin:create'); mockState.pluginOptions.push(options); let id = Number(options.startId) || 0; const plugin: RecordPlugin = { @@ -93,6 +96,7 @@ vi.mock('websocket-ts', () => { class ArrayQueue { items: string[] = []; add(value: string) { + mockState.operations.push('buffer:add'); this.items.push(value); } clear() { @@ -166,6 +170,7 @@ async function importFreshClient() { mockState.recordCalls = []; mockState.websockets = []; mockState.pluginOptions = []; + mockState.operations = []; return await import('../src/index'); } @@ -216,7 +221,15 @@ describe('@rrweb/browser-client sequenceId', () => { emit: () => undefined, }); - expect(bufferEvents()[0]).toMatchObject({ sequenceId: 1 }); + 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, From 1a559f1b9e05c153980dedcd6de8d1a4a9675508 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:04:04 +0200 Subject: [PATCH 11/21] test: observe pre-start browser client buffer --- packages/browser-client/test/sequence-id.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index 716caf02eb..fb51d46e6d 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -95,6 +95,9 @@ vi.mock('@rrweb/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); @@ -126,7 +129,9 @@ vi.mock('websocket-ts', () => { class WebsocketBuilder { constructor(private readonly url: string) {} withBuffer(buffer: QueueLike) { - mockState.buffers.push(buffer); + if (!mockState.buffers.includes(buffer)) { + mockState.buffers.push(buffer); + } return this; } withBackoff() { From 459afa8105a00b49bb3b01c27d0c39b7ae10fa29 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:08:03 +0200 Subject: [PATCH 12/21] feat: add browser client sequence ids --- packages/browser-client/package.json | 1 + packages/browser-client/src/index.ts | 169 ++++++++++++++++++++++++-- packages/browser-client/tsconfig.json | 7 +- 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/packages/browser-client/package.json b/packages/browser-client/package.json index 1b5a759154..2d5a1a128f 100644 --- a/packages/browser-client/package.json +++ b/packages/browser-client/package.json @@ -65,6 +65,7 @@ }, "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", diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index dc953fa1bc..47f544ed1c 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,7 +65,10 @@ let defaultClientConfig: clientConfig = { includePii: false, }; const sessionStorageName = 'rrweb-browser-client-recording-id'; +const sequenceStoragePrefix = 'rrweb-browser-client-sequence-id:'; +const browserClientStartTokenName = '__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; @@ -92,6 +105,10 @@ export function stop(resetRecordingId: boolean) { rrwebStopFn = undefined; } if (ws) { + const recordingId = getCurrentRecordingId(); + const activeSequenceState = recordingId + ? getSequenceState(recordingId) + : undefined; const closeEvent: customEventWithTime = { timestamp: nowTimestamp(), type: EventType.Custom, @@ -103,7 +120,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,6 +134,10 @@ export function stop(resetRecordingId: boolean) { buffer.clear(); } if (resetRecordingId) { + const recordingId = getCurrentRecordingId(); + if (recordingId) { + removeSequenceId(recordingId); + } removeRecordingId(); } } @@ -164,6 +191,103 @@ function getSetRecordingId(): string | null { return value; } +function sequenceStorageName(recordingId: string): string { + return `${sequenceStoragePrefix}${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)) { + 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, + }), + ], + }; +} + // 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 +444,13 @@ export function start( ); return; } + const activeSequenceState = getSequenceState(recordingId); + const startToken = Symbol('rrweb-browser-client-start'); + const windowState = window as unknown as Record; + windowState[browserClientStartTokenName] = startToken; + + const isActiveStart = () => + windowState[browserClientStartTokenName] === startToken; const initialPayload: nameValues = { domain: document.location.hostname || document.location.href.split('?')[0], // latter is for debugging (e.g. a file:// url) @@ -390,9 +521,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 +536,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 +544,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 +558,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 +570,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 +589,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 +623,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 +633,7 @@ export function start( } if (rrwebStopFn !== undefined) { rrwebStopFn(); + rrwebStopFn = undefined; startWhenVisible = true; } }); @@ -508,7 +652,12 @@ export const addCustomEvent = (tag: string, payload: T) => { }, }; // let websocket buffer handle it - buffer.add(JSON.stringify(customEvent)); + const state = getSetSequenceState(); + buffer.add( + JSON.stringify( + state ? ensureSequenceId(customEvent, state) : customEvent, + ), + ); } }; 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" }, From f490ae490316173816716d7206bc3d022de86172 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:14:00 +0200 Subject: [PATCH 13/21] fix: prevent browser client sequence regressions --- packages/browser-client/src/index.ts | 25 ++++++-- .../browser-client/test/sequence-id.test.ts | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 47f544ed1c..9a807c5f7a 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -99,6 +99,9 @@ function scriptSourceFromElement( } export function stop(resetRecordingId: boolean) { + if (resetRecordingId) { + invalidateActiveStart(); + } // reset all state so that start() can start afresh if (rrwebStopFn !== undefined) { rrwebStopFn(); @@ -146,6 +149,12 @@ function removeRecordingId(): void { sessionStorage.removeItem(sessionStorageName); } +function invalidateActiveStart(): void { + delete (window as unknown as Record)[ + browserClientStartTokenName + ]; +} + function getSetVisitorId() { const nameEQ = 'rrweb-browser-client-visitor-id='; let value: string | null = null; @@ -256,12 +265,16 @@ function ensureSequenceId( 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; - } + 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 }; } diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index fb51d46e6d..d80363c226 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -244,6 +244,32 @@ describe('@rrweb/browser-client sequenceId', () => { 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'); @@ -323,6 +349,44 @@ describe('@rrweb/browser-client sequenceId', () => { expect(sessionStorage.getItem(key)).toBeNull(); }); + 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('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(); From 96b73626d00c345b9a46de9e64c0acf88192cc26 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:16:22 +0200 Subject: [PATCH 14/21] fix: sequence browser client custom events --- packages/browser-client/src/index.ts | 10 ++++++--- .../browser-client/test/sequence-id.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 9a807c5f7a..947732a34d 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -666,10 +666,14 @@ export const addCustomEvent = (tag: string, payload: T) => { }; // let websocket buffer handle it const state = getSetSequenceState(); + if (!state) { + console.error( + '@rrweb/browser-client: Unable to addCustomEvent(); sessionStorage unavailable', + ); + return; + } buffer.add( - JSON.stringify( - state ? ensureSequenceId(customEvent, state) : customEvent, - ), + JSON.stringify(ensureSequenceId(customEvent, state)), ); } }; diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index d80363c226..41595d2378 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -326,6 +326,28 @@ describe('@rrweb/browser-client sequenceId', () => { 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(); From 40983d9e4e32796e7d2cf9dce9decef6b096f568 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:19:27 +0200 Subject: [PATCH 15/21] test: assert browser client sequence ids --- .../browser-client/test/integration.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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); } From 116154479b3becb8ea20c7630db6bb66b7f9547f Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 3 Jun 2026 07:22:12 +0200 Subject: [PATCH 16/21] docs: document browser client sequence ids --- .changeset/browser-client-sequence-id.md | 6 ++++++ packages/browser-client/README.md | 2 ++ .../plugins/rrweb-plugin-sequential-id-record/README.md | 4 ++++ 3 files changed, 12 insertions(+) create mode 100644 .changeset/browser-client-sequence-id.md diff --git a/.changeset/browser-client-sequence-id.md b/.changeset/browser-client-sequence-id.md new file mode 100644 index 0000000000..2f48a1a983 --- /dev/null +++ b/.changeset/browser-client-sequence-id.md @@ -0,0 +1,6 @@ +--- +"@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. 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/plugins/rrweb-plugin-sequential-id-record/README.md b/packages/plugins/rrweb-plugin-sequential-id-record/README.md index 068e80f82f..c8d9547361 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. From 59ce60d602d358f0e3fa3dab93b24ad857c0735e Mon Sep 17 00:00:00 2001 From: Juice10 Date: Wed, 3 Jun 2026 08:03:10 +0000 Subject: [PATCH 17/21] Apply formatting changes --- .../2026-06-02-browser-client-sequence-id.md | 16 +++++++++++++--- packages/browser-client/src/index.ts | 4 +--- 2 files changed, 14 insertions(+), 6 deletions(-) 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 index 0ee8788bd7..65594143f3 100644 --- a/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md +++ b/docs/superpowers/plans/2026-06-02-browser-client-sequence-id.md @@ -28,6 +28,7 @@ ### 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` @@ -184,6 +185,7 @@ 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` @@ -266,6 +268,7 @@ 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** @@ -649,6 +652,7 @@ 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` @@ -738,7 +742,9 @@ function isValidSequenceId(value: unknown): value is number { function readSequenceId(recordingId: string): number { try { - const value = Number(sessionStorage.getItem(sequenceStorageName(recordingId))); + const value = Number( + sessionStorage.getItem(sequenceStorageName(recordingId)), + ); return Number.isInteger(value) && value >= 0 ? value : 0; } catch { return 0; @@ -949,6 +955,7 @@ 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` @@ -1040,6 +1047,7 @@ 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** @@ -1126,6 +1134,7 @@ 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` @@ -1136,8 +1145,8 @@ Create `.changeset/browser-client-sequence-id.md`. ```md --- -"@rrweb/browser-client": patch -"@rrweb/rrweb-plugin-sequential-id-record": patch +'@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. @@ -1188,6 +1197,7 @@ 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** diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 947732a34d..84dcaf7316 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -672,9 +672,7 @@ export const addCustomEvent = (tag: string, payload: T) => { ); return; } - buffer.add( - JSON.stringify(ensureSequenceId(customEvent, state)), - ); + buffer.add(JSON.stringify(ensureSequenceId(customEvent, state))); } }; From 9a25441625ace88871e4e69b2a57bcd11e173801 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 5 Jun 2026 11:41:47 +0200 Subject: [PATCH 18/21] fix: address browser client sequence id review --- .changeset/browser-client-sequence-id.md | 3 +-- .changeset/sequential-id-record-start-id.md | 5 +++++ packages/browser-client/src/index.ts | 6 +++++- .../browser-client/test/sequence-id.test.ts | 21 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .changeset/sequential-id-record-start-id.md diff --git a/.changeset/browser-client-sequence-id.md b/.changeset/browser-client-sequence-id.md index 2f48a1a983..95775976cf 100644 --- a/.changeset/browser-client-sequence-id.md +++ b/.changeset/browser-client-sequence-id.md @@ -1,6 +1,5 @@ --- "@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. +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/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 84dcaf7316..ed0f4a3a53 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -146,7 +146,11 @@ export function stop(resetRecordingId: boolean) { } function removeRecordingId(): void { - sessionStorage.removeItem(sessionStorageName); + try { + sessionStorage.removeItem(sessionStorageName); + } catch { + // Best-effort cleanup only. + } } function invalidateActiveStart(): void { diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index 41595d2378..1d5f5f8f81 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -371,6 +371,27 @@ describe('@rrweb/browser-client sequenceId', () => { 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(); From 349706f55e917635f48d83e98afcacd41ac2a9a8 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 5 Jun 2026 11:51:33 +0200 Subject: [PATCH 19/21] Scope browser client start tokens by recording --- packages/browser-client/src/index.ts | 16 ++-- .../browser-client/test/sequence-id.test.ts | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index ed0f4a3a53..c84aa6e534 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -66,7 +66,7 @@ let defaultClientConfig: clientConfig = { }; const sessionStorageName = 'rrweb-browser-client-recording-id'; const sequenceStoragePrefix = 'rrweb-browser-client-sequence-id:'; -const browserClientStartTokenName = '__rrweb_browser_client_start_token__'; +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; @@ -154,8 +154,10 @@ function removeRecordingId(): void { } function invalidateActiveStart(): void { + const recordingId = getCurrentRecordingId(); + if (!recordingId) return; delete (window as unknown as Record)[ - browserClientStartTokenName + browserClientStartTokenName(recordingId) ]; } @@ -208,6 +210,10 @@ 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; } @@ -464,10 +470,10 @@ export function start( const activeSequenceState = getSequenceState(recordingId); const startToken = Symbol('rrweb-browser-client-start'); const windowState = window as unknown as Record; - windowState[browserClientStartTokenName] = startToken; + const startTokenName = browserClientStartTokenName(recordingId); + windowState[startTokenName] = startToken; - const isActiveStart = () => - windowState[browserClientStartTokenName] === startToken; + 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) diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index 1d5f5f8f81..8c4292d415 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -189,6 +189,18 @@ 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), @@ -206,6 +218,7 @@ function sentEvents() { beforeEach(() => { sessionStorage.clear(); document.body.innerHTML = ''; + clearStartTokens(); setDocumentHidden(false); window.history.replaceState({}, '', 'http://localhost/'); }); @@ -411,6 +424,75 @@ describe('@rrweb/browser-client sequenceId', () => { 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('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(sentEvents()[0]).toMatchObject({ sequenceId: 2 }); + }); + it('does not restart recording after permanent stop cancels a frozen start', async () => { const client = await importFreshClient(); From db92d8acaa45d37f48adeec90b3415752e35ccd9 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 5 Jun 2026 11:56:47 +0200 Subject: [PATCH 20/21] Invalidate start tokens without storage --- packages/browser-client/src/index.ts | 14 ++++++--- .../browser-client/test/sequence-id.test.ts | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index c84aa6e534..3a26c6dff7 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -74,6 +74,7 @@ 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 { @@ -155,10 +156,14 @@ function removeRecordingId(): void { function invalidateActiveStart(): void { const recordingId = getCurrentRecordingId(); - if (!recordingId) return; - delete (window as unknown as Record)[ - browserClientStartTokenName(recordingId) - ]; + const tokenNames = recordingId + ? [browserClientStartTokenName(recordingId)] + : Array.from(activeStartTokenNames); + const windowState = window as unknown as Record; + for (const tokenName of tokenNames) { + delete windowState[tokenName]; + activeStartTokenNames.delete(tokenName); + } } function getSetVisitorId() { @@ -472,6 +477,7 @@ export function start( const windowState = window as unknown as Record; const startTokenName = browserClientStartTokenName(recordingId); windowState[startTokenName] = startToken; + activeStartTokenNames.add(startTokenName); const isActiveStart = () => windowState[startTokenName] === startToken; diff --git a/packages/browser-client/test/sequence-id.test.ts b/packages/browser-client/test/sequence-id.test.ts index 8c4292d415..9d05c4dfec 100644 --- a/packages/browser-client/test/sequence-id.test.ts +++ b/packages/browser-client/test/sequence-id.test.ts @@ -12,6 +12,7 @@ type QueueLike = { }; type WebsocketLike = { + url?: string; send: ReturnType; close: ReturnType; addEventListener: ReturnType; @@ -139,6 +140,7 @@ vi.mock('websocket-ts', () => { } build() { const ws = new Websocket(); + ws.url = this.url; mockState.websockets.push(ws); return ws; } @@ -451,6 +453,34 @@ describe('@rrweb/browser-client sequenceId', () => { ).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(); @@ -490,6 +520,7 @@ describe('@rrweb/browser-client sequenceId', () => { ).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 }); }); From 8495733c2bc1d7411cdf36af8e00c150a069924f Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 5 Jun 2026 12:01:09 +0200 Subject: [PATCH 21/21] Avoid Array.from in start token fallback --- packages/browser-client/src/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 3a26c6dff7..99f689ba71 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -156,14 +156,17 @@ function removeRecordingId(): void { function invalidateActiveStart(): void { const recordingId = getCurrentRecordingId(); - const tokenNames = recordingId - ? [browserClientStartTokenName(recordingId)] - : Array.from(activeStartTokenNames); const windowState = window as unknown as Record; - for (const tokenName of tokenNames) { + if (recordingId) { + const tokenName = browserClientStartTokenName(recordingId); delete windowState[tokenName]; activeStartTokenNames.delete(tokenName); + return; } + activeStartTokenNames.forEach((tokenName) => { + delete windowState[tokenName]; + activeStartTokenNames.delete(tokenName); + }); } function getSetVisitorId() {