Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/public/content/docs/(tracking)/session-replay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,26 @@ Add `sessionReplay` to your `init` call. The replay script loads automatically f

```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
import { startReplayRecorder } from '@openpanel/web/replay';

const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
recorder: startReplayRecorder,
},
});
```

With the npm package, the replay module is a dynamic import code-split by your bundler. It is never included in your main bundle when session replay is disabled.
Pass `recorder` from `@openpanel/web/replay` when enabling replay in the npm package. That keeps rrweb out of apps that never import the subpath. Script-tag installs still load `op1-replay.js` from the CDN automatically.

## Options

| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `boolean` | `false` | Enable session replay recording |
| `recorder` | `function` | — | Required for the npm package: pass `startReplayRecorder` from `@openpanel/web/replay`. Not needed for script-tag installs |
| `maskAllInputs` | `boolean` | `true` | Mask all input field values |
| `maskAllText` | `boolean` | `true` | Mask all text content in the recording |
| `unmaskTextSelector` | `string` | — | CSS selector for elements whose text should NOT be masked when `maskAllText` is true |
Expand Down
6 changes: 4 additions & 2 deletions apps/public/content/guides/session-replay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ See the [Web SDK docs](/docs/sdks/web) or [Script tag docs](/docs/sdks/script) f

## Enable session replay [#enable]

Session replay is **off by default**. Enable it by adding `sessionReplay: { enabled: true }` to your init config.
Session replay is **off by default**. For the script tag, add `sessionReplay: { enabled: true }` to your init config. For the npm package, also import `@openpanel/web/replay` and pass `recorder` (see below).

### Script tag

Expand All @@ -80,18 +80,20 @@ The replay script (`op1-replay.js`) is fetched automatically alongside the main

```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
import { startReplayRecorder } from '@openpanel/web/replay';

const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
recorder: startReplayRecorder,
},
});
```

With the npm package, the replay module is a dynamic import resolved by your bundler. It is automatically code-split from your main bundle—if you don't enable replay, the module is never included.
Import `startReplayRecorder` from `@openpanel/web/replay` and pass it as `recorder`. Apps that never import that subpath do not ship rrweb.

### Next.js

Expand Down
7 changes: 6 additions & 1 deletion packages/sdks/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
},
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"exports": {
".": "./index.ts",
"./replay": "./src/replay/index.ts"
},
"dependencies": {
"@openpanel/sdk": "workspace:1.3.1-local",
Expand Down
155 changes: 93 additions & 62 deletions packages/sdks/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import type {
TrackProperties,
} from '@openpanel/sdk';
import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
import {
resolveSessionReplayRecorder,
type SessionReplayRecorder,
} from './resolve-replay-recorder';

export type * from '@openpanel/sdk';
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
export type {
SessionReplayChunkPayload,
SessionReplayRecorder,
SessionReplayRecorderConfig,
} from './resolve-replay-recorder';

export type SessionReplayOptions = {
enabled: boolean;
Expand All @@ -31,18 +40,31 @@ export type SessionReplayOptions = {
/**
* URL to the replay recorder script.
* Only used when loading the SDK via a script tag (IIFE / op1.js).
* When using the npm package with a bundler this option is ignored
* because the bundler resolves the replay module from the package.
* When using the npm package with a bundler this option is ignored
* pass `recorder` from `@openpanel/web/replay` instead.
*/
scriptUrl?: string;
/**
* Recorder implementation. Required for the npm / bundler build when
* `enabled` is true, so rrweb is only included when you import
* `@openpanel/web/replay`. The script-tag build loads the CDN
* recorder automatically when this is omitted.
*
* @example
* import { startReplayRecorder } from '@openpanel/web/replay'
* new OpenPanel({
* sessionReplay: { enabled: true, recorder: startReplayRecorder },
* })
*/
recorder?: SessionReplayRecorder;
};

// Injected at build time only in the IIFE (tracker) build.
// In the library build this is `undefined`.
declare const __OPENPANEL_REPLAY_URL__: string | undefined;

// Capture script element synchronously; currentScript is only set during sync execution.
// Used by loadReplayModule() to derive the replay script URL in the IIFE build.
// Used by loadIifeReplayRecorder() to derive the replay script URL in the IIFE build.
const _replayScriptRef: HTMLScriptElement | null =
typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement | null)
Expand Down Expand Up @@ -113,75 +135,84 @@ export class OpenPanel extends OpenPanelBase {
const sampleRate = this.options.sessionReplay.sampleRate ?? 1;
const sampled = Math.random() < sampleRate;
if (sampled) {
this.loadReplayModule().then((mod) => {
if (!mod) {
return;
}
mod.startReplayRecorder(this.options.sessionReplay!, (chunk) => {
// Replay chunks go through send() and are queued when disabled or waitForProfile
// until ready() is called (base SDK also queues replay until sessionId is set).
this.send({
type: 'replay',
payload: {
...chunk,
sessionId: this.sessionId,
},
});
});
});
void this.startSessionReplay();
}
}
}
}

