wip
This commit is contained in:
@@ -76,6 +76,7 @@ export async function postEvent(
|
|||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId: '',
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -3,8 +3,19 @@ import { assocPath, pathOr, pick } from 'ramda';
|
|||||||
|
|
||||||
import { HttpError } from '@/utils/errors';
|
import { HttpError } from '@/utils/errors';
|
||||||
import { generateId, slug } from '@openpanel/common';
|
import { generateId, slug } from '@openpanel/common';
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
import {
|
||||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
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 { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import { getRedisCache } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
@@ -13,11 +24,90 @@ import {
|
|||||||
type IDecrementPayload,
|
type IDecrementPayload,
|
||||||
type IIdentifyPayload,
|
type IIdentifyPayload,
|
||||||
type IIncrementPayload,
|
type IIncrementPayload,
|
||||||
|
type IReplayPayload,
|
||||||
type ITrackHandlerPayload,
|
type ITrackHandlerPayload,
|
||||||
type ITrackPayload,
|
type ITrackPayload,
|
||||||
zTrackHandlerPayload,
|
zTrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} 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']) {
|
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||||
return Object.entries(
|
return Object.entries(
|
||||||
pick(
|
pick(
|
||||||
@@ -45,14 +135,15 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
|||||||
| IIdentifyPayload
|
| IIdentifyPayload
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
return (
|
if (identity) {
|
||||||
identity ||
|
return identity;
|
||||||
(body.payload.profileId
|
}
|
||||||
? {
|
|
||||||
profileId: String(body.payload.profileId),
|
return body.payload.profileId
|
||||||
}
|
? {
|
||||||
: undefined)
|
profileId: String(body.payload.profileId),
|
||||||
);
|
}
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -104,8 +195,8 @@ interface TrackContext {
|
|||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
timestamp: { value: number; isFromPast: boolean };
|
timestamp: { value: number; isFromPast: boolean };
|
||||||
identity?: IIdentifyPayload;
|
identity?: IIdentifyPayload;
|
||||||
currentDeviceId?: string;
|
deviceId: string;
|
||||||
previousDeviceId?: string;
|
sessionId: string;
|
||||||
geo: GeoLocation;
|
geo: GeoLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,49 +219,27 @@ async function buildContext(
|
|||||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||||
|
|
||||||
const headers = getStringHeaders(request.headers);
|
const headers = getStringHeaders(request.headers);
|
||||||
|
|
||||||
const identity = getIdentity(validatedBody);
|
const identity = getIdentity(validatedBody);
|
||||||
const profileId = identity?.profileId;
|
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') {
|
if (profileId && validatedBody.type === 'track') {
|
||||||
validatedBody.payload.profileId = profileId;
|
validatedBody.payload.profileId = profileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get geo location (needed for track and identify)
|
// 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)
|
const { deviceId, sessionId } = await getDeviceId({
|
||||||
let currentDeviceId: string | undefined;
|
projectId,
|
||||||
let previousDeviceId: string | undefined;
|
ip,
|
||||||
|
ua,
|
||||||
if (validatedBody.type === 'track') {
|
salts,
|
||||||
const overrideDeviceId =
|
overrideDeviceId:
|
||||||
typeof validatedBody.payload.properties?.__deviceId === 'string'
|
validatedBody.type === 'track' &&
|
||||||
? validatedBody.payload.properties.__deviceId
|
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||||
: undefined;
|
? 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,
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -182,8 +251,8 @@ async function buildContext(
|
|||||||
isFromPast: timestamp.isTimestampFromThePast,
|
isFromPast: timestamp.isTimestampFromThePast,
|
||||||
},
|
},
|
||||||
identity,
|
identity,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
previousDeviceId,
|
sessionId,
|
||||||
geo,
|
geo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,30 +261,19 @@ async function handleTrack(
|
|||||||
payload: ITrackPayload,
|
payload: ITrackPayload,
|
||||||
context: TrackContext,
|
context: TrackContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
|
||||||
projectId,
|
|
||||||
currentDeviceId,
|
|
||||||
previousDeviceId,
|
|
||||||
geo,
|
|
||||||
headers,
|
|
||||||
timestamp,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
if (!currentDeviceId || !previousDeviceId) {
|
|
||||||
throw new HttpError('Device ID generation failed', { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||||
const groupId = uaInfo.isServer
|
const groupId = uaInfo.isServer
|
||||||
? payload.profileId
|
? payload.profileId
|
||||||
? `${projectId}:${payload.profileId}`
|
? `${projectId}:${payload.profileId}`
|
||||||
: `${projectId}:${generateId()}`
|
: `${projectId}:${generateId()}`
|
||||||
: currentDeviceId;
|
: deviceId;
|
||||||
const jobId = [
|
const jobId = [
|
||||||
slug(payload.name),
|
slug(payload.name),
|
||||||
timestamp.value,
|
timestamp.value,
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
groupId,
|
groupId,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -242,8 +300,10 @@ async function handleTrack(
|
|||||||
},
|
},
|
||||||
uaInfo,
|
uaInfo,
|
||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
previousDeviceId,
|
sessionId,
|
||||||
|
currentDeviceId: '', // TODO: Remove
|
||||||
|
previousDeviceId: '', // TODO: Remove
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -330,6 +390,33 @@ async function handleDecrement(
|
|||||||
await adjustProfileProperty(payload, context.projectId, -1);
|
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(
|
export async function handler(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload;
|
Body: ITrackHandlerPayload;
|
||||||
@@ -375,6 +462,9 @@ export async function handler(
|
|||||||
case 'decrement':
|
case 'decrement':
|
||||||
await handleDecrement(validatedBody.payload, context);
|
await handleDecrement(validatedBody.payload, context);
|
||||||
break;
|
break;
|
||||||
|
case 'replay':
|
||||||
|
await handleReplay(validatedBody.payload, context);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
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(
|
export async function fetchDeviceId(
|
||||||
@@ -421,20 +514,31 @@ export async function fetchDeviceId(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const multi = getRedisCache().multi();
|
const multi = getRedisCache().multi();
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
multi.hget(
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||||
|
'data',
|
||||||
|
);
|
||||||
|
multi.hget(
|
||||||
|
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||||
|
'data',
|
||||||
|
);
|
||||||
const res = await multi.exec();
|
const res = await multi.exec();
|
||||||
|
|
||||||
if (res?.[0]?.[1]) {
|
if (res?.[0]?.[1]) {
|
||||||
|
const data = JSON.parse(res?.[0]?.[1] as string);
|
||||||
|
const sessionId = data.payload.sessionId;
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: currentDeviceId,
|
deviceId: sessionId,
|
||||||
|
sessionId,
|
||||||
message: 'current session exists for this device id',
|
message: 'current session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res?.[1]?.[1]) {
|
if (res?.[1]?.[1]) {
|
||||||
|
const data = JSON.parse(res?.[1]?.[1] as string);
|
||||||
|
const sessionId = data.payload.sessionId;
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: previousDeviceId,
|
deviceId: sessionId,
|
||||||
|
sessionId,
|
||||||
message: 'previous session exists for this device id',
|
message: 'previous session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -444,6 +548,7 @@ export async function fetchDeviceId(
|
|||||||
|
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: currentDeviceId,
|
deviceId: currentDeviceId,
|
||||||
|
sessionId: '',
|
||||||
message: 'No session exists for this device id',
|
message: 'No session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
deviceId: { type: 'string' },
|
deviceId: { type: 'string' },
|
||||||
|
sessionId: { type: 'string' },
|
||||||
message: { type: 'string', optional: true },
|
message: { type: 'string', optional: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"pushmodal": "^1.0.3",
|
"pushmodal": "^1.0.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"random-animal-name": "^0.1.1",
|
"random-animal-name": "^0.1.1",
|
||||||
|
"rrweb-player": "2.0.0-alpha.20",
|
||||||
"rc-virtual-list": "^3.14.5",
|
"rc-virtual-list": "^3.14.5",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
|
|||||||
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal file
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/start/src/components/sessions/replay/index.tsx
Normal file
139
apps/start/src/components/sessions/replay/index.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
210
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
210
apps/start/src/components/sessions/replay/replay-context.tsx
Normal 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 };
|
||||||
@@ -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> */}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
99
apps/start/src/components/sessions/replay/replay-player.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
234
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ProjectLink } from '@/components/links';
|
|||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Video } from 'lucide-react';
|
||||||
|
|
||||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
@@ -44,13 +45,25 @@ export function useColumns() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const session = row.original;
|
const session = row.original;
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<div className="row gap-2 items-center">
|
||||||
href={`/sessions/${session.id}`}
|
<ProjectLink
|
||||||
className="font-medium"
|
href={`/sessions/${session.id}`}
|
||||||
title={session.id}
|
className="font-medium"
|
||||||
>
|
title={session.id}
|
||||||
{session.id.slice(0, 8)}...
|
>
|
||||||
</ProjectLink>
|
{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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
|||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
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 { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
@@ -117,6 +118,9 @@ function Component() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
<div className="mb-6">
|
||||||
|
<ReplayShell sessionId={sessionId} projectId={projectId} />
|
||||||
|
</div>
|
||||||
<EventsTable query={query} />
|
<EventsTable query={query} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
43
apps/start/src/types/rrweb-player.d.ts
vendored
Normal file
43
apps/start/src/types/rrweb-player.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import type { ILogger } from '@openpanel/logger';
|
import type { ILogger } from '@openpanel/logger';
|
||||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ async function createEventAndNotify(
|
|||||||
checkNotificationRulesForEvent(payload).catch(() => {}),
|
checkNotificationRulesForEvent(payload).catch(() => {}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('Event created:', event);
|
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +85,8 @@ export async function incomingEvent(
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
|
sessionId,
|
||||||
uaInfo: _uaInfo,
|
uaInfo: _uaInfo,
|
||||||
} = jobPayload;
|
} = jobPayload;
|
||||||
const properties = body.properties ?? {};
|
const properties = body.properties ?? {};
|
||||||
@@ -157,7 +157,6 @@ export async function incomingEvent(
|
|||||||
: undefined,
|
: undefined,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
console.log('HERE?');
|
|
||||||
// if timestamp is from the past we dont want to create a new session
|
// if timestamp is from the past we dont want to create a new session
|
||||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||||
const session = profileId
|
const session = profileId
|
||||||
@@ -167,8 +166,6 @@ export async function incomingEvent(
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log('Server?');
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...baseEvent,
|
...baseEvent,
|
||||||
deviceId: session?.device_id ?? '',
|
deviceId: session?.device_id ?? '',
|
||||||
@@ -194,31 +191,31 @@ export async function incomingEvent(
|
|||||||
|
|
||||||
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
||||||
}
|
}
|
||||||
console.log('not?');
|
|
||||||
const sessionEnd = await getSessionEnd({
|
const sessionEnd = await getSessionEnd({
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
profileId,
|
profileId,
|
||||||
});
|
});
|
||||||
console.log('Server?');
|
const activeSession = sessionEnd
|
||||||
const lastScreenView = sessionEnd
|
|
||||||
? await sessionBuffer.getExistingSession({
|
? await sessionBuffer.getExistingSession({
|
||||||
sessionId: sessionEnd.sessionId,
|
sessionId: sessionEnd.sessionId,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
||||||
deviceId: sessionEnd?.deviceId ?? currentDeviceId,
|
deviceId: sessionEnd?.deviceId ?? deviceId,
|
||||||
sessionId: sessionEnd?.sessionId ?? uuid(),
|
sessionId: sessionEnd?.sessionId ?? sessionId,
|
||||||
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
||||||
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
||||||
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
||||||
// if the path is not set, use the last screen view path
|
// if the path is not set, use the last screen view path
|
||||||
path: baseEvent.path || lastScreenView?.exit_path || '',
|
path: baseEvent.path || activeSession?.exit_path || '',
|
||||||
origin: baseEvent.origin || lastScreenView?.exit_origin || '',
|
origin: baseEvent.origin || activeSession?.exit_origin || '',
|
||||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||||
console.log('SessionEnd?', sessionEnd);
|
|
||||||
if (!sessionEnd) {
|
if (!sessionEnd) {
|
||||||
logger.info('Creating session start event', { event: payload });
|
logger.info('Creating session start event', { event: payload });
|
||||||
await createEventAndNotify(
|
await createEventAndNotify(
|
||||||
|
|||||||
@@ -39,17 +39,20 @@ export async function getSessionEnd({
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
profileId,
|
profileId,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
|
deviceId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
}) {
|
}) {
|
||||||
const sessionEnd = await getSessionEndJob({
|
const sessionEnd = await getSessionEndJob({
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionEnd) {
|
if (sessionEnd) {
|
||||||
@@ -81,6 +84,7 @@ export async function getSessionEndJob(args: {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
|
deviceId: string;
|
||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -130,20 +134,31 @@ export async function getSessionEndJob(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check current device job
|
// TODO: Remove this when migrated to deviceId
|
||||||
const currentJob = await sessionsQueue.getJob(
|
if (args.currentDeviceId && args.previousDeviceId) {
|
||||||
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
// Check current device job
|
||||||
);
|
const currentJob = await sessionsQueue.getJob(
|
||||||
if (currentJob) {
|
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
||||||
return await handleJobStates(currentJob, 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
|
// Check current device job
|
||||||
const previousJob = await sessionsQueue.getJob(
|
const currentJob = await sessionsQueue.getJob(
|
||||||
getSessionEndJobId(args.projectId, args.previousDeviceId),
|
getSessionEndJobId(args.projectId, args.deviceId),
|
||||||
);
|
);
|
||||||
if (previousJob) {
|
if (currentJob) {
|
||||||
return await handleJobStates(previousJob, args.previousDeviceId);
|
return await handleJobStates(currentJob, args.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export function shortId() {
|
|||||||
return nanoid(4);
|
return nanoid(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateId() {
|
export function generateId(prefix?: string, length?: number) {
|
||||||
return nanoid(8);
|
return prefix ? `${prefix}_${nanoid(length ?? 8)}` : nanoid(length ?? 8);
|
||||||
}
|
}
|
||||||
|
|||||||
65
packages/db/code-migrations/10-add-session-replay.ts
Normal file
65
packages/db/code-migrations/10-add-session-replay.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,19 @@ async function migrate() {
|
|||||||
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
|
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
|
||||||
|
|
||||||
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
|
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
|
||||||
const migrations = fs.readdirSync(migrationsDir).filter((file) => {
|
const migrations = fs
|
||||||
const version = file.split('-')[0];
|
.readdirSync(migrationsDir)
|
||||||
return (
|
.filter((file) => {
|
||||||
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
|
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();
|
const finishedMigrations = await db.codeMigration.findMany();
|
||||||
|
|
||||||
|
|||||||
@@ -163,10 +163,46 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
: '',
|
: '',
|
||||||
sign: 1,
|
sign: 1,
|
||||||
version: 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) {
|
async add(event: IClickhouseEvent) {
|
||||||
if (!event.session_id) {
|
if (!event.session_id) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const TABLE_NAMES = {
|
|||||||
cohort_events_mv: 'cohort_events_mv',
|
cohort_events_mv: 'cohort_events_mv',
|
||||||
sessions: 'sessions',
|
sessions: 'sessions',
|
||||||
events_imports: 'events_imports',
|
events_imports: 'events_imports',
|
||||||
|
session_replay_chunks: 'session_replay_chunks',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type IClickhouseSession = {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
sign: 1 | 0;
|
sign: 1 | 0;
|
||||||
version: number;
|
version: number;
|
||||||
|
has_replay?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IServiceSession {
|
export interface IServiceSession {
|
||||||
@@ -90,6 +91,7 @@ export interface IServiceSession {
|
|||||||
utmContent: string;
|
utmContent: string;
|
||||||
utmTerm: string;
|
utmTerm: string;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
|
hasReplay: boolean;
|
||||||
profile?: IServiceProfile;
|
profile?: IServiceProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
utmContent: session.utm_content,
|
utmContent: session.utm_content,
|
||||||
utmTerm: session.utm_term,
|
utmTerm: session.utm_term,
|
||||||
revenue: session.revenue,
|
revenue: session.revenue,
|
||||||
|
hasReplay: session.has_replay ?? false,
|
||||||
profile: undefined,
|
profile: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -229,6 +232,7 @@ export async function getSessionList({
|
|||||||
'screen_view_count',
|
'screen_view_count',
|
||||||
'event_count',
|
'event_count',
|
||||||
'revenue',
|
'revenue',
|
||||||
|
'has_replay',
|
||||||
];
|
];
|
||||||
|
|
||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
@@ -321,6 +325,41 @@ export async function getSessionsCount({
|
|||||||
|
|
||||||
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
|
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 {
|
class SessionService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ export interface EventsQueuePayloadIncomingEvent {
|
|||||||
latitude: number | undefined;
|
latitude: number | undefined;
|
||||||
};
|
};
|
||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string; // TODO: Remove
|
||||||
previousDeviceId: string;
|
previousDeviceId: string; // TODO: Remove
|
||||||
|
deviceId: string;
|
||||||
|
sessionId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface EventsQueuePayloadCreateEvent {
|
export interface EventsQueuePayloadCreateEvent {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export type OpenPanelOptions = {
|
|||||||
export class OpenPanel {
|
export class OpenPanel {
|
||||||
api: Api;
|
api: Api;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
sessionId?: string;
|
||||||
global?: Record<string, unknown>;
|
global?: Record<string, unknown>;
|
||||||
queue: TrackHandlerPayload[] = [];
|
queue: TrackHandlerPayload[] = [];
|
||||||
|
|
||||||
@@ -69,6 +71,16 @@ export class OpenPanel {
|
|||||||
this.flush();
|
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) {
|
async send(payload: TrackHandlerPayload) {
|
||||||
if (this.options.disabled) {
|
if (this.options.disabled) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -78,11 +90,25 @@ export class OpenPanel {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.waitForProfile && !this.profileId) {
|
if (this.shouldQueue(payload)) {
|
||||||
this.queue.push(payload);
|
this.queue.push(payload);
|
||||||
return Promise.resolve();
|
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>) {
|
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> {
|
async fetchDeviceId(): Promise<string> {
|
||||||
const result = await this.api.fetch<undefined, { deviceId: string }>(
|
return Promise.resolve(this.deviceId ?? '');
|
||||||
'/track/device-id',
|
|
||||||
undefined,
|
|
||||||
{ method: 'GET', keepalive: false },
|
|
||||||
);
|
|
||||||
return result?.deviceId ?? '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.profileId = undefined;
|
this.profileId = undefined;
|
||||||
// should we force a session end here?
|
this.deviceId = undefined;
|
||||||
|
this.sessionId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
this.queue.forEach((item) => {
|
const remaining: TrackHandlerPayload[] = [];
|
||||||
this.send({
|
for (const item of this.queue) {
|
||||||
...item,
|
if (this.shouldQueue(item)) {
|
||||||
// Not sure why ts-expect-error is needed here
|
remaining.push(item);
|
||||||
// @ts-expect-error
|
continue;
|
||||||
payload: {
|
}
|
||||||
...item.payload,
|
const payload =
|
||||||
profileId: item.payload.profileId ?? this.profileId,
|
item.type === 'replay'
|
||||||
},
|
? item.payload
|
||||||
});
|
: {
|
||||||
});
|
...item.payload,
|
||||||
this.queue = [];
|
profileId:
|
||||||
|
'profileId' in item.payload
|
||||||
|
? (item.payload.profileId ?? this.profileId)
|
||||||
|
: this.profileId,
|
||||||
|
};
|
||||||
|
this.send({ ...item, payload } as TrackHandlerPayload);
|
||||||
|
}
|
||||||
|
this.queue = remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(...args: any[]) {
|
log(...args: any[]) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -7,11 +7,36 @@ import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
|||||||
export type * from '@openpanel/sdk';
|
export type * from '@openpanel/sdk';
|
||||||
export { OpenPanel as OpenPanelBase } 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 & {
|
export type OpenPanelOptions = OpenPanelBaseOptions & {
|
||||||
trackOutgoingLinks?: boolean;
|
trackOutgoingLinks?: boolean;
|
||||||
trackScreenViews?: boolean;
|
trackScreenViews?: boolean;
|
||||||
trackAttributes?: boolean;
|
trackAttributes?: boolean;
|
||||||
trackHashChanges?: boolean;
|
trackHashChanges?: boolean;
|
||||||
|
sessionReplay?: SessionReplayOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toCamelCase(str: string) {
|
function toCamelCase(str: string) {
|
||||||
@@ -66,6 +91,76 @@ export class OpenPanel extends OpenPanelBase {
|
|||||||
if (this.options.trackAttributes) {
|
if (this.options.trackAttributes) {
|
||||||
this.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/sdks/web/src/replay/index.ts
Normal file
2
packages/sdks/web/src/replay/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { startReplayRecorder, stopReplayRecorder } from './recorder';
|
||||||
|
export type { ReplayChunkPayload, ReplayRecorderConfig } from './recorder';
|
||||||
131
packages/sdks/web/src/replay/recorder.ts
Normal file
131
packages/sdks/web/src/replay/recorder.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,47 @@
|
|||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig([
|
||||||
entry: ['index.ts', 'src/tracker.ts'],
|
// Library build (npm package) — cjs + esm + dts
|
||||||
format: ['cjs', 'esm', 'iife'],
|
// Dynamic import('./replay') is preserved; the host app's bundler
|
||||||
dts: true,
|
// will code-split it into a separate chunk automatically.
|
||||||
splitting: false,
|
{
|
||||||
sourcemap: false,
|
entry: ['index.ts'],
|
||||||
clean: true,
|
format: ['cjs', 'esm'],
|
||||||
minify: true,
|
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'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getSessionList, sessionService } from '@openpanel/db';
|
import {
|
||||||
|
getSessionList,
|
||||||
|
getSessionReplayEvents,
|
||||||
|
sessionService,
|
||||||
|
} from '@openpanel/db';
|
||||||
import { zChartEventFilter } from '@openpanel/validation';
|
import { zChartEventFilter } from '@openpanel/validation';
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
@@ -61,4 +65,10 @@ export const sessionRouter = createTRPCRouter({
|
|||||||
.query(async ({ input: { sessionId, projectId } }) => {
|
.query(async ({ input: { sessionId, projectId } }) => {
|
||||||
return sessionService.byId(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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export const zAliasPayload = z.object({
|
|||||||
alias: z.string().min(1),
|
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', [
|
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('track'),
|
type: z.literal('track'),
|
||||||
@@ -84,6 +93,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('alias'),
|
type: z.literal('alias'),
|
||||||
payload: zAliasPayload,
|
payload: zAliasPayload,
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('replay'),
|
||||||
|
payload: zReplayPayload,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
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 IIncrementPayload = z.infer<typeof zIncrementPayload>;
|
||||||
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
||||||
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
||||||
|
export type IReplayPayload = z.infer<typeof zReplayPayload>;
|
||||||
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
||||||
|
|
||||||
// Deprecated types for beta version of the SDKs
|
// Deprecated types for beta version of the SDKs
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ const publishPackages = (
|
|||||||
execSync(
|
execSync(
|
||||||
`cp ${workspacePath('packages/sdks/web/dist/src/tracker.global.js')} ${workspacePath('./apps/public/public/op1.js')}`,
|
`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')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user