This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-09 09:54:22 +00:00
parent 38d9b65ec8
commit 551927af06
33 changed files with 1746 additions and 142 deletions

View File

@@ -76,6 +76,7 @@ export async function postEvent(
geo,
currentDeviceId,
previousDeviceId,
deviceId: '',
},
groupId,
jobId,

View File

@@ -3,8 +3,19 @@ import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { generateId, slug } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import {
generateDeviceId,
generateSecureId,
parseUserAgent,
} from '@openpanel/common/server';
import {
TABLE_NAMES,
ch,
getProfileById,
getSalts,
sessionBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
@@ -13,11 +24,90 @@ import {
type IDecrementPayload,
type IIdentifyPayload,
type IIncrementPayload,
type IReplayPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
async function getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId,
}: {
projectId: string;
ip: string;
ua: string | undefined;
salts: { current: string; previous: string };
overrideDeviceId?: string;
}) {
if (overrideDeviceId) {
return { deviceId: overrideDeviceId, sessionId: undefined };
}
if (!ua) {
return { deviceId: '', sessionId: undefined };
}
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
});
const previousDeviceId = generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
});
return await getDeviceIdFromSession({
projectId,
currentDeviceId,
previousDeviceId,
});
}
async function getDeviceIdFromSession({
projectId,
currentDeviceId,
previousDeviceId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
}) {
try {
const multi = getRedisCache().multi();
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data',
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data',
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return { deviceId: currentDeviceId, sessionId };
}
if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return { deviceId: previousDeviceId, sessionId };
}
} catch (error) {
console.error('Error getting session end GET /track/device-id', error);
}
return { deviceId: currentDeviceId, sessionId: generateSecureId('se') };
}
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
pick(
@@ -45,14 +135,15 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
| IIdentifyPayload
| undefined;
return (
identity ||
(body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined)
);
if (identity) {
return identity;
}
return body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined;
}
return undefined;
@@ -104,8 +195,8 @@ interface TrackContext {
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
currentDeviceId?: string;
previousDeviceId?: string;
deviceId: string;
sessionId: string;
geo: GeoLocation;
}
@@ -128,49 +219,27 @@ async function buildContext(
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody);
const profileId = identity?.profileId;
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
}
// Get geo location (needed for track and identify)
const geo = await getGeoLocation(ip);
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
// Generate device IDs if needed (for track)
let currentDeviceId: string | undefined;
let previousDeviceId: string | undefined;
if (validatedBody.type === 'track') {
const overrideDeviceId =
typeof validatedBody.payload.properties?.__deviceId === 'string'
? validatedBody.payload.properties.__deviceId
: undefined;
const salts = await getSalts();
currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
}
const { deviceId, sessionId } = await getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId:
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined,
});
return {
projectId,
@@ -182,8 +251,8 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
currentDeviceId,
previousDeviceId,
deviceId,
sessionId,
geo,
};
}
@@ -192,30 +261,19 @@ async function handleTrack(
payload: ITrackPayload,
context: TrackContext,
): Promise<void> {
const {
projectId,
currentDeviceId,
previousDeviceId,
geo,
headers,
timestamp,
} = context;
if (!currentDeviceId || !previousDeviceId) {
throw new HttpError('Device ID generation failed', { status: 500 });
}
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
: deviceId;
const jobId = [
slug(payload.name),
timestamp.value,
projectId,
currentDeviceId,
deviceId,
groupId,
]
.filter(Boolean)
@@ -242,8 +300,10 @@ async function handleTrack(
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
deviceId,
sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
},
groupId,
jobId,
@@ -330,6 +390,33 @@ async function handleDecrement(
await adjustProfileProperty(payload, context.projectId, -1);
}
async function handleReplay(
payload: IReplayPayload,
context: TrackContext,
): Promise<void> {
if (!context.sessionId) {
throw new HttpError('Session ID is required for replay', { status: 400 });
}
const row = {
project_id: context.projectId,
session_id: context.sessionId,
profile_id: '', // TODO: remove
chunk_index: payload.chunk_index,
started_at: payload.started_at,
ended_at: payload.ended_at,
events_count: payload.events_count,
is_full_snapshot: payload.is_full_snapshot,
payload: payload.payload,
};
await ch.insert({
table: TABLE_NAMES.session_replay_chunks,
values: [row],
format: 'JSONEachRow',
});
await sessionBuffer.markHasReplay(row.session_id);
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
@@ -375,6 +462,9 @@ export async function handler(
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
@@ -383,7 +473,10 @@ export async function handler(
});
}
reply.status(200).send();
reply.status(200).send({
deviceId: context.deviceId,
sessionId: context.sessionId,
});
}
export async function fetchDeviceId(
@@ -421,20 +514,31 @@ export async function fetchDeviceId(
try {
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data',
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data',
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: currentDeviceId,
deviceId: sessionId,
sessionId,
message: 'current session exists for this device id',
});
}
if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: previousDeviceId,
deviceId: sessionId,
sessionId,
message: 'previous session exists for this device id',
});
}
@@ -444,6 +548,7 @@ export async function fetchDeviceId(
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId: '',
message: 'No session exists for this device id',
});
}