/**
* Load the replay recorder module.
*
* - **IIFE build (op1.js)**: `__OPENPANEL_REPLAY_URL__` is replaced at
* build time with a CDN URL (e.g. `https://openpanel.dev/op1-replay.js`).
* The user can also override it via `sessionReplay.scriptUrl`.
* We load the IIFE replay script via a classic `<script>` tag which
* avoids CORS issues (dynamic `import(url)` uses `cors` mode).
* The IIFE exposes its exports on `window.__openpanel_replay`.
* Start session replay with either an explicit `recorder` (npm) or the
* CDN script (IIFE / op1.js). The library build never `import()`s
* `./replay`, so rrweb stays out of apps that do not opt in.
*/
private async startSessionReplay(): Promise<void> {
const options = this.options.sessionReplay;
if (!options) {
return;
}

const recorder = await resolveSessionReplayRecorder({
recorder: options.recorder,
isIifeBuild: typeof __OPENPANEL_REPLAY_URL__ !== 'undefined',
loadIifeRecorder: () => this.loadIifeReplayRecorder(),
});

if (!recorder) {
console.warn(
'[OpenPanel] sessionReplay.enabled but no recorder was provided. Import startReplayRecorder from @openpanel/web/replay and pass it as sessionReplay.recorder.',
);
return;
}

recorder(options, (chunk) => {
// Replay chunks go through send() and are queued when disabled or waitForProfile
// until ready() is called (base SDK also queues replay until sessionId is set).
this.send({
type: 'replay',
payload: {
...chunk,
sessionId: this.sessionId,
},
});
});
}

/**
* Load the IIFE replay recorder from the CDN (script-tag builds only).
*
* - **Library build (npm)**: `__OPENPANEL_REPLAY_URL__` is `undefined`
* (never replaced). We use `import('./replay')` which the host app's
* bundler resolves and code-splits from the package source.
* `__OPENPANEL_REPLAY_URL__` is replaced at build time with a CDN URL
* (e.g. `https://openpanel.dev/op1-replay.js`). The user can also
* override it via `sessionReplay.scriptUrl`. Classic `<script>` avoids
* CORS issues that dynamic `import(url)` would hit. Exports land on
* `window.__openpanel_replay`.
*/
private async loadReplayModule(): Promise<typeof import('./replay') | null> {
private async loadIifeReplayRecorder(): Promise<SessionReplayRecorder | null> {
try {
// typeof check avoids a ReferenceError when the constant is not
// defined (library build). tsup replaces the constant with a
// string literal only in the IIFE build, so this branch is
// dead-code-eliminated in the library build.
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
const scriptEl = _replayScriptRef;
const url =
this.options.sessionReplay?.scriptUrl ||
scriptEl?.src?.replace('.js', '-replay.js') ||
'https://openpanel.dev/op1-replay.js';

// Already loaded (e.g. user included the script manually)
if ((window as any).__openpanel_replay) {
return (window as any).__openpanel_replay;
}

// Load via classic <script> tag — no CORS restrictions
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
resolve((window as any).__openpanel_replay ?? null);
};
script.onerror = () => {
console.warn('[OpenPanel] Failed to load replay script from', url);
resolve(null);
};
document.head.appendChild(script);
});
const scriptEl = _replayScriptRef;
const url =
this.options.sessionReplay?.scriptUrl ||
scriptEl?.src?.replace('.js', '-replay.js') ||
'https://openpanel.dev/op1-replay.js';

