feat: session replay

* wip

* wip

* wip

* wip

* final fixes

* comments

* fix
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-26 14:09:53 +01:00
committed by GitHub
parent 38d9b65ec8
commit aa81bbfe77
67 changed files with 3059 additions and 556 deletions

View File

@@ -4,6 +4,6 @@ export function shortId() {
return nanoid(4);
}
export function generateId() {
return nanoid(8);
export function generateId(prefix?: string, length?: number) {
return prefix ? `${prefix}_${nanoid(length ?? 8)}` : nanoid(length ?? 8);
}

View File

@@ -0,0 +1,60 @@
import fs from 'node:fs';
import path from 'node:path';
import { TABLE_NAMES } from '../src/clickhouse/client';
import {
addColumns,
createTable,
modifyTTL,
runClickhouseMigrationCommands,
} from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
const isClustered = getIsCluster();
const sqls: string[] = [
...createTable({
name: TABLE_NAMES.session_replay_chunks,
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`session_id` String CODEC(ZSTD(3))',
'`chunk_index` UInt16',
'`started_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3))',
'`ended_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3))',
'`events_count` UInt16',
'`is_full_snapshot` Bool',
'`payload` String CODEC(ZSTD(6))',
],
orderBy: ['project_id', 'session_id', 'chunk_index'],
partitionBy: 'toYYYYMM(started_at)',
settings: {
index_granularity: 8192,
},
distributionHash: 'cityHash64(project_id, session_id)',
replicatedVersion: '1',
isClustered,
}),
modifyTTL({
tableName: TABLE_NAMES.session_replay_chunks,
isClustered,
ttl: 'started_at + INTERVAL 30 DAY',
}),
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
)
.join('\n\n---\n\n'),
);
if (!process.argv.includes('--dry')) {
await runClickhouseMigrationCommands(sqls);
}
}

View File

@@ -19,12 +19,19 @@ async function migrate() {
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
const migrations = fs.readdirSync(migrationsDir).filter((file) => {
const version = file.split('-')[0];
return (
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
);
});
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => {
const version = file.split('-')[0];
return (
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
);
})
.sort((a, b) => {
const aVersion = Number.parseInt(a.split('-')[0]!);
const bVersion = Number.parseInt(b.split('-')[0]!);
return aVersion - bVersion;
});
const finishedMigrations = await db.codeMigration.findMany();

View File

@@ -2,6 +2,7 @@ import { BotBuffer as BotBufferRedis } from './bot-buffer';
import { EventBuffer as EventBufferRedis } from './event-buffer';
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
import { ReplayBuffer } from './replay-buffer';
import { SessionBuffer } from './session-buffer';
export const eventBuffer = new EventBufferRedis();
@@ -9,5 +10,7 @@ export const profileBuffer = new ProfileBufferRedis();
export const botBuffer = new BotBufferRedis();
export const sessionBuffer = new SessionBuffer();
export const profileBackfillBuffer = new ProfileBackfillBuffer();
export const replayBuffer = new ReplayBuffer();
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
export type { IClickhouseSessionReplayChunk } from './replay-buffer';

View File