View File

@@ -26,6 +26,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
type: 'object',
properties: {
deviceId: { type: 'string' },
sessionId: { type: 'string' },
message: { type: 'string', optional: true },
},
},

View File

@@ -116,6 +116,7 @@
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"rrweb-player": "2.0.0-alpha.20",
"rc-virtual-list": "^3.14.5",
"react": "catalog:",
"react-animate-height": "^3.2.3",

View File

@@ -0,0 +1,42 @@
import { cn } from '@/utils/cn';
import type { ReactNode } from 'react';
export function BrowserChrome({
url,
children,
right,
controls = (
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
),
className,
}: {
url?: ReactNode;
children: ReactNode;
right?: ReactNode;
controls?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background',
className,
)}
>
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background h-10">
{controls}
{url !== false && (
<div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
{url}
</div>
)}
{right}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import {
ReplayProvider,
useReplayContext,
} from '@/components/sessions/replay/replay-context';
import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed';
import { ReplayPlayer } from '@/components/sessions/replay/replay-player';
import { ReplayTimeline } from '@/components/sessions/replay/replay-timeline';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IServiceEvent } from '@openpanel/db';
import { type ReactNode, useMemo } from 'react';
import { BrowserChrome } from './browser-chrome';
import { ReplayTime } from './replay-controls';
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
const { currentTime, startTime } = useReplayContext();
const currentUrl = useMemo(() => {
if (startTime == null || !events.length) return '';
const withOffset = events
.map((ev) => ({
event: ev,
offsetMs: getEventOffsetMs(ev, startTime),
}))
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
.sort((a, b) => a.offsetMs - b.offsetMs);
const latest = withOffset[withOffset.length - 1];
if (!latest) return '';
const { origin = '', path = '/' } = latest.event;
const pathPart = path.startsWith('/') ? path : `/${path}`;
return `${origin}${pathPart}`;
}, [events, currentTime, startTime]);
return <span className="text-muted-foreground truncate">{currentUrl}</span>;
}
function ReplayContent({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
const trpc = useTRPC();
const {
data: replayData,
isLoading: replayLoading,
isError: replayError,
} = useQuery(trpc.session.replay.queryOptions({ sessionId, projectId }));
const { data: eventsData } = useQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
}),
);
const events = eventsData?.data ?? [];
const replayEvents = replayData?.events as
| Array<{ type: number; data: unknown; timestamp: number }>
| undefined;
if (replayLoading) {
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome>
<div className="flex h-[320px] items-center justify-center bg-black">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted-foreground/20" />
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
if (replayError || !replayEvents?.length) {
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<span className="text-muted-foreground">about:blank</span>}
>
<div className="flex h-[320px] items-center justify-center bg-black text-muted-foreground">
No replay data available for this session.
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
return (
<ReplayProvider>
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<BrowserUrlBar events={events} />}
right={<ReplayTime />}
>
<ReplayPlayer events={replayEvents} />
<ReplayTimeline events={events} />
</BrowserChrome>
</div>
<div className="hidden lg:block relative">
<div className="absolute inset-0">
<ReplayEventFeed events={events} />
</div>
</div>
</div>
</ReplayProvider>
);
}
export function ReplayShell({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
return <ReplayContent sessionId={sessionId} projectId={projectId} />;
}

View File

@@ -0,0 +1,210 @@
'use client';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export interface ReplayPlayerInstance {
play: () => void;
pause: () => void;
toggle: () => void;
goto: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
getMetaData: () => { startTime: number; endTime: number; totalTime: number };
getReplayer: () => { getCurrentTime: () => number };
$destroy?: () => void;
}
interface ReplayContextValue {
currentTime: number;
isPlaying: boolean;
speed: number;
duration: number;
startTime: number | null;
isReady: boolean;
play: () => void;
pause: () => void;
toggle: () => void;
seek: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
registerPlayer: (player: ReplayPlayerInstance) => void;
unregisterPlayer: () => void;
}
const ReplayContext = createContext<ReplayContextValue | null>(null);
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const;
export function useReplayContext() {
const ctx = useContext(ReplayContext);
if (!ctx) {
throw new Error('useReplayContext must be used within ReplayProvider');
}
return ctx;
}
export function ReplayProvider({ children }: { children: ReactNode }) {
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeedState] = useState(1);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
const rafIdRef = useRef<number | null>(null);
const lastUpdateRef = useRef(0);
// Refs so stable callbacks can read latest values
const isPlayingRef = useRef(false);
const durationRef = useRef(0);
const currentTimeRef = useRef(0);
const registerPlayer = useCallback((player: ReplayPlayerInstance) => {
playerRef.current = player;
try {
const meta = player.getMetaData();
durationRef.current = meta.totalTime;
setDuration(meta.totalTime);
setStartTime(meta.startTime);
setCurrentTime(0);
currentTimeRef.current = 0;
setIsReady(true);
} catch {
setIsReady(false);
}
}, []);
const unregisterPlayer = useCallback(() => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
playerRef.current = null;
setIsReady(false);
setCurrentTime(0);
currentTimeRef.current = 0;
setDuration(0);
durationRef.current = 0;
setStartTime(null);
setIsPlaying(false);
isPlayingRef.current = false;
}, []);
const play = useCallback(() => {
playerRef.current?.play();
setIsPlaying(true);
isPlayingRef.current = true;
}, []);
const pause = useCallback(() => {
playerRef.current?.pause();
setIsPlaying(false);
isPlayingRef.current = false;
}, []);
const toggle = useCallback(() => {
const player = playerRef.current;
if (!player) return;
// If at the end, reset to start and play
const atEnd = currentTimeRef.current >= durationRef.current - 100;
if (atEnd && !isPlayingRef.current) {
player.goto(0, true);
setCurrentTime(0);
currentTimeRef.current = 0;
setIsPlaying(true);
isPlayingRef.current = true;
return;
}
player.toggle();
const next = !isPlayingRef.current;
setIsPlaying(next);
isPlayingRef.current = next;
}, []);
const seek = useCallback((timeOffset: number, play?: boolean) => {
const player = playerRef.current;
if (!player) return;
const shouldPlay = play ?? isPlayingRef.current;
player.goto(timeOffset, shouldPlay);
setCurrentTime(timeOffset);
currentTimeRef.current = timeOffset;
setIsPlaying(shouldPlay);
isPlayingRef.current = shouldPlay;
}, []);
const setSpeed = useCallback((s: number) => {
if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
playerRef.current?.setSpeed(s);
setSpeedState(s);
}, []);
useEffect(() => {
if (!isReady || !playerRef.current) return;
const tick = () => {
const player = playerRef.current;
if (!player) return;
try {
const replayer = player.getReplayer();
const now = replayer.getCurrentTime();
// Throttle state updates to ~10fps (every 100ms) to avoid excessive re-renders
const t = Math.floor(now / 100);
if (t !== lastUpdateRef.current) {
lastUpdateRef.current = t;
setCurrentTime(now);
currentTimeRef.current = now;
}
// Detect end of replay
if (
now >= durationRef.current - 50 &&
durationRef.current > 0 &&
isPlayingRef.current
) {
setIsPlaying(false);
isPlayingRef.current = false;
}
} catch {
// Player may be destroyed
}
rafIdRef.current = requestAnimationFrame(tick);
};
rafIdRef.current = requestAnimationFrame(tick);
return () => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [isReady]);
const value: ReplayContextValue = {
currentTime,
isPlaying,
speed,
duration,
startTime,
isReady,
play,
pause,
toggle,
seek,
setSpeed,
registerPlayer,
unregisterPlayer,
};
return (
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
);
}
export { SPEED_OPTIONS };