if ((window as any).__openpanel_replay?.startReplayRecorder) {
return (window as any).__openpanel_replay.startReplayRecorder;
}
// Library / bundler context — resolved by the bundler
return await import('./replay');

return new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
resolve(
(window as any).__openpanel_replay?.startReplayRecorder ?? null,
);
};
script.onerror = () => {
console.warn('[OpenPanel] Failed to load replay script from', url);
resolve(null);
};
document.head.appendChild(script);
});
} catch (e) {
console.warn('[OpenPanel] Failed to load replay module', e);
return null;
Expand Down
44 changes: 44 additions & 0 deletions packages/sdks/web/src/resolve-replay-recorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';

import { resolveSessionReplayRecorder } from './resolve-replay-recorder';

describe('resolveSessionReplayRecorder', () => {
it('prefers an explicit recorder so the host can opt into rrweb', async () => {
const recorder = vi.fn();
const loadIifeRecorder = vi.fn();

const resolved = await resolveSessionReplayRecorder({
recorder,
isIifeBuild: false,
loadIifeRecorder,
});

expect(resolved).toBe(recorder);
expect(loadIifeRecorder).not.toHaveBeenCalled();
});

it('loads the CDN script only for the IIFE build', async () => {
const recorder = vi.fn();
const loadIifeRecorder = vi.fn().mockResolvedValue(recorder);

const resolved = await resolveSessionReplayRecorder({
isIifeBuild: true,
loadIifeRecorder,
});

expect(loadIifeRecorder).toHaveBeenCalledOnce();
expect(resolved).toBe(recorder);
});

it('returns null in the library build when no recorder is provided', async () => {
const loadIifeRecorder = vi.fn();

const resolved = await resolveSessionReplayRecorder({
isIifeBuild: false,
loadIifeRecorder,
});

expect(resolved).toBeNull();
expect(loadIifeRecorder).not.toHaveBeenCalled();
});
});
48 changes: 48 additions & 0 deletions packages/sdks/web/src/resolve-replay-recorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export type SessionReplayRecorderConfig = {
maskAllInputs?: boolean;
maskAllText?: boolean;
unmaskTextSelector?: string;
blockSelector?: string;
blockClass?: string;
ignoreSelector?: string;
flushIntervalMs?: number;
maxEventsPerChunk?: number;
maxPayloadBytes?: number;
};

export type SessionReplayChunkPayload = {
chunk_index: number;
events_count: number;
is_full_snapshot: boolean;
started_at: string;
ended_at: string;
payload: string;
};

export type SessionReplayRecorder = (
config: SessionReplayRecorderConfig,
sendChunk: (payload: SessionReplayChunkPayload) => void,
) => void;

/**
* Decides how session replay starts.
*
* Library / bundler consumers must pass `recorder` (typically
* `startReplayRecorder` from `@openpanel/web/replay`) so rrweb is only
* pulled into a bundle that actually imports that subpath.
*
* The IIFE (script-tag) build still loads `op1-replay.js` from the CDN.
*/
export async function resolveSessionReplayRecorder(options: {
recorder?: SessionReplayRecorder;
isIifeBuild: boolean;
loadIifeRecorder: () => Promise<SessionReplayRecorder | null>;
}): Promise<SessionReplayRecorder | null> {
if (options.recorder) {
return options.recorder;
}
if (options.isIifeBuild) {
return options.loadIifeRecorder();
}
return null;
}
Loading