7 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
420f540874 fix 2026-02-26 14:04:48 +01:00
Carl-Gerhard Lindesvärd
6ddea4a7bc comments 2026-02-26 12:40:28 +01:00
Carl-Gerhard Lindesvärd
d5513d8a47 final fixes 2026-02-26 11:37:20 +01:00
Carl-Gerhard Lindesvärd
b193ccb7d0 wip 2026-02-25 22:44:51 +01:00
Carl-Gerhard Lindesvärd
41993d3463 wip 2026-02-25 22:44:50 +01:00
Carl-Gerhard Lindesvärd
47adf46625 wip 2026-02-25 22:43:59 +01:00
Carl-Gerhard Lindesvärd
551927af06 wip 2026-02-25 22:43:59 +01:00
67 changed files with 3059 additions and 556 deletions

View File

@@ -1,5 +1,7 @@
# CLAUDE.md # CLAUDE.md
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview

View File

@@ -1,26 +1,25 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { generateId, slug } from '@openpanel/common'; import { generateId, slug } from '@openpanel/common';
import { parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { DeprecatedPostEventPayload } from '@openpanel/validation'; import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getStringHeaders, getTimestamp } from './track.controller'; import { getStringHeaders, getTimestamp } from './track.controller';
import { getDeviceId } from '@/utils/ids';
export async function postEvent( export async function postEvent(
request: FastifyRequest<{ request: FastifyRequest<{
Body: DeprecatedPostEventPayload; Body: DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const { timestamp, isTimestampFromThePast } = getTimestamp( const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp, request.timestamp,
request.body, request.body
); );
const ip = request.clientIp; const ip = request.clientIp;
const ua = request.headers['user-agent']; const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
@@ -30,34 +29,22 @@ export async function postEvent(
} }
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua const { deviceId, sessionId } = await getDeviceId({
? generateDeviceId({ projectId,
salt: salts.current, ip,
origin: projectId, ua,
ip, salts,
ua, });
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
const uaInfo = parseUserAgent(ua, request.body?.properties); const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? request.body?.profileId ? `${projectId}:${request.body?.profileId ?? generateId()}`
? `${projectId}:${request.body?.profileId}` : deviceId;
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [ const jobId = [
slug(request.body.name), slug(request.body.name),
timestamp, timestamp,
projectId, projectId,
currentDeviceId, deviceId,
groupId, groupId,
] ]
.filter(Boolean) .filter(Boolean)
@@ -74,8 +61,10 @@ export async function postEvent(
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId, currentDeviceId: '',
previousDeviceId, previousDeviceId: '',
deviceId,
sessionId: sessionId ?? '',
}, },
groupId, groupId,
jobId, jobId,

View File

@@ -1,22 +1,27 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
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 { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import {
getProfileById,
getSalts,
replayBuffer,
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';
import { 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';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { getDeviceId } from '@/utils/ids';
export function getStringHeaders(headers: FastifyRequest['headers']) { export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries( return Object.entries(
@@ -28,14 +33,14 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
'openpanel-client-id', 'openpanel-client-id',
'request-id', 'request-id',
], ],
headers, headers
), )
).reduce( ).reduce(
(acc, [key, value]) => ({ (acc, [key, value]) => ({
...acc, ...acc,
[key]: value ? String(value) : undefined, [key]: value ? String(value) : undefined,
}), }),
{}, {}
); );
} }
@@ -45,14 +50,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;
@@ -60,7 +66,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
export function getTimestamp( export function getTimestamp(
timestamp: FastifyRequest['timestamp'], timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload'], payload: ITrackHandlerPayload['payload']
) { ) {
const safeTimestamp = timestamp || Date.now(); const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp = const userDefinedTimestamp =
@@ -104,8 +110,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;
} }
@@ -113,7 +119,7 @@ async function buildContext(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
}>, }>,
validatedBody: ITrackHandlerPayload, validatedBody: ITrackHandlerPayload
): Promise<TrackContext> { ): Promise<TrackContext> {
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
@@ -128,49 +134,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,46 +166,35 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast, isFromPast: timestamp.isTimestampFromThePast,
}, },
identity, identity,
currentDeviceId, deviceId,
previousDeviceId, sessionId,
geo, geo,
}; };
} }
async function handleTrack( 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)
.join('-'); .join('-');
const promises = []; const promises: Promise<unknown>[] = [];
// If we have more than one property in the identity object, we should identify the user // If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user // Otherwise its only a profileId and we should not identify the user
@@ -242,12 +215,14 @@ async function handleTrack(
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId, deviceId,
previousDeviceId, sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
}, },
groupId, groupId,
jobId, jobId,
}), })
); );
await Promise.all(promises); await Promise.all(promises);
@@ -255,7 +230,7 @@ async function handleTrack(
async function handleIdentify( async function handleIdentify(
payload: IIdentifyPayload, payload: IIdentifyPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
const { projectId, geo, ua } = context; const { projectId, geo, ua } = context;
const uaInfo = parseUserAgent(ua, payload.properties); const uaInfo = parseUserAgent(ua, payload.properties);
@@ -285,7 +260,7 @@ async function handleIdentify(
async function adjustProfileProperty( async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload, payload: IIncrementPayload | IDecrementPayload,
projectId: string, projectId: string,
direction: 1 | -1, direction: 1 | -1
): Promise<void> { ): Promise<void> {
const { profileId, property, value } = payload; const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
@@ -295,7 +270,7 @@ async function adjustProfileProperty(
const parsed = Number.parseInt( const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties), pathOr<string>('0', property.split('.'), profile.properties),
10, 10
); );
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
@@ -305,7 +280,7 @@ async function adjustProfileProperty(
profile.properties = assocPath( profile.properties = assocPath(
property.split('.'), property.split('.'),
parsed + direction * (value || 1), parsed + direction * (value || 1),
profile.properties, profile.properties
); );
await upsertProfile({ await upsertProfile({
@@ -318,23 +293,44 @@ async function adjustProfileProperty(
async function handleIncrement( async function handleIncrement(
payload: IIncrementPayload, payload: IIncrementPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1); await adjustProfileProperty(payload, context.projectId, 1);
} }
async function handleDecrement( async function handleDecrement(
payload: IDecrementPayload, payload: IDecrementPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
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,
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 replayBuffer.add(row);
}
export async function handler( export async function handler(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
// Validate request body with Zod // Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body); const validationResult = zTrackHandlerPayload.safeParse(request.body);
@@ -375,6 +371,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,12 +382,15 @@ 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(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
const salts = await getSalts(); const salts = await getSalts();
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
@@ -421,20 +423,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: currentDeviceId,
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: previousDeviceId,
sessionId,
message: 'previous session exists for this device id', message: 'previous session exists for this device id',
}); });
} }
@@ -444,6 +457,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',
}); });
} }

View File

@@ -1,20 +1,21 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { import type {
DeprecatedPostEventPayload, DeprecatedPostEventPayload,
ITrackHandlerPayload, ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isDuplicatedEvent } from '@/utils/deduplicate';
export async function duplicateHook( export async function duplicateHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const ip = req.clientIp; const ip = req.clientIp;
const origin = req.headers.origin; const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id']; const clientId = req.headers['openpanel-client-id'];
const shouldCheck = ip && origin && clientId; const isReplay = 'type' in req.body && req.body.type === 'replay';
const shouldCheck = ip && origin && clientId && !isReplay;
const isDuplicate = shouldCheck const isDuplicate = shouldCheck
? await isDuplicatedEvent({ ? await isDuplicatedEvent({

View File

@@ -1,4 +1,3 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda'; import { path, pick } from 'ramda';
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS']; const ignoreMethods = ['OPTIONS'];
const getTrpcInput = ( const getTrpcInput = (
request: FastifyRequest, request: FastifyRequest
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
const input = path<any>(['query', 'input'], request); const input = path<any>(['query', 'input'], request);
try { try {
@@ -18,7 +17,7 @@ const getTrpcInput = (
export async function requestLoggingHook( export async function requestLoggingHook(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
if (ignoreMethods.includes(request.method)) { if (ignoreMethods.includes(request.method)) {
return; return;
@@ -40,9 +39,8 @@ export async function requestLoggingHook(
elapsed: reply.elapsedTime, elapsed: reply.elapsedTime,
headers: pick( headers: pick(
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'], ['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
request.headers, request.headers
), ),
body: request.body,
}); });
} }
} }

View File

@@ -3,12 +3,12 @@ process.env.TZ = 'UTC';
import compress from '@fastify/compress'; import compress from '@fastify/compress';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors'; import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; import {
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; decodeSessionToken,
import type { FastifyBaseLogger, FastifyRequest } from 'fastify'; EMPTY_SESSION,
import Fastify from 'fastify'; type SessionValidationResult,
import metricsPlugin from 'fastify-metrics'; validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { import {
type IServiceClientWithProject, type IServiceClientWithProject,
@@ -17,13 +17,11 @@ import {
import { getRedisPub } from '@openpanel/redis'; import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc'; import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc'; import { appRouter, createContext } from '@openpanel/trpc';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
EMPTY_SESSION, import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
type SessionValidationResult, import Fastify from 'fastify';
decodeSessionToken, import metricsPlugin from 'fastify-metrics';
validateSessionToken,
} from '@openpanel/auth';
import sourceMapSupport from 'source-map-support'; import sourceMapSupport from 'source-map-support';
import { import {
healthcheck, healthcheck,
@@ -72,7 +70,7 @@ const startServer = async () => {
try { try {
const fastify = Fastify({ const fastify = Fastify({
maxParamLength: 15_000, maxParamLength: 15_000,
bodyLimit: 1048576 * 500, // 500MB bodyLimit: 1_048_576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger, loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true, disableRequestLogging: true,
genReqId: (req) => genReqId: (req) =>
@@ -84,7 +82,7 @@ const startServer = async () => {
fastify.register(cors, () => { fastify.register(cors, () => {
return ( return (
req: FastifyRequest, req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void, callback: (error: Error | null, options: FastifyCorsOptions) => void
) => { ) => {
// TODO: set prefix on dashboard routes // TODO: set prefix on dashboard routes
const corsPaths = [ const corsPaths = [
@@ -97,7 +95,7 @@ const startServer = async () => {
]; ];
const isPrivatePath = corsPaths.some((path) => const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path), req.url.startsWith(path)
); );
if (isPrivatePath) { if (isPrivatePath) {
@@ -118,6 +116,7 @@ const startServer = async () => {
return callback(null, { return callback(null, {
origin: '*', origin: '*',
maxAge: 86_400 * 7, // cache preflight for 7 days
}); });
}; };
}); });
@@ -149,7 +148,7 @@ const startServer = async () => {
try { try {
const sessionId = decodeSessionToken(req.cookies?.session); const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () => const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session), validateSessionToken(req.cookies.session)
); );
req.session = session; req.session = session;
} catch (e) { } catch (e) {
@@ -158,7 +157,7 @@ const startServer = async () => {
} else if (process.env.DEMO_USER_ID) { } else if (process.env.DEMO_USER_ID) {
try { try {
const session = await runWithAlsSession('1', () => const session = await runWithAlsSession('1', () =>
validateSessionToken(null), validateSessionToken(null)
); );
req.session = session; req.session = session;
} catch (e) { } catch (e) {
@@ -173,7 +172,7 @@ const startServer = async () => {
prefix: '/trpc', prefix: '/trpc',
trpcOptions: { trpcOptions: {
router: appRouter, router: appRouter,
createContext: createContext, createContext,
onError(ctx) { onError(ctx) {
if ( if (
ctx.error.code === 'UNAUTHORIZED' && ctx.error.code === 'UNAUTHORIZED' &&
@@ -217,7 +216,7 @@ const startServer = async () => {
reply.send({ reply.send({
status: 'ok', status: 'ok',
message: 'Successfully running OpenPanel.dev API', message: 'Successfully running OpenPanel.dev API',
}), })
); );
}); });
@@ -274,7 +273,7 @@ const startServer = async () => {
} catch (error) { } catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error); logger.warn('Failed to set redis notify-keyspace-events', error);
logger.warn( logger.warn(
'If you use a managed Redis service, you may need to set this manually.', 'If you use a managed Redis service, you may need to set this manually.'
); );
logger.warn('Otherwise some functions may not work as expected.'); logger.warn('Otherwise some functions may not work as expected.');
} }

View File

@@ -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 },
}, },
}, },

158
apps/api/src/utils/ids.ts Normal file
View File

@@ -0,0 +1,158 @@
import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json';
import { getRedisCache } from '@openpanel/redis';
export 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: '' };
}
if (!ua) {
return { deviceId: '', sessionId: '' };
}
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 = getSafeJson<{ payload: { sessionId: string } }>(
(res?.[0]?.[1] as string) ?? ''
);
if (data) {
const sessionId = data.payload.sessionId;
return { deviceId: currentDeviceId, sessionId };
}
}
if (res?.[1]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>(
(res?.[1]?.[1] as string) ?? ''
);
if (data) {
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: getSessionId({
projectId,
deviceId: currentDeviceId,
graceMs: 5 * 1000,
windowMs: 1000 * 60 * 30,
}),
};
}
/**
* Deterministic session id for (projectId, deviceId) within a time window,
* with a grace period at the *start* of each window to avoid boundary splits.
*
* - windowMs: 30 minutes by default
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
*/
function getSessionId(params: {
projectId: string;
deviceId: string;
eventMs?: number; // use event timestamp; defaults to Date.now()
windowMs?: number; // default 5 min
graceMs?: number; // default 1 min
bytes?: number; // default 16 (128-bit). You can set 24 or 32 for longer ids.
}): string {
const {
projectId,
deviceId,
eventMs = Date.now(),
windowMs = 5 * 60 * 1000,
graceMs = 60 * 1000,
bytes = 16,
} = params;
if (!projectId) {
throw new Error('projectId is required');
}
if (!deviceId) {
throw new Error('deviceId is required');
}
if (windowMs <= 0) {
throw new Error('windowMs must be > 0');
}
if (graceMs < 0 || graceMs >= windowMs) {
throw new Error('graceMs must be >= 0 and < windowMs');
}
if (bytes < 8 || bytes > 32) {
throw new Error('bytes must be between 8 and 32');
}
const bucket = Math.floor(eventMs / windowMs);
const offset = eventMs - bucket * windowMs;
// Grace at the start of the bucket: stick to the previous bucket.
const chosenBucket = offset < graceMs ? bucket - 1 : bucket;
const input = `sess:v1:${projectId}:${deviceId}:${chosenBucket}`;
const digest = crypto.createHash('sha256').update(input).digest();
const truncated = digest.subarray(0, bytes);
// base64url
return truncated
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}

View File

@@ -33,7 +33,7 @@ This is the most common flow and most secure one. Your backend receives webhooks
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" /> <FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website"> <FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint. When you create the checkout, you should first call `op.getDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
```javascript ```javascript
fetch('https://domain.com/api/checkout', { fetch('https://domain.com/api/checkout', {
@@ -42,7 +42,7 @@ fetch('https://domain.com/api/checkout', {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now deviceId: op.getDeviceId(), // ✅ since deviceId is here we can link the payment now
// ... other checkout data // ... other checkout data
}), }),
}) })
@@ -360,5 +360,5 @@ op.clearRevenue(): void
### Fetch your current users device id ### Fetch your current users device id
```javascript ```javascript
op.fetchDeviceId(): Promise<string> op.getDeviceId(): string
``` ```

View File

@@ -54,7 +54,8 @@ import { OpenPanelComponent } from '@openpanel/astro';
##### Astro options ##### Astro options
- `profileId` - If you have a user id, you can pass it here to identify the user - `profileId` - If you have a user id, you can pass it here to identify the user
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
- `globalProperties` - This is an object of properties that will be sent with every event. - `globalProperties` - This is an object of properties that will be sent with every event.

View File

@@ -62,7 +62,8 @@ export default function RootLayout({ children }) {
##### NextJS options ##### NextJS options
- `profileId` - If you have a user id, you can pass it here to identify the user - `profileId` - If you have a user id, you can pass it here to identify the user
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
- `globalProperties` - This is an object of properties that will be sent with every event. - `globalProperties` - This is an object of properties that will be sent with every event.
@@ -286,12 +287,12 @@ import { createRouteHandler } from '@openpanel/nextjs/server';
export const { GET, POST } = createRouteHandler(); export const { GET, POST } = createRouteHandler();
``` ```
Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server. Remember to change the `apiUrl` and `scriptUrl` in the `OpenPanelComponent` to your own server.
```tsx ```tsx
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op" // [!code highlight] apiUrl="/api/op" // [!code highlight]
cdnUrl="/api/op/op1.js" // [!code highlight] scriptUrl="/api/op/op1.js" // [!code highlight]
clientId="your-client-id" clientId="your-client-id"
trackScreenViews={true} trackScreenViews={true}
/> />

View File

@@ -136,7 +136,7 @@ For more accurate tracking, handle revenue in your backend webhook. This ensures
```tsx ```tsx
// Frontend: include deviceId when starting checkout // Frontend: include deviceId when starting checkout
const deviceId = await op.fetchDeviceId(); const deviceId = op.getDeviceId();
const response = await fetch('/api/checkout', { const response = await fetch('/api/checkout', {
method: 'POST', method: 'POST',

View File

@@ -225,7 +225,7 @@ Then update your OpenPanelComponent to use the proxy endpoint.
```tsx ```tsx
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op" apiUrl="/api/op"
cdnUrl="/api/op/op1.js" scriptUrl="/api/op/op1.js"
clientId="your-client-id" clientId="your-client-id"
trackScreenViews={true} trackScreenViews={true}
/> />

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { getRootMetadata } from '@/lib/metadata';
import { cn } from '@/lib/utils';
import { RootProvider } from 'fumadocs-ui/provider/next'; import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
import { TooltipProvider } from '@/components/ui/tooltip';
import { getRootMetadata } from '@/lib/metadata';
import { cn } from '@/lib/utils';
import './global.css'; import './global.css';
import { OpenPanelComponent } from '@openpanel/nextjs'; import { OpenPanelComponent } from '@openpanel/nextjs';
@@ -31,22 +31,20 @@ export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html <html
lang="en"
className={cn(font.className, mono.variable)} className={cn(font.className, mono.variable)}
lang="en"
suppressHydrationWarning suppressHydrationWarning
> >
<body className="flex flex-col min-h-screen bg-background"> <body className="flex min-h-screen flex-col bg-background">
<RootProvider> <RootProvider>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>{children}</TooltipProvider>
</RootProvider> </RootProvider>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && ( {process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID} clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
trackAttributes trackAttributes
trackScreenViews
trackOutgoingLinks trackOutgoingLinks
trackScreenViews
/> />
)} )}
</body> </body>

View File

@@ -38,9 +38,10 @@
"@openpanel/integrations": "workspace:^", "@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*", "@openpanel/json": "workspace:*",
"@openpanel/payments": "workspace:*", "@openpanel/payments": "workspace:*",
"@openpanel/sdk": "^1.0.8",
"@openpanel/sdk-info": "workspace:^", "@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^", "@openpanel/validation": "workspace:^",
"@openpanel/web": "^1.0.1", "@openpanel/web": "^1.0.12",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -141,6 +142,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"rrweb-player": "2.0.0-alpha.20",
"short-unique-id": "^5.0.3", "short-unique-id": "^5.0.3",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.4.0", "sonner": "^1.4.0",

View File

@@ -89,7 +89,7 @@ export function useColumns() {
projectId: row.original.projectId, projectId: row.original.projectId,
}); });
}} }}
className="font-medium" className="font-medium hover:underline"
> >
{renderName()} {renderName()}
</button> </button>
@@ -144,10 +144,21 @@ export function useColumns() {
{ {
accessorKey: 'sessionId', accessorKey: 'sessionId',
header: 'Session ID', header: 'Session ID',
size: 320, size: 100,
meta: { meta: {
hidden: true, hidden: true,
}, },
cell({ row }) {
const { sessionId } = row.original;
return (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0,6)}
</ProjectLink>
);
},
}, },
{ {
accessorKey: 'deviceId', accessorKey: 'deviceId',

View File

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

View File

@@ -0,0 +1,242 @@
import type { IServiceEvent } from '@openpanel/db';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Maximize2, Minimize2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BrowserChrome } from './browser-chrome';
import { ReplayTime } from './replay-controls';
import { ReplayTimeline } from './replay-timeline';
import { getEventOffsetMs } from './replay-utils';
import {
ReplayProvider,
useCurrentTime,
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 { useTRPC } from '@/integrations/trpc/react';
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
const { startTime } = useReplayContext();
const currentTime = useCurrentTime(250);
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.at(-1);
if (!latest) {
return '';
}
const { origin = '', path = '/' } = latest.event;
return `${origin}${path}`;
}, [events, currentTime, startTime]);
return <span className="truncate text-muted-foreground">{currentUrl}</span>;
}
/**
* Feeds remaining chunks into the player after it's ready.
* Receives already-fetched chunks from the initial batch, then pages
* through the rest using replayChunksFrom.
*/
function ReplayChunkLoader({
sessionId,
projectId,
fromIndex,
}: {
sessionId: string;
projectId: string;
fromIndex: number;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { addEvent, refreshDuration } = useReplayContext();
useEffect(() => {
function recursive(fromIndex: number) {
queryClient
.fetchQuery(
trpc.session.replayChunksFrom.queryOptions({
sessionId,
projectId,
fromIndex,
})
)
.then((res) => {
res.data.forEach((row) => {
row?.events?.forEach((event) => {
addEvent(event);
});
});
refreshDuration();
if (res.hasMore) {
recursive(fromIndex + res.data.length);
}
})
.catch(() => {
// chunk loading failed — replay may be incomplete
});
}
recursive(fromIndex);
}, []);
return null;
}
function FullscreenButton({
containerRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggle = useCallback(() => {
if (!containerRef.current) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
}, [containerRef]);
return (
<button
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground"
onClick={toggle}
type="button"
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
);
}
function ReplayContent({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
const trpc = useTRPC();
const containerRef = useRef<HTMLDivElement>(null);
const { data: eventsData } = useQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
})
);
// Fetch first batch of chunks (includes chunk 0 for player init + more)
const { data: firstBatch, isLoading: replayLoading } = useQuery(
trpc.session.replayChunksFrom.queryOptions({
sessionId,
projectId,
fromIndex: 0,
})
);
const events = eventsData?.data ?? [];
const playerEvents =
firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [];
const hasMore = firstBatch?.hasMore ?? false;
const hasReplay = playerEvents.length !== 0;
function renderReplay() {
if (replayLoading) {
return (
<div className="col h-[320px] items-center justify-center gap-4 bg-background">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted" />
<div>Loading session replay</div>
</div>
);
}
if (hasReplay) {
return <ReplayPlayer events={playerEvents} />;
}
return (
<div className="flex h-[320px] items-center justify-center bg-background text-muted-foreground text-sm">
No replay data available for this session.
</div>
);
}
return (
<ReplayProvider>
<div
className="grid gap-4 lg:grid-cols-[1fr_380px] [&:fullscreen]:flex [&:fullscreen]:flex-col [&:fullscreen]:bg-background [&:fullscreen]:p-4"
id="replay"
ref={containerRef}
>
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
right={
<div className="flex items-center gap-2">
{hasReplay && <ReplayTime />}
<FullscreenButton containerRef={containerRef} />
</div>
}
url={
hasReplay ? (
<BrowserUrlBar events={events} />
) : (
<span className="text-muted-foreground">about:blank</span>
)
}
>
{renderReplay()}
{hasReplay && <ReplayTimeline events={events} />}
</BrowserChrome>
</div>
<div className="relative hidden lg:block">
<div className="absolute inset-0">
<ReplayEventFeed events={events} replayLoading={replayLoading} />
</div>
</div>
</div>
{hasReplay && hasMore && (
<ReplayChunkLoader
fromIndex={firstBatch?.data?.length ?? 0}
projectId={projectId}
sessionId={sessionId}
/>
)}
</ReplayProvider>
);
}
export function ReplayShell({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
return <ReplayContent projectId={projectId} sessionId={sessionId} />;
}

View File

@@ -0,0 +1,205 @@
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 };
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
addEventListener: (event: string, handler: (e: { payload: unknown }) => void) => void;
$set?: (props: Record<string, unknown>) => void;
$destroy?: () => void;
}
type CurrentTimeListener = (t: number) => void;
interface ReplayContextValue {
// High-frequency value — read via ref, not state. Use subscribeToCurrentTime
// or useCurrentTime() to get updates without causing 60fps re-renders.
currentTimeRef: React.MutableRefObject<number>;
subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void;
// Low-frequency state (safe to consume directly)
isPlaying: boolean;
duration: number;
startTime: number | null;
isReady: boolean;
// Playback controls
play: () => void;
pause: () => void;
toggle: () => void;
seek: (timeMs: number) => void;
setSpeed: (speed: number) => void;
// Lazy chunk loading
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
refreshDuration: () => void;
// Called by ReplayPlayer to register/unregister the rrweb instance
onPlayerReady: (player: ReplayPlayerInstance, playerStartTime: number) => void;
onPlayerDestroy: () => void;
// State setters exposed so ReplayPlayer can wire rrweb event listeners
setCurrentTime: (t: number) => void;
setIsPlaying: (p: boolean) => void;
setDuration: (d: number) => 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;
}
/**
* Subscribe to currentTime updates at a throttled rate.
* intervalMs=0 means every tick (use for the progress bar DOM writes).
* intervalMs=250 means 4 updates/second (use for text displays).
*/
export function useCurrentTime(intervalMs = 0): number {
const { currentTimeRef, subscribeToCurrentTime } = useReplayContext();
const [time, setTime] = useState(currentTimeRef.current);
const lastUpdateRef = useRef(0);
useEffect(() => {
return subscribeToCurrentTime((t) => {
if (intervalMs === 0) {
setTime(t);
return;
}
const now = performance.now();
if (now - lastUpdateRef.current >= intervalMs) {
lastUpdateRef.current = now;
setTime(t);
}
});
}, [subscribeToCurrentTime, intervalMs]);
return time;
}
export function ReplayProvider({ children }: { children: ReactNode }) {
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const isPlayingRef = useRef(false);
const currentTimeRef = useRef(0);
const listenersRef = useRef<Set<CurrentTimeListener>>(new Set());
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
const setIsPlayingWithRef = useCallback((playing: boolean) => {
isPlayingRef.current = playing;
setIsPlaying(playing);
}, []);
const subscribeToCurrentTime = useCallback((fn: CurrentTimeListener) => {
listenersRef.current.add(fn);
return () => {
listenersRef.current.delete(fn);
};
}, []);
// Called by ReplayPlayer on every ui-update-current-time tick.
// Updates the ref and notifies subscribers — no React state update here.
const setCurrentTime = useCallback((t: number) => {
currentTimeRef.current = t;
for (const fn of listenersRef.current) {
fn(t);
}
}, []);
const onPlayerReady = useCallback(
(player: ReplayPlayerInstance, playerStartTime: number) => {
playerRef.current = player;
setStartTime(playerStartTime);
currentTimeRef.current = 0;
setIsPlayingWithRef(false);
setIsReady(true);
},
[setIsPlayingWithRef],
);
const onPlayerDestroy = useCallback(() => {
playerRef.current = null;
setIsReady(false);
currentTimeRef.current = 0;
setDuration(0);
setStartTime(null);
setIsPlayingWithRef(false);
}, [setIsPlayingWithRef]);
const play = useCallback(() => {
playerRef.current?.play();
}, []);
const pause = useCallback(() => {
playerRef.current?.pause();
}, []);
const toggle = useCallback(() => {
playerRef.current?.toggle();
}, []);
const seek = useCallback((timeMs: number) => {
playerRef.current?.goto(timeMs, isPlayingRef.current);
}, []);
const setSpeed = useCallback((s: number) => {
if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
playerRef.current?.setSpeed(s);
}, []);
const addEvent = useCallback(
(event: { type: number; data: unknown; timestamp: number }) => {
playerRef.current?.addEvent(event);
},
[],
);
const refreshDuration = useCallback(() => {
const total = playerRef.current?.getMetaData().totalTime ?? 0;
if (total > 0) setDuration(total);
}, []);
const value: ReplayContextValue = {
currentTimeRef,
subscribeToCurrentTime,
isPlaying,
duration,
startTime,
isReady,
play,
pause,
toggle,
seek,
setSpeed,
addEvent,
refreshDuration,
onPlayerReady,
onPlayerDestroy,
setCurrentTime,
setIsPlaying: setIsPlayingWithRef,
setDuration,
};
return (
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
);
}
export { SPEED_OPTIONS };

View File

@@ -0,0 +1,33 @@
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
import { Button } from '@/components/ui/button';
import { Pause, Play } from 'lucide-react';
import { formatDuration } from './replay-utils';
export function ReplayTime() {
const { duration } = useReplayContext();
const currentTime = useCurrentTime(250);
return (
<span className="text-sm tabular-nums text-muted-foreground font-mono">
{formatDuration(currentTime)} / {formatDuration(duration)}
</span>
);
}
export function ReplayPlayPauseButton() {
const { isPlaying, isReady, toggle } = 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>
);
}

View File

@@ -0,0 +1,107 @@
import { useCurrentTime, 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';
import { getEventOffsetMs } from './replay-utils';
type EventWithOffset = { event: IServiceEvent; offsetMs: number };
export function ReplayEventFeed({ events, replayLoading }: { events: IServiceEvent[]; replayLoading: boolean }) {
const { startTime, isReady, seek } = useReplayContext();
const currentTime = useCurrentTime(100);
const viewportRef = useRef<HTMLDivElement | null>(null);
const prevCountRef = useRef(0);
// Pre-sort events by offset once when events/startTime changes.
// This is the expensive part — done once, not on every tick.
const sortedEvents = useMemo<EventWithOffset[]>(() => {
if (startTime == null || !isReady) return [];
return events
.map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) }))
.filter(({ offsetMs }) => offsetMs >= -10_000)
.sort((a, b) => a.offsetMs - b.offsetMs);
}, [events, startTime, isReady]);
// Binary search to find how many events are visible at currentTime.
// O(log n) instead of O(n) filter on every tick.
const visibleCount = useMemo(() => {
let lo = 0;
let hi = sortedEvents.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}, [sortedEvents, currentTime]);
const visibleEvents = sortedEvents.slice(0, visibleCount);
const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null;
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]);
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 w-full flex-col">
{visibleEvents.map(({ event, offsetMs }) => (
<div
key={event.id}
className="animate-in fade-in-0 slide-in-from-bottom-3 min-w-0 duration-300 fill-mode-both"
>
<ReplayEventItem
event={event}
isCurrent={event.id === currentEventId}
onClick={() => seek(Math.max(0, offsetMs))}
/>
</div>
))}
{!replayLoading && visibleEvents.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
Events will appear as the replay plays.
</div>
)}
{replayLoading &&
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 border-b px-3 py-2"
>
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div
className="h-3 animate-pulse rounded bg-muted"
style={{ width: `${50 + (i % 4) * 12}%` }}
/>
</div>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</ScrollArea>
</BrowserChrome>
);
}

View File

@@ -0,0 +1,51 @@
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import type { IServiceEvent } from '@openpanel/db';
function formatTime(date: Date | string): string {
const d = date instanceof Date ? date : new Date(date);
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
const s = d.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
export function ReplayEventItem({
event,
isCurrent,
onClick,
}: {
event: IServiceEvent;
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',
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">
{formatTime(event.createdAt)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,184 @@
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
import { useEffect, useMemo, useRef, useState } 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;
}
function calcDimensions(
containerWidth: number,
aspectRatio: number,
): { width: number; height: number } {
const maxHeight = window.innerHeight * 0.7;
const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight);
const width = Math.min(containerWidth, Math.round(height * aspectRatio));
return { width, height };
}
export function ReplayPlayer({
events,
}: {
events: Array<{ type: number; data: unknown; timestamp: number }>;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const {
onPlayerReady,
onPlayerDestroy,
setCurrentTime,
setIsPlaying,
setDuration,
} = useReplayContext();
const [importError, setImportError] = useState(false);
const recordedDimensions = useMemo(
() => getRecordedDimensions(events),
[events],
);
useEffect(() => {
if (!events.length || !containerRef.current) return;
// Clear any previous player DOM
containerRef.current.innerHTML = '';
let mounted = true;
let player: ReplayPlayerInstance | null = null;
let handleVisibilityChange: (() => void) | null = null;
const aspectRatio = recordedDimensions
? recordedDimensions.width / recordedDimensions.height
: 16 / 9;
const { width, height } = calcDimensions(
containerRef.current.offsetWidth,
aspectRatio,
);
import('rrweb-player')
.then((module) => {
if (!containerRef.current || !mounted) return;
const PlayerConstructor = module.default;
player = new PlayerConstructor({
target: containerRef.current,
props: {
events,
width,
height,
autoPlay: false,
showController: false,
speedOption: [0.5, 1, 2, 4, 8],
UNSAFE_replayCanvas: true,
skipInactive: false,
},
}) as ReplayPlayerInstance;
playerRef.current = player;
// Track play state from replayer (getMetaData() does not expose isPlaying)
let playingState = false;
// Wire rrweb's built-in event emitter — no RAF loop needed.
// Note: rrweb-player does NOT emit ui-update-duration; duration is
// read from getMetaData() on init and after each addEvent batch.
player.addEventListener('ui-update-current-time', (e) => {
const t = e.payload as number;
setCurrentTime(t);
});
player.addEventListener('ui-update-player-state', (e) => {
const playing = e.payload === 'playing';
playingState = playing;
setIsPlaying(playing);
});
// Pause on tab hide; resume on show (prevents timer drift).
// getMetaData() does not expose isPlaying, so we use playingState
// kept in sync by ui-update-player-state above.
let wasPlaying = false;
handleVisibilityChange = () => {
if (!player) return;
if (document.hidden) {
wasPlaying = playingState;
if (wasPlaying) player.pause();
} else {
if (wasPlaying) {
player.play();
wasPlaying = false;
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Notify context — marks isReady = true and sets initial duration
const meta = player.getMetaData();
if (meta.totalTime > 0) setDuration(meta.totalTime);
onPlayerReady(player, meta.startTime);
})
.catch(() => {
if (mounted) setImportError(true);
});
const onWindowResize = () => {
if (!containerRef.current || !mounted || !playerRef.current?.$set) return;
const { width: w, height: h } = calcDimensions(
containerRef.current.offsetWidth,
aspectRatio,
);
playerRef.current.$set({ width: w, height: h });
};
window.addEventListener('resize', onWindowResize);
return () => {
mounted = false;
window.removeEventListener('resize', onWindowResize);
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (player) {
player.pause();
}
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
playerRef.current = null;
onPlayerDestroy();
};
}, [events, recordedDimensions, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, setDuration]);
if (importError) {
return (
<div className="flex h-[320px] items-center justify-center bg-black text-sm text-muted-foreground">
Failed to load replay player.
</div>
);
}
return (
<div className="relative flex w-full justify-center overflow-hidden">
<div
ref={containerRef}
className="w-full"
style={{ maxHeight: '70vh' }}
/>
</div>
);
}

View File

@@ -0,0 +1,261 @@
import { useCurrentTime, 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, useEffect, useMemo, useRef, useState } from 'react';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import { ReplayPlayPauseButton } from './replay-controls';
import { formatDuration, getEventOffsetMs } from './replay-utils';
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
const { currentTimeRef, duration, startTime, isReady, seek, subscribeToCurrentTime } =
useReplayContext();
// currentTime as React state is only needed for keyboard seeks (low frequency).
// The progress bar and thumb are updated directly via DOM refs to avoid re-renders.
const currentTime = useCurrentTime(250);
const trackRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverInfo, setHoverInfo] = useState<{
pct: number;
timeMs: number;
} | null>(null);
const dragCleanupRef = useRef<(() => void) | null>(null);
const rafDragRef = useRef<number | null>(null);
// Clean up any in-progress drag listeners when the component unmounts
useEffect(() => {
return () => {
dragCleanupRef.current?.();
};
}, []);
// Update progress bar and thumb directly via DOM on every tick — no React re-render.
useEffect(() => {
if (duration <= 0) return;
return subscribeToCurrentTime((t) => {
const pct = Math.max(0, Math.min(100, (t / duration) * 100));
if (progressBarRef.current) {
progressBarRef.current.style.width = `${pct}%`;
}
if (thumbRef.current) {
thumbRef.current.style.left = `calc(${pct}% - 8px)`;
}
});
}, [subscribeToCurrentTime, duration]);
const getTimeFromClientX = useCallback(
(clientX: number) => {
if (!trackRef.current || duration <= 0) return null;
const rect = trackRef.current.getBoundingClientRect();
if (rect.width <= 0 || !Number.isFinite(rect.width)) {
return null;
}
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 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;
const info = getTimeFromClientX(e.clientX);
if (info) seek(info.timeMs);
},
[getTimeFromClientX, seek],
);
const eventsWithOffset = useMemo(
() =>
events
.map((ev) => ({
event: ev,
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
}))
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration),
[events, startTime, duration],
);
// Group events that are within 24px of each other on the track.
// We need the track width for pixel math — use a stable ref-based calculation.
const groupedEvents = useMemo(() => {
if (!eventsWithOffset.length || duration <= 0) return [];
// Sort by offsetMs so we sweep left-to-right
const sorted = [...eventsWithOffset].sort((a, b) => a.offsetMs - b.offsetMs);
// 24px in ms — recalculated from container width; fall back to 2% of duration
const trackWidth = trackRef.current?.offsetWidth ?? 600;
const thresholdMs = (24 / trackWidth) * duration;
const groups: { items: typeof sorted; pct: number }[] = [];
for (const item of sorted) {
const last = groups[groups.length - 1];
const lastPct = last ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 : -Infinity;
const thisPct = (item.offsetMs / duration) * 100;
if (last && item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= thresholdMs) {
last.items.push(item);
// Anchor the group at its first item's position
} else {
groups.push({ items: [item], pct: thisPct });
}
// keep pct pointing at the first item (already set on push)
void lastPct;
}
return groups;
}, [eventsWithOffset, duration]);
if (!isReady || duration <= 0) return null;
const progressPct = Math.max(0, Math.min(100, (currentTimeRef.current / duration) * 100));
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 outline-0"
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
ref={progressBarRef}
className="bg-primary h-full rounded-full"
style={{ width: `${progressPct}%` }}
/>
</div>
<div
ref={thumbRef}
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
style={{ left: `calc(${progressPct}% - 8px)` }}
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 }}
>
{formatDuration(hoverInfo.timeMs)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{groupedEvents.map((group) => {
const first = group.items[0]!;
const isGroup = group.items.length > 1;
return (
<Tooltip key={first.event.id}>
<TooltipTrigger asChild>
<button
type="button"
data-timeline-event
className="absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-105"
style={{ left: `${group.pct}%`, marginLeft: -12 }}
onClick={(e) => {
e.stopPropagation();
seek(first.offsetMs);
}}
aria-label={isGroup ? `${group.items.length} events at ${formatDuration(first.offsetMs)}` : `${first.event.name} at ${formatDuration(first.offsetMs)}`}
>
<EventIcon name={first.event.name} meta={first.event.meta} size="sm" />
{isGroup && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold leading-none text-background">
{group.items.length}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="col gap-1.5">
{group.items.map(({ event: ev, offsetMs }) => (
<div key={ev.id} className="row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
<span className="font-medium">
{ev.name === 'screen_view' ? ev.path : ev.name}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDuration(offsetMs)}
</span>
</div>
))}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,20 @@
import type { IServiceEvent } from '@openpanel/db';
export 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;
}
/** Format a duration in milliseconds as M:SS */
export function formatDuration(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')}`;
}

View File

@@ -1,13 +1,12 @@
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { getProfileName } from '@/utils/getters';
import { round } from '@openpanel/common'; import { round } from '@openpanel/common';
import type { IServiceSession } from '@openpanel/db'; import type { IServiceSession } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { getProfileName } from '@/utils/getters';
function formatDuration(milliseconds: number): string { function formatDuration(milliseconds: number): string {
const seconds = milliseconds / 1000; const seconds = milliseconds / 1000;
@@ -44,13 +43,25 @@ export function useColumns() {
cell: ({ row }) => { cell: ({ row }) => {
const session = row.original; const session = row.original;
return ( return (
<ProjectLink <div className="row items-center gap-2">
href={`/sessions/${session.id}`} <ProjectLink
className="font-medium" className="font-medium"
title={session.id} href={`/sessions/${session.id}`}
> title={session.id}
{session.id.slice(0, 8)}... >
</ProjectLink> {session.id.slice(0, 8)}...
</ProjectLink>
{session.hasReplay && (
<ProjectLink
aria-label="View replay"
className="text-muted-foreground hover:text-foreground"
href={`/sessions/${session.id}#replay`}
title="View replay"
>
<Video className="size-4" />
</ProjectLink>
)}
</div>
); );
}, },
}, },
@@ -63,8 +74,8 @@ export function useColumns() {
if (session.profile) { if (session.profile) {
return ( return (
<ProjectLink <ProjectLink
className="row items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(session.profile.id)}`} href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium row gap-2 items-center"
> >
<ProfileAvatar size="sm" {...session.profile} /> <ProfileAvatar size="sm" {...session.profile} />
{getProfileName(session.profile)} {getProfileName(session.profile)}
@@ -73,8 +84,8 @@ export function useColumns() {
} }
return ( return (
<ProjectLink <ProjectLink
className="font-medium font-mono"
href={`/profiles/${encodeURIComponent(session.profileId)}`} href={`/profiles/${encodeURIComponent(session.profileId)}`}
className="font-mono font-medium"
> >
{session.profileId} {session.profileId}
</ProjectLink> </ProjectLink>

View File

@@ -48,7 +48,7 @@ function ScrollArea({
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
ref={ref} ref={ref}
> >
{children} {children}

View File

@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
export function usePageTabs(tabs: { id: string; label: string }[]) { export function usePageTabs(tabs: { id: string; label: string }[]) {
const location = useLocation(); const location = useLocation();
const tab = location.pathname.split('/').pop(); const segments = location.pathname.split('/').filter(Boolean);
const tab = segments[segments.length - 1];
if (!tab) { if (!tab) {
return { return {

View File

@@ -1,22 +1,3 @@
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { ProjectLink } from '@/components/links';
import {
WidgetButtons,
WidgetHead,
} from '@/components/overview/overview-widget';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Button } from '@/components/ui/button';
import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db'; import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { FilterIcon, XIcon } from 'lucide-react'; import { FilterIcon, XIcon } from 'lucide-react';
@@ -24,6 +5,24 @@ import { omit } from 'ramda';
import { Suspense, useState } from 'react'; import { Suspense, useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent } from './Modal/Container'; import { ModalContent } from './Modal/Container';
import { ProjectLink } from '@/components/links';
import {
WidgetButtons,
WidgetHead,
} from '@/components/overview/overview-widget';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import { Button } from '@/components/ui/button';
import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
interface Props { interface Props {
id: string; id: string;
@@ -55,7 +54,7 @@ const filterable: Partial<Record<keyof IServiceEvent, keyof IClickhouseEvent>> =
export default function EventDetails(props: Props) { export default function EventDetails(props: Props) {
return ( return (
<ModalContent className="!p-0"> <ModalContent className="!p-0">
<Widget className="bg-transparent border-0 min-w-0"> <Widget className="min-w-0 border-0 bg-transparent">
<Suspense fallback={<EventDetailsSkeleton />}> <Suspense fallback={<EventDetailsSkeleton />}>
<EventDetailsContent {...props} /> <EventDetailsContent {...props} />
</Suspense> </Suspense>
@@ -84,7 +83,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
id, id,
projectId, projectId,
createdAt, createdAt,
}), })
); );
const { event, session } = query.data; const { event, session } = query.data;
@@ -158,7 +157,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
event, event,
}); });
} }
}, }
); );
} }
@@ -209,7 +208,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
> >
<ArrowRightIcon className="size-4" /> <ArrowRightIcon className="size-4" />
</Button> */} </Button> */}
<Button size="icon" variant={'ghost'} onClick={() => popModal()}> <Button onClick={() => popModal()} size="icon" variant={'ghost'}>
<XIcon className="size-4" /> <XIcon className="size-4" />
</Button> </Button>
</div> </div>
@@ -218,10 +217,10 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<WidgetButtons> <WidgetButtons>
{Object.entries(TABS).map(([, tab]) => ( {Object.entries(TABS).map(([, tab]) => (
<button <button
key={tab.id}
type="button"
onClick={() => setWidget(tab)}
className={cn(tab.id === widget.id && 'active')} className={cn(tab.id === widget.id && 'active')}
key={tab.id}
onClick={() => setWidget(tab)}
type="button"
> >
{tab.title} {tab.title}
</button> </button>
@@ -231,29 +230,29 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<WidgetBody className="col gap-4 bg-def-100"> <WidgetBody className="col gap-4 bg-def-100">
{profile && ( {profile && (
<ProjectLink <ProjectLink
onClick={() => popModal()} className="card col gap-2 p-4 py-2 hover:bg-def-100"
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${encodeURIComponent(profile.id)}`}
className="card p-4 py-2 col gap-2 hover:bg-def-100" onClick={() => popModal()}
> >
<div className="row items-center gap-2 justify-between"> <div className="row items-center justify-between gap-2">
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
{profile.avatar && ( {profile.avatar && (
<img <img
className="size-4 bg-border rounded-full" className="size-4 rounded-full bg-border"
src={profile.avatar} src={profile.avatar}
/> />
)} )}
<div className="font-medium truncate"> <div className="truncate font-medium">
{getProfileName(profile, false)} {getProfileName(profile, false)}
</div> </div>
</div> </div>
<div className="row items-center gap-2 shrink-0"> <div className="row shrink-0 items-center gap-2">
<div className="row gap-1 items-center"> <div className="row items-center gap-1">
<SerieIcon name={event.country} /> <SerieIcon name={event.country} />
<SerieIcon name={event.os} /> <SerieIcon name={event.os} />
<SerieIcon name={event.browser} /> <SerieIcon name={event.browser} />
</div> </div>
<div className="text-muted-foreground truncate max-w-40"> <div className="max-w-40 truncate text-muted-foreground">
{event.referrerName || event.referrer} {event.referrerName || event.referrer}
</div> </div>
</div> </div>
@@ -276,16 +275,16 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<KeyValueGrid <KeyValueGrid
columns={1} columns={1}
data={properties} data={properties}
onItemClick={(item) => {
popModal();
setFilter(`properties.${item.name}`, item.value as any);
}}
renderValue={(item) => ( renderValue={(item) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono">{String(item.value)}</span> <span className="font-mono">{String(item.value)}</span>
<FilterIcon className="size-3 shrink-0" /> <FilterIcon className="size-3 shrink-0" />
</div> </div>
)} )}
onItemClick={(item) => {
popModal();
setFilter(`properties.${item.name}`, item.value as any);
}}
/> />
</section> </section>
)} )}
@@ -296,25 +295,6 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<KeyValueGrid <KeyValueGrid
columns={1} columns={1}
data={data} data={data}
renderValue={(item) => {
const isFilterable = item.value && (filterable as any)[item.name];
if (isFilterable) {
return (
<div className="flex items-center gap-2">
<FieldValue
name={item.name}
value={item.value}
event={event}
/>
<FilterIcon className="size-3 shrink-0" />
</div>
);
}
return (
<FieldValue name={item.name} value={item.value} event={event} />
);
}}
onItemClick={(item) => { onItemClick={(item) => {
const isFilterable = item.value && (filterable as any)[item.name]; const isFilterable = item.value && (filterable as any)[item.name];
if (isFilterable) { if (isFilterable) {
@@ -322,26 +302,45 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
setFilter(item.name as keyof IServiceEvent, item.value); setFilter(item.name as keyof IServiceEvent, item.value);
} }
}} }}
renderValue={(item) => {
const isFilterable = item.value && (filterable as any)[item.name];
if (isFilterable) {
return (
<div className="flex items-center gap-2">
<FieldValue
event={event}
name={item.name}
value={item.value}
/>
<FilterIcon className="size-3 shrink-0" />
</div>
);
}
return (
<FieldValue event={event} name={item.name} value={item.value} />
);
}}
/> />
</section> </section>
<section> <section>
<div className="mb-2 flex justify-between font-medium"> <div className="mb-2 flex justify-between font-medium">
<div>All events for {event.name}</div> <div>All events for {event.name}</div>
<button <button
type="button"
className="text-muted-foreground hover:underline" className="text-muted-foreground hover:underline"
onClick={() => { onClick={() => {
setEvents([event.name]); setEvents([event.name]);
popModal(); popModal();
}} }}
type="button"
> >
Show all Show all
</button> </button>
</div> </div>
<div className="card p-4"> <div className="card p-4">
<ReportChartShortcut <ReportChartShortcut
projectId={event.projectId}
chartType="linear" chartType="linear"
projectId={event.projectId}
series={[ series={[
{ {
id: 'A', id: 'A',
@@ -365,52 +364,52 @@ function EventDetailsSkeleton() {
<> <>
<WidgetHead> <WidgetHead>
<div className="row items-center justify-between"> <div className="row items-center justify-between">
<div className="h-6 w-32 bg-muted animate-pulse rounded" /> <div className="h-6 w-32 animate-pulse rounded bg-muted" />
<div className="row items-center gap-2 pr-2"> <div className="row items-center gap-2 pr-2">
<div className="h-8 w-8 bg-muted animate-pulse rounded" /> <div className="h-8 w-8 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 bg-muted animate-pulse rounded" /> <div className="h-8 w-8 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 bg-muted animate-pulse rounded" /> <div className="h-8 w-8 animate-pulse rounded bg-muted" />
</div> </div>
</div> </div>
<WidgetButtons> <WidgetButtons>
<div className="h-8 w-20 bg-muted animate-pulse rounded" /> <div className="h-8 w-20 animate-pulse rounded bg-muted" />
<div className="h-8 w-20 bg-muted animate-pulse rounded" /> <div className="h-8 w-20 animate-pulse rounded bg-muted" />
</WidgetButtons> </WidgetButtons>
</WidgetHead> </WidgetHead>
<WidgetBody className="col gap-4 bg-def-100"> <WidgetBody className="col gap-4 bg-def-100">
{/* Profile skeleton */} {/* Profile skeleton */}
<div className="card p-4 py-2 col gap-2"> <div className="card col gap-2 p-4 py-2">
<div className="row items-center gap-2 justify-between"> <div className="row items-center justify-between gap-2">
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<div className="size-4 bg-muted animate-pulse rounded-full" /> <div className="size-4 animate-pulse rounded-full bg-muted" />
<div className="h-4 w-24 bg-muted animate-pulse rounded" /> <div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div> </div>
<div className="row items-center gap-2 shrink-0"> <div className="row shrink-0 items-center gap-2">
<div className="row gap-1 items-center"> <div className="row items-center gap-1">
<div className="size-4 bg-muted animate-pulse rounded" /> <div className="size-4 animate-pulse rounded bg-muted" />
<div className="size-4 bg-muted animate-pulse rounded" /> <div className="size-4 animate-pulse rounded bg-muted" />
<div className="size-4 bg-muted animate-pulse rounded" /> <div className="size-4 animate-pulse rounded bg-muted" />
</div> </div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" /> <div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div> </div>
</div> </div>
<div className="h-4 w-64 bg-muted animate-pulse rounded" /> <div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div> </div>
{/* Properties skeleton */} {/* Properties skeleton */}
<section> <section>
<div className="mb-2 flex justify-between font-medium"> <div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-20 bg-muted animate-pulse rounded" /> <div className="h-5 w-20 animate-pulse rounded bg-muted" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div <div
className="flex items-center justify-between rounded bg-muted/50 p-3"
key={i.toString()} key={i.toString()}
className="flex items-center justify-between p-3 bg-muted/50 rounded"
> >
<div className="h-4 w-24 bg-muted animate-pulse rounded" /> <div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 bg-muted animate-pulse rounded" /> <div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div> </div>
))} ))}
</div> </div>
@@ -419,16 +418,16 @@ function EventDetailsSkeleton() {
{/* Information skeleton */} {/* Information skeleton */}
<section> <section>
<div className="mb-2 flex justify-between font-medium"> <div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-24 bg-muted animate-pulse rounded" /> <div className="h-5 w-24 animate-pulse rounded bg-muted" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div <div
className="flex items-center justify-between rounded bg-muted/50 p-3"
key={i.toString()} key={i.toString()}
className="flex items-center justify-between p-3 bg-muted/50 rounded"
> >
<div className="h-4 w-20 bg-muted animate-pulse rounded" /> <div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-28 bg-muted animate-pulse rounded" /> <div className="h-4 w-28 animate-pulse rounded bg-muted" />
</div> </div>
))} ))}
</div> </div>
@@ -437,11 +436,11 @@ function EventDetailsSkeleton() {
{/* Chart skeleton */} {/* Chart skeleton */}
<section> <section>
<div className="mb-2 flex justify-between font-medium"> <div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-40 bg-muted animate-pulse rounded" /> <div className="h-5 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-16 bg-muted animate-pulse rounded" /> <div className="h-4 w-16 animate-pulse rounded bg-muted" />
</div> </div>
<div className="card p-4"> <div className="card p-4">
<div className="h-32 w-full bg-muted animate-pulse rounded" /> <div className="h-32 w-full animate-pulse rounded bg-muted" />
</div> </div>
</section> </section>
</WidgetBody> </WidgetBody>

View File

@@ -83,6 +83,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from '.
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events' import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions' import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute( const AppOrganizationIdProfileRouteImport = createFileRoute(
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/', path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute = const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({ AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
id: '/events', id: '/events',
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute '/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute '/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -778,6 +787,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute '/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/profiles/' | '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/' | '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/profiles/$profileId/' | '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/profiles/_tabs/' | '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/' | '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
} }
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
path: '/sessions'
fullPath: '/$organizationId/$projectId/profiles/$profileId/sessions'
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': { '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events' id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
path: '/events' path: '/events'
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren { interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
} }
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
{ {
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute, AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute, AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute,
} }

View File

@@ -0,0 +1,33 @@
import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions',
)({
component: Component,
});
function Component() {
const { projectId, profileId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions(
{
projectId,
profileId,
take: 50,
search: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return <SessionsTable query={query} />;
}

View File

@@ -43,6 +43,7 @@ function Component() {
label: 'Overview', label: 'Overview',
}, },
{ id: 'events', label: 'Events' }, { id: 'events', label: 'Events' },
{ id: 'sessions', label: 'Sessions' },
]); ]);
const handleTabChange = (tabId: string) => { const handleTabChange = (tabId: string) => {

View File

@@ -1,21 +1,28 @@
import { EventsTable } from '@/components/events/table'; import type { IServiceEvent, IServiceSession } from '@openpanel/db';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { EventIcon } from '@/components/events/event-icon';
import FullPageLoadingState from '@/components/full-page-loading-state'; 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 { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; import { ReplayShell } from '@/components/sessions/replay';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { import {
useEventQueryFilters, Widget,
useEventQueryNamesFilter, WidgetBody,
} from '@/hooks/use-event-query-filters'; WidgetHead,
WidgetTitle,
} from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { createProjectTitle } from '@/utils/title'; import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/sessions_/$sessionId', '/_app/$organizationId/$projectId/sessions_/$sessionId'
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {
@@ -24,66 +31,148 @@ export const Route = createFileRoute(
context.trpc.session.byId.queryOptions({ context.trpc.session.byId.queryOptions({
sessionId: params.sessionId, sessionId: params.sessionId,
projectId: params.projectId, projectId: params.projectId,
}), })
),
context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
projectId: params.projectId,
sessionId: params.sessionId,
filters: [],
columnVisibility: {},
})
), ),
]); ]);
}, },
head: () => { head: () => ({
return { meta: [{ title: createProjectTitle('Session') }],
meta: [ }),
{
title: createProjectTitle('Sessions'),
},
],
};
},
pendingComponent: FullPageLoadingState, pendingComponent: FullPageLoadingState,
}); });
function Component() { function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
const { projectId, sessionId } = Route.useParams(); return {
const trpc = useTRPC(); ...session,
name: 'screen_view',
sessionId: session.id,
properties: {},
path: session.exitPath,
origin: session.exitOrigin,
importedAt: undefined,
meta: undefined,
sdkName: undefined,
sdkVersion: undefined,
profile: undefined,
};
}
const LIMIT = 50; function VisitedRoutes({ paths }: { paths: string[] }) {
const counted = paths.reduce<Record<string, number>>((acc, p) => {
acc[p] = (acc[p] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
const { data: session } = useSuspenseQuery( if (sorted.length === 0) {
trpc.session.byId.queryOptions({ return null;
sessionId, }
projectId,
}),
);
const [filters] = useEventQueryFilters();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
sessionId,
filters,
events: eventNames,
startDate: startDate || undefined,
endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return ( return (
<PageContainer> <Widget className="w-full">
<PageHeader <WidgetHead>
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`} <WidgetTitle>Visited pages</WidgetTitle>
> </WidgetHead>
<div className="row gap-4 mb-6"> <div className="flex flex-col gap-1 p-1">
{sorted.map(([path, count]) => (
<div className="group relative px-3 py-2" key={path}>
<div
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex min-w-0 justify-between gap-2">
<span className="truncate text-sm">{path}</span>
<span className="shrink-0 font-medium text-sm">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function EventDistribution({ events }: { events: IServiceEvent[] }) {
const counted = events.reduce<Record<string, number>>((acc, e) => {
acc[e.name] = (acc[e.name] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
if (sorted.length === 0) {
return null;
}
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Event distribution</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{sorted.map(([name, count]) => (
<div className="group relative px-3 py-2" key={name}>
<div
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex justify-between gap-2">
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
<span className="shrink-0 font-medium text-sm">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function Component() {
const { projectId, sessionId, organizationId } = Route.useParams();
const trpc = useTRPC();
const number = useNumber();
const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({ sessionId, projectId })
);
const { data: eventsData } = useSuspenseQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
})
);
const events = eventsData?.data ?? [];
const isIdentified =
session.profileId && session.profileId !== session.deviceId;
const { data: profile } = useSuspenseQuery(
trpc.profile.byId.queryOptions({
profileId: session.profileId,
projectId,
})
);
const fakeEvent = sessionToFakeEvent(session);
return (
<PageContainer className="col gap-8">
<PageHeader title={`Session: ${session.id}`}>
<div className="row mb-6 gap-4">
{session.country && ( {session.country && (
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<SerieIcon name={session.country} /> <SerieIcon name={session.country} />
<span> <span>
{session.country} {session.country}
@@ -92,32 +181,195 @@ function Component() {
</div> </div>
)} )}
{session.device && ( {session.device && (
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<SerieIcon name={session.device} /> <SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span> <span className="capitalize">{session.device}</span>
</div> </div>
)} )}
{session.os && ( {session.os && (
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<SerieIcon name={session.os} /> <SerieIcon name={session.os} />
<span>{session.os}</span> <span>{session.os}</span>
</div> </div>
)} )}
{session.model && ( {session.model && (
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<SerieIcon name={session.model} /> <SerieIcon name={session.model} />
<span>{session.model}</span> <span>{session.model}</span>
</div> </div>
)} )}
{session.browser && ( {session.browser && (
<div className="row gap-2 items-center"> <div className="row items-center gap-2">
<SerieIcon name={session.browser} /> <SerieIcon name={session.browser} />
<span>{session.browser}</span> <span>{session.browser}</span>
</div> </div>
)} )}
</div> </div>
</PageHeader> </PageHeader>
<EventsTable query={query} />
{session.hasReplay && (
<ReplayShell projectId={projectId} sessionId={sessionId} />
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
{/* Left column */}
<div className="col gap-6">
{/* Session info */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Session info</WidgetTitle>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={1}
copyable
data={[
{
name: 'duration',
value: number.formatWithUnit(session.duration / 1000, 'min'),
},
{ name: 'createdAt', value: session.createdAt },
{ name: 'endedAt', value: session.endedAt },
{ name: 'screenViews', value: session.screenViewCount },
{ name: 'events', value: session.eventCount },
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
...(session.entryPath
? [{ name: 'entryPath', value: session.entryPath }]
: []),
...(session.exitPath
? [{ name: 'exitPath', value: session.exitPath }]
: []),
...(session.referrerName
? [{ name: 'referrerName', value: session.referrerName }]
: []),
...(session.referrer
? [{ name: 'referrer', value: session.referrer }]
: []),
...(session.utmSource
? [{ name: 'utmSource', value: session.utmSource }]
: []),
...(session.utmMedium
? [{ name: 'utmMedium', value: session.utmMedium }]
: []),
...(session.utmCampaign
? [{ name: 'utmCampaign', value: session.utmCampaign }]
: []),
...(session.revenue > 0
? [{ name: 'revenue', value: `$${session.revenue}` }]
: []),
{ name: 'country', value: session.country, event: fakeEvent },
...(session.city
? [{ name: 'city', value: session.city, event: fakeEvent }]
: []),
...(session.os
? [{ name: 'os', value: session.os, event: fakeEvent }]
: []),
...(session.browser
? [
{
name: 'browser',
value: session.browser,
event: fakeEvent,
},
]
: []),
...(session.device
? [
{
name: 'device',
value: session.device,
event: fakeEvent,
},
]
: []),
...(session.brand
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
: []),
...(session.model
? [{ name: 'model', value: session.model, event: fakeEvent }]
: []),
]}
/>
</Widget>
{/* Profile card */}
{isIdentified && profile && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Profile</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
<Link
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
params={{
organizationId,
projectId,
profileId: session.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
<ProfileAvatar {...profile} size="lg" />
<div className="col min-w-0 gap-0.5">
<span className="truncate font-medium">
{getProfileName(profile, false) ?? session.profileId}
</span>
{profile.email && (
<span className="truncate text-muted-foreground text-sm">
{profile.email}
</span>
)}
</div>
</Link>
</WidgetBody>
</Widget>
)}
{/* Visited pages */}
<VisitedRoutes
paths={events
.filter((e) => e.name === 'screen_view' && e.path)
.map((e) => e.path)}
/>
{/* Event distribution */}
<EventDistribution events={events} />
</div>
{/* Right column */}
<div className="col gap-6">
{/* Events list */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Events</WidgetTitle>
</WidgetHead>
<div className="divide-y">
{events.map((event) => (
<div
className="row items-center gap-3 px-4 py-2"
key={event.id}
>
<EventIcon meta={event.meta} name={event.name} size="sm" />
<div className="col min-w-0 flex-1">
<span className="truncate font-medium text-sm">
{event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ')}
</span>
</div>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{formatDateTime(event.createdAt)}
</span>
</div>
))}
{events.length === 0 && (
<div className="py-8 text-center text-muted-foreground text-sm">
No events found
</div>
)}
</div>
</Widget>
</div>
</div>
</PageContainer> </PageContainer>
); );
} }

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

@@ -0,0 +1,47 @@
declare module 'rrweb-player' {
interface RrwebPlayerProps {
events: Array<{ type: number; data: unknown; timestamp: number }>;
width?: number;
height?: number;
autoPlay?: boolean;
showController?: boolean;
speedOption?: number[];
UNSAFE_replayCanvas?: boolean;
skipInactive?: boolean;
}
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;
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
addEventListener?: (
event: string,
handler: (...args: unknown[]) => void,
) => void;
$set?: (props: Partial<RrwebPlayerProps>) => void;
$destroy?: () => void;
}
const rrwebPlayer: new (options: RrwebPlayerOptions) => RrwebPlayerInstance;
export default rrwebPlayer;
}

View File

@@ -2,19 +2,14 @@ import { OpenPanel } from '@openpanel/web';
const clientId = import.meta.env.VITE_OP_CLIENT_ID; const clientId = import.meta.env.VITE_OP_CLIENT_ID;
const createOpInstance = () => { export const op = new OpenPanel({
if (!clientId || clientId === 'undefined') { clientId,
return new Proxy({} as OpenPanel, { disabled: clientId === 'undefined' || !clientId,
get: () => () => {}, // apiUrl: 'http://localhost:3333',
}); trackScreenViews: true,
} trackOutgoingLinks: true,
trackAttributes: true,
return new OpenPanel({ // sessionReplay: {
clientId, // enabled: true,
trackScreenViews: true, // }
trackOutgoingLinks: true, });
trackAttributes: true,
});
};
export const op = createOpInstance();

View File

@@ -63,6 +63,11 @@ export async function bootCron() {
type: 'flushProfileBackfill', type: 'flushProfileBackfill',
pattern: 1000 * 30, pattern: 1000 * 30,
}, },
{
name: 'flush',
type: 'flushReplay',
pattern: 1000 * 10,
},
{ {
name: 'insightsDaily', name: 'insightsDaily',
type: 'insightsDaily', type: 'insightsDaily',

View File

@@ -1,6 +1,6 @@
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { eventBuffer, profileBackfillBuffer, profileBuffer, sessionBuffer } from '@openpanel/db'; import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue'; import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects'; import { jobdeleteProjects } from './cron.delete-projects';
@@ -26,6 +26,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'flushProfileBackfill': { case 'flushProfileBackfill': {
return await profileBackfillBuffer.tryFlush(); return await profileBackfillBuffer.tryFlush();
} }
case 'flushReplay': {
return await replayBuffer.tryFlush();
}
case 'ping': { case 'ping': {
return await ping(); return await ping();
} }

View File

@@ -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';
@@ -53,10 +52,9 @@ async function createEventAndNotify(
logger.info('Creating event', { event: payload }); logger.info('Creating event', { event: payload });
const [event] = await Promise.all([ const [event] = await Promise.all([
createEvent(payload), createEvent(payload),
checkNotificationRulesForEvent(payload).catch(() => {}), checkNotificationRulesForEvent(payload).catch(() => null),
]); ]);
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(

View File

@@ -32,6 +32,8 @@ const SESSION_TIMEOUT = 30 * 60 * 1000;
const projectId = 'test-project'; const projectId = 'test-project';
const currentDeviceId = 'device-123'; const currentDeviceId = 'device-123';
const previousDeviceId = 'device-456'; const previousDeviceId = 'device-456';
// Valid UUID used when creating a new session in tests
const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234';
const geo = { const geo = {
country: 'US', country: 'US',
city: 'New York', city: 'New York',
@@ -67,7 +69,7 @@ describe('incomingEvent', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it.only('should create a session start and an event', async () => { it('should create a session start and an event', async () => {
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add'); const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add');
const timestamp = new Date(); const timestamp = new Date();
// Mock job data // Mock job data
@@ -90,12 +92,15 @@ describe('incomingEvent', () => {
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
deviceId: currentDeviceId,
sessionId: newSessionId,
}; };
const event = { const event = {
name: 'test_event', name: 'test_event',
deviceId: currentDeviceId, deviceId: currentDeviceId,
profileId: '', profileId: '',
sessionId: expect.stringMatching( sessionId: expect.stringMatching(
// biome-ignore lint/performance/useTopLevelRegex: test
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
), ),
projectId, projectId,
@@ -182,6 +187,8 @@ describe('incomingEvent', () => {
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
deviceId: currentDeviceId,
sessionId: 'session-123',
}; };
const changeDelay = vi.fn(); const changeDelay = vi.fn();
@@ -263,6 +270,8 @@ describe('incomingEvent', () => {
projectId, projectId,
currentDeviceId: '', currentDeviceId: '',
previousDeviceId: '', previousDeviceId: '',
deviceId: '',
sessionId: '',
uaInfo: uaInfoServer, uaInfo: uaInfoServer,
}; };
@@ -367,6 +376,8 @@ describe('incomingEvent', () => {
projectId, projectId,
currentDeviceId: '', currentDeviceId: '',
previousDeviceId: '', previousDeviceId: '',
deviceId: '',
sessionId: '',
uaInfo: uaInfoServer, uaInfo: uaInfoServer,
}; };

View File

@@ -4,6 +4,7 @@ import {
botBuffer, botBuffer,
eventBuffer, eventBuffer,
profileBuffer, profileBuffer,
replayBuffer,
sessionBuffer, sessionBuffer,
} from '@openpanel/db'; } from '@openpanel/db';
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue'; import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
@@ -124,3 +125,14 @@ register.registerMetric(
}, },
}), }),
); );
register.registerMetric(
new client.Gauge({
name: `buffer_${replayBuffer.name}_count`,
help: 'Number of unprocessed replay chunks',
async collect() {
const metric = await replayBuffer.getBufferSize();
this.set(metric);
},
}),
);

View File

@@ -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

View File

@@ -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);
} }

View File

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

View File

@@ -19,12 +19,19 @@ async function migrate() {
const migration = args.filter((arg) => !arg.startsWith('--'))[0]; const 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();

View File

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

View File

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

View File

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

View File

@@ -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',
}; };
/** /**

View File

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

View File

@@ -65,8 +65,10 @@ export interface EventsQueuePayloadIncomingEvent {
latitude: number | undefined; 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 {
@@ -123,12 +125,17 @@ export type CronQueuePayloadFlushProfileBackfill = {
type: 'flushProfileBackfill'; type: 'flushProfileBackfill';
payload: undefined; payload: undefined;
}; };
export type CronQueuePayloadFlushReplay = {
type: 'flushReplay';
payload: undefined;
};
export type CronQueuePayload = export type CronQueuePayload =
| CronQueuePayloadSalt | CronQueuePayloadSalt
| CronQueuePayloadFlushEvents | CronQueuePayloadFlushEvents
| CronQueuePayloadFlushSessions | CronQueuePayloadFlushSessions
| CronQueuePayloadFlushProfiles | CronQueuePayloadFlushProfiles
| CronQueuePayloadFlushProfileBackfill | CronQueuePayloadFlushProfileBackfill
| CronQueuePayloadFlushReplay
| CronQueuePayloadPing | CronQueuePayloadPing
| CronQueuePayloadProject | CronQueuePayloadProject
| CronQueuePayloadInsightsDaily | CronQueuePayloadInsightsDaily

View File

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

View File

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

View File

@@ -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,26 @@ 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);
// Disable keepalive for replay since it has a hard body limit and breaks the request
const result = await this.api.fetch<
TrackHandlerPayload,
{ deviceId: string; sessionId: string }
>('/track', payload, { keepalive: payload.type !== 'replay' });
this.deviceId = result?.deviceId;
const hadSession = !!this.sessionId;
this.sessionId = result?.sessionId;
// Flush queued items (e.g. replay chunks) when sessionId first arrives
if (!hadSession && this.sessionId) {
this.flush();
}
return result;
} }
setGlobalProperties(properties: Record<string, unknown>) { setGlobalProperties(properties: Record<string, unknown>) {
@@ -149,7 +176,7 @@ export class OpenPanel {
async revenue( async revenue(
amount: number, amount: number,
properties?: TrackProperties & { deviceId?: string }, properties?: TrackProperties & { deviceId?: string }
) { ) {
const deviceId = properties?.deviceId; const deviceId = properties?.deviceId;
delete properties?.deviceId; delete properties?.deviceId;
@@ -160,33 +187,47 @@ export class OpenPanel {
}); });
} }
async fetchDeviceId(): Promise<string> { getDeviceId(): string {
const result = await this.api.fetch<undefined, { deviceId: string }>( return this.deviceId ?? '';
'/track/device-id', }
undefined,
{ method: 'GET', keepalive: false }, getSessionId(): string {
); return this.sessionId ?? '';
return result?.deviceId ?? ''; }
/**
* @deprecated Use `getDeviceId()` instead. This async method is no longer needed.
*/
fetchDeviceId(): Promise<string> {
return Promise.resolve(this.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[]) {

View File

@@ -10,7 +10,9 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"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:*",
@@ -18,4 +20,4 @@
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

View File

@@ -7,11 +7,43 @@ 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;
// Capture script element synchronously; currentScript is only set during sync execution.
// Used by loadReplayModule() to derive the replay script URL in the IIFE build.
const _replayScriptRef: HTMLScriptElement | null =
typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement | null)
: null;
export type OpenPanelOptions = OpenPanelBaseOptions & { 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 +98,75 @@ 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') {
const scriptEl = _replayScriptRef;
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
// Already loaded (e.g. user included the script manually)
if ((window as any).__openpanel_replay) {
return (window as any).__openpanel_replay;
}
// Load via classic <script> tag — no CORS restrictions
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
resolve((window as any).__openpanel_replay ?? null);
};
script.onerror = () => {
console.warn('[OpenPanel] Failed to load replay script from', url);
resolve(null);
};
document.head.appendChild(script);
});
}
// Library / bundler context — resolved by the bundler
return await import('./replay');
} catch (e) {
console.warn('[OpenPanel] Failed to load replay module', e);
return null;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,15 @@ export const zAliasPayload = z.object({
alias: z.string().min(1), alias: z.string().min(1),
}); });
export const zReplayPayload = z.object({
chunk_index: z.number().int().min(0).max(65_535),
events_count: z.number().int().min(1),
is_full_snapshot: z.boolean(),
started_at: z.string().datetime(),
ended_at: z.string().datetime(),
payload: z.string().max(1_048_576 * 2), // 2MB max
});
export const zTrackHandlerPayload = z.discriminatedUnion('type', [ 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

137
pnpm-lock.yaml generated
View File

@@ -465,6 +465,9 @@ importers:
'@openpanel/payments': '@openpanel/payments':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/payments version: link:../../packages/payments
'@openpanel/sdk':
specifier: ^1.0.8
version: 1.0.8
'@openpanel/sdk-info': '@openpanel/sdk-info':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/sdks/_info version: link:../../packages/sdks/_info
@@ -472,8 +475,8 @@ importers:
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/validation version: link:../../packages/validation
'@openpanel/web': '@openpanel/web':
specifier: ^1.0.1 specifier: ^1.0.12
version: 1.0.1 version: 1.0.12
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.2.12 specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -774,6 +777,9 @@ importers:
remark-rehype: remark-rehype:
specifier: ^11.1.2 specifier: ^11.1.2
version: 11.1.2 version: 11.1.2
rrweb-player:
specifier: 2.0.0-alpha.20
version: 2.0.0-alpha.20
short-unique-id: short-unique-id:
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.3 version: 5.0.3
@@ -1478,7 +1484,7 @@ importers:
packages/sdks/astro: packages/sdks/astro:
dependencies: dependencies:
'@openpanel/web': '@openpanel/web':
specifier: workspace:1.0.7-local specifier: workspace:1.0.12-local
version: link:../web version: link:../web
devDependencies: devDependencies:
astro: astro:
@@ -1491,7 +1497,7 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../common version: link:../../common
'@openpanel/sdk': '@openpanel/sdk':
specifier: workspace:1.0.4-local specifier: workspace:1.0.8-local
version: link:../sdk version: link:../sdk
express: express:
specifier: ^4.17.0 || ^5.0.0 specifier: ^4.17.0 || ^5.0.0
@@ -1516,7 +1522,7 @@ importers:
packages/sdks/nextjs: packages/sdks/nextjs:
dependencies: dependencies:
'@openpanel/web': '@openpanel/web':
specifier: workspace:1.0.7-local specifier: workspace:1.0.12-local
version: link:../web version: link:../web
next: next:
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
@@ -1544,7 +1550,7 @@ importers:
packages/sdks/nuxt: packages/sdks/nuxt:
dependencies: dependencies:
'@openpanel/web': '@openpanel/web':
specifier: workspace:1.0.7-local specifier: workspace:1.0.12-local
version: link:../web version: link:../web
h3: h3:
specifier: ^1.0.0 specifier: ^1.0.0
@@ -1581,7 +1587,7 @@ importers:
packages/sdks/react-native: packages/sdks/react-native:
dependencies: dependencies:
'@openpanel/sdk': '@openpanel/sdk':
specifier: workspace:1.0.4-local specifier: workspace:1.0.8-local
version: link:../sdk version: link:../sdk
expo-application: expo-application:
specifier: 5 - 7 specifier: 5 - 7
@@ -1627,8 +1633,14 @@ importers:
packages/sdks/web: packages/sdks/web:
dependencies: dependencies:
'@openpanel/sdk': '@openpanel/sdk':
specifier: workspace:1.0.4-local specifier: workspace:1.0.8-local
version: link:../sdk version: link:../sdk
'@rrweb/types':
specifier: 2.0.0-alpha.20
version: 2.0.0-alpha.20
rrweb:
specifier: 2.0.0-alpha.20
version: 2.0.0-alpha.20
devDependencies: devDependencies:
'@openpanel/tsconfig': '@openpanel/tsconfig':
specifier: workspace:* specifier: workspace:*
@@ -5864,14 +5876,14 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@openpanel/sdk@1.0.0':
resolution: {integrity: sha512-FNmmfjdXoC/VHEjA+WkrQ4lyM5lxEmV7xDd57uj4E+lIS0sU3DLG2mV/dpS8AscnZbUvuMn3kPhiLCqYzuv/gg==}
'@openpanel/sdk@1.0.2': '@openpanel/sdk@1.0.2':
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==} resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
'@openpanel/web@1.0.1': '@openpanel/sdk@1.0.8':
resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==} resolution: {integrity: sha512-mr7HOZ/vqrJaATDFxcv3yyLjXcUXgsfboa0o0GlhiAYUh2B1Q0kgsm5qkfbtZhTqYP4BmNCWRkfRlpFp4pfpPQ==}
'@openpanel/web@1.0.12':
resolution: {integrity: sha512-39oL19HYrw4qAzlxbtFP/rfLOaciWJXCxPwL6bk+u4SUcrrOrwmjSg0CwQYyrd2p3wp1QnsCiyv2n0EtEpjQMA==}
'@openpanel/web@1.0.5': '@openpanel/web@1.0.5':
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==} resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
@@ -8705,6 +8717,18 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rrweb/packer@2.0.0-alpha.20':
resolution: {integrity: sha512-GsByg2olGZ2n3To6keFG604QAboipuXZvjYxO2wITSwARBf/sZdy6cbUEjF0RS+QnuTM5GaVXeQapNMLmpKbrA==}
'@rrweb/replay@2.0.0-alpha.20':
resolution: {integrity: sha512-VodsLb+C2bYNNVbb0U14tKLa9ctzUxYIlt9VnxPATWvfyXHLTku8BhRWptuW/iIjVjmG49LBoR1ilxw/HMiJ1w==}
'@rrweb/types@2.0.0-alpha.20':
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
'@rrweb/utils@2.0.0-alpha.20':
resolution: {integrity: sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==}
'@segment/loosely-validate-event@2.0.0': '@segment/loosely-validate-event@2.0.0':
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==} resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
@@ -9656,6 +9680,9 @@ packages:
'@tsconfig/node18@1.0.3': '@tsconfig/node18@1.0.3':
resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==} resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==}
'@tsconfig/svelte@1.0.13':
resolution: {integrity: sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==}
'@turf/boolean-point-in-polygon@6.5.0': '@turf/boolean-point-in-polygon@6.5.0':
resolution: {integrity: sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==} resolution: {integrity: sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==}
@@ -9722,6 +9749,9 @@ packages:
'@types/cors@2.8.17': '@types/cors@2.8.17':
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
'@types/css-font-loading-module@0.0.7':
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
'@types/d3-array@3.2.1': '@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -10391,6 +10421,9 @@ packages:
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
abbrev@2.0.0: abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -10782,6 +10815,10 @@ packages:
base-64@1.0.0: base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@0.0.2: base64-js@0.0.2:
resolution: {integrity: sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==} resolution: {integrity: sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -12679,6 +12716,9 @@ packages:
fetchdts@0.1.7: fetchdts@0.1.7:
resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fifo@2.4.1: fifo@2.4.1:
resolution: {integrity: sha512-XTbUCNmo54Jav0hcL6VxDuY4x1eCQH61HEF80C2Oww283pfjQ2C8avZeyq4v43sW2S2403kmzssE9j4lbF66Sg==} resolution: {integrity: sha512-XTbUCNmo54Jav0hcL6VxDuY4x1eCQH61HEF80C2Oww283pfjQ2C8avZeyq4v43sW2S2403kmzssE9j4lbF66Sg==}
@@ -16943,9 +16983,21 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
rrdom@2.0.0-alpha.20:
resolution: {integrity: sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==}
rrweb-cssom@0.8.0: rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
rrweb-player@2.0.0-alpha.20:
resolution: {integrity: sha512-3ZCv1ksUxuIOn3Vn/eWrwWs9Xy+4KVjISD+q26ZLfisZ3hZ0CPgYG3iC22pmmycIeMS2svOfvf7gPh7jExwpUA==}
rrweb-snapshot@2.0.0-alpha.20:
resolution: {integrity: sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==}
rrweb@2.0.0-alpha.20:
resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==}
run-applescript@7.1.0: run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -24961,13 +25013,15 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
'@openpanel/sdk@1.0.0': {}
'@openpanel/sdk@1.0.2': {} '@openpanel/sdk@1.0.2': {}
'@openpanel/web@1.0.1': '@openpanel/sdk@1.0.8': {}
'@openpanel/web@1.0.12':
dependencies: dependencies:
'@openpanel/sdk': 1.0.0 '@openpanel/sdk': 1.0.8
'@rrweb/types': 2.0.0-alpha.20
rrweb: 2.0.0-alpha.20
'@openpanel/web@1.0.5': '@openpanel/web@1.0.5':
dependencies: dependencies:
@@ -28030,6 +28084,20 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.52.5': '@rollup/rollup-win32-x64-msvc@4.52.5':
optional: true optional: true
'@rrweb/packer@2.0.0-alpha.20':
dependencies:
'@rrweb/types': 2.0.0-alpha.20
fflate: 0.4.8
'@rrweb/replay@2.0.0-alpha.20':
dependencies:
'@rrweb/types': 2.0.0-alpha.20
rrweb: 2.0.0-alpha.20
'@rrweb/types@2.0.0-alpha.20': {}
'@rrweb/utils@2.0.0-alpha.20': {}
'@segment/loosely-validate-event@2.0.0': '@segment/loosely-validate-event@2.0.0':
dependencies: dependencies:
component-type: 1.2.2 component-type: 1.2.2
@@ -29384,6 +29452,8 @@ snapshots:
'@tsconfig/node18@1.0.3': {} '@tsconfig/node18@1.0.3': {}
'@tsconfig/svelte@1.0.13': {}
'@turf/boolean-point-in-polygon@6.5.0': '@turf/boolean-point-in-polygon@6.5.0':
dependencies: dependencies:
'@turf/helpers': 6.5.0 '@turf/helpers': 6.5.0
@@ -29474,6 +29544,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.24 '@types/node': 20.19.24
'@types/css-font-loading-module@0.0.7': {}
'@types/d3-array@3.2.1': {} '@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6': '@types/d3-axis@3.0.6':
@@ -30364,6 +30436,8 @@ snapshots:
'@xmldom/xmldom@0.8.10': {} '@xmldom/xmldom@0.8.10': {}
'@xstate/fsm@1.6.5': {}
abbrev@2.0.0: {} abbrev@2.0.0: {}
abbrev@3.0.1: {} abbrev@3.0.1: {}
@@ -30909,6 +30983,8 @@ snapshots:
base-64@1.0.0: {} base-64@1.0.0: {}
base64-arraybuffer@1.0.2: {}
base64-js@0.0.2: {} base64-js@0.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
@@ -33466,6 +33542,8 @@ snapshots:
fetchdts@0.1.7: {} fetchdts@0.1.7: {}
fflate@0.4.8: {}
fifo@2.4.1: {} fifo@2.4.1: {}
figures@5.0.0: figures@5.0.0:
@@ -39012,8 +39090,33 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
rrdom@2.0.0-alpha.20:
dependencies:
rrweb-snapshot: 2.0.0-alpha.20
rrweb-cssom@0.8.0: {} rrweb-cssom@0.8.0: {}
rrweb-player@2.0.0-alpha.20:
dependencies:
'@rrweb/packer': 2.0.0-alpha.20
'@rrweb/replay': 2.0.0-alpha.20
'@tsconfig/svelte': 1.0.13
rrweb-snapshot@2.0.0-alpha.20:
dependencies:
postcss: 8.5.6
rrweb@2.0.0-alpha.20:
dependencies:
'@rrweb/types': 2.0.0-alpha.20
'@rrweb/utils': 2.0.0-alpha.20
'@types/css-font-loading-module': 0.0.7
'@xstate/fsm': 1.6.5
base64-arraybuffer: 1.0.2
mitt: 3.0.1
rrdom: 2.0.0-alpha.20
rrweb-snapshot: 2.0.0-alpha.20
run-applescript@7.1.0: {} run-applescript@7.1.0: {}
run-async@2.4.1: {} run-async@2.4.1: {}

41
test.ts
View File

@@ -1,41 +0,0 @@
const text =
'Now I want you to create a new comparison, we should compare OpenPanel to %s. Do a deep research of %s and then create our structured json output with your result.';
const competitors = [
// Top-tier mainstream analytics (very high popularity / broad usage)
'Google Analytics', // GA4 is still the most widely used web analytics tool worldwide :contentReference[oaicite:1]{index=1}
'Mixpanel', // Widely used for product/event analytics, large customer base and market share :contentReference[oaicite:2]{index=2}
'Amplitude', // Frequently shows up among top product analytics tools in 2025 rankings :contentReference[oaicite:3]{index=3}
// Well-established alternatives (recognized, used by many, good balance of features/privacy/hosting)
'Matomo', // Open-source, powers 1M+ websites globally — leading ethical/self-hosted alternative :contentReference[oaicite:4]{index=4}
'PostHog', // Rising in popularity as a GA4 alternative with both web & product analytics, event-based tracking, self-hostable :contentReference[oaicite:5]{index=5}
'Heap', // Known in analytics rankings among top tools, often offers flexible event & session analytics :contentReference[oaicite:6]{index=6}
// Privacy-first / open-source or self-hosted lightweight solutions (gaining traction, niche but relevant)
'Plausible', // Frequently recommended as lightweight, GDPR-friendly, privacy-aware analytics alternative :contentReference[oaicite:7]{index=7}
'Fathom Analytics', // Another privacy-centric alternative often listed among top GA-alternatives :contentReference[oaicite:8]{index=8}
'Umami', // Lightweight open-source analytics; listed among top self-hosted / privacy-aware tools in 2025 reviews :contentReference[oaicite:9]{index=9}
'Kissmetrics', // Long-time product/behaviour analytics tool, still appears in “top analytics tools” listings :contentReference[oaicite:10]{index=10}
'Hotjar', // Popular for heatmaps / session recordings / user behavior insights — often used alongside analytics for qualitative data :contentReference[oaicite:11]{index=11}
// More niche, specialized or less widely adopted (but still valid alternatives / complements)
'Simple Analytics',
'GoatCounter',
'Pirsch Analytics',
'Cabin Analytics',
'Ackee',
'FullStory',
'LogRocket',
'Adobe Analytics', // Enterprise-grade, deep integration — strong reputation but more expensive and targeted at larger orgs :contentReference[oaicite:12]{index=12},
'Countly',
'Appsflyer',
'Adjust',
'Smartlook',
'Mouseflow',
'Crazy Egg',
'Microsoft Clarity',
];
for (const competitor of competitors) {
console.log('--------------------------------');
console.log(text.replaceAll('%s', competitor));
}

View File

@@ -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')}`,
);
} }
} }
}; };