@@ -0,0 +1,92 @@
import { getSafeJson } from '@openpanel/json';
import { getRedisCache } from '@openpanel/redis';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { BaseBuffer } from './base-buffer';
export interface IClickhouseSessionReplayChunk {
project_id: string;
session_id: string;
chunk_index: number;
started_at: string;
ended_at: string;
events_count: number;
is_full_snapshot: boolean;
payload: string;
}
export class ReplayBuffer extends BaseBuffer {
private batchSize = process.env.REPLAY_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.REPLAY_BUFFER_BATCH_SIZE, 10)
: 500;
private chunkSize = process.env.REPLAY_BUFFER_CHUNK_SIZE
? Number.parseInt(process.env.REPLAY_BUFFER_CHUNK_SIZE, 10)
: 500;
private readonly redisKey = 'replay-buffer';
constructor() {
super({
name: 'replay',
onFlush: async () => {
await this.processBuffer();
},
});
}
async add(chunk: IClickhouseSessionReplayChunk) {
try {
const redis = getRedisCache();
const result = await redis
.multi()
.rpush(this.redisKey, JSON.stringify(chunk))
.incr(this.bufferCounterKey)
.llen(this.redisKey)
.exec();
const bufferLength = (result?.[2]?.[1] as number) ?? 0;
if (bufferLength >= this.batchSize) {
await this.tryFlush();
}
} catch (error) {
this.logger.error('Failed to add replay chunk to buffer', { error });
}
}
async processBuffer() {
const redis = getRedisCache();
try {
const items = await redis.lrange(this.redisKey, 0, this.batchSize - 1);
if (items.length === 0) {
return;
}
const chunks = items
.map((item) => getSafeJson<IClickhouseSessionReplayChunk>(item))
.filter((item): item is IClickhouseSessionReplayChunk => item != null);
for (const chunk of this.chunks(chunks, this.chunkSize)) {
await ch.insert({
table: TABLE_NAMES.session_replay_chunks,
values: chunk,
format: 'JSONEachRow',
});
}
await redis
.multi()
.ltrim(this.redisKey, items.length, -1)
.decrby(this.bufferCounterKey, items.length)
.exec();
this.logger.debug('Processed replay chunks', { count: items.length });
} catch (error) {
this.logger.error('Failed to process replay buffer', { error });
}
}
async getBufferSize() {
const redis = getRedisCache();
return this.getBufferSizeWithCounter(() => redis.llen(this.redisKey));
}
}

View File