View File

@@ -0,0 +1,69 @@
'use client';
import {
SPEED_OPTIONS,
useReplayContext,
} from '@/components/sessions/replay/replay-context';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ChevronDown, Pause, Play, SkipBack, SkipForward } from 'lucide-react';
function formatTime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function ReplayTime() {
const { currentTime, duration } = useReplayContext();
return (
<span className="text-sm tabular-nums text-muted-foreground font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
);
}
export function ReplayPlayPauseButton() {
const { isPlaying, isReady, toggle, seek } = useReplayContext();
if (!isReady) return null;
return (
<Button
type="button"
variant={isPlaying ? 'outline' : 'default'}
size="icon"
onClick={toggle}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
);
}
// {/* <DropdownMenu>
// <DropdownMenuTrigger asChild>
// <Button variant="outline" size="sm" className="h-8 gap-1">
// {speed}x
// <ChevronDown className="h-3.5 w-3.5" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end">
// {SPEED_OPTIONS.map((s) => (
// <DropdownMenuItem
// key={s}
// onClick={() => setSpeed(s)}
// className={speed === s ? 'bg-accent' : ''}
// >
// {s}x
// </DropdownMenuItem>
// ))}
// </DropdownMenuContent>
// </DropdownMenu> */}

View File

@@ -0,0 +1,95 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IServiceEvent } from '@openpanel/db';
import { useEffect, useMemo, useRef } from 'react';
import { BrowserChrome } from './browser-chrome';
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
const { currentTime, startTime, isReady, seek } = useReplayContext();
const viewportRef = useRef<HTMLDivElement | null>(null);
const prevCountRef = useRef(0);
const { visibleEvents, currentEventId } = useMemo(() => {
if (startTime == null || !isReady) {
return { visibleEvents: [], currentEventId: null as string | null };
}
const withOffset = events
.map((ev) => ({
event: ev,
offsetMs: getEventOffsetMs(ev, startTime),
}))
// Include events up to 10s before recording started (e.g. screen views)
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
.sort((a, b) => a.offsetMs - b.offsetMs);
const visibleEvents = withOffset.map(({ event, offsetMs }) => ({
event,
offsetMs,
}));
const current =
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
const currentEventId = current?.event.id ?? null;
return { visibleEvents, currentEventId };
}, [events, startTime, isReady, currentTime]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || visibleEvents.length === 0) return;
const isNewItem = visibleEvents.length > prevCountRef.current;
prevCountRef.current = visibleEvents.length;
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: isNewItem ? 'smooth' : 'instant',
});
});
}, [visibleEvents.length]);
if (!isReady) return null;
return (
<BrowserChrome
url={false}
controls={<span className="text-lg font-medium">Timeline</span>}
className="h-full"
>
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
<div className="flex flex-col">
{visibleEvents.map(({ event, offsetMs }) => (
<div
key={event.id}
className="animate-in fade-in-0 slide-in-from-bottom-3 duration-300 fill-mode-both"
>
<ReplayEventItem
event={event}
offsetMs={offsetMs}
isCurrent={event.id === currentEventId}
onClick={() => seek(Math.max(0, offsetMs))}
/>
</div>
))}
{visibleEvents.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
Events will appear as the replay plays.
</div>
)}
</div>
</ScrollArea>
</BrowserChrome>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import type { IServiceEvent } from '@openpanel/db';
function formatOffset(ms: number): string {
const sign = ms < 0 ? '-' : '+';
const abs = Math.abs(ms);
const totalSeconds = Math.floor(abs / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
}
export function ReplayEventItem({
event,
offsetMs,
isCurrent,
onClick,
}: {
event: IServiceEvent;
offsetMs: number;
isCurrent: boolean;
onClick: () => void;
}) {
const displayName =
event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ');
return (
<button
type="button"
onClick={onClick}
className={cn(
'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent bg-card',
isCurrent ? 'bg-accent/10' : 'bg-card',
)}
>
<div className="row items-center gap-2">
<div className="flex-shrink-0">
<EventIcon name={event.name} meta={event.meta} size="sm" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{displayName}
</div>
</div>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatOffset(offsetMs)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
import { useEffect, useMemo, useRef } from 'react';
import 'rrweb-player/dist/style.css';
/** rrweb meta event (type 4) carries the recorded viewport size */
function getRecordedDimensions(
events: Array<{ type: number; data: unknown }>,
): { width: number; height: number } | null {
const meta = events.find((e) => e.type === 4);
if (
meta &&
typeof meta.data === 'object' &&
meta.data !== null &&
'width' in meta.data &&
'height' in meta.data
) {
const { width, height } = meta.data as { width: number; height: number };
if (width > 0 && height > 0) return { width, height };
}
return null;
}
export function ReplayPlayer({
events,
}: {
events: Array<{ type: number; data: unknown; timestamp: number }>;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const { registerPlayer, unregisterPlayer } = useReplayContext();
const recordedDimensions = useMemo(
() => getRecordedDimensions(events),
[events],
);
useEffect(() => {
if (!events.length || !containerRef.current) return;
let mounted = true;
import('rrweb-player').then((module) => {
const PlayerConstructor = module.default;
if (!containerRef.current || !mounted) return;
containerRef.current.innerHTML = '';
const maxHeight = window.innerHeight * 0.7;
const containerWidth = containerRef.current.offsetWidth;
const aspectRatio = recordedDimensions
? recordedDimensions.width / recordedDimensions.height
: 16 / 9;
const height = Math.min(
Math.round(containerWidth / aspectRatio),
maxHeight,
);
const width = Math.min(
containerWidth,
Math.round(height * aspectRatio),
);
const player = new PlayerConstructor({
target: containerRef.current,
props: {
events,
width,
height,
autoPlay: false,
showController: false,
speedOption: [0.5, 1, 2, 4, 8],
},
}) as ReplayPlayerInstance;
playerRef.current = player;
registerPlayer(player);
});
return () => {
mounted = false;
unregisterPlayer();
if (playerRef.current?.$destroy) {
playerRef.current.$destroy();
playerRef.current = null;
}
};
}, [events, registerPlayer, unregisterPlayer, recordedDimensions]);
return (
<div className="relative flex w-full justify-center overflow-hidden">
<div
ref={containerRef}
className="w-full"
style={{ maxHeight: '70vh' }}
/>
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { IServiceEvent } from '@openpanel/db';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useRef, useState } from 'react';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import { ReplayPlayPauseButton } from './replay-controls';
function formatTime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
const { currentTime, duration, startTime, isReady, seek } =
useReplayContext();
const trackRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverInfo, setHoverInfo] = useState<{
pct: number;
timeMs: number;
} | null>(null);
const getTimeFromClientX = useCallback(
(clientX: number) => {
if (!trackRef.current || duration <= 0) return null;
const rect = trackRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
return { pct, timeMs: pct * duration };
},
[duration],
);
const handleTrackMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('[data-timeline-event]')) {
setHoverInfo(null);
return;
}
const info = getTimeFromClientX(e.clientX);
if (info) setHoverInfo(info);
},
[getTimeFromClientX],
);
const handleTrackMouseLeave = useCallback(() => {
if (!isDragging) setHoverInfo(null);
}, [isDragging]);
const seekToPosition = useCallback(
(clientX: number) => {
const info = getTimeFromClientX(clientX);
if (info) seek(info.timeMs);
},
[getTimeFromClientX, seek],
);
const handleTrackMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle direct clicks on the track, not on child elements like the thumb
if (
e.target !== trackRef.current &&
!(e.target as HTMLElement).closest('.replay-track-bg')
)
return;
seekToPosition(e.clientX);
},
[seekToPosition],
);
const handleThumbMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const onMouseMove = (moveEvent: MouseEvent) => {
seekToPosition(moveEvent.clientX);
const info = getTimeFromClientX(moveEvent.clientX);
if (info) setHoverInfo(info);
};
const onMouseUp = () => {
setIsDragging(false);
setHoverInfo(null);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[seekToPosition, getTimeFromClientX],
);
if (!isReady || duration <= 0) return null;
const progressPct =
duration > 0
? Math.max(0, Math.min(100, (currentTime / duration) * 100))
: 0;
const eventsWithOffset = events
.map((ev) => ({
event: ev,
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
}))
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration);
return (
<TooltipProvider delayDuration={300}>
<div className="row items-center gap-4 p-4">
<ReplayPlayPauseButton />
<div className="col gap-4 flex-1 px-2">
<div
ref={trackRef}
role="slider"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
tabIndex={0}
className="relative flex h-8 cursor-pointer items-center"
onMouseDown={handleTrackMouseDown}
onMouseMove={handleTrackMouseMove}
onMouseLeave={handleTrackMouseLeave}
onKeyDown={(e) => {
const step = 5000;
if (e.key === 'ArrowLeft') {
e.preventDefault();
seek(Math.max(0, currentTime - step));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
seek(Math.min(duration, currentTime + step));
}
}}
>
<div className="replay-track-bg bg-muted h-1.5 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-75"
style={{ width: `${progressPct}%` }}
/>
</div>
<div
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm transition-[left] duration-75 active:cursor-grabbing"
style={{ left: `calc(${progressPct}% - 8px)` }}
onMouseDown={handleThumbMouseDown}
aria-hidden
/>
{/* Hover timestamp tooltip */}
<AnimatePresence>
{hoverInfo && (
<motion.div
className="pointer-events-none absolute z-20"
style={{
left: `${hoverInfo.pct * 100}%`,
top: 0,
bottom: 0,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Vertical line */}
<div className="absolute left-0 top-1/2 h-4 w-px -translate-x-1/2 -translate-y-1/2 bg-foreground/30" />
{/* Timestamp badge */}
<motion.div
className="absolute bottom-6 left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background shadow"
initial={{ opacity: 0, y: 16, scale: 0.5 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.5 }}
transition={{ duration: 0.2 }}
>
{formatTime(hoverInfo.timeMs)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{eventsWithOffset.map(({ event: ev, offsetMs }) => {
const pct = (offsetMs / duration) * 100;
return (
<Tooltip key={ev.id}>
<TooltipTrigger asChild>
<button
type="button"
data-timeline-event
className={cn(
'absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-125',
)}
style={{ left: `${pct}%`, marginLeft: -12 }}
onClick={(e) => {
e.stopPropagation();
seek(offsetMs);
}}
aria-label={`${ev.name} at ${formatTime(offsetMs)}`}
>
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="col gap-2">
<div className="font-medium row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
{ev.name === 'screen_view' ? ev.path : ev.name}
</div>
<div className="text-muted-foreground">
{formatTime(offsetMs)}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@@ -2,6 +2,7 @@ import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
@@ -44,13 +45,25 @@ export function useColumns() {
cell: ({ row }) => {
const session = row.original;
return (
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
<div className="row gap-2 items-center">
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
{session.hasReplay && (
<ProjectLink
href={`/sessions/${session.id}#replay`}
className="text-muted-foreground hover:text-foreground"
title="View replay"
aria-label="View replay"
>
<Video className="size-4" />
</ProjectLink>
)}
</div>
);
},
},

View File

@@ -3,6 +3,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { ReplayShell } from '@/components/sessions/replay';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
@@ -117,6 +118,9 @@ function Component() {
)}
</div>
</PageHeader>
<div className="mb-6">
<ReplayShell sessionId={sessionId} projectId={projectId} />
</div>
<EventsTable query={query} />
</PageContainer>
);

43
apps/start/src/types/rrweb-player.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
declare module 'rrweb-player' {
interface RrwebPlayerProps {
events: Array<{ type: number; data: unknown; timestamp: number }>;
width?: number;
height?: number;
autoPlay?: boolean;
showController?: boolean;
speedOption?: number[];
}
interface RrwebPlayerOptions {
target: HTMLElement;
props: RrwebPlayerProps;
}
interface RrwebReplayer {
getCurrentTime: () => number;
}
interface RrwebPlayerMetaData {
startTime: number;
endTime: number;
totalTime: number;
}
interface RrwebPlayerInstance {
play: () => void;
pause: () => void;
toggle: () => void;
goto: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
getMetaData: () => RrwebPlayerMetaData;
getReplayer: () => RrwebReplayer;
addEventListener?: (
event: string,
handler: (...args: unknown[]) => void,
) => void;
$destroy?: () => void;
}
const rrwebPlayer: new (options: RrwebPlayerOptions) => RrwebPlayerInstance;
export default rrwebPlayer;
}

View File

@@ -15,7 +15,6 @@ import {
import type { ILogger } from '@openpanel/logger';
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
import * as R from 'ramda';
import { v4 as uuid } from 'uuid';
import { logger as baseLogger } from '@/utils/logger';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
@@ -56,7 +55,6 @@ async function createEventAndNotify(
checkNotificationRulesForEvent(payload).catch(() => {}),
]);
console.log('Event created:', event);
return event;
}
@@ -87,6 +85,8 @@ export async function incomingEvent(
projectId,
currentDeviceId,
previousDeviceId,
deviceId,
sessionId,
uaInfo: _uaInfo,
} = jobPayload;
const properties = body.properties ?? {};
@@ -157,7 +157,6 @@ export async function incomingEvent(
: undefined,
} as const;
console.log('HERE?');
// if timestamp is from the past we dont want to create a new session
if (uaInfo.isServer || isTimestampFromThePast) {
const session = profileId
@@ -167,8 +166,6 @@ export async function incomingEvent(
})
: null;
console.log('Server?');
const payload = {
...baseEvent,
deviceId: session?.device_id ?? '',
@@ -194,31 +191,31 @@ export async function incomingEvent(
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
}
console.log('not?');
const sessionEnd = await getSessionEnd({
projectId,
currentDeviceId,
previousDeviceId,
deviceId,
profileId,
});
console.log('Server?');
const lastScreenView = sessionEnd
const activeSession = sessionEnd
? await sessionBuffer.getExistingSession({
sessionId: sessionEnd.sessionId,
})
: null;
const payload: IServiceCreateEventPayload = merge(baseEvent, {
deviceId: sessionEnd?.deviceId ?? currentDeviceId,
sessionId: sessionEnd?.sessionId ?? uuid(),
deviceId: sessionEnd?.deviceId ?? deviceId,
sessionId: sessionEnd?.sessionId ?? sessionId,
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
// if the path is not set, use the last screen view path
path: baseEvent.path || lastScreenView?.exit_path || '',
origin: baseEvent.origin || lastScreenView?.exit_origin || '',
path: baseEvent.path || activeSession?.exit_path || '',
origin: baseEvent.origin || activeSession?.exit_origin || '',
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
console.log('SessionEnd?', sessionEnd);
if (!sessionEnd) {
logger.info('Creating session start event', { event: payload });
await createEventAndNotify(

View File

@@ -39,17 +39,20 @@ export async function getSessionEnd({
projectId,
currentDeviceId,
previousDeviceId,
deviceId,
profileId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string;
profileId: string;
}) {
const sessionEnd = await getSessionEndJob({
projectId,
currentDeviceId,
previousDeviceId,
deviceId,
});
if (sessionEnd) {
@@ -81,6 +84,7 @@ export async function getSessionEndJob(args: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string;
retryCount?: number;
}): Promise<{
deviceId: string;
@@ -130,20 +134,31 @@ export async function getSessionEndJob(args: {
return null;
}
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.currentDeviceId),
);
if (currentJob) {
return await handleJobStates(currentJob, args.currentDeviceId);
// TODO: Remove this when migrated to deviceId
if (args.currentDeviceId && args.previousDeviceId) {
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.currentDeviceId),
);
if (currentJob) {
return await handleJobStates(currentJob, args.currentDeviceId);
}
// Check previous device job
const previousJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.previousDeviceId),
);
if (previousJob) {
return await handleJobStates(previousJob, args.previousDeviceId);
}
}
// Check previous device job
const previousJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.previousDeviceId),
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.deviceId),
);
if (previousJob) {
return await handleJobStates(previousJob, args.previousDeviceId);
if (currentJob) {
return await handleJobStates(currentJob, args.deviceId);
}
// Create session

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,65 @@
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,
}),
...addColumns(
TABLE_NAMES.sessions,
['`has_replay` Bool DEFAULT 0'],
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

@@ -163,10 +163,46 @@ export class SessionBuffer extends BaseBuffer {
: '',
sign: 1,
version: 1,
has_replay: false,
},
];
}
async markHasReplay(sessionId: string): Promise<void> {
console.log('markHasReplay', sessionId);
const existingSession = await this.getExistingSession({ sessionId });
if (!existingSession) {
console.log('no existing session or has replay', existingSession);
return;
}
if (existingSession.has_replay) {
return;
}
const oldSession = assocPath(['sign'], -1, clone(existingSession));
const newSession = assocPath(['sign'], 1, clone(existingSession));
newSession.version = existingSession.version + 1;
newSession.has_replay = true;
const multi = this.redis.multi();
multi.set(
`session:${sessionId}`,
JSON.stringify(newSession),
'EX',
60 * 60,
);
multi.rpush(this.redisKey, JSON.stringify(newSession));
multi.rpush(this.redisKey, JSON.stringify(oldSession));
multi.incrby(this.bufferCounterKey, 2);
await multi.exec();
const bufferLength = await this.getBufferSize();
if (bufferLength >= this.batchSize) {
await this.tryFlush();
}
}
async add(event: IClickhouseEvent) {
if (!event.session_id) {
return;

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

@@ -52,6 +52,7 @@ export type IClickhouseSession = {
revenue: number;
sign: 1 | 0;
version: number;
has_replay?: boolean;
};
export interface IServiceSession {
@@ -90,6 +91,7 @@ export interface IServiceSession {
utmContent: string;
utmTerm: string;
revenue: number;
hasReplay: boolean;
profile?: IServiceProfile;
}
@@ -141,6 +143,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
utmContent: session.utm_content,
utmTerm: session.utm_term,
revenue: session.revenue,
hasReplay: session.has_replay ?? false,
profile: undefined,
};
}
@@ -229,6 +232,7 @@ export async function getSessionList({
'screen_view_count',
'event_count',
'revenue',
'has_replay',
];
columns.forEach((column) => {
@@ -321,6 +325,41 @@ export async function getSessionsCount({
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
export async function getSessionReplayEvents(
sessionId: string,
projectId: string,
): Promise<{ events: unknown[] }> {
const chunks = await clix(ch)
.select<{ chunk_index: number; payload: string }>(['chunk_index', 'payload'])
.from(TABLE_NAMES.session_replay_chunks)
.where('session_id', '=', sessionId)
.where('project_id', '=', projectId)
.orderBy('chunk_index', 'ASC')
.execute();
const allEvents = chunks.flatMap((chunk) =>
JSON.parse(chunk.payload) as unknown[],
);
// rrweb event types: 2 = FullSnapshot, 4 = Meta
// Incremental snapshots (type 3) before the first FullSnapshot are orphaned
// and cause the player to fast-forward through empty time. Strip them but
// keep Meta events (type 4) since rrweb needs them for viewport dimensions.
const firstFullSnapshotIdx = allEvents.findIndex(
(e: any) => e.type === 2,
);
let events = allEvents;
if (firstFullSnapshotIdx > 0) {
const metaEvents = allEvents
.slice(0, firstFullSnapshotIdx)
.filter((e: any) => e.type === 4);
events = [...metaEvents, ...allEvents.slice(firstFullSnapshotIdx)];
}
return { events };
}
class SessionService {
constructor(private client: typeof ch) {}

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 {

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,25 @@ 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);
const result = await this.api.fetch<
TrackHandlerPayload,
{ deviceId: string; sessionId: string }
>('/track', payload);
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>) {
@@ -160,33 +186,44 @@ export class OpenPanel {
});
}
getDeviceId(): string {
return this.deviceId ?? '';
}
getSessionId(): string {
return this.sessionId ?? '';
}
async fetchDeviceId(): Promise<string> {
const result = await this.api.fetch<undefined, { deviceId: string }>(
'/track/device-id',
undefined,
{ method: 'GET', keepalive: false },
);
return result?.deviceId ?? '';
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

@@ -11,6 +11,8 @@
},
"dependencies": {
"@openpanel/sdk": "workspace:1.0.4-local"
"@rrweb/types": "2.0.0-alpha.20",
"rrweb": "2.0.0-alpha.20"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",

View File

@@ -7,11 +7,36 @@ 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;
export type OpenPanelOptions = OpenPanelBaseOptions & {
trackOutgoingLinks?: boolean;
trackScreenViews?: boolean;
trackAttributes?: boolean;
trackHashChanges?: boolean;
sessionReplay?: SessionReplayOptions;
};
function toCamelCase(str: string) {
@@ -66,6 +91,76 @@ 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') {
// IIFE / script-tag context — load from CDN (or user override)
const url =
this.options.sessionReplay?.scriptUrl ?? __OPENPANEL_REPLAY_URL__;
// 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,131 @@
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;
}
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 startedAt = buffer[0]!.timestamp;
const endedAt = buffer[buffer.length - 1]!.timestamp;
const payloadJson = JSON.stringify(buffer);
if (payloadJson.length > maxPayloadBytes) {
// If over size limit, split by taking only up to maxPayloadBytes (simplified: flush as-is and log or truncate)
// For MVP we still send; server will reject if over 1MB
}
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 = [];
}
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 = () => {
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

@@ -1,11 +1,47 @@
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).
{
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',
),
},
},
// 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,6 +1,10 @@
import { z } from 'zod';
import { getSessionList, sessionService } from '@openpanel/db';
import {
getSessionList,
getSessionReplayEvents,
sessionService,
} from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -61,4 +65,10 @@ export const sessionRouter = createTRPCRouter({
.query(async ({ input: { sessionId, projectId } }) => {
return sessionService.byId(sessionId, projectId);
}),
replay: protectedProcedure
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
.query(async ({ input: { sessionId, projectId } }) => {
return getSessionReplayEvents(sessionId, projectId);
}),
});

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(65535),
events_count: z.number().int().min(1),
is_full_snapshot: z.boolean(),
started_at: z.string(),
ended_at: z.string(),
payload: z.string().max(1_048_576), // 1MB 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

View File

@@ -261,6 +261,9 @@ const publishPackages = (
execSync(
`cp ${workspacePath('packages/sdks/web/dist/src/tracker.global.js')} ${workspacePath('./apps/public/public/op1.js')}`,
);
execSync(
`cp ${workspacePath('packages/sdks/web/dist/src/replay.global.js')} ${workspacePath('./apps/public/public/op1-replay.js')}`,
);
}
}
};