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
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.
## 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 { parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getStringHeaders, getTimestamp } from './track.controller';
import { getDeviceId } from '@/utils/ids';
export async function postEvent(
request: FastifyRequest<{
Body: DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp,
request.body,
request.body
);
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 headers = getStringHeaders(request.headers);
@@ -30,34 +29,22 @@ export async function postEvent(
}
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
const { deviceId, sessionId } = await getDeviceId({
projectId,
ip,
ua,
salts,
});
const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer
? request.body?.profileId
? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
? `${projectId}:${request.body?.profileId ?? generateId()}`
: deviceId;
const jobId = [
slug(request.body.name),
timestamp,
projectId,
currentDeviceId,
deviceId,
groupId,
]
.filter(Boolean)
@@ -74,8 +61,10 @@ export async function postEvent(
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
currentDeviceId: '',
previousDeviceId: '',
deviceId,
sessionId: sessionId ?? '',
},
groupId,
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 { 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 { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import {
type IDecrementPayload,
type IIdentifyPayload,
type IIncrementPayload,
type IReplayPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} 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']) {
return Object.entries(
@@ -28,14 +33,14 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
'openpanel-client-id',
'request-id',
],
headers,
),
headers
)
).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value ? String(value) : undefined,
}),
{},
{}
);
}
@@ -45,14 +50,15 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
| IIdentifyPayload
| undefined;
return (
identity ||
(body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined)
);
if (identity) {
return identity;
}
return body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined;
}
return undefined;
@@ -60,7 +66,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload'],
payload: ITrackHandlerPayload['payload']
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -104,8 +110,8 @@ interface TrackContext {
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
currentDeviceId?: string;
previousDeviceId?: string;
deviceId: string;
sessionId: string;
geo: GeoLocation;
}
@@ -113,7 +119,7 @@ async function buildContext(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload,
validatedBody: ITrackHandlerPayload
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) {
@@ -128,49 +134,27 @@ async function buildContext(
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody);
const profileId = identity?.profileId;
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
}
// Get geo location (needed for track and identify)
const geo = await getGeoLocation(ip);
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
// Generate device IDs if needed (for track)
let currentDeviceId: string | undefined;
let previousDeviceId: string | undefined;
if (validatedBody.type === 'track') {
const overrideDeviceId =
typeof validatedBody.payload.properties?.__deviceId === 'string'
? validatedBody.payload.properties.__deviceId
: undefined;
const salts = await getSalts();
currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
}
const { deviceId, sessionId } = await getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId:
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined,
});
return {
projectId,
@@ -182,46 +166,35 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
currentDeviceId,
previousDeviceId,
deviceId,
sessionId,
geo,
};
}
async function handleTrack(
payload: ITrackPayload,
context: TrackContext,
context: TrackContext
): Promise<void> {
const {
projectId,
currentDeviceId,
previousDeviceId,
geo,
headers,
timestamp,
} = context;
if (!currentDeviceId || !previousDeviceId) {
throw new HttpError('Device ID generation failed', { status: 500 });
}
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
: deviceId;
const jobId = [
slug(payload.name),
timestamp.value,
projectId,
currentDeviceId,
deviceId,
groupId,
]
.filter(Boolean)
.join('-');
const promises = [];
const promises: Promise<unknown>[] = [];
// 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
@@ -242,12 +215,14 @@ async function handleTrack(
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
deviceId,
sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
},
groupId,
jobId,
}),
})
);
await Promise.all(promises);
@@ -255,7 +230,7 @@ async function handleTrack(
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext,
context: TrackContext
): Promise<void> {
const { projectId, geo, ua } = context;
const uaInfo = parseUserAgent(ua, payload.properties);
@@ -285,7 +260,7 @@ async function handleIdentify(
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1,
direction: 1 | -1
): Promise<void> {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
@@ -295,7 +270,7 @@ async function adjustProfileProperty(
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
@@ -305,7 +280,7 @@ async function adjustProfileProperty(
profile.properties = assocPath(
property.split('.'),
parsed + direction * (value || 1),
profile.properties,
profile.properties
);
await upsertProfile({
@@ -318,23 +293,44 @@ async function adjustProfileProperty(
async function handleIncrement(
payload: IIncrementPayload,
context: TrackContext,
context: TrackContext
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1);
}
async function handleDecrement(
payload: IDecrementPayload,
context: TrackContext,
context: TrackContext
): Promise<void> {
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(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
@@ -375,6 +371,9 @@ export async function handler(
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
@@ -383,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(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const salts = await getSalts();
const projectId = request.client?.projectId;
@@ -421,20 +423,31 @@ export async function fetchDeviceId(
try {
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId,
message: 'current session exists for this device id',
});
}
if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: previousDeviceId,
sessionId,
message: 'previous session exists for this device id',
});
}
@@ -444,6 +457,7 @@ export async function fetchDeviceId(
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId: '',
message: 'No session exists for this device id',
});
}

View File

@@ -1,20 +1,21 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isDuplicatedEvent } from '@/utils/deduplicate';
export async function duplicateHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const ip = req.clientIp;
const origin = req.headers.origin;
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
? await isDuplicatedEvent({

View File

@@ -1,4 +1,3 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS'];
const getTrpcInput = (
request: FastifyRequest,
request: FastifyRequest
): Record<string, unknown> | undefined => {
const input = path<any>(['query', 'input'], request);
try {
@@ -18,7 +17,7 @@ const getTrpcInput = (
export async function requestLoggingHook(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
if (ignoreMethods.includes(request.method)) {
return;
@@ -40,9 +39,8 @@ export async function requestLoggingHook(
elapsed: reply.elapsedTime,
headers: pick(
['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 cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import {
decodeSessionToken,
EMPTY_SESSION,
type SessionValidationResult,
validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
@@ -17,13 +17,11 @@ import {
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
decodeSessionToken,
validateSessionToken,
} from '@openpanel/auth';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
@@ -72,7 +70,7 @@ const startServer = async () => {
try {
const fastify = Fastify({
maxParamLength: 15_000,
bodyLimit: 1048576 * 500, // 500MB
bodyLimit: 1_048_576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true,
genReqId: (req) =>
@@ -84,7 +82,7 @@ const startServer = async () => {
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void,
callback: (error: Error | null, options: FastifyCorsOptions) => void
) => {
// TODO: set prefix on dashboard routes
const corsPaths = [
@@ -97,7 +95,7 @@ const startServer = async () => {
];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
req.url.startsWith(path)
);
if (isPrivatePath) {
@@ -118,6 +116,7 @@ const startServer = async () => {
return callback(null, {
origin: '*',
maxAge: 86_400 * 7, // cache preflight for 7 days
});
};
});
@@ -149,7 +148,7 @@ const startServer = async () => {
try {
const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
validateSessionToken(req.cookies.session)
);
req.session = session;
} catch (e) {
@@ -158,7 +157,7 @@ const startServer = async () => {
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null),
validateSessionToken(null)
);
req.session = session;
} catch (e) {
@@ -173,7 +172,7 @@ const startServer = async () => {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext: createContext,
createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
@@ -217,7 +216,7 @@ const startServer = async () => {
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
})
);
});
@@ -274,7 +273,7 @@ const startServer = async () => {
} catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error);
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.');
}

View File

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

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={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
fetch('https://domain.com/api/checkout', {
@@ -42,7 +42,7 @@ fetch('https://domain.com/api/checkout', {
'Content-Type': 'application/json',
},
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
}),
})
@@ -360,5 +360,5 @@ op.clearRevenue(): void
### Fetch your current users device id
```javascript
op.fetchDeviceId(): Promise<string>
op.getDeviceId(): string
```

View File

@@ -54,7 +54,8 @@ import { OpenPanelComponent } from '@openpanel/astro';
##### Astro options
- `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)
- `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
- `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)
- `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();
```
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
<OpenPanelComponent
apiUrl="/api/op" // [!code highlight]
cdnUrl="/api/op/op1.js" // [!code highlight]
scriptUrl="/api/op/op1.js" // [!code highlight]
clientId="your-client-id"
trackScreenViews={true}
/>

View File

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

View File

@@ -225,7 +225,7 @@ Then update your OpenPanelComponent to use the proxy endpoint.
```tsx
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
scriptUrl="/api/op/op1.js"
clientId="your-client-id"
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 type { Metadata, Viewport } from 'next';
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 { OpenPanelComponent } from '@openpanel/nextjs';
@@ -31,22 +31,20 @@ export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={cn(font.className, mono.variable)}
lang="en"
suppressHydrationWarning
>
<body className="flex flex-col min-h-screen bg-background">
<body className="flex min-h-screen flex-col bg-background">
<RootProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
trackAttributes
trackScreenViews
trackOutgoingLinks
trackScreenViews
/>
)}
</body>

View File

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

View File

@@ -89,7 +89,7 @@ export function useColumns() {
projectId: row.original.projectId,
});
}}
className="font-medium"
className="font-medium hover:underline"
>
{renderName()}
</button>
@@ -144,10 +144,21 @@ export function useColumns() {
{
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
size: 100,
meta: {
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',

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 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 {
const seconds = milliseconds / 1000;
@@ -44,13 +43,25 @@ export function useColumns() {
cell: ({ row }) => {
const session = row.original;
return (
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
<div className="row items-center gap-2">
<ProjectLink
className="font-medium"
href={`/sessions/${session.id}`}
title={session.id}
>
{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) {
return (
<ProjectLink
className="row items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium row gap-2 items-center"
>
<ProfileAvatar size="sm" {...session.profile} />
{getProfileName(session.profile)}
@@ -73,8 +84,8 @@ export function useColumns() {
}
return (
<ProjectLink
className="font-medium font-mono"
href={`/profiles/${encodeURIComponent(session.profileId)}`}
className="font-mono font-medium"
>
{session.profileId}
</ProjectLink>

View File

@@ -48,7 +48,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.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}
>
{children}

View File

@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
export function usePageTabs(tabs: { id: string; label: string }[]) {
const location = useLocation();
const tab = location.pathname.split('/').pop();
const segments = location.pathname.split('/').filter(Boolean);
const tab = segments[segments.length - 1];
if (!tab) {
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 { useSuspenseQuery } from '@tanstack/react-query';
import { FilterIcon, XIcon } from 'lucide-react';
@@ -24,6 +5,24 @@ import { omit } from 'ramda';
import { Suspense, useState } from 'react';
import { popModal } from '.';
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 {
id: string;
@@ -55,7 +54,7 @@ const filterable: Partial<Record<keyof IServiceEvent, keyof IClickhouseEvent>> =
export default function EventDetails(props: Props) {
return (
<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 />}>
<EventDetailsContent {...props} />
</Suspense>
@@ -84,7 +83,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
id,
projectId,
createdAt,
}),
})
);
const { event, session } = query.data;
@@ -158,7 +157,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
event,
});
}
},
}
);
}
@@ -209,7 +208,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
>
<ArrowRightIcon className="size-4" />
</Button> */}
<Button size="icon" variant={'ghost'} onClick={() => popModal()}>
<Button onClick={() => popModal()} size="icon" variant={'ghost'}>
<XIcon className="size-4" />
</Button>
</div>
@@ -218,10 +217,10 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<WidgetButtons>
{Object.entries(TABS).map(([, tab]) => (
<button
key={tab.id}
type="button"
onClick={() => setWidget(tab)}
className={cn(tab.id === widget.id && 'active')}
key={tab.id}
onClick={() => setWidget(tab)}
type="button"
>
{tab.title}
</button>
@@ -231,29 +230,29 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<WidgetBody className="col gap-4 bg-def-100">
{profile && (
<ProjectLink
onClick={() => popModal()}
className="card col gap-2 p-4 py-2 hover:bg-def-100"
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 gap-2 min-w-0">
<div className="row items-center justify-between gap-2">
<div className="row min-w-0 items-center gap-2">
{profile.avatar && (
<img
className="size-4 bg-border rounded-full"
className="size-4 rounded-full bg-border"
src={profile.avatar}
/>
)}
<div className="font-medium truncate">
<div className="truncate font-medium">
{getProfileName(profile, false)}
</div>
</div>
<div className="row items-center gap-2 shrink-0">
<div className="row gap-1 items-center">
<div className="row shrink-0 items-center gap-2">
<div className="row items-center gap-1">
<SerieIcon name={event.country} />
<SerieIcon name={event.os} />
<SerieIcon name={event.browser} />
</div>
<div className="text-muted-foreground truncate max-w-40">
<div className="max-w-40 truncate text-muted-foreground">
{event.referrerName || event.referrer}
</div>
</div>
@@ -276,16 +275,16 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<KeyValueGrid
columns={1}
data={properties}
onItemClick={(item) => {
popModal();
setFilter(`properties.${item.name}`, item.value as any);
}}
renderValue={(item) => (
<div className="flex items-center gap-2">
<span className="font-mono">{String(item.value)}</span>
<FilterIcon className="size-3 shrink-0" />
</div>
)}
onItemClick={(item) => {
popModal();
setFilter(`properties.${item.name}`, item.value as any);
}}
/>
</section>
)}
@@ -296,25 +295,6 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
<KeyValueGrid
columns={1}
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) => {
const isFilterable = item.value && (filterable as any)[item.name];
if (isFilterable) {
@@ -322,26 +302,45 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
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>
<div className="mb-2 flex justify-between font-medium">
<div>All events for {event.name}</div>
<button
type="button"
className="text-muted-foreground hover:underline"
onClick={() => {
setEvents([event.name]);
popModal();
}}
type="button"
>
Show all
</button>
</div>
<div className="card p-4">
<ReportChartShortcut
projectId={event.projectId}
chartType="linear"
projectId={event.projectId}
series={[
{
id: 'A',
@@ -365,52 +364,52 @@ function EventDetailsSkeleton() {
<>
<WidgetHead>
<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="h-8 w-8 bg-muted animate-pulse rounded" />
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
<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 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
</div>
</div>
<WidgetButtons>
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
<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 animate-pulse rounded bg-muted" />
</WidgetButtons>
</WidgetHead>
<WidgetBody className="col gap-4 bg-def-100">
{/* Profile skeleton */}
<div className="card p-4 py-2 col gap-2">
<div className="row items-center gap-2 justify-between">
<div className="row items-center gap-2 min-w-0">
<div className="size-4 bg-muted animate-pulse rounded-full" />
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="card col gap-2 p-4 py-2">
<div className="row items-center justify-between gap-2">
<div className="row min-w-0 items-center gap-2">
<div className="size-4 animate-pulse rounded-full bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
<div className="row items-center gap-2 shrink-0">
<div className="row gap-1 items-center">
<div className="size-4 bg-muted animate-pulse rounded" />
<div className="size-4 bg-muted animate-pulse rounded" />
<div className="size-4 bg-muted animate-pulse rounded" />
<div className="row shrink-0 items-center gap-2">
<div className="row items-center gap-1">
<div className="size-4 animate-pulse rounded bg-muted" />
<div className="size-4 animate-pulse rounded bg-muted" />
<div className="size-4 animate-pulse rounded bg-muted" />
</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 className="h-4 w-64 bg-muted animate-pulse rounded" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
{/* Properties skeleton */}
<section>
<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 className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
className="flex items-center justify-between rounded bg-muted/50 p-3"
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-32 bg-muted animate-pulse rounded" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
))}
</div>
@@ -419,16 +418,16 @@ function EventDetailsSkeleton() {
{/* Information skeleton */}
<section>
<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 className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
className="flex items-center justify-between rounded bg-muted/50 p-3"
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-28 bg-muted animate-pulse rounded" />
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
</div>
))}
</div>
@@ -437,11 +436,11 @@ function EventDetailsSkeleton() {
{/* Chart skeleton */}
<section>
<div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-40 bg-muted animate-pulse rounded" />
<div className="h-4 w-16 bg-muted animate-pulse rounded" />
<div className="h-5 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
</div>
<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>
</section>
</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 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 AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute(
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
id: '/events',
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRoutesByTo {
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -778,6 +787,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_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
}
export interface FileRouteTypes {
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
id:
| '__root__'
| '/'
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById
}
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
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': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
path: '/events'
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
{
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
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',
},
{ id: 'events', label: 'Events' },
{ id: 'sessions', label: 'Sessions' },
]);
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 { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
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 {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
Widget,
WidgetBody,
WidgetHead,
WidgetTitle,
} from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
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(
'/_app/$organizationId/$projectId/sessions_/$sessionId',
'/_app/$organizationId/$projectId/sessions_/$sessionId'
)({
component: Component,
loader: async ({ context, params }) => {
@@ -24,66 +31,148 @@ export const Route = createFileRoute(
context.trpc.session.byId.queryOptions({
sessionId: params.sessionId,
projectId: params.projectId,
}),
})
),
context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
projectId: params.projectId,
sessionId: params.sessionId,
filters: [],
columnVisibility: {},
})
),
]);
},
head: () => {
return {
meta: [
{
title: createProjectTitle('Sessions'),
},
],
};
},
head: () => ({
meta: [{ title: createProjectTitle('Session') }],
}),
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId, sessionId } = Route.useParams();
const trpc = useTRPC();
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
return {
...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(
trpc.session.byId.queryOptions({
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,
},
),
);
if (sorted.length === 0) {
return null;
}
return (
<PageContainer>
<PageHeader
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
>
<div className="row gap-4 mb-6">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Visited pages</WidgetTitle>
</WidgetHead>
<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 && (
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<SerieIcon name={session.country} />
<span>
{session.country}
@@ -92,32 +181,195 @@ function Component() {
</div>
)}
{session.device && (
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span>
</div>
)}
{session.os && (
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<SerieIcon name={session.os} />
<span>{session.os}</span>
</div>
)}
{session.model && (
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<SerieIcon name={session.model} />
<span>{session.model}</span>
</div>
)}
{session.browser && (
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<SerieIcon name={session.browser} />
<span>{session.browser}</span>
</div>
)}
</div>
</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>
);
}

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 createOpInstance = () => {
if (!clientId || clientId === 'undefined') {
return new Proxy({} as OpenPanel, {
get: () => () => {},
});
}
return new OpenPanel({
clientId,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
};
export const op = createOpInstance();
export const op = new OpenPanel({
clientId,
disabled: clientId === 'undefined' || !clientId,
// apiUrl: 'http://localhost:3333',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
// sessionReplay: {
// enabled: true,
// }
});

View File

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

View File

@@ -1,6 +1,6 @@
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 { jobdeleteProjects } from './cron.delete-projects';
@@ -26,6 +26,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'flushProfileBackfill': {
return await profileBackfillBuffer.tryFlush();
}
case 'flushReplay': {
return await replayBuffer.tryFlush();
}
case 'ping': {
return await ping();
}

View File

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

View File

@@ -32,6 +32,8 @@ const SESSION_TIMEOUT = 30 * 60 * 1000;
const projectId = 'test-project';
const currentDeviceId = 'device-123';
const previousDeviceId = 'device-456';
// Valid UUID used when creating a new session in tests
const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234';
const geo = {
country: 'US',
city: 'New York',
@@ -67,7 +69,7 @@ describe('incomingEvent', () => {
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 timestamp = new Date();
// Mock job data
@@ -90,12 +92,15 @@ describe('incomingEvent', () => {
projectId,
currentDeviceId,
previousDeviceId,
deviceId: currentDeviceId,
sessionId: newSessionId,
};
const event = {
name: 'test_event',
deviceId: currentDeviceId,
profileId: '',
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
),
projectId,
@@ -182,6 +187,8 @@ describe('incomingEvent', () => {
projectId,
currentDeviceId,
previousDeviceId,
deviceId: currentDeviceId,
sessionId: 'session-123',
};
const changeDelay = vi.fn();
@@ -263,6 +270,8 @@ describe('incomingEvent', () => {
projectId,
currentDeviceId: '',
previousDeviceId: '',
deviceId: '',
sessionId: '',
uaInfo: uaInfoServer,
};
@@ -367,6 +376,8 @@ describe('incomingEvent', () => {
projectId,
currentDeviceId: '',
previousDeviceId: '',
deviceId: '',
sessionId: '',
uaInfo: uaInfoServer,
};

View File

@@ -4,6 +4,7 @@ import {
botBuffer,
eventBuffer,
profileBuffer,
replayBuffer,
sessionBuffer,
} from '@openpanel/db';
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,
currentDeviceId,
previousDeviceId,
deviceId,
profileId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string;
profileId: string;
}) {
const sessionEnd = await getSessionEndJob({
projectId,
currentDeviceId,
previousDeviceId,
deviceId,
});
if (sessionEnd) {
@@ -81,6 +84,7 @@ export async function getSessionEndJob(args: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
deviceId: string;
retryCount?: number;
}): Promise<{
deviceId: string;
@@ -130,20 +134,31 @@ export async function getSessionEndJob(args: {
return null;
}
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.currentDeviceId),
);
if (currentJob) {
return await handleJobStates(currentJob, args.currentDeviceId);
// TODO: Remove this when migrated to deviceId
if (args.currentDeviceId && args.previousDeviceId) {
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.currentDeviceId),
);
if (currentJob) {
return await handleJobStates(currentJob, args.currentDeviceId);
}
// Check previous device job
const previousJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.previousDeviceId),
);
if (previousJob) {
return await handleJobStates(previousJob, args.previousDeviceId);
}
}
// Check previous device job
const previousJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.previousDeviceId),
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.deviceId),
);
if (previousJob) {
return await handleJobStates(previousJob, args.previousDeviceId);
if (currentJob) {
return await handleJobStates(currentJob, args.deviceId);
}
// Create session

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,43 @@ import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
export type * from '@openpanel/sdk';
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
export type SessionReplayOptions = {
enabled: boolean;
sampleRate?: number;
maskAllInputs?: boolean;
maskTextSelector?: string;
blockSelector?: string;
blockClass?: string;
ignoreSelector?: string;
flushIntervalMs?: number;
maxEventsPerChunk?: number;
maxPayloadBytes?: number;
/**
* URL to the replay recorder script.
* Only used when loading the SDK via a script tag (IIFE / op1.js).
* When using the npm package with a bundler this option is ignored
* because the bundler resolves the replay module from the package.
*/
scriptUrl?: string;
};
// Injected at build time only in the IIFE (tracker) build.
// In the library build this is `undefined`.
declare const __OPENPANEL_REPLAY_URL__: string | undefined;
// Capture script element synchronously; currentScript is only set during sync execution.
// Used by loadReplayModule() to derive the replay script URL in the IIFE build.
const _replayScriptRef: HTMLScriptElement | null =
typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement | null)
: null;
export type OpenPanelOptions = OpenPanelBaseOptions & {
trackOutgoingLinks?: boolean;
trackScreenViews?: boolean;
trackAttributes?: boolean;
trackHashChanges?: boolean;
sessionReplay?: SessionReplayOptions;
};
function toCamelCase(str: string) {
@@ -66,6 +98,75 @@ export class OpenPanel extends OpenPanelBase {
if (this.options.trackAttributes) {
this.trackAttributes();
}
if (this.options.sessionReplay?.enabled) {
const sampleRate = this.options.sessionReplay.sampleRate ?? 1;
const sampled = Math.random() < sampleRate;
if (sampled) {
this.loadReplayModule().then((mod) => {
if (!mod) return;
mod.startReplayRecorder(this.options.sessionReplay!, (chunk) => {
this.send({
type: 'replay',
payload: {
...chunk,
},
});
});
});
}
}
}
}
/**
* Load the replay recorder module.
*
* - **IIFE build (op1.js)**: `__OPENPANEL_REPLAY_URL__` is replaced at
* build time with a CDN URL (e.g. `https://openpanel.dev/op1-replay.js`).
* The user can also override it via `sessionReplay.scriptUrl`.
* We load the IIFE replay script via a classic `<script>` tag which
* avoids CORS issues (dynamic `import(url)` uses `cors` mode).
* The IIFE exposes its exports on `window.__openpanel_replay`.
*
* - **Library build (npm)**: `__OPENPANEL_REPLAY_URL__` is `undefined`
* (never replaced). We use `import('./replay')` which the host app's
* bundler resolves and code-splits from the package source.
*/
private async loadReplayModule(): Promise<typeof import('./replay') | null> {
try {
// typeof check avoids a ReferenceError when the constant is not
// defined (library build). tsup replaces the constant with a
// string literal only in the IIFE build, so this branch is
// dead-code-eliminated in the library build.
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
const scriptEl = _replayScriptRef;
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
// Already loaded (e.g. user included the script manually)
if ((window as any).__openpanel_replay) {
return (window as any).__openpanel_replay;
}
// Load via classic <script> tag — no CORS restrictions
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
resolve((window as any).__openpanel_replay ?? null);
};
script.onerror = () => {
console.warn('[OpenPanel] Failed to load replay script from', url);
resolve(null);
};
document.head.appendChild(script);
});
}
// Library / bundler context — resolved by the bundler
return await import('./replay');
} catch (e) {
console.warn('[OpenPanel] Failed to load replay module', e);
return null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

137
pnpm-lock.yaml generated
View File

@@ -465,6 +465,9 @@ importers:
'@openpanel/payments':
specifier: workspace:*
version: link:../../packages/payments
'@openpanel/sdk':
specifier: ^1.0.8
version: 1.0.8
'@openpanel/sdk-info':
specifier: workspace:^
version: link:../../packages/sdks/_info
@@ -472,8 +475,8 @@ importers:
specifier: workspace:^
version: link:../../packages/validation
'@openpanel/web':
specifier: ^1.0.1
version: 1.0.1
specifier: ^1.0.12
version: 1.0.12
'@radix-ui/react-accordion':
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)
@@ -774,6 +777,9 @@ importers:
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
rrweb-player:
specifier: 2.0.0-alpha.20
version: 2.0.0-alpha.20
short-unique-id:
specifier: ^5.0.3
version: 5.0.3
@@ -1478,7 +1484,7 @@ importers:
packages/sdks/astro:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
devDependencies:
astro:
@@ -1491,7 +1497,7 @@ importers:
specifier: workspace:*
version: link:../../common
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
version: link:../sdk
express:
specifier: ^4.17.0 || ^5.0.0
@@ -1516,7 +1522,7 @@ importers:
packages/sdks/nextjs:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
next:
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
@@ -1544,7 +1550,7 @@ importers:
packages/sdks/nuxt:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
h3:
specifier: ^1.0.0
@@ -1581,7 +1587,7 @@ importers:
packages/sdks/react-native:
dependencies:
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
version: link:../sdk
expo-application:
specifier: 5 - 7
@@ -1627,8 +1633,14 @@ importers:
packages/sdks/web:
dependencies:
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
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:
'@openpanel/tsconfig':
specifier: workspace:*
@@ -5864,14 +5876,14 @@ packages:
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
'@openpanel/sdk@1.0.0':
resolution: {integrity: sha512-FNmmfjdXoC/VHEjA+WkrQ4lyM5lxEmV7xDd57uj4E+lIS0sU3DLG2mV/dpS8AscnZbUvuMn3kPhiLCqYzuv/gg==}
'@openpanel/sdk@1.0.2':
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
'@openpanel/web@1.0.1':
resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==}
'@openpanel/sdk@1.0.8':
resolution: {integrity: sha512-mr7HOZ/vqrJaATDFxcv3yyLjXcUXgsfboa0o0GlhiAYUh2B1Q0kgsm5qkfbtZhTqYP4BmNCWRkfRlpFp4pfpPQ==}
'@openpanel/web@1.0.12':
resolution: {integrity: sha512-39oL19HYrw4qAzlxbtFP/rfLOaciWJXCxPwL6bk+u4SUcrrOrwmjSg0CwQYyrd2p3wp1QnsCiyv2n0EtEpjQMA==}
'@openpanel/web@1.0.5':
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
@@ -8705,6 +8717,18 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
@@ -9656,6 +9680,9 @@ packages:
'@tsconfig/node18@1.0.3':
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':
resolution: {integrity: sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==}
@@ -9722,6 +9749,9 @@ packages:
'@types/cors@2.8.17':
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':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -10391,6 +10421,9 @@ packages:
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
engines: {node: '>=10.0.0'}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -10782,6 +10815,10 @@ packages:
base-64@1.0.0:
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:
resolution: {integrity: sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==}
engines: {node: '>= 0.4'}
@@ -12679,6 +12716,9 @@ packages:
fetchdts@0.1.7:
resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fifo@2.4.1:
resolution: {integrity: sha512-XTbUCNmo54Jav0hcL6VxDuY4x1eCQH61HEF80C2Oww283pfjQ2C8avZeyq4v43sW2S2403kmzssE9j4lbF66Sg==}
@@ -16943,9 +16983,21 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rrdom@2.0.0-alpha.20:
resolution: {integrity: sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==}
rrweb-cssom@0.8.0:
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:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
@@ -24961,13 +25013,15 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@openpanel/sdk@1.0.0': {}
'@openpanel/sdk@1.0.2': {}
'@openpanel/web@1.0.1':
'@openpanel/sdk@1.0.8': {}
'@openpanel/web@1.0.12':
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':
dependencies:
@@ -28030,6 +28084,20 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.52.5':
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':
dependencies:
component-type: 1.2.2
@@ -29384,6 +29452,8 @@ snapshots:
'@tsconfig/node18@1.0.3': {}
'@tsconfig/svelte@1.0.13': {}
'@turf/boolean-point-in-polygon@6.5.0':
dependencies:
'@turf/helpers': 6.5.0
@@ -29474,6 +29544,8 @@ snapshots:
dependencies:
'@types/node': 20.19.24
'@types/css-font-loading-module@0.0.7': {}
'@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6':
@@ -30364,6 +30436,8 @@ snapshots:
'@xmldom/xmldom@0.8.10': {}
'@xstate/fsm@1.6.5': {}
abbrev@2.0.0: {}
abbrev@3.0.1: {}
@@ -30909,6 +30983,8 @@ snapshots:
base-64@1.0.0: {}
base64-arraybuffer@1.0.2: {}
base64-js@0.0.2: {}
base64-js@1.5.1: {}
@@ -33466,6 +33542,8 @@ snapshots:
fetchdts@0.1.7: {}
fflate@0.4.8: {}
fifo@2.4.1: {}
figures@5.0.0:
@@ -39012,8 +39090,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrdom@2.0.0-alpha.20:
dependencies:
rrweb-snapshot: 2.0.0-alpha.20
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-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(
`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')}`,
);
}
}
};