@@ -1,8 +1,7 @@
import { type Redis, getRedisCache } from '@openpanel/redis';
import { getSafeJson } from '@openpanel/json';
import { getRedisCache, type Redis } from '@openpanel/redis';
import { assocPath, clone } from 'ramda';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import type { IClickhouseEvent } from '../services/event.service';
import type { IClickhouseSession } from '../services/session.service';
import { BaseBuffer } from './base-buffer';
@@ -35,14 +34,14 @@ export class SessionBuffer extends BaseBuffer {
| {
projectId: string;
profileId: string;
},
}
) {
let hit: string | null = null;
if ('sessionId' in options) {
hit = await this.redis.get(`session:${options.sessionId}`);
} else {
hit = await this.redis.get(
`session:${options.projectId}:${options.profileId}`,
`session:${options.projectId}:${options.profileId}`
);
}
@@ -54,7 +53,7 @@ export class SessionBuffer extends BaseBuffer {
}
async getSession(
event: IClickhouseEvent,
event: IClickhouseEvent
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
const existingSession = await this.getExistingSession({
sessionId: event.session_id,
@@ -186,14 +185,14 @@ export class SessionBuffer extends BaseBuffer {
`session:${newSession.id}`,
JSON.stringify(newSession),
'EX',
60 * 60,
60 * 60
);
if (newSession.profile_id) {
multi.set(
`session:${newSession.project_id}:${newSession.profile_id}`,
JSON.stringify(newSession),
'EX',
60 * 60,
60 * 60
);
}
for (const session of sessions) {
@@ -220,10 +219,12 @@ export class SessionBuffer extends BaseBuffer {
const events = await this.redis.lrange(
this.redisKey,
0,
this.batchSize - 1,
this.batchSize - 1
);
if (events.length === 0) return;
if (events.length === 0) {
return;
}
const sessions = events
.map((e) => getSafeJson<IClickhouseSession>(e))
@@ -258,7 +259,7 @@ export class SessionBuffer extends BaseBuffer {
}
}
async getBufferSize() {
getBufferSize() {
return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey));
}
}

View File

@@ -59,6 +59,7 @@ export const TABLE_NAMES = {
cohort_events_mv: 'cohort_events_mv',
sessions: 'sessions',
events_imports: 'events_imports',
session_replay_chunks: 'session_replay_chunks',
};
/**

View File

@@ -1,19 +1,21 @@
import { getSafeJson } from '@openpanel/json';
import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation';
import sqlstring from 'sqlstring';
import {
TABLE_NAMES,
ch,
chQuery,
convertClickhouseDateToJs,
formatClickhouseDate,
TABLE_NAMES,
} from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
import { getOrganizationByProjectIdCached } from './organization.service';
import { type IServiceProfile, getProfilesCached } from './profile.service';
import { getProfilesCached, type IServiceProfile } from './profile.service';
export type IClickhouseSession = {
export interface IClickhouseSession {
id: string;
profile_id: string;
event_count: number;
@@ -52,7 +54,9 @@ export type IClickhouseSession = {
revenue: number;
sign: 1 | 0;
version: number;
};
// Dynamically added
has_replay?: boolean;
}
export interface IServiceSession {
id: string;
@@ -91,6 +95,7 @@ export interface IServiceSession {
utmTerm: string;
revenue: number;
profile?: IServiceProfile;
hasReplay?: boolean;
}
export interface GetSessionListOptions {
@@ -114,8 +119,8 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
entryOrigin: session.entry_origin,
exitPath: session.exit_path,
exitOrigin: session.exit_origin,
createdAt: new Date(session.created_at),
endedAt: new Date(session.ended_at),
createdAt: convertClickhouseDateToJs(session.created_at),
endedAt: convertClickhouseDateToJs(session.ended_at),
referrer: session.referrer,
referrerName: session.referrer_name,
referrerType: session.referrer_type,
@@ -142,19 +147,18 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
utmTerm: session.utm_term,
revenue: session.revenue,
profile: undefined,
hasReplay: session.has_replay,
};
}
type Direction = 'initial' | 'next' | 'prev';
type PageInfo = {
interface PageInfo {
next?: Cursor; // use last row
};
}
type Cursor = {
interface Cursor {
createdAt: string; // ISO 8601 with ms
id: string;
};
}
export async function getSessionList({
cursor,
@@ -176,8 +180,9 @@ export async function getSessionList({
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
}
if (profileId)
if (profileId) {
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
}
if (search) {
const s = sqlstring.escape(`%${search}%`);
sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`;
@@ -191,13 +196,11 @@ export async function getSessionList({
const dateIntervalInDays =
organization?.subscriptionPeriodEventsLimit &&
organization?.subscriptionPeriodEventsLimit > 1_000_000
? 1
? 2
: 360;
if (cursor) {
const cAt = sqlstring.escape(cursor.createdAt);
// TODO: remove id from cursor
const cId = sqlstring.escape(cursor.id);
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
sb.orderBy.created_at = 'created_at DESC';
@@ -235,10 +238,14 @@ export async function getSessionList({
sb.select[column] = column;
});
sb.select.has_replay = `toBool(src.session_id != '') as hasReplay`;
sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`;
const sql = getSql();
const data = await chQuery<
IClickhouseSession & {
latestCreatedAt: string;
hasReplay: boolean;
}
>(sql);
@@ -321,23 +328,79 @@ export async function getSessionsCount({
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
export interface ISessionReplayChunkMeta {
chunk_index: number;
started_at: string;
ended_at: string;
events_count: number;
is_full_snapshot: boolean;
}
const REPLAY_CHUNKS_PAGE_SIZE = 40;
export async function getSessionReplayChunksFrom(
sessionId: string,
projectId: string,
fromIndex: number
) {
const rows = await chQuery<{ chunk_index: number; payload: string }>(
`SELECT chunk_index, payload
FROM ${TABLE_NAMES.session_replay_chunks}
WHERE session_id = ${sqlstring.escape(sessionId)}
AND project_id = ${sqlstring.escape(projectId)}
ORDER BY started_at, ended_at, chunk_index
LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1}
OFFSET ${fromIndex}`
);
return {
data: rows
.slice(0, REPLAY_CHUNKS_PAGE_SIZE)
.map((row, index) => {
const events = getSafeJson<
{ type: number; data: unknown; timestamp: number }[]
>(row.payload);
if (!events) {
return null;
}
return { chunkIndex: index + fromIndex, events };
})
.filter(Boolean),
hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE,
};
}
class SessionService {
constructor(private client: typeof ch) {}
async byId(sessionId: string, projectId: string) {
const result = await clix(this.client)
.select<IClickhouseSession>(['*'])
.from(TABLE_NAMES.sessions)
.where('id', '=', sessionId)
.where('project_id', '=', projectId)
.where('sign', '=', 1)
.execute();
const [sessionRows, hasReplayRows] = await Promise.all([
clix(this.client)
.select<IClickhouseSession>(['*'])
.from(TABLE_NAMES.sessions, true)
.where('id', '=', sessionId)
.where('project_id', '=', projectId)
.where('sign', '=', 1)
.execute(),
chQuery<{ n: number }>(
`SELECT 1 AS n
FROM ${TABLE_NAMES.session_replay_chunks}
WHERE session_id = ${sqlstring.escape(sessionId)}
AND project_id = ${sqlstring.escape(projectId)}
LIMIT 1`
),
]);
if (!result[0]) {
if (!sessionRows[0]) {
throw new Error('Session not found');
}
return transformSession(result[0]);
const session = transformSession(sessionRows[0]);
return {
...session,
hasReplay: hasReplayRows.length > 0,
};
}
}

View File

@@ -65,8 +65,10 @@ export interface EventsQueuePayloadIncomingEvent {
latitude: number | undefined;
};
headers: Record<string, string | undefined>;
currentDeviceId: string;
previousDeviceId: string;
currentDeviceId: string; // TODO: Remove
previousDeviceId: string; // TODO: Remove
deviceId: string;
sessionId: string;
};
}
export interface EventsQueuePayloadCreateEvent {
@@ -123,12 +125,17 @@ export type CronQueuePayloadFlushProfileBackfill = {
type: 'flushProfileBackfill';
payload: undefined;
};
export type CronQueuePayloadFlushReplay = {
type: 'flushReplay';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
| CronQueuePayloadFlushSessions
| CronQueuePayloadFlushProfiles
| CronQueuePayloadFlushProfileBackfill
| CronQueuePayloadFlushReplay
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily

View File

@@ -4,12 +4,14 @@ import { getInitSnippet } from '@openpanel/web';
type Props = Omit<OpenPanelOptions, 'filter'> & {
profileId?: string;
/** @deprecated Use `scriptUrl` instead. */
cdnUrl?: string;
scriptUrl?: string;
filter?: string;
globalProperties?: Record<string, unknown>;
};
const { profileId, cdnUrl, globalProperties, ...options } = Astro.props;
const { profileId, cdnUrl, scriptUrl, globalProperties, ...options } = Astro.props;
const CDN_URL = 'https://openpanel.dev/op1.js';
@@ -60,5 +62,5 @@ ${methods
.join('\n')}`;
---
<script src={cdnUrl ?? CDN_URL} async defer />
<script src={scriptUrl ?? cdnUrl ?? CDN_URL} async defer />
<script is:inline set:html={scriptContent} />

View File

@@ -1,8 +1,3 @@
// adding .js next/script import fixes an issues
// with esm and nextjs (when using pages dir)
import Script from 'next/script.js';
import React from 'react';
import type {
DecrementPayload,
IdentifyPayload,
@@ -12,6 +7,11 @@ import type {
TrackProperties,
} from '@openpanel/web';
import { getInitSnippet } from '@openpanel/web';
// adding .js next/script import fixes an issues
// with esm and nextjs (when using pages dir)
import Script from 'next/script.js';
// biome-ignore lint/correctness/noUnusedImports: nextjs requires this
import React from 'react';
export * from '@openpanel/web';
@@ -19,7 +19,9 @@ const CDN_URL = 'https://openpanel.dev/op1.js';
type OpenPanelComponentProps = Omit<OpenPanelOptions, 'filter'> & {
profileId?: string;
/** @deprecated Use `scriptUrl` instead. */
cdnUrl?: string;
scriptUrl?: string;
filter?: string;
globalProperties?: Record<string, unknown>;
strategy?: 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker';
@@ -42,6 +44,7 @@ const stringify = (obj: unknown) => {
export function OpenPanelComponent({
profileId,
cdnUrl,
scriptUrl,
globalProperties,
strategy = 'afterInteractive',
...options
@@ -80,10 +83,8 @@ export function OpenPanelComponent({
return (
<>
<Script src={appendVersion(cdnUrl || CDN_URL)} async defer />
<Script async defer src={appendVersion(scriptUrl || cdnUrl || CDN_URL)} />
<Script
id="openpanel-init"
strategy={strategy}
dangerouslySetInnerHTML={{
__html: `${getInitSnippet()}
${methods
@@ -92,6 +93,8 @@ export function OpenPanelComponent({
})
.join('\n')}`,
}}
id="openpanel-init"
strategy={strategy}
/>
</>
);
@@ -101,25 +104,21 @@ type IdentifyComponentProps = IdentifyPayload;
export function IdentifyComponent(props: IdentifyComponentProps) {
return (
<>
<Script
dangerouslySetInnerHTML={{
__html: `window.op('identify', ${JSON.stringify(props)});`,
}}
/>
</>
<Script
dangerouslySetInnerHTML={{
__html: `window.op('identify', ${JSON.stringify(props)});`,
}}
/>
);
}
export function SetGlobalPropertiesComponent(props: Record<string, unknown>) {
return (
<>
<Script
dangerouslySetInnerHTML={{
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
}}
/>
</>
<Script
dangerouslySetInnerHTML={{
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
}}
/>
);
}
@@ -137,6 +136,7 @@ export function useOpenPanel() {
clearRevenue,
pendingRevenue,
fetchDeviceId,
getDeviceId,
};
}
@@ -152,7 +152,7 @@ function screenView(properties?: TrackProperties): void;
function screenView(path: string, properties?: TrackProperties): void;
function screenView(
pathOrProperties?: string | TrackProperties,
propertiesOrUndefined?: TrackProperties,
propertiesOrUndefined?: TrackProperties
) {
window.op?.('screenView', pathOrProperties, propertiesOrUndefined);
}
@@ -172,6 +172,9 @@ function decrement(payload: DecrementPayload) {
function fetchDeviceId() {
return window.op.fetchDeviceId();
}
function getDeviceId() {
return window.op.getDeviceId();
}
function clearRevenue() {
window.op.clearRevenue();
}

View File

@@ -37,6 +37,8 @@ export type OpenPanelOptions = {
export class OpenPanel {
api: Api;
profileId?: string;
deviceId?: string;
sessionId?: string;
global?: Record<string, unknown>;
queue: TrackHandlerPayload[] = [];
@@ -69,6 +71,16 @@ export class OpenPanel {
this.flush();
}
private shouldQueue(payload: TrackHandlerPayload): boolean {
if (payload.type === 'replay' && !this.sessionId) {
return true;
}
if (this.options.waitForProfile && !this.profileId) {
return true;
}
return false;
}
async send(payload: TrackHandlerPayload) {
if (this.options.disabled) {
return Promise.resolve();
@@ -78,11 +90,26 @@ export class OpenPanel {
return Promise.resolve();
}
if (this.options.waitForProfile && !this.profileId) {
if (this.shouldQueue(payload)) {
this.queue.push(payload);
return Promise.resolve();
}
return this.api.fetch('/track', payload);
// Disable keepalive for replay since it has a hard body limit and breaks the request
const result = await this.api.fetch<
TrackHandlerPayload,
{ deviceId: string; sessionId: string }
>('/track', payload, { keepalive: payload.type !== 'replay' });
this.deviceId = result?.deviceId;
const hadSession = !!this.sessionId;
this.sessionId = result?.sessionId;
// Flush queued items (e.g. replay chunks) when sessionId first arrives
if (!hadSession && this.sessionId) {
this.flush();
}
return result;
}
setGlobalProperties(properties: Record<string, unknown>) {
@@ -149,7 +176,7 @@ export class OpenPanel {
async revenue(
amount: number,
properties?: TrackProperties & { deviceId?: string },
properties?: TrackProperties & { deviceId?: string }
) {
const deviceId = properties?.deviceId;
delete properties?.deviceId;
@@ -160,33 +187,47 @@ export class OpenPanel {
});
}
async fetchDeviceId(): Promise<string> {
const result = await this.api.fetch<undefined, { deviceId: string }>(
'/track/device-id',
undefined,
{ method: 'GET', keepalive: false },
);
return result?.deviceId ?? '';
getDeviceId(): string {
return this.deviceId ?? '';
}
getSessionId(): string {
return this.sessionId ?? '';
}
/**
* @deprecated Use `getDeviceId()` instead. This async method is no longer needed.
*/
fetchDeviceId(): Promise<string> {
return Promise.resolve(this.deviceId ?? '');
}
clear() {
this.profileId = undefined;
// should we force a session end here?
this.deviceId = undefined;
this.sessionId = undefined;
}
flush() {
this.queue.forEach((item) => {
this.send({
...item,
// Not sure why ts-expect-error is needed here
// @ts-expect-error
payload: {
...item.payload,
profileId: item.payload.profileId ?? this.profileId,
},
});
});
this.queue = [];
const remaining: TrackHandlerPayload[] = [];
for (const item of this.queue) {
if (this.shouldQueue(item)) {
remaining.push(item);
continue;
}
const payload =
item.type === 'replay'
? item.payload
: {
...item.payload,
profileId:
'profileId' in item.payload
? (item.payload.profileId ?? this.profileId)
: this.profileId,
};
this.send({ ...item, payload } as TrackHandlerPayload);
}
this.queue = remaining;
}
log(...args: any[]) {

View File

@@ -10,7 +10,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/sdk": "workspace:1.0.4-local"
"@openpanel/sdk": "workspace:1.0.4-local",
"@rrweb/types": "2.0.0-alpha.20",
"rrweb": "2.0.0-alpha.20"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
@@ -18,4 +20,4 @@
"tsup": "^7.2.0",
"typescript": "catalog:"
}
}
}

View File

@@ -7,11 +7,43 @@ import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
export type * from '@openpanel/sdk';
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
export type SessionReplayOptions = {
enabled: boolean;
sampleRate?: number;
maskAllInputs?: boolean;
maskTextSelector?: string;
blockSelector?: string;
blockClass?: string;
ignoreSelector?: string;
flushIntervalMs?: number;
maxEventsPerChunk?: number;
maxPayloadBytes?: number;
/**
* 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.
*/
scriptUrl?: string;
};
// 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.
const _replayScriptRef: HTMLScriptElement | null =
typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement | null)
: null;
export type OpenPanelOptions = OpenPanelBaseOptions & {
trackOutgoingLinks?: boolean;
trackScreenViews?: boolean;
trackAttributes?: boolean;
trackHashChanges?: boolean;
sessionReplay?: SessionReplayOptions;
};
function toCamelCase(str: string) {
@@ -66,6 +98,75 @@ export class OpenPanel extends OpenPanelBase {
if (this.options.trackAttributes) {
this.trackAttributes();
}
if (this.options.sessionReplay?.enabled) {
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) => {
this.send({
type: 'replay',
payload: {
...chunk,
},
});
});
});
}
}
}
}
/**
* 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`.
*
* - **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.
*/
private async loadReplayModule(): Promise<typeof import('./replay') | 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);
});
}
// Library / bundler context — resolved by the bundler
return await import('./replay');
} catch (e) {
console.warn('[OpenPanel] Failed to load replay module', e);
return null;
}
}

View File

@@ -0,0 +1,2 @@
export { startReplayRecorder, stopReplayRecorder } from './recorder';
export type { ReplayChunkPayload, ReplayRecorderConfig } from './recorder';

View File

@@ -0,0 +1,160 @@
import type { eventWithTime } from 'rrweb';
import { record } from 'rrweb';
export type ReplayRecorderConfig = {
maskAllInputs?: boolean;
maskTextSelector?: string;
blockSelector?: string;
blockClass?: string;
ignoreSelector?: string;
flushIntervalMs?: number;
maxEventsPerChunk?: number;
maxPayloadBytes?: number;
};
export type ReplayChunkPayload = {
chunk_index: number;
events_count: number;
is_full_snapshot: boolean;
started_at: string;
ended_at: string;
payload: string;
};
let stopRecording: (() => void) | null = null;
export function startReplayRecorder(
config: ReplayRecorderConfig,
sendChunk: (payload: ReplayChunkPayload) => void,
): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
// Stop any existing recorder before starting a new one to avoid leaks
if (stopRecording) {
stopRecording();
}
const maxEventsPerChunk = config.maxEventsPerChunk ?? 200;
const flushIntervalMs = config.flushIntervalMs ?? 10_000;
const maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1MB
let buffer: eventWithTime[] = [];
let chunkIndex = 0;
let flushTimer: ReturnType<typeof setInterval> | null = null;
function flush(isFullSnapshot: boolean): void {
if (buffer.length === 0) return;
const payloadJson = JSON.stringify(buffer);
const payloadBytes = new TextEncoder().encode(payloadJson).length;
if (payloadBytes > maxPayloadBytes) {
if (buffer.length > 1) {
const mid = Math.floor(buffer.length / 2);
const firstHalf = buffer.slice(0, mid);
const secondHalf = buffer.slice(mid);
const firstHasFullSnapshot =
isFullSnapshot && firstHalf.some((e) => e.type === 2);
buffer = firstHalf;
flush(firstHasFullSnapshot);
buffer = secondHalf;
flush(false);
return;
}
// Single event exceeds limit — drop it to avoid server rejection
buffer = [];
return;
}
const startedAt = buffer[0]!.timestamp;
const endedAt = buffer[buffer.length - 1]!.timestamp;
try {
sendChunk({
chunk_index: chunkIndex,
events_count: buffer.length,
is_full_snapshot: isFullSnapshot,
started_at: new Date(startedAt).toISOString(),
ended_at: new Date(endedAt).toISOString(),
payload: payloadJson,
});
chunkIndex += 1;
buffer = [];
} catch (err) {
console.error('[ReplayRecorder] sendChunk failed', err);
throw err;
}
}
function flushIfNeeded(isCheckout: boolean): void {
const isFullSnapshot =
isCheckout ||
buffer.some((e) => e.type === 2); /* EventType.FullSnapshot */
if (buffer.length >= maxEventsPerChunk) {
flush(isFullSnapshot);
} else if (isCheckout && buffer.length > 0) {
flush(true);
}
}
const stopFn = record({
emit(event: eventWithTime, isCheckout?: boolean) {
buffer.push(event);
flushIfNeeded(!!isCheckout);
},
checkoutEveryNms: flushIntervalMs,
maskAllInputs: config.maskAllInputs ?? true,
maskTextSelector: config.maskTextSelector ?? '[data-openpanel-replay-mask]',
blockSelector: config.blockSelector ?? '[data-openpanel-replay-block]',
blockClass: config.blockClass,
ignoreSelector: config.ignoreSelector,
});
flushTimer = setInterval(() => {
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}, flushIntervalMs);
function onVisibilityChange(): void {
if (document.visibilityState === 'hidden' && buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}
function onPageHide(): void {
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
stopRecording = () => {
// Flush any buffered events before tearing down (same logic as flushTimer)
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
stopFn?.();
stopRecording = null;
};
}
export function stopReplayRecorder(): void {
if (stopRecording) {
stopRecording();
}
}

View File

@@ -14,7 +14,9 @@ type ExposedMethodsNames =
| 'clearRevenue'
| 'pendingRevenue'
| 'screenView'
| 'fetchDeviceId';
| 'fetchDeviceId'
| 'getDeviceId'
| 'getSessionId';
export type ExposedMethods = {
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
@@ -38,7 +40,7 @@ type OpenPanelMethodSignatures = {
} & {
screenView(
pathOrProperties?: string | TrackProperties,
properties?: TrackProperties,
properties?: TrackProperties
): void;
};

View File

@@ -1,4 +1,5 @@
// Test callable function API
/** biome-ignore-all lint/correctness/noUnusedVariables: test */
function testCallableAPI() {
// ✅ Should work - correct callable syntax
window.op('track', 'button_clicked', { location: 'header' });
@@ -29,6 +30,7 @@ function testDirectMethodAPI() {
window.op.flushRevenue();
window.op.clearRevenue();
window.op.fetchDeviceId();
window.op.getDeviceId();
// ❌ Should error - wrong arguments for track
// @ts-expect-error - track expects (name: string, properties?: TrackProperties)

View File

@@ -1,11 +1,70 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['index.ts', 'src/tracker.ts'],
format: ['cjs', 'esm', 'iife'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});
export default defineConfig([
// Library build (npm package) — cjs + esm + dts
// Dynamic import('./replay') is preserved; the host app's bundler
// will code-split it into a separate chunk automatically.
{
entry: ['index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
},
// IIFE build (script tag: op1.js)
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
// knows to load the replay module from the CDN instead of a
// relative import (which doesn't work in a standalone script).
// The replay module is excluded via an esbuild plugin so it is
// never bundled into op1.js — it will be loaded lazily via <script>.
{
entry: { 'src/tracker': 'src/tracker.ts' },
format: ['iife'],
splitting: false,
sourcemap: false,
minify: true,
define: {
__OPENPANEL_REPLAY_URL__: JSON.stringify(
'https://openpanel.dev/op1-replay.js'
),
},
esbuildPlugins: [
{
name: 'exclude-replay-from-iife',
setup(build) {
// Intercept any import that resolves to the replay module and
// return an empty object. The actual loading happens at runtime
// via a <script> tag (see loadReplayModule in index.ts).
build.onResolve(
{ filter: /[/\\]replay([/\\]index)?(\.[jt]s)?$/ },
() => ({
path: 'replay-empty-stub',
namespace: 'replay-stub',
})
);
build.onLoad({ filter: /.*/, namespace: 'replay-stub' }, () => ({
contents: 'module.exports = {}',
loader: 'js',
}));
},
},
],
},
// Replay module — built as both ESM (npm) and IIFE (CDN).
// ESM → consumed by the host-app's bundler via `import('./replay')`.
// IIFE → loaded at runtime via a classic <script> tag (no CORS issues).
// Exposes `window.__openpanel_replay`.
// rrweb must be bundled in (noExternal) because browsers can't resolve
// bare specifiers like "rrweb" from a standalone ES module / script.
{
entry: { 'src/replay': 'src/replay/index.ts' },
format: ['esm', 'iife'],
globalName: '__openpanel_replay',
splitting: false,
sourcemap: false,
minify: true,
noExternal: ['rrweb', '@rrweb/types'],
},
]);

View File

@@ -1,8 +1,10 @@
import { z } from 'zod';
import { getSessionList, sessionService } from '@openpanel/db';
import {
getSessionList,
getSessionReplayChunksFrom,
sessionService,
} from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export function encodeCursor(cursor: {
@@ -14,7 +16,7 @@ export function encodeCursor(cursor: {
}
export function decodeCursor(
encoded: string,
encoded: string
): { createdAt: string; id: string } | null {
try {
const json = Buffer.from(encoded, 'base64url').toString('utf8');
@@ -40,7 +42,7 @@ export const sessionRouter = createTRPCRouter({
endDate: z.date().optional(),
search: z.string().optional(),
take: z.number().default(50),
}),
})
)
.query(async ({ input }) => {
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
@@ -58,7 +60,19 @@ export const sessionRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
.query(async ({ input: { sessionId, projectId } }) => {
.query(({ input: { sessionId, projectId } }) => {
return sessionService.byId(sessionId, projectId);
}),
replayChunksFrom: protectedProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
fromIndex: z.number().int().min(0).default(0),
})
)
.query(({ input: { sessionId, projectId, fromIndex } }) => {
return getSessionReplayChunksFrom(sessionId, projectId, fromIndex);
}),
});

View File

@@ -63,6 +63,15 @@ export const zAliasPayload = z.object({
alias: z.string().min(1),
});
export const zReplayPayload = z.object({
chunk_index: z.number().int().min(0).max(65_535),
events_count: z.number().int().min(1),
is_full_snapshot: z.boolean(),
started_at: z.string().datetime(),
ended_at: z.string().datetime(),
payload: z.string().max(1_048_576 * 2), // 2MB max
});
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
z.object({
type: z.literal('track'),
@@ -84,6 +93,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
type: z.literal('alias'),
payload: zAliasPayload,
}),
z.object({
type: z.literal('replay'),
payload: zReplayPayload,
}),
]);
export type ITrackPayload = z.infer<typeof zTrackPayload>;
@@ -91,6 +104,7 @@ export type IIdentifyPayload = z.infer<typeof zIdentifyPayload>;
export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type IReplayPayload = z.infer<typeof zReplayPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs