feat: session replay
* wip * wip * wip * wip * final fixes * comments * fix
This commit is contained in:
committed by
GitHub
parent
38d9b65ec8
commit
aa81bbfe77
@@ -1,5 +1,7 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
|
||||||
import { getSalts } from '@openpanel/db';
|
|
||||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
|
||||||
|
|
||||||
import { generateId, slug } from '@openpanel/common';
|
import { generateId, slug } from '@openpanel/common';
|
||||||
|
import { parseUserAgent } from '@openpanel/common/server';
|
||||||
|
import { getSalts } from '@openpanel/db';
|
||||||
import { getGeoLocation } from '@openpanel/geo';
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||||
|
import { getDeviceId } from '@/utils/ids';
|
||||||
|
|
||||||
export async function postEvent(
|
export async function postEvent(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: DeprecatedPostEventPayload;
|
Body: DeprecatedPostEventPayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { timestamp, isTimestampFromThePast } = getTimestamp(
|
const { timestamp, isTimestampFromThePast } = getTimestamp(
|
||||||
request.timestamp,
|
request.timestamp,
|
||||||
request.body,
|
request.body
|
||||||
);
|
);
|
||||||
const ip = request.clientIp;
|
const ip = request.clientIp;
|
||||||
const ua = request.headers['user-agent'];
|
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
const headers = getStringHeaders(request.headers);
|
const headers = getStringHeaders(request.headers);
|
||||||
|
|
||||||
@@ -30,34 +29,22 @@ export async function postEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||||
const currentDeviceId = ua
|
const { deviceId, sessionId } = await getDeviceId({
|
||||||
? generateDeviceId({
|
projectId,
|
||||||
salt: salts.current,
|
ip,
|
||||||
origin: projectId,
|
ua,
|
||||||
ip,
|
salts,
|
||||||
ua,
|
});
|
||||||
})
|
|
||||||
: '';
|
|
||||||
const previousDeviceId = ua
|
|
||||||
? generateDeviceId({
|
|
||||||
salt: salts.previous,
|
|
||||||
origin: projectId,
|
|
||||||
ip,
|
|
||||||
ua,
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||||
const groupId = uaInfo.isServer
|
const groupId = uaInfo.isServer
|
||||||
? request.body?.profileId
|
? `${projectId}:${request.body?.profileId ?? generateId()}`
|
||||||
? `${projectId}:${request.body?.profileId}`
|
: deviceId;
|
||||||
: `${projectId}:${generateId()}`
|
|
||||||
: currentDeviceId;
|
|
||||||
const jobId = [
|
const jobId = [
|
||||||
slug(request.body.name),
|
slug(request.body.name),
|
||||||
timestamp,
|
timestamp,
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
groupId,
|
groupId,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -74,8 +61,10 @@ export async function postEvent(
|
|||||||
},
|
},
|
||||||
uaInfo,
|
uaInfo,
|
||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId: '',
|
||||||
previousDeviceId,
|
previousDeviceId: '',
|
||||||
|
deviceId,
|
||||||
|
sessionId: sessionId ?? '',
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import { assocPath, pathOr, pick } from 'ramda';
|
|
||||||
|
|
||||||
import { HttpError } from '@/utils/errors';
|
|
||||||
import { generateId, slug } from '@openpanel/common';
|
import { generateId, slug } from '@openpanel/common';
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
import {
|
||||||
|
getProfileById,
|
||||||
|
getSalts,
|
||||||
|
replayBuffer,
|
||||||
|
upsertProfile,
|
||||||
|
} from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import { getRedisCache } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IDecrementPayload,
|
type IDecrementPayload,
|
||||||
type IIdentifyPayload,
|
type IIdentifyPayload,
|
||||||
type IIncrementPayload,
|
type IIncrementPayload,
|
||||||
|
type IReplayPayload,
|
||||||
type ITrackHandlerPayload,
|
type ITrackHandlerPayload,
|
||||||
type ITrackPayload,
|
type ITrackPayload,
|
||||||
zTrackHandlerPayload,
|
zTrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { assocPath, pathOr, pick } from 'ramda';
|
||||||
|
import { HttpError } from '@/utils/errors';
|
||||||
|
import { getDeviceId } from '@/utils/ids';
|
||||||
|
|
||||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||||
return Object.entries(
|
return Object.entries(
|
||||||
@@ -28,14 +33,14 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
|||||||
'openpanel-client-id',
|
'openpanel-client-id',
|
||||||
'request-id',
|
'request-id',
|
||||||
],
|
],
|
||||||
headers,
|
headers
|
||||||
),
|
)
|
||||||
).reduce(
|
).reduce(
|
||||||
(acc, [key, value]) => ({
|
(acc, [key, value]) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[key]: value ? String(value) : undefined,
|
[key]: value ? String(value) : undefined,
|
||||||
}),
|
}),
|
||||||
{},
|
{}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +50,15 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
|||||||
| IIdentifyPayload
|
| IIdentifyPayload
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
return (
|
if (identity) {
|
||||||
identity ||
|
return identity;
|
||||||
(body.payload.profileId
|
}
|
||||||
? {
|
|
||||||
profileId: String(body.payload.profileId),
|
return body.payload.profileId
|
||||||
}
|
? {
|
||||||
: undefined)
|
profileId: String(body.payload.profileId),
|
||||||
);
|
}
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -60,7 +66,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
|||||||
|
|
||||||
export function getTimestamp(
|
export function getTimestamp(
|
||||||
timestamp: FastifyRequest['timestamp'],
|
timestamp: FastifyRequest['timestamp'],
|
||||||
payload: ITrackHandlerPayload['payload'],
|
payload: ITrackHandlerPayload['payload']
|
||||||
) {
|
) {
|
||||||
const safeTimestamp = timestamp || Date.now();
|
const safeTimestamp = timestamp || Date.now();
|
||||||
const userDefinedTimestamp =
|
const userDefinedTimestamp =
|
||||||
@@ -104,8 +110,8 @@ interface TrackContext {
|
|||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
timestamp: { value: number; isFromPast: boolean };
|
timestamp: { value: number; isFromPast: boolean };
|
||||||
identity?: IIdentifyPayload;
|
identity?: IIdentifyPayload;
|
||||||
currentDeviceId?: string;
|
deviceId: string;
|
||||||
previousDeviceId?: string;
|
sessionId: string;
|
||||||
geo: GeoLocation;
|
geo: GeoLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +119,7 @@ async function buildContext(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload;
|
Body: ITrackHandlerPayload;
|
||||||
}>,
|
}>,
|
||||||
validatedBody: ITrackHandlerPayload,
|
validatedBody: ITrackHandlerPayload
|
||||||
): Promise<TrackContext> {
|
): Promise<TrackContext> {
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -128,49 +134,27 @@ async function buildContext(
|
|||||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||||
|
|
||||||
const headers = getStringHeaders(request.headers);
|
const headers = getStringHeaders(request.headers);
|
||||||
|
|
||||||
const identity = getIdentity(validatedBody);
|
const identity = getIdentity(validatedBody);
|
||||||
const profileId = identity?.profileId;
|
const profileId = identity?.profileId;
|
||||||
|
|
||||||
// We might get a profileId from the alias table
|
|
||||||
// If we do, we should use that instead of the one from the payload
|
|
||||||
if (profileId && validatedBody.type === 'track') {
|
if (profileId && validatedBody.type === 'track') {
|
||||||
validatedBody.payload.profileId = profileId;
|
validatedBody.payload.profileId = profileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get geo location (needed for track and identify)
|
// Get geo location (needed for track and identify)
|
||||||
const geo = await getGeoLocation(ip);
|
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||||
|
|
||||||
// Generate device IDs if needed (for track)
|
const { deviceId, sessionId } = await getDeviceId({
|
||||||
let currentDeviceId: string | undefined;
|
projectId,
|
||||||
let previousDeviceId: string | undefined;
|
ip,
|
||||||
|
ua,
|
||||||
if (validatedBody.type === 'track') {
|
salts,
|
||||||
const overrideDeviceId =
|
overrideDeviceId:
|
||||||
typeof validatedBody.payload.properties?.__deviceId === 'string'
|
validatedBody.type === 'track' &&
|
||||||
? validatedBody.payload.properties.__deviceId
|
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||||
: undefined;
|
? validatedBody.payload?.properties.__deviceId
|
||||||
|
: undefined,
|
||||||
const salts = await getSalts();
|
});
|
||||||
currentDeviceId =
|
|
||||||
overrideDeviceId ||
|
|
||||||
(ua
|
|
||||||
? generateDeviceId({
|
|
||||||
salt: salts.current,
|
|
||||||
origin: projectId,
|
|
||||||
ip,
|
|
||||||
ua,
|
|
||||||
})
|
|
||||||
: '');
|
|
||||||
previousDeviceId = ua
|
|
||||||
? generateDeviceId({
|
|
||||||
salt: salts.previous,
|
|
||||||
origin: projectId,
|
|
||||||
ip,
|
|
||||||
ua,
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -182,46 +166,35 @@ async function buildContext(
|
|||||||
isFromPast: timestamp.isTimestampFromThePast,
|
isFromPast: timestamp.isTimestampFromThePast,
|
||||||
},
|
},
|
||||||
identity,
|
identity,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
previousDeviceId,
|
sessionId,
|
||||||
geo,
|
geo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTrack(
|
async function handleTrack(
|
||||||
payload: ITrackPayload,
|
payload: ITrackPayload,
|
||||||
context: TrackContext,
|
context: TrackContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
|
||||||
projectId,
|
|
||||||
currentDeviceId,
|
|
||||||
previousDeviceId,
|
|
||||||
geo,
|
|
||||||
headers,
|
|
||||||
timestamp,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
if (!currentDeviceId || !previousDeviceId) {
|
|
||||||
throw new HttpError('Device ID generation failed', { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||||
const groupId = uaInfo.isServer
|
const groupId = uaInfo.isServer
|
||||||
? payload.profileId
|
? payload.profileId
|
||||||
? `${projectId}:${payload.profileId}`
|
? `${projectId}:${payload.profileId}`
|
||||||
: `${projectId}:${generateId()}`
|
: `${projectId}:${generateId()}`
|
||||||
: currentDeviceId;
|
: deviceId;
|
||||||
const jobId = [
|
const jobId = [
|
||||||
slug(payload.name),
|
slug(payload.name),
|
||||||
timestamp.value,
|
timestamp.value,
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
groupId,
|
groupId,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('-');
|
.join('-');
|
||||||
|
|
||||||
const promises = [];
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
// If we have more than one property in the identity object, we should identify the user
|
// If we have more than one property in the identity object, we should identify the user
|
||||||
// Otherwise its only a profileId and we should not identify the user
|
// Otherwise its only a profileId and we should not identify the user
|
||||||
@@ -242,12 +215,14 @@ async function handleTrack(
|
|||||||
},
|
},
|
||||||
uaInfo,
|
uaInfo,
|
||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
deviceId,
|
||||||
previousDeviceId,
|
sessionId,
|
||||||
|
currentDeviceId: '', // TODO: Remove
|
||||||
|
previousDeviceId: '', // TODO: Remove
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
jobId,
|
jobId,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
@@ -255,7 +230,7 @@ async function handleTrack(
|
|||||||
|
|
||||||
async function handleIdentify(
|
async function handleIdentify(
|
||||||
payload: IIdentifyPayload,
|
payload: IIdentifyPayload,
|
||||||
context: TrackContext,
|
context: TrackContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { projectId, geo, ua } = context;
|
const { projectId, geo, ua } = context;
|
||||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||||
@@ -285,7 +260,7 @@ async function handleIdentify(
|
|||||||
async function adjustProfileProperty(
|
async function adjustProfileProperty(
|
||||||
payload: IIncrementPayload | IDecrementPayload,
|
payload: IIncrementPayload | IDecrementPayload,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
direction: 1 | -1,
|
direction: 1 | -1
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { profileId, property, value } = payload;
|
const { profileId, property, value } = payload;
|
||||||
const profile = await getProfileById(profileId, projectId);
|
const profile = await getProfileById(profileId, projectId);
|
||||||
@@ -295,7 +270,7 @@ async function adjustProfileProperty(
|
|||||||
|
|
||||||
const parsed = Number.parseInt(
|
const parsed = Number.parseInt(
|
||||||
pathOr<string>('0', property.split('.'), profile.properties),
|
pathOr<string>('0', property.split('.'), profile.properties),
|
||||||
10,
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
@@ -305,7 +280,7 @@ async function adjustProfileProperty(
|
|||||||
profile.properties = assocPath(
|
profile.properties = assocPath(
|
||||||
property.split('.'),
|
property.split('.'),
|
||||||
parsed + direction * (value || 1),
|
parsed + direction * (value || 1),
|
||||||
profile.properties,
|
profile.properties
|
||||||
);
|
);
|
||||||
|
|
||||||
await upsertProfile({
|
await upsertProfile({
|
||||||
@@ -318,23 +293,44 @@ async function adjustProfileProperty(
|
|||||||
|
|
||||||
async function handleIncrement(
|
async function handleIncrement(
|
||||||
payload: IIncrementPayload,
|
payload: IIncrementPayload,
|
||||||
context: TrackContext,
|
context: TrackContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await adjustProfileProperty(payload, context.projectId, 1);
|
await adjustProfileProperty(payload, context.projectId, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDecrement(
|
async function handleDecrement(
|
||||||
payload: IDecrementPayload,
|
payload: IDecrementPayload,
|
||||||
context: TrackContext,
|
context: TrackContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await adjustProfileProperty(payload, context.projectId, -1);
|
await adjustProfileProperty(payload, context.projectId, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReplay(
|
||||||
|
payload: IReplayPayload,
|
||||||
|
context: TrackContext
|
||||||
|
): Promise<void> {
|
||||||
|
if (!context.sessionId) {
|
||||||
|
throw new HttpError('Session ID is required for replay', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
project_id: context.projectId,
|
||||||
|
session_id: context.sessionId,
|
||||||
|
chunk_index: payload.chunk_index,
|
||||||
|
started_at: payload.started_at,
|
||||||
|
ended_at: payload.ended_at,
|
||||||
|
events_count: payload.events_count,
|
||||||
|
is_full_snapshot: payload.is_full_snapshot,
|
||||||
|
payload: payload.payload,
|
||||||
|
};
|
||||||
|
await replayBuffer.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handler(
|
export async function handler(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload;
|
Body: ITrackHandlerPayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
// Validate request body with Zod
|
// Validate request body with Zod
|
||||||
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
||||||
@@ -375,6 +371,9 @@ export async function handler(
|
|||||||
case 'decrement':
|
case 'decrement':
|
||||||
await handleDecrement(validatedBody.payload, context);
|
await handleDecrement(validatedBody.payload, context);
|
||||||
break;
|
break;
|
||||||
|
case 'replay':
|
||||||
|
await handleReplay(validatedBody.payload, context);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -383,12 +382,15 @@ export async function handler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.status(200).send();
|
reply.status(200).send({
|
||||||
|
deviceId: context.deviceId,
|
||||||
|
sessionId: context.sessionId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDeviceId(
|
export async function fetchDeviceId(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const salts = await getSalts();
|
const salts = await getSalts();
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
@@ -421,20 +423,31 @@ export async function fetchDeviceId(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const multi = getRedisCache().multi();
|
const multi = getRedisCache().multi();
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
multi.hget(
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||||
|
'data'
|
||||||
|
);
|
||||||
|
multi.hget(
|
||||||
|
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||||
|
'data'
|
||||||
|
);
|
||||||
const res = await multi.exec();
|
const res = await multi.exec();
|
||||||
|
|
||||||
if (res?.[0]?.[1]) {
|
if (res?.[0]?.[1]) {
|
||||||
|
const data = JSON.parse(res?.[0]?.[1] as string);
|
||||||
|
const sessionId = data.payload.sessionId;
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: currentDeviceId,
|
deviceId: currentDeviceId,
|
||||||
|
sessionId,
|
||||||
message: 'current session exists for this device id',
|
message: 'current session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res?.[1]?.[1]) {
|
if (res?.[1]?.[1]) {
|
||||||
|
const data = JSON.parse(res?.[1]?.[1] as string);
|
||||||
|
const sessionId = data.payload.sessionId;
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: previousDeviceId,
|
deviceId: previousDeviceId,
|
||||||
|
sessionId,
|
||||||
message: 'previous session exists for this device id',
|
message: 'previous session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -444,6 +457,7 @@ export async function fetchDeviceId(
|
|||||||
|
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
deviceId: currentDeviceId,
|
deviceId: currentDeviceId,
|
||||||
|
sessionId: '',
|
||||||
message: 'No session exists for this device id',
|
message: 'No session exists for this device id',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
|
||||||
import type {
|
import type {
|
||||||
DeprecatedPostEventPayload,
|
DeprecatedPostEventPayload,
|
||||||
ITrackHandlerPayload,
|
ITrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||||
|
|
||||||
export async function duplicateHook(
|
export async function duplicateHook(
|
||||||
req: FastifyRequest<{
|
req: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const ip = req.clientIp;
|
const ip = req.clientIp;
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
const clientId = req.headers['openpanel-client-id'];
|
const clientId = req.headers['openpanel-client-id'];
|
||||||
const shouldCheck = ip && origin && clientId;
|
const isReplay = 'type' in req.body && req.body.type === 'replay';
|
||||||
|
const shouldCheck = ip && origin && clientId && !isReplay;
|
||||||
|
|
||||||
const isDuplicate = shouldCheck
|
const isDuplicate = shouldCheck
|
||||||
? await isDuplicatedEvent({
|
? await isDuplicatedEvent({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { path, pick } from 'ramda';
|
import { path, pick } from 'ramda';
|
||||||
|
|
||||||
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
|
|||||||
const ignoreMethods = ['OPTIONS'];
|
const ignoreMethods = ['OPTIONS'];
|
||||||
|
|
||||||
const getTrpcInput = (
|
const getTrpcInput = (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest
|
||||||
): Record<string, unknown> | undefined => {
|
): Record<string, unknown> | undefined => {
|
||||||
const input = path<any>(['query', 'input'], request);
|
const input = path<any>(['query', 'input'], request);
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +17,7 @@ const getTrpcInput = (
|
|||||||
|
|
||||||
export async function requestLoggingHook(
|
export async function requestLoggingHook(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
if (ignoreMethods.includes(request.method)) {
|
if (ignoreMethods.includes(request.method)) {
|
||||||
return;
|
return;
|
||||||
@@ -40,9 +39,8 @@ export async function requestLoggingHook(
|
|||||||
elapsed: reply.elapsedTime,
|
elapsed: reply.elapsedTime,
|
||||||
headers: pick(
|
headers: pick(
|
||||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
||||||
request.headers,
|
request.headers
|
||||||
),
|
),
|
||||||
body: request.body,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ process.env.TZ = 'UTC';
|
|||||||
import compress from '@fastify/compress';
|
import compress from '@fastify/compress';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
import {
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
decodeSessionToken,
|
||||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
EMPTY_SESSION,
|
||||||
import Fastify from 'fastify';
|
type SessionValidationResult,
|
||||||
import metricsPlugin from 'fastify-metrics';
|
validateSessionToken,
|
||||||
|
} from '@openpanel/auth';
|
||||||
import { generateId } from '@openpanel/common';
|
import { generateId } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
type IServiceClientWithProject,
|
type IServiceClientWithProject,
|
||||||
@@ -17,13 +17,11 @@ import {
|
|||||||
import { getRedisPub } from '@openpanel/redis';
|
import { getRedisPub } from '@openpanel/redis';
|
||||||
import type { AppRouter } from '@openpanel/trpc';
|
import type { AppRouter } from '@openpanel/trpc';
|
||||||
import { appRouter, createContext } from '@openpanel/trpc';
|
import { appRouter, createContext } from '@openpanel/trpc';
|
||||||
|
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
EMPTY_SESSION,
|
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||||
type SessionValidationResult,
|
import Fastify from 'fastify';
|
||||||
decodeSessionToken,
|
import metricsPlugin from 'fastify-metrics';
|
||||||
validateSessionToken,
|
|
||||||
} from '@openpanel/auth';
|
|
||||||
import sourceMapSupport from 'source-map-support';
|
import sourceMapSupport from 'source-map-support';
|
||||||
import {
|
import {
|
||||||
healthcheck,
|
healthcheck,
|
||||||
@@ -72,7 +70,7 @@ const startServer = async () => {
|
|||||||
try {
|
try {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
maxParamLength: 15_000,
|
maxParamLength: 15_000,
|
||||||
bodyLimit: 1048576 * 500, // 500MB
|
bodyLimit: 1_048_576 * 500, // 500MB
|
||||||
loggerInstance: logger as unknown as FastifyBaseLogger,
|
loggerInstance: logger as unknown as FastifyBaseLogger,
|
||||||
disableRequestLogging: true,
|
disableRequestLogging: true,
|
||||||
genReqId: (req) =>
|
genReqId: (req) =>
|
||||||
@@ -84,7 +82,7 @@ const startServer = async () => {
|
|||||||
fastify.register(cors, () => {
|
fastify.register(cors, () => {
|
||||||
return (
|
return (
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
callback: (error: Error | null, options: FastifyCorsOptions) => void
|
||||||
) => {
|
) => {
|
||||||
// TODO: set prefix on dashboard routes
|
// TODO: set prefix on dashboard routes
|
||||||
const corsPaths = [
|
const corsPaths = [
|
||||||
@@ -97,7 +95,7 @@ const startServer = async () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const isPrivatePath = corsPaths.some((path) =>
|
const isPrivatePath = corsPaths.some((path) =>
|
||||||
req.url.startsWith(path),
|
req.url.startsWith(path)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPrivatePath) {
|
if (isPrivatePath) {
|
||||||
@@ -118,6 +116,7 @@ const startServer = async () => {
|
|||||||
|
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
origin: '*',
|
origin: '*',
|
||||||
|
maxAge: 86_400 * 7, // cache preflight for 7 days
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -149,7 +148,7 @@ const startServer = async () => {
|
|||||||
try {
|
try {
|
||||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||||
const session = await runWithAlsSession(sessionId, () =>
|
const session = await runWithAlsSession(sessionId, () =>
|
||||||
validateSessionToken(req.cookies.session),
|
validateSessionToken(req.cookies.session)
|
||||||
);
|
);
|
||||||
req.session = session;
|
req.session = session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -158,7 +157,7 @@ const startServer = async () => {
|
|||||||
} else if (process.env.DEMO_USER_ID) {
|
} else if (process.env.DEMO_USER_ID) {
|
||||||
try {
|
try {
|
||||||
const session = await runWithAlsSession('1', () =>
|
const session = await runWithAlsSession('1', () =>
|
||||||
validateSessionToken(null),
|
validateSessionToken(null)
|
||||||
);
|
);
|
||||||
req.session = session;
|
req.session = session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -173,7 +172,7 @@ const startServer = async () => {
|
|||||||
prefix: '/trpc',
|
prefix: '/trpc',
|
||||||
trpcOptions: {
|
trpcOptions: {
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createContext,
|
createContext,
|
||||||
onError(ctx) {
|
onError(ctx) {
|
||||||
if (
|
if (
|
||||||
ctx.error.code === 'UNAUTHORIZED' &&
|
ctx.error.code === 'UNAUTHORIZED' &&
|
||||||
@@ -217,7 +216,7 @@ const startServer = async () => {
|
|||||||
reply.send({
|
reply.send({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Successfully running OpenPanel.dev API',
|
message: 'Successfully running OpenPanel.dev API',
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,7 +273,7 @@ const startServer = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to set redis notify-keyspace-events', error);
|
logger.warn('Failed to set redis notify-keyspace-events', error);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'If you use a managed Redis service, you may need to set this manually.',
|
'If you use a managed Redis service, you may need to set this manually.'
|
||||||
);
|
);
|
||||||
logger.warn('Otherwise some functions may not work as expected.');
|
logger.warn('Otherwise some functions may not work as expected.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
deviceId: { type: 'string' },
|
deviceId: { type: 'string' },
|
||||||
|
sessionId: { type: 'string' },
|
||||||
message: { type: 'string', optional: true },
|
message: { type: 'string', optional: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
158
apps/api/src/utils/ids.ts
Normal file
158
apps/api/src/utils/ids.ts
Normal 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, '');
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ This is the most common flow and most secure one. Your backend receives webhooks
|
|||||||
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||||
|
|
||||||
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
||||||
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
|
When you create the checkout, you should first call `op.getDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
fetch('https://domain.com/api/checkout', {
|
fetch('https://domain.com/api/checkout', {
|
||||||
@@ -42,7 +42,7 @@ fetch('https://domain.com/api/checkout', {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now
|
deviceId: op.getDeviceId(), // ✅ since deviceId is here we can link the payment now
|
||||||
// ... other checkout data
|
// ... other checkout data
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -360,5 +360,5 @@ op.clearRevenue(): void
|
|||||||
### Fetch your current users device id
|
### Fetch your current users device id
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
op.fetchDeviceId(): Promise<string>
|
op.getDeviceId(): string
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ import { OpenPanelComponent } from '@openpanel/astro';
|
|||||||
##### Astro options
|
##### Astro options
|
||||||
|
|
||||||
- `profileId` - If you have a user id, you can pass it here to identify the user
|
- `profileId` - If you have a user id, you can pass it here to identify the user
|
||||||
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
- `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
||||||
|
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
||||||
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
|
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
|
||||||
- `globalProperties` - This is an object of properties that will be sent with every event.
|
- `globalProperties` - This is an object of properties that will be sent with every event.
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export default function RootLayout({ children }) {
|
|||||||
##### NextJS options
|
##### NextJS options
|
||||||
|
|
||||||
- `profileId` - If you have a user id, you can pass it here to identify the user
|
- `profileId` - If you have a user id, you can pass it here to identify the user
|
||||||
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
- `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
||||||
|
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
||||||
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
|
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
|
||||||
- `globalProperties` - This is an object of properties that will be sent with every event.
|
- `globalProperties` - This is an object of properties that will be sent with every event.
|
||||||
|
|
||||||
@@ -286,12 +287,12 @@ import { createRouteHandler } from '@openpanel/nextjs/server';
|
|||||||
export const { GET, POST } = createRouteHandler();
|
export const { GET, POST } = createRouteHandler();
|
||||||
```
|
```
|
||||||
|
|
||||||
Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server.
|
Remember to change the `apiUrl` and `scriptUrl` in the `OpenPanelComponent` to your own server.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<OpenPanelComponent
|
<OpenPanelComponent
|
||||||
apiUrl="/api/op" // [!code highlight]
|
apiUrl="/api/op" // [!code highlight]
|
||||||
cdnUrl="/api/op/op1.js" // [!code highlight]
|
scriptUrl="/api/op/op1.js" // [!code highlight]
|
||||||
clientId="your-client-id"
|
clientId="your-client-id"
|
||||||
trackScreenViews={true}
|
trackScreenViews={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ For more accurate tracking, handle revenue in your backend webhook. This ensures
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Frontend: include deviceId when starting checkout
|
// Frontend: include deviceId when starting checkout
|
||||||
const deviceId = await op.fetchDeviceId();
|
const deviceId = op.getDeviceId();
|
||||||
|
|
||||||
const response = await fetch('/api/checkout', {
|
const response = await fetch('/api/checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ Then update your OpenPanelComponent to use the proxy endpoint.
|
|||||||
```tsx
|
```tsx
|
||||||
<OpenPanelComponent
|
<OpenPanelComponent
|
||||||
apiUrl="/api/op"
|
apiUrl="/api/op"
|
||||||
cdnUrl="/api/op/op1.js"
|
scriptUrl="/api/op/op1.js"
|
||||||
clientId="your-client-id"
|
clientId="your-client-id"
|
||||||
trackScreenViews={true}
|
trackScreenViews={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
77
apps/public/public/op1-replay.js
Normal file
77
apps/public/public/op1-replay.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
|
|||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
||||||
import { getRootMetadata } from '@/lib/metadata';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { getRootMetadata } from '@/lib/metadata';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import './global.css';
|
import './global.css';
|
||||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||||
|
|
||||||
@@ -31,22 +31,20 @@ export const metadata: Metadata = getRootMetadata();
|
|||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
|
||||||
className={cn(font.className, mono.variable)}
|
className={cn(font.className, mono.variable)}
|
||||||
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="flex flex-col min-h-screen bg-background">
|
<body className="flex min-h-screen flex-col bg-background">
|
||||||
<RootProvider>
|
<RootProvider>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
||||||
<OpenPanelComponent
|
<OpenPanelComponent
|
||||||
apiUrl="/api/op"
|
|
||||||
cdnUrl="/api/op/op1.js"
|
|
||||||
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
||||||
trackAttributes
|
trackAttributes
|
||||||
trackScreenViews
|
|
||||||
trackOutgoingLinks
|
trackOutgoingLinks
|
||||||
|
trackScreenViews
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/payments": "workspace:*",
|
"@openpanel/payments": "workspace:*",
|
||||||
|
"@openpanel/sdk": "^1.0.8",
|
||||||
"@openpanel/sdk-info": "workspace:^",
|
"@openpanel/sdk-info": "workspace:^",
|
||||||
"@openpanel/validation": "workspace:^",
|
"@openpanel/validation": "workspace:^",
|
||||||
"@openpanel/web": "^1.0.1",
|
"@openpanel/web": "^1.0.12",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -141,6 +142,7 @@
|
|||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
|
"rrweb-player": "2.0.0-alpha.20",
|
||||||
"short-unique-id": "^5.0.3",
|
"short-unique-id": "^5.0.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.4.0",
|
"sonner": "^1.4.0",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function useColumns() {
|
|||||||
projectId: row.original.projectId,
|
projectId: row.original.projectId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="font-medium"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{renderName()}
|
{renderName()}
|
||||||
</button>
|
</button>
|
||||||
@@ -144,10 +144,21 @@ export function useColumns() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'sessionId',
|
accessorKey: 'sessionId',
|
||||||
header: 'Session ID',
|
header: 'Session ID',
|
||||||
size: 320,
|
size: 100,
|
||||||
meta: {
|
meta: {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
|
cell({ row }) {
|
||||||
|
const { sessionId } = row.original;
|
||||||
|
return (
|
||||||
|
<ProjectLink
|
||||||
|
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||||
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{sessionId.slice(0,6)}
|
||||||
|
</ProjectLink>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'deviceId',
|
accessorKey: 'deviceId',
|
||||||
|
|||||||
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal file
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function BrowserChrome({
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
right,
|
||||||
|
controls = (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
url?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
controls?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col overflow-hidden rounded-lg border border-border bg-background',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background h-10">
|
||||||
|
{controls}
|
||||||
|
{url !== false && (
|
||||||
|
<div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
apps/start/src/components/sessions/replay/index.tsx
Normal file
242
apps/start/src/components/sessions/replay/index.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
205
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
205
apps/start/src/components/sessions/replay/replay-context.tsx
Normal 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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/start/src/components/sessions/replay/replay-event-feed.tsx
Normal file
107
apps/start/src/components/sessions/replay/replay-event-feed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
184
apps/start/src/components/sessions/replay/replay-player.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
261
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/start/src/components/sessions/replay/replay-utils.ts
Normal file
20
apps/start/src/components/sessions/replay/replay-utils.ts
Normal 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')}`;
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { ProjectLink } from '@/components/links';
|
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
|
||||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
|
||||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
|
||||||
import { getProfileName } from '@/utils/getters';
|
|
||||||
import { round } from '@openpanel/common';
|
import { round } from '@openpanel/common';
|
||||||
import type { IServiceSession } from '@openpanel/db';
|
import type { IServiceSession } from '@openpanel/db';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Video } from 'lucide-react';
|
||||||
|
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
|
|
||||||
function formatDuration(milliseconds: number): string {
|
function formatDuration(milliseconds: number): string {
|
||||||
const seconds = milliseconds / 1000;
|
const seconds = milliseconds / 1000;
|
||||||
@@ -44,13 +43,25 @@ export function useColumns() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const session = row.original;
|
const session = row.original;
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<div className="row items-center gap-2">
|
||||||
href={`/sessions/${session.id}`}
|
<ProjectLink
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
title={session.id}
|
href={`/sessions/${session.id}`}
|
||||||
>
|
title={session.id}
|
||||||
{session.id.slice(0, 8)}...
|
>
|
||||||
</ProjectLink>
|
{session.id.slice(0, 8)}...
|
||||||
|
</ProjectLink>
|
||||||
|
{session.hasReplay && (
|
||||||
|
<ProjectLink
|
||||||
|
aria-label="View replay"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
href={`/sessions/${session.id}#replay`}
|
||||||
|
title="View replay"
|
||||||
|
>
|
||||||
|
<Video className="size-4" />
|
||||||
|
</ProjectLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,8 +74,8 @@ export function useColumns() {
|
|||||||
if (session.profile) {
|
if (session.profile) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
|
className="row items-center gap-2 font-medium"
|
||||||
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
||||||
className="font-medium row gap-2 items-center"
|
|
||||||
>
|
>
|
||||||
<ProfileAvatar size="sm" {...session.profile} />
|
<ProfileAvatar size="sm" {...session.profile} />
|
||||||
{getProfileName(session.profile)}
|
{getProfileName(session.profile)}
|
||||||
@@ -73,8 +84,8 @@ export function useColumns() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
|
className="font-medium font-mono"
|
||||||
href={`/profiles/${encodeURIComponent(session.profileId)}`}
|
href={`/profiles/${encodeURIComponent(session.profileId)}`}
|
||||||
className="font-mono font-medium"
|
|
||||||
>
|
>
|
||||||
{session.profileId}
|
{session.profileId}
|
||||||
</ProjectLink>
|
</ProjectLink>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
|
|||||||
|
|
||||||
export function usePageTabs(tabs: { id: string; label: string }[]) {
|
export function usePageTabs(tabs: { id: string; label: string }[]) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const tab = location.pathname.split('/').pop();
|
const segments = location.pathname.split('/').filter(Boolean);
|
||||||
|
const tab = segments[segments.length - 1];
|
||||||
|
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,22 +1,3 @@
|
|||||||
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
|
|
||||||
import {
|
|
||||||
useEventQueryFilters,
|
|
||||||
useEventQueryNamesFilter,
|
|
||||||
} from '@/hooks/use-event-query-filters';
|
|
||||||
|
|
||||||
import { ProjectLink } from '@/components/links';
|
|
||||||
import {
|
|
||||||
WidgetButtons,
|
|
||||||
WidgetHead,
|
|
||||||
} from '@/components/overview/overview-widget';
|
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid';
|
|
||||||
import { Widget, WidgetBody } from '@/components/widget';
|
|
||||||
import { fancyMinutes } from '@/hooks/use-numer-formatter';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { getProfileName } from '@/utils/getters';
|
|
||||||
import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db';
|
import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { FilterIcon, XIcon } from 'lucide-react';
|
import { FilterIcon, XIcon } from 'lucide-react';
|
||||||
@@ -24,6 +5,24 @@ import { omit } from 'ramda';
|
|||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
import { ModalContent } from './Modal/Container';
|
import { ModalContent } from './Modal/Container';
|
||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
|
import {
|
||||||
|
WidgetButtons,
|
||||||
|
WidgetHead,
|
||||||
|
} from '@/components/overview/overview-widget';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||||
|
import { Widget, WidgetBody } from '@/components/widget';
|
||||||
|
import {
|
||||||
|
useEventQueryFilters,
|
||||||
|
useEventQueryNamesFilter,
|
||||||
|
} from '@/hooks/use-event-query-filters';
|
||||||
|
import { fancyMinutes } from '@/hooks/use-numer-formatter';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,7 +54,7 @@ const filterable: Partial<Record<keyof IServiceEvent, keyof IClickhouseEvent>> =
|
|||||||
export default function EventDetails(props: Props) {
|
export default function EventDetails(props: Props) {
|
||||||
return (
|
return (
|
||||||
<ModalContent className="!p-0">
|
<ModalContent className="!p-0">
|
||||||
<Widget className="bg-transparent border-0 min-w-0">
|
<Widget className="min-w-0 border-0 bg-transparent">
|
||||||
<Suspense fallback={<EventDetailsSkeleton />}>
|
<Suspense fallback={<EventDetailsSkeleton />}>
|
||||||
<EventDetailsContent {...props} />
|
<EventDetailsContent {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -84,7 +83,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
id,
|
id,
|
||||||
projectId,
|
projectId,
|
||||||
createdAt,
|
createdAt,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const { event, session } = query.data;
|
const { event, session } = query.data;
|
||||||
@@ -158,7 +157,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +208,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-4" />
|
<ArrowRightIcon className="size-4" />
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<Button size="icon" variant={'ghost'} onClick={() => popModal()}>
|
<Button onClick={() => popModal()} size="icon" variant={'ghost'}>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,10 +217,10 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
<WidgetButtons>
|
<WidgetButtons>
|
||||||
{Object.entries(TABS).map(([, tab]) => (
|
{Object.entries(TABS).map(([, tab]) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setWidget(tab)}
|
|
||||||
className={cn(tab.id === widget.id && 'active')}
|
className={cn(tab.id === widget.id && 'active')}
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setWidget(tab)}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</button>
|
</button>
|
||||||
@@ -231,29 +230,29 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
<WidgetBody className="col gap-4 bg-def-100">
|
<WidgetBody className="col gap-4 bg-def-100">
|
||||||
{profile && (
|
{profile && (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
onClick={() => popModal()}
|
className="card col gap-2 p-4 py-2 hover:bg-def-100"
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||||
className="card p-4 py-2 col gap-2 hover:bg-def-100"
|
onClick={() => popModal()}
|
||||||
>
|
>
|
||||||
<div className="row items-center gap-2 justify-between">
|
<div className="row items-center justify-between gap-2">
|
||||||
<div className="row items-center gap-2 min-w-0">
|
<div className="row min-w-0 items-center gap-2">
|
||||||
{profile.avatar && (
|
{profile.avatar && (
|
||||||
<img
|
<img
|
||||||
className="size-4 bg-border rounded-full"
|
className="size-4 rounded-full bg-border"
|
||||||
src={profile.avatar}
|
src={profile.avatar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="font-medium truncate">
|
<div className="truncate font-medium">
|
||||||
{getProfileName(profile, false)}
|
{getProfileName(profile, false)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row items-center gap-2 shrink-0">
|
<div className="row shrink-0 items-center gap-2">
|
||||||
<div className="row gap-1 items-center">
|
<div className="row items-center gap-1">
|
||||||
<SerieIcon name={event.country} />
|
<SerieIcon name={event.country} />
|
||||||
<SerieIcon name={event.os} />
|
<SerieIcon name={event.os} />
|
||||||
<SerieIcon name={event.browser} />
|
<SerieIcon name={event.browser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground truncate max-w-40">
|
<div className="max-w-40 truncate text-muted-foreground">
|
||||||
{event.referrerName || event.referrer}
|
{event.referrerName || event.referrer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,16 +275,16 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
<KeyValueGrid
|
<KeyValueGrid
|
||||||
columns={1}
|
columns={1}
|
||||||
data={properties}
|
data={properties}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
popModal();
|
||||||
|
setFilter(`properties.${item.name}`, item.value as any);
|
||||||
|
}}
|
||||||
renderValue={(item) => (
|
renderValue={(item) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono">{String(item.value)}</span>
|
<span className="font-mono">{String(item.value)}</span>
|
||||||
<FilterIcon className="size-3 shrink-0" />
|
<FilterIcon className="size-3 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
onItemClick={(item) => {
|
|
||||||
popModal();
|
|
||||||
setFilter(`properties.${item.name}`, item.value as any);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -296,25 +295,6 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
<KeyValueGrid
|
<KeyValueGrid
|
||||||
columns={1}
|
columns={1}
|
||||||
data={data}
|
data={data}
|
||||||
renderValue={(item) => {
|
|
||||||
const isFilterable = item.value && (filterable as any)[item.name];
|
|
||||||
if (isFilterable) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FieldValue
|
|
||||||
name={item.name}
|
|
||||||
value={item.value}
|
|
||||||
event={event}
|
|
||||||
/>
|
|
||||||
<FilterIcon className="size-3 shrink-0" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FieldValue name={item.name} value={item.value} event={event} />
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onItemClick={(item) => {
|
onItemClick={(item) => {
|
||||||
const isFilterable = item.value && (filterable as any)[item.name];
|
const isFilterable = item.value && (filterable as any)[item.name];
|
||||||
if (isFilterable) {
|
if (isFilterable) {
|
||||||
@@ -322,26 +302,45 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
setFilter(item.name as keyof IServiceEvent, item.value);
|
setFilter(item.name as keyof IServiceEvent, item.value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
renderValue={(item) => {
|
||||||
|
const isFilterable = item.value && (filterable as any)[item.name];
|
||||||
|
if (isFilterable) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FieldValue
|
||||||
|
event={event}
|
||||||
|
name={item.name}
|
||||||
|
value={item.value}
|
||||||
|
/>
|
||||||
|
<FilterIcon className="size-3 shrink-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldValue event={event} name={item.name} value={item.value} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex justify-between font-medium">
|
<div className="mb-2 flex justify-between font-medium">
|
||||||
<div>All events for {event.name}</div>
|
<div>All events for {event.name}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:underline"
|
className="text-muted-foreground hover:underline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEvents([event.name]);
|
setEvents([event.name]);
|
||||||
popModal();
|
popModal();
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
Show all
|
Show all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<ReportChartShortcut
|
<ReportChartShortcut
|
||||||
projectId={event.projectId}
|
|
||||||
chartType="linear"
|
chartType="linear"
|
||||||
|
projectId={event.projectId}
|
||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -365,52 +364,52 @@ function EventDetailsSkeleton() {
|
|||||||
<>
|
<>
|
||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<div className="row items-center justify-between">
|
<div className="row items-center justify-between">
|
||||||
<div className="h-6 w-32 bg-muted animate-pulse rounded" />
|
<div className="h-6 w-32 animate-pulse rounded bg-muted" />
|
||||||
<div className="row items-center gap-2 pr-2">
|
<div className="row items-center gap-2 pr-2">
|
||||||
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WidgetButtons>
|
<WidgetButtons>
|
||||||
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
|
||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody className="col gap-4 bg-def-100">
|
<WidgetBody className="col gap-4 bg-def-100">
|
||||||
{/* Profile skeleton */}
|
{/* Profile skeleton */}
|
||||||
<div className="card p-4 py-2 col gap-2">
|
<div className="card col gap-2 p-4 py-2">
|
||||||
<div className="row items-center gap-2 justify-between">
|
<div className="row items-center justify-between gap-2">
|
||||||
<div className="row items-center gap-2 min-w-0">
|
<div className="row min-w-0 items-center gap-2">
|
||||||
<div className="size-4 bg-muted animate-pulse rounded-full" />
|
<div className="size-4 animate-pulse rounded-full bg-muted" />
|
||||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div className="row items-center gap-2 shrink-0">
|
<div className="row shrink-0 items-center gap-2">
|
||||||
<div className="row gap-1 items-center">
|
<div className="row items-center gap-1">
|
||||||
<div className="size-4 bg-muted animate-pulse rounded" />
|
<div className="size-4 animate-pulse rounded bg-muted" />
|
||||||
<div className="size-4 bg-muted animate-pulse rounded" />
|
<div className="size-4 animate-pulse rounded bg-muted" />
|
||||||
<div className="size-4 bg-muted animate-pulse rounded" />
|
<div className="size-4 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Properties skeleton */}
|
{/* Properties skeleton */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex justify-between font-medium">
|
<div className="mb-2 flex justify-between font-medium">
|
||||||
<div className="h-5 w-20 bg-muted animate-pulse rounded" />
|
<div className="h-5 w-20 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center justify-between rounded bg-muted/50 p-3"
|
||||||
key={i.toString()}
|
key={i.toString()}
|
||||||
className="flex items-center justify-between p-3 bg-muted/50 rounded"
|
|
||||||
>
|
>
|
||||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,16 +418,16 @@ function EventDetailsSkeleton() {
|
|||||||
{/* Information skeleton */}
|
{/* Information skeleton */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex justify-between font-medium">
|
<div className="mb-2 flex justify-between font-medium">
|
||||||
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
|
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center justify-between rounded bg-muted/50 p-3"
|
||||||
key={i.toString()}
|
key={i.toString()}
|
||||||
className="flex items-center justify-between p-3 bg-muted/50 rounded"
|
|
||||||
>
|
>
|
||||||
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -437,11 +436,11 @@ function EventDetailsSkeleton() {
|
|||||||
{/* Chart skeleton */}
|
{/* Chart skeleton */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex justify-between font-medium">
|
<div className="mb-2 flex justify-between font-medium">
|
||||||
<div className="h-5 w-40 bg-muted animate-pulse rounded" />
|
<div className="h-5 w-40 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-4 w-16 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="h-32 w-full bg-muted animate-pulse rounded" />
|
<div className="h-32 w-full animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from '.
|
|||||||
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||||
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||||
|
|
||||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||||
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
||||||
|
id: '/sessions',
|
||||||
|
path: '/sessions',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
|
||||||
id: '/events',
|
id: '/events',
|
||||||
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -778,6 +787,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/profiles/'
|
| '/$organizationId/$projectId/profiles/'
|
||||||
| '/$organizationId/$projectId/settings/'
|
| '/$organizationId/$projectId/settings/'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/'
|
| '/$organizationId/$projectId/profiles/$profileId/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||||
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
|
||||||
|
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||||
|
path: '/sessions'
|
||||||
|
fullPath: '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
|
||||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||||
path: '/events'
|
path: '/events'
|
||||||
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
|
|||||||
|
|
||||||
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
|
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
|
|||||||
{
|
{
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
|
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute,
|
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ function Component() {
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
},
|
},
|
||||||
{ id: 'events', label: 'Events' },
|
{ id: 'events', label: 'Events' },
|
||||||
|
{ id: 'sessions', label: 'Sessions' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { EventsTable } from '@/components/events/table';
|
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
|
import { EventIcon } from '@/components/events/event-icon';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
import { ReplayShell } from '@/components/sessions/replay';
|
||||||
|
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
Widget,
|
||||||
useEventQueryNamesFilter,
|
WidgetBody,
|
||||||
} from '@/hooks/use-event-query-filters';
|
WidgetHead,
|
||||||
|
WidgetTitle,
|
||||||
|
} from '@/components/widget';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { formatDateTime } from '@/utils/date';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
import { createProjectTitle } from '@/utils/title';
|
import { createProjectTitle } from '@/utils/title';
|
||||||
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
'/_app/$organizationId/$projectId/sessions_/$sessionId'
|
||||||
)({
|
)({
|
||||||
component: Component,
|
component: Component,
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
@@ -24,66 +31,148 @@ export const Route = createFileRoute(
|
|||||||
context.trpc.session.byId.queryOptions({
|
context.trpc.session.byId.queryOptions({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
}),
|
})
|
||||||
|
),
|
||||||
|
context.queryClient.prefetchQuery(
|
||||||
|
context.trpc.event.events.queryOptions({
|
||||||
|
projectId: params.projectId,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
filters: [],
|
||||||
|
columnVisibility: {},
|
||||||
|
})
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
head: () => {
|
head: () => ({
|
||||||
return {
|
meta: [{ title: createProjectTitle('Session') }],
|
||||||
meta: [
|
}),
|
||||||
{
|
|
||||||
title: createProjectTitle('Sessions'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
pendingComponent: FullPageLoadingState,
|
pendingComponent: FullPageLoadingState,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Component() {
|
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
|
||||||
const { projectId, sessionId } = Route.useParams();
|
return {
|
||||||
const trpc = useTRPC();
|
...session,
|
||||||
|
name: 'screen_view',
|
||||||
|
sessionId: session.id,
|
||||||
|
properties: {},
|
||||||
|
path: session.exitPath,
|
||||||
|
origin: session.exitOrigin,
|
||||||
|
importedAt: undefined,
|
||||||
|
meta: undefined,
|
||||||
|
sdkName: undefined,
|
||||||
|
sdkVersion: undefined,
|
||||||
|
profile: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const LIMIT = 50;
|
function VisitedRoutes({ paths }: { paths: string[] }) {
|
||||||
|
const counted = paths.reduce<Record<string, number>>((acc, p) => {
|
||||||
|
acc[p] = (acc[p] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||||
|
const max = sorted[0]?.[1] ?? 1;
|
||||||
|
|
||||||
const { data: session } = useSuspenseQuery(
|
if (sorted.length === 0) {
|
||||||
trpc.session.byId.queryOptions({
|
return null;
|
||||||
sessionId,
|
}
|
||||||
projectId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filters] = useEventQueryFilters();
|
|
||||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
|
||||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
|
||||||
const [eventNames] = useEventQueryNamesFilter();
|
|
||||||
const columnVisibility = useReadColumnVisibility('events');
|
|
||||||
const query = useInfiniteQuery(
|
|
||||||
trpc.event.events.infiniteQueryOptions(
|
|
||||||
{
|
|
||||||
projectId,
|
|
||||||
sessionId,
|
|
||||||
filters,
|
|
||||||
events: eventNames,
|
|
||||||
startDate: startDate || undefined,
|
|
||||||
endDate: endDate || undefined,
|
|
||||||
columnVisibility: columnVisibility ?? {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: columnVisibility !== null,
|
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<Widget className="w-full">
|
||||||
<PageHeader
|
<WidgetHead>
|
||||||
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
|
<WidgetTitle>Visited pages</WidgetTitle>
|
||||||
>
|
</WidgetHead>
|
||||||
<div className="row gap-4 mb-6">
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{sorted.map(([path, count]) => (
|
||||||
|
<div className="group relative px-3 py-2" key={path}>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||||
|
style={{ width: `${(count / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex min-w-0 justify-between gap-2">
|
||||||
|
<span className="truncate text-sm">{path}</span>
|
||||||
|
<span className="shrink-0 font-medium text-sm">{count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventDistribution({ events }: { events: IServiceEvent[] }) {
|
||||||
|
const counted = events.reduce<Record<string, number>>((acc, e) => {
|
||||||
|
acc[e.name] = (acc[e.name] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||||
|
const max = sorted[0]?.[1] ?? 1;
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle>Event distribution</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{sorted.map(([name, count]) => (
|
||||||
|
<div className="group relative px-3 py-2" key={name}>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||||
|
style={{ width: `${(count / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex justify-between gap-2">
|
||||||
|
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
|
||||||
|
<span className="shrink-0 font-medium text-sm">{count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId, sessionId, organizationId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const number = useNumber();
|
||||||
|
|
||||||
|
const { data: session } = useSuspenseQuery(
|
||||||
|
trpc.session.byId.queryOptions({ sessionId, projectId })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: eventsData } = useSuspenseQuery(
|
||||||
|
trpc.event.events.queryOptions({
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
filters: [],
|
||||||
|
columnVisibility: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = eventsData?.data ?? [];
|
||||||
|
|
||||||
|
const isIdentified =
|
||||||
|
session.profileId && session.profileId !== session.deviceId;
|
||||||
|
|
||||||
|
const { data: profile } = useSuspenseQuery(
|
||||||
|
trpc.profile.byId.queryOptions({
|
||||||
|
profileId: session.profileId,
|
||||||
|
projectId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fakeEvent = sessionToFakeEvent(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="col gap-8">
|
||||||
|
<PageHeader title={`Session: ${session.id}`}>
|
||||||
|
<div className="row mb-6 gap-4">
|
||||||
{session.country && (
|
{session.country && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<SerieIcon name={session.country} />
|
<SerieIcon name={session.country} />
|
||||||
<span>
|
<span>
|
||||||
{session.country}
|
{session.country}
|
||||||
@@ -92,32 +181,195 @@ function Component() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.device && (
|
{session.device && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<SerieIcon name={session.device} />
|
<SerieIcon name={session.device} />
|
||||||
<span className="capitalize">{session.device}</span>
|
<span className="capitalize">{session.device}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.os && (
|
{session.os && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<SerieIcon name={session.os} />
|
<SerieIcon name={session.os} />
|
||||||
<span>{session.os}</span>
|
<span>{session.os}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.model && (
|
{session.model && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<SerieIcon name={session.model} />
|
<SerieIcon name={session.model} />
|
||||||
<span>{session.model}</span>
|
<span>{session.model}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.browser && (
|
{session.browser && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<SerieIcon name={session.browser} />
|
<SerieIcon name={session.browser} />
|
||||||
<span>{session.browser}</span>
|
<span>{session.browser}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<EventsTable query={query} />
|
|
||||||
|
{session.hasReplay && (
|
||||||
|
<ReplayShell projectId={projectId} sessionId={sessionId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="col gap-6">
|
||||||
|
{/* Session info */}
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle>Session info</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<KeyValueGrid
|
||||||
|
className="border-0"
|
||||||
|
columns={1}
|
||||||
|
copyable
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
name: 'duration',
|
||||||
|
value: number.formatWithUnit(session.duration / 1000, 'min'),
|
||||||
|
},
|
||||||
|
{ name: 'createdAt', value: session.createdAt },
|
||||||
|
{ name: 'endedAt', value: session.endedAt },
|
||||||
|
{ name: 'screenViews', value: session.screenViewCount },
|
||||||
|
{ name: 'events', value: session.eventCount },
|
||||||
|
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
|
||||||
|
...(session.entryPath
|
||||||
|
? [{ name: 'entryPath', value: session.entryPath }]
|
||||||
|
: []),
|
||||||
|
...(session.exitPath
|
||||||
|
? [{ name: 'exitPath', value: session.exitPath }]
|
||||||
|
: []),
|
||||||
|
...(session.referrerName
|
||||||
|
? [{ name: 'referrerName', value: session.referrerName }]
|
||||||
|
: []),
|
||||||
|
...(session.referrer
|
||||||
|
? [{ name: 'referrer', value: session.referrer }]
|
||||||
|
: []),
|
||||||
|
...(session.utmSource
|
||||||
|
? [{ name: 'utmSource', value: session.utmSource }]
|
||||||
|
: []),
|
||||||
|
...(session.utmMedium
|
||||||
|
? [{ name: 'utmMedium', value: session.utmMedium }]
|
||||||
|
: []),
|
||||||
|
...(session.utmCampaign
|
||||||
|
? [{ name: 'utmCampaign', value: session.utmCampaign }]
|
||||||
|
: []),
|
||||||
|
...(session.revenue > 0
|
||||||
|
? [{ name: 'revenue', value: `$${session.revenue}` }]
|
||||||
|
: []),
|
||||||
|
{ name: 'country', value: session.country, event: fakeEvent },
|
||||||
|
...(session.city
|
||||||
|
? [{ name: 'city', value: session.city, event: fakeEvent }]
|
||||||
|
: []),
|
||||||
|
...(session.os
|
||||||
|
? [{ name: 'os', value: session.os, event: fakeEvent }]
|
||||||
|
: []),
|
||||||
|
...(session.browser
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'browser',
|
||||||
|
value: session.browser,
|
||||||
|
event: fakeEvent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(session.device
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'device',
|
||||||
|
value: session.device,
|
||||||
|
event: fakeEvent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(session.brand
|
||||||
|
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
|
||||||
|
: []),
|
||||||
|
...(session.model
|
||||||
|
? [{ name: 'model', value: session.model, event: fakeEvent }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
|
||||||
|
{/* Profile card */}
|
||||||
|
{isIdentified && profile && (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle>Profile</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="p-0">
|
||||||
|
<Link
|
||||||
|
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
|
||||||
|
params={{
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
profileId: session.profileId,
|
||||||
|
}}
|
||||||
|
to="/$organizationId/$projectId/profiles/$profileId"
|
||||||
|
>
|
||||||
|
<ProfileAvatar {...profile} size="lg" />
|
||||||
|
<div className="col min-w-0 gap-0.5">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{getProfileName(profile, false) ?? session.profileId}
|
||||||
|
</span>
|
||||||
|
{profile.email && (
|
||||||
|
<span className="truncate text-muted-foreground text-sm">
|
||||||
|
{profile.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visited pages */}
|
||||||
|
<VisitedRoutes
|
||||||
|
paths={events
|
||||||
|
.filter((e) => e.name === 'screen_view' && e.path)
|
||||||
|
.map((e) => e.path)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Event distribution */}
|
||||||
|
<EventDistribution events={events} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="col gap-6">
|
||||||
|
{/* Events list */}
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle>Events</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<div className="divide-y">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
className="row items-center gap-3 px-4 py-2"
|
||||||
|
key={event.id}
|
||||||
|
>
|
||||||
|
<EventIcon meta={event.meta} name={event.name} size="sm" />
|
||||||
|
<div className="col min-w-0 flex-1">
|
||||||
|
<span className="truncate font-medium text-sm">
|
||||||
|
{event.name === 'screen_view' && event.path
|
||||||
|
? event.path
|
||||||
|
: event.name.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||||
|
{formatDateTime(event.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No events found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/start/src/types/rrweb-player.d.ts
vendored
Normal file
47
apps/start/src/types/rrweb-player.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,19 +2,14 @@ import { OpenPanel } from '@openpanel/web';
|
|||||||
|
|
||||||
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
|
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
|
||||||
|
|
||||||
const createOpInstance = () => {
|
export const op = new OpenPanel({
|
||||||
if (!clientId || clientId === 'undefined') {
|
clientId,
|
||||||
return new Proxy({} as OpenPanel, {
|
disabled: clientId === 'undefined' || !clientId,
|
||||||
get: () => () => {},
|
// apiUrl: 'http://localhost:3333',
|
||||||
});
|
trackScreenViews: true,
|
||||||
}
|
trackOutgoingLinks: true,
|
||||||
|
trackAttributes: true,
|
||||||
return new OpenPanel({
|
// sessionReplay: {
|
||||||
clientId,
|
// enabled: true,
|
||||||
trackScreenViews: true,
|
// }
|
||||||
trackOutgoingLinks: true,
|
});
|
||||||
trackAttributes: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const op = createOpInstance();
|
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export async function bootCron() {
|
|||||||
type: 'flushProfileBackfill',
|
type: 'flushProfileBackfill',
|
||||||
pattern: 1000 * 30,
|
pattern: 1000 * 30,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'flush',
|
||||||
|
type: 'flushReplay',
|
||||||
|
pattern: 1000 * 10,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'insightsDaily',
|
name: 'insightsDaily',
|
||||||
type: 'insightsDaily',
|
type: 'insightsDaily',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { eventBuffer, profileBackfillBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
|
import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
|
||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
@@ -26,6 +26,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'flushProfileBackfill': {
|
case 'flushProfileBackfill': {
|
||||||
return await profileBackfillBuffer.tryFlush();
|
return await profileBackfillBuffer.tryFlush();
|
||||||
}
|
}
|
||||||
|
case 'flushReplay': {
|
||||||
|
return await replayBuffer.tryFlush();
|
||||||
|
}
|
||||||
case 'ping': {
|
case 'ping': {
|
||||||
return await ping();
|
return await ping();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import type { ILogger } from '@openpanel/logger';
|
import type { ILogger } from '@openpanel/logger';
|
||||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
||||||
|
|
||||||
@@ -53,10 +52,9 @@ async function createEventAndNotify(
|
|||||||
logger.info('Creating event', { event: payload });
|
logger.info('Creating event', { event: payload });
|
||||||
const [event] = await Promise.all([
|
const [event] = await Promise.all([
|
||||||
createEvent(payload),
|
createEvent(payload),
|
||||||
checkNotificationRulesForEvent(payload).catch(() => {}),
|
checkNotificationRulesForEvent(payload).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('Event created:', event);
|
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +85,8 @@ export async function incomingEvent(
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
|
sessionId,
|
||||||
uaInfo: _uaInfo,
|
uaInfo: _uaInfo,
|
||||||
} = jobPayload;
|
} = jobPayload;
|
||||||
const properties = body.properties ?? {};
|
const properties = body.properties ?? {};
|
||||||
@@ -157,7 +157,6 @@ export async function incomingEvent(
|
|||||||
: undefined,
|
: undefined,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
console.log('HERE?');
|
|
||||||
// if timestamp is from the past we dont want to create a new session
|
// if timestamp is from the past we dont want to create a new session
|
||||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||||
const session = profileId
|
const session = profileId
|
||||||
@@ -167,8 +166,6 @@ export async function incomingEvent(
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log('Server?');
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...baseEvent,
|
...baseEvent,
|
||||||
deviceId: session?.device_id ?? '',
|
deviceId: session?.device_id ?? '',
|
||||||
@@ -194,31 +191,31 @@ export async function incomingEvent(
|
|||||||
|
|
||||||
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
||||||
}
|
}
|
||||||
console.log('not?');
|
|
||||||
const sessionEnd = await getSessionEnd({
|
const sessionEnd = await getSessionEnd({
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
profileId,
|
profileId,
|
||||||
});
|
});
|
||||||
console.log('Server?');
|
const activeSession = sessionEnd
|
||||||
const lastScreenView = sessionEnd
|
|
||||||
? await sessionBuffer.getExistingSession({
|
? await sessionBuffer.getExistingSession({
|
||||||
sessionId: sessionEnd.sessionId,
|
sessionId: sessionEnd.sessionId,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
||||||
deviceId: sessionEnd?.deviceId ?? currentDeviceId,
|
deviceId: sessionEnd?.deviceId ?? deviceId,
|
||||||
sessionId: sessionEnd?.sessionId ?? uuid(),
|
sessionId: sessionEnd?.sessionId ?? sessionId,
|
||||||
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
referrer: sessionEnd?.referrer ?? baseEvent.referrer,
|
||||||
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
||||||
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
||||||
// if the path is not set, use the last screen view path
|
// if the path is not set, use the last screen view path
|
||||||
path: baseEvent.path || lastScreenView?.exit_path || '',
|
path: baseEvent.path || activeSession?.exit_path || '',
|
||||||
origin: baseEvent.origin || lastScreenView?.exit_origin || '',
|
origin: baseEvent.origin || activeSession?.exit_origin || '',
|
||||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||||
console.log('SessionEnd?', sessionEnd);
|
|
||||||
if (!sessionEnd) {
|
if (!sessionEnd) {
|
||||||
logger.info('Creating session start event', { event: payload });
|
logger.info('Creating session start event', { event: payload });
|
||||||
await createEventAndNotify(
|
await createEventAndNotify(
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|||||||
const projectId = 'test-project';
|
const projectId = 'test-project';
|
||||||
const currentDeviceId = 'device-123';
|
const currentDeviceId = 'device-123';
|
||||||
const previousDeviceId = 'device-456';
|
const previousDeviceId = 'device-456';
|
||||||
|
// Valid UUID used when creating a new session in tests
|
||||||
|
const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234';
|
||||||
const geo = {
|
const geo = {
|
||||||
country: 'US',
|
country: 'US',
|
||||||
city: 'New York',
|
city: 'New York',
|
||||||
@@ -67,7 +69,7 @@ describe('incomingEvent', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.only('should create a session start and an event', async () => {
|
it('should create a session start and an event', async () => {
|
||||||
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add');
|
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add');
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
// Mock job data
|
// Mock job data
|
||||||
@@ -90,12 +92,15 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId: currentDeviceId,
|
||||||
|
sessionId: newSessionId,
|
||||||
};
|
};
|
||||||
const event = {
|
const event = {
|
||||||
name: 'test_event',
|
name: 'test_event',
|
||||||
deviceId: currentDeviceId,
|
deviceId: currentDeviceId,
|
||||||
profileId: '',
|
profileId: '',
|
||||||
sessionId: expect.stringMatching(
|
sessionId: expect.stringMatching(
|
||||||
|
// biome-ignore lint/performance/useTopLevelRegex: test
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
),
|
),
|
||||||
projectId,
|
projectId,
|
||||||
@@ -182,6 +187,8 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId: currentDeviceId,
|
||||||
|
sessionId: 'session-123',
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeDelay = vi.fn();
|
const changeDelay = vi.fn();
|
||||||
@@ -263,6 +270,8 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId: '',
|
currentDeviceId: '',
|
||||||
previousDeviceId: '',
|
previousDeviceId: '',
|
||||||
|
deviceId: '',
|
||||||
|
sessionId: '',
|
||||||
uaInfo: uaInfoServer,
|
uaInfo: uaInfoServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -367,6 +376,8 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId: '',
|
currentDeviceId: '',
|
||||||
previousDeviceId: '',
|
previousDeviceId: '',
|
||||||
|
deviceId: '',
|
||||||
|
sessionId: '',
|
||||||
uaInfo: uaInfoServer,
|
uaInfo: uaInfoServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
botBuffer,
|
botBuffer,
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
profileBuffer,
|
profileBuffer,
|
||||||
|
replayBuffer,
|
||||||
sessionBuffer,
|
sessionBuffer,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
||||||
@@ -124,3 +125,14 @@ register.registerMetric(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
register.registerMetric(
|
||||||
|
new client.Gauge({
|
||||||
|
name: `buffer_${replayBuffer.name}_count`,
|
||||||
|
help: 'Number of unprocessed replay chunks',
|
||||||
|
async collect() {
|
||||||
|
const metric = await replayBuffer.getBufferSize();
|
||||||
|
this.set(metric);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -39,17 +39,20 @@ export async function getSessionEnd({
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
profileId,
|
profileId,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
|
deviceId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
}) {
|
}) {
|
||||||
const sessionEnd = await getSessionEndJob({
|
const sessionEnd = await getSessionEndJob({
|
||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionEnd) {
|
if (sessionEnd) {
|
||||||
@@ -81,6 +84,7 @@ export async function getSessionEndJob(args: {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string;
|
||||||
previousDeviceId: string;
|
previousDeviceId: string;
|
||||||
|
deviceId: string;
|
||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -130,20 +134,31 @@ export async function getSessionEndJob(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check current device job
|
// TODO: Remove this when migrated to deviceId
|
||||||
const currentJob = await sessionsQueue.getJob(
|
if (args.currentDeviceId && args.previousDeviceId) {
|
||||||
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
// Check current device job
|
||||||
);
|
const currentJob = await sessionsQueue.getJob(
|
||||||
if (currentJob) {
|
getSessionEndJobId(args.projectId, args.currentDeviceId),
|
||||||
return await handleJobStates(currentJob, args.currentDeviceId);
|
);
|
||||||
|
if (currentJob) {
|
||||||
|
return await handleJobStates(currentJob, args.currentDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check previous device job
|
||||||
|
const previousJob = await sessionsQueue.getJob(
|
||||||
|
getSessionEndJobId(args.projectId, args.previousDeviceId),
|
||||||
|
);
|
||||||
|
if (previousJob) {
|
||||||
|
return await handleJobStates(previousJob, args.previousDeviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check previous device job
|
// Check current device job
|
||||||
const previousJob = await sessionsQueue.getJob(
|
const currentJob = await sessionsQueue.getJob(
|
||||||
getSessionEndJobId(args.projectId, args.previousDeviceId),
|
getSessionEndJobId(args.projectId, args.deviceId),
|
||||||
);
|
);
|
||||||
if (previousJob) {
|
if (currentJob) {
|
||||||
return await handleJobStates(previousJob, args.previousDeviceId);
|
return await handleJobStates(currentJob, args.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export function shortId() {
|
|||||||
return nanoid(4);
|
return nanoid(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateId() {
|
export function generateId(prefix?: string, length?: number) {
|
||||||
return nanoid(8);
|
return prefix ? `${prefix}_${nanoid(length ?? 8)}` : nanoid(length ?? 8);
|
||||||
}
|
}
|
||||||
|
|||||||
60
packages/db/code-migrations/10-add-session-replay.ts
Normal file
60
packages/db/code-migrations/10-add-session-replay.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,19 @@ async function migrate() {
|
|||||||
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
|
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
|
||||||
|
|
||||||
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
|
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
|
||||||
const migrations = fs.readdirSync(migrationsDir).filter((file) => {
|
const migrations = fs
|
||||||
const version = file.split('-')[0];
|
.readdirSync(migrationsDir)
|
||||||
return (
|
.filter((file) => {
|
||||||
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
|
const version = file.split('-')[0];
|
||||||
);
|
return (
|
||||||
});
|
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aVersion = Number.parseInt(a.split('-')[0]!);
|
||||||
|
const bVersion = Number.parseInt(b.split('-')[0]!);
|
||||||
|
return aVersion - bVersion;
|
||||||
|
});
|
||||||
|
|
||||||
const finishedMigrations = await db.codeMigration.findMany();
|
const finishedMigrations = await db.codeMigration.findMany();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BotBuffer as BotBufferRedis } from './bot-buffer';
|
|||||||
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
||||||
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
||||||
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
||||||
|
import { ReplayBuffer } from './replay-buffer';
|
||||||
import { SessionBuffer } from './session-buffer';
|
import { SessionBuffer } from './session-buffer';
|
||||||
|
|
||||||
export const eventBuffer = new EventBufferRedis();
|
export const eventBuffer = new EventBufferRedis();
|
||||||
@@ -9,5 +10,7 @@ export const profileBuffer = new ProfileBufferRedis();
|
|||||||
export const botBuffer = new BotBufferRedis();
|
export const botBuffer = new BotBufferRedis();
|
||||||
export const sessionBuffer = new SessionBuffer();
|
export const sessionBuffer = new SessionBuffer();
|
||||||
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
||||||
|
export const replayBuffer = new ReplayBuffer();
|
||||||
|
|
||||||
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
||||||
|
export type { IClickhouseSessionReplayChunk } from './replay-buffer';
|
||||||
|
|||||||
92
packages/db/src/buffers/replay-buffer.ts
Normal file
92
packages/db/src/buffers/replay-buffer.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { type Redis, getRedisCache } from '@openpanel/redis';
|
|
||||||
|
|
||||||
import { getSafeJson } from '@openpanel/json';
|
import { getSafeJson } from '@openpanel/json';
|
||||||
|
import { getRedisCache, type Redis } from '@openpanel/redis';
|
||||||
import { assocPath, clone } from 'ramda';
|
import { assocPath, clone } from 'ramda';
|
||||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
import { ch, TABLE_NAMES } from '../clickhouse/client';
|
||||||
import type { IClickhouseEvent } from '../services/event.service';
|
import type { IClickhouseEvent } from '../services/event.service';
|
||||||
import type { IClickhouseSession } from '../services/session.service';
|
import type { IClickhouseSession } from '../services/session.service';
|
||||||
import { BaseBuffer } from './base-buffer';
|
import { BaseBuffer } from './base-buffer';
|
||||||
@@ -35,14 +34,14 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
| {
|
| {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
},
|
}
|
||||||
) {
|
) {
|
||||||
let hit: string | null = null;
|
let hit: string | null = null;
|
||||||
if ('sessionId' in options) {
|
if ('sessionId' in options) {
|
||||||
hit = await this.redis.get(`session:${options.sessionId}`);
|
hit = await this.redis.get(`session:${options.sessionId}`);
|
||||||
} else {
|
} else {
|
||||||
hit = await this.redis.get(
|
hit = await this.redis.get(
|
||||||
`session:${options.projectId}:${options.profileId}`,
|
`session:${options.projectId}:${options.profileId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSession(
|
async getSession(
|
||||||
event: IClickhouseEvent,
|
event: IClickhouseEvent
|
||||||
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
|
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
|
||||||
const existingSession = await this.getExistingSession({
|
const existingSession = await this.getExistingSession({
|
||||||
sessionId: event.session_id,
|
sessionId: event.session_id,
|
||||||
@@ -186,14 +185,14 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
`session:${newSession.id}`,
|
`session:${newSession.id}`,
|
||||||
JSON.stringify(newSession),
|
JSON.stringify(newSession),
|
||||||
'EX',
|
'EX',
|
||||||
60 * 60,
|
60 * 60
|
||||||
);
|
);
|
||||||
if (newSession.profile_id) {
|
if (newSession.profile_id) {
|
||||||
multi.set(
|
multi.set(
|
||||||
`session:${newSession.project_id}:${newSession.profile_id}`,
|
`session:${newSession.project_id}:${newSession.profile_id}`,
|
||||||
JSON.stringify(newSession),
|
JSON.stringify(newSession),
|
||||||
'EX',
|
'EX',
|
||||||
60 * 60,
|
60 * 60
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -220,10 +219,12 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
const events = await this.redis.lrange(
|
const events = await this.redis.lrange(
|
||||||
this.redisKey,
|
this.redisKey,
|
||||||
0,
|
0,
|
||||||
this.batchSize - 1,
|
this.batchSize - 1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (events.length === 0) return;
|
if (events.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sessions = events
|
const sessions = events
|
||||||
.map((e) => getSafeJson<IClickhouseSession>(e))
|
.map((e) => getSafeJson<IClickhouseSession>(e))
|
||||||
@@ -258,7 +259,7 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBufferSize() {
|
getBufferSize() {
|
||||||
return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey));
|
return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const TABLE_NAMES = {
|
|||||||
cohort_events_mv: 'cohort_events_mv',
|
cohort_events_mv: 'cohort_events_mv',
|
||||||
sessions: 'sessions',
|
sessions: 'sessions',
|
||||||
events_imports: 'events_imports',
|
events_imports: 'events_imports',
|
||||||
|
session_replay_chunks: 'session_replay_chunks',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
import { getSafeJson } from '@openpanel/json';
|
||||||
import { cacheable } from '@openpanel/redis';
|
import { cacheable } from '@openpanel/redis';
|
||||||
import type { IChartEventFilter } from '@openpanel/validation';
|
import type { IChartEventFilter } from '@openpanel/validation';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
|
||||||
ch,
|
ch,
|
||||||
chQuery,
|
chQuery,
|
||||||
|
convertClickhouseDateToJs,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
|
TABLE_NAMES,
|
||||||
} from '../clickhouse/client';
|
} from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
import { getOrganizationByProjectIdCached } from './organization.service';
|
import { getOrganizationByProjectIdCached } from './organization.service';
|
||||||
import { type IServiceProfile, getProfilesCached } from './profile.service';
|
import { getProfilesCached, type IServiceProfile } from './profile.service';
|
||||||
|
|
||||||
export type IClickhouseSession = {
|
export interface IClickhouseSession {
|
||||||
id: string;
|
id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
event_count: number;
|
event_count: number;
|
||||||
@@ -52,7 +54,9 @@ export type IClickhouseSession = {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
sign: 1 | 0;
|
sign: 1 | 0;
|
||||||
version: number;
|
version: number;
|
||||||
};
|
// Dynamically added
|
||||||
|
has_replay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IServiceSession {
|
export interface IServiceSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -91,6 +95,7 @@ export interface IServiceSession {
|
|||||||
utmTerm: string;
|
utmTerm: string;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
profile?: IServiceProfile;
|
profile?: IServiceProfile;
|
||||||
|
hasReplay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetSessionListOptions {
|
export interface GetSessionListOptions {
|
||||||
@@ -114,8 +119,8 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
entryOrigin: session.entry_origin,
|
entryOrigin: session.entry_origin,
|
||||||
exitPath: session.exit_path,
|
exitPath: session.exit_path,
|
||||||
exitOrigin: session.exit_origin,
|
exitOrigin: session.exit_origin,
|
||||||
createdAt: new Date(session.created_at),
|
createdAt: convertClickhouseDateToJs(session.created_at),
|
||||||
endedAt: new Date(session.ended_at),
|
endedAt: convertClickhouseDateToJs(session.ended_at),
|
||||||
referrer: session.referrer,
|
referrer: session.referrer,
|
||||||
referrerName: session.referrer_name,
|
referrerName: session.referrer_name,
|
||||||
referrerType: session.referrer_type,
|
referrerType: session.referrer_type,
|
||||||
@@ -142,19 +147,18 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
utmTerm: session.utm_term,
|
utmTerm: session.utm_term,
|
||||||
revenue: session.revenue,
|
revenue: session.revenue,
|
||||||
profile: undefined,
|
profile: undefined,
|
||||||
|
hasReplay: session.has_replay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Direction = 'initial' | 'next' | 'prev';
|
interface PageInfo {
|
||||||
|
|
||||||
type PageInfo = {
|
|
||||||
next?: Cursor; // use last row
|
next?: Cursor; // use last row
|
||||||
};
|
}
|
||||||
|
|
||||||
type Cursor = {
|
interface Cursor {
|
||||||
createdAt: string; // ISO 8601 with ms
|
createdAt: string; // ISO 8601 with ms
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export async function getSessionList({
|
export async function getSessionList({
|
||||||
cursor,
|
cursor,
|
||||||
@@ -176,8 +180,9 @@ export async function getSessionList({
|
|||||||
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
|
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profileId)
|
if (profileId) {
|
||||||
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
||||||
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const s = sqlstring.escape(`%${search}%`);
|
const s = sqlstring.escape(`%${search}%`);
|
||||||
sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`;
|
sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`;
|
||||||
@@ -191,13 +196,11 @@ export async function getSessionList({
|
|||||||
const dateIntervalInDays =
|
const dateIntervalInDays =
|
||||||
organization?.subscriptionPeriodEventsLimit &&
|
organization?.subscriptionPeriodEventsLimit &&
|
||||||
organization?.subscriptionPeriodEventsLimit > 1_000_000
|
organization?.subscriptionPeriodEventsLimit > 1_000_000
|
||||||
? 1
|
? 2
|
||||||
: 360;
|
: 360;
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const cAt = sqlstring.escape(cursor.createdAt);
|
const cAt = sqlstring.escape(cursor.createdAt);
|
||||||
// TODO: remove id from cursor
|
|
||||||
const cId = sqlstring.escape(cursor.id);
|
|
||||||
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
|
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
|
||||||
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
|
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
|
||||||
sb.orderBy.created_at = 'created_at DESC';
|
sb.orderBy.created_at = 'created_at DESC';
|
||||||
@@ -235,10 +238,14 @@ export async function getSessionList({
|
|||||||
sb.select[column] = column;
|
sb.select[column] = column;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sb.select.has_replay = `toBool(src.session_id != '') as hasReplay`;
|
||||||
|
sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`;
|
||||||
|
|
||||||
const sql = getSql();
|
const sql = getSql();
|
||||||
const data = await chQuery<
|
const data = await chQuery<
|
||||||
IClickhouseSession & {
|
IClickhouseSession & {
|
||||||
latestCreatedAt: string;
|
latestCreatedAt: string;
|
||||||
|
hasReplay: boolean;
|
||||||
}
|
}
|
||||||
>(sql);
|
>(sql);
|
||||||
|
|
||||||
@@ -321,23 +328,79 @@ export async function getSessionsCount({
|
|||||||
|
|
||||||
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
|
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
|
||||||
|
|
||||||
|
export interface ISessionReplayChunkMeta {
|
||||||
|
chunk_index: number;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string;
|
||||||
|
events_count: number;
|
||||||
|
is_full_snapshot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPLAY_CHUNKS_PAGE_SIZE = 40;
|
||||||
|
|
||||||
|
export async function getSessionReplayChunksFrom(
|
||||||
|
sessionId: string,
|
||||||
|
projectId: string,
|
||||||
|
fromIndex: number
|
||||||
|
) {
|
||||||
|
const rows = await chQuery<{ chunk_index: number; payload: string }>(
|
||||||
|
`SELECT chunk_index, payload
|
||||||
|
FROM ${TABLE_NAMES.session_replay_chunks}
|
||||||
|
WHERE session_id = ${sqlstring.escape(sessionId)}
|
||||||
|
AND project_id = ${sqlstring.escape(projectId)}
|
||||||
|
ORDER BY started_at, ended_at, chunk_index
|
||||||
|
LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1}
|
||||||
|
OFFSET ${fromIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows
|
||||||
|
.slice(0, REPLAY_CHUNKS_PAGE_SIZE)
|
||||||
|
.map((row, index) => {
|
||||||
|
const events = getSafeJson<
|
||||||
|
{ type: number; data: unknown; timestamp: number }[]
|
||||||
|
>(row.payload);
|
||||||
|
if (!events) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { chunkIndex: index + fromIndex, events };
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class SessionService {
|
class SessionService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
async byId(sessionId: string, projectId: string) {
|
async byId(sessionId: string, projectId: string) {
|
||||||
const result = await clix(this.client)
|
const [sessionRows, hasReplayRows] = await Promise.all([
|
||||||
.select<IClickhouseSession>(['*'])
|
clix(this.client)
|
||||||
.from(TABLE_NAMES.sessions)
|
.select<IClickhouseSession>(['*'])
|
||||||
.where('id', '=', sessionId)
|
.from(TABLE_NAMES.sessions, true)
|
||||||
.where('project_id', '=', projectId)
|
.where('id', '=', sessionId)
|
||||||
.where('sign', '=', 1)
|
.where('project_id', '=', projectId)
|
||||||
.execute();
|
.where('sign', '=', 1)
|
||||||
|
.execute(),
|
||||||
|
chQuery<{ n: number }>(
|
||||||
|
`SELECT 1 AS n
|
||||||
|
FROM ${TABLE_NAMES.session_replay_chunks}
|
||||||
|
WHERE session_id = ${sqlstring.escape(sessionId)}
|
||||||
|
AND project_id = ${sqlstring.escape(projectId)}
|
||||||
|
LIMIT 1`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!result[0]) {
|
if (!sessionRows[0]) {
|
||||||
throw new Error('Session not found');
|
throw new Error('Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return transformSession(result[0]);
|
const session = transformSession(sessionRows[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
hasReplay: hasReplayRows.length > 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ export interface EventsQueuePayloadIncomingEvent {
|
|||||||
latitude: number | undefined;
|
latitude: number | undefined;
|
||||||
};
|
};
|
||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
currentDeviceId: string;
|
currentDeviceId: string; // TODO: Remove
|
||||||
previousDeviceId: string;
|
previousDeviceId: string; // TODO: Remove
|
||||||
|
deviceId: string;
|
||||||
|
sessionId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface EventsQueuePayloadCreateEvent {
|
export interface EventsQueuePayloadCreateEvent {
|
||||||
@@ -123,12 +125,17 @@ export type CronQueuePayloadFlushProfileBackfill = {
|
|||||||
type: 'flushProfileBackfill';
|
type: 'flushProfileBackfill';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
export type CronQueuePayloadFlushReplay = {
|
||||||
|
type: 'flushReplay';
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
export type CronQueuePayload =
|
export type CronQueuePayload =
|
||||||
| CronQueuePayloadSalt
|
| CronQueuePayloadSalt
|
||||||
| CronQueuePayloadFlushEvents
|
| CronQueuePayloadFlushEvents
|
||||||
| CronQueuePayloadFlushSessions
|
| CronQueuePayloadFlushSessions
|
||||||
| CronQueuePayloadFlushProfiles
|
| CronQueuePayloadFlushProfiles
|
||||||
| CronQueuePayloadFlushProfileBackfill
|
| CronQueuePayloadFlushProfileBackfill
|
||||||
|
| CronQueuePayloadFlushReplay
|
||||||
| CronQueuePayloadPing
|
| CronQueuePayloadPing
|
||||||
| CronQueuePayloadProject
|
| CronQueuePayloadProject
|
||||||
| CronQueuePayloadInsightsDaily
|
| CronQueuePayloadInsightsDaily
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { getInitSnippet } from '@openpanel/web';
|
|||||||
|
|
||||||
type Props = Omit<OpenPanelOptions, 'filter'> & {
|
type Props = Omit<OpenPanelOptions, 'filter'> & {
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
/** @deprecated Use `scriptUrl` instead. */
|
||||||
cdnUrl?: string;
|
cdnUrl?: string;
|
||||||
|
scriptUrl?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
globalProperties?: Record<string, unknown>;
|
globalProperties?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { profileId, cdnUrl, globalProperties, ...options } = Astro.props;
|
const { profileId, cdnUrl, scriptUrl, globalProperties, ...options } = Astro.props;
|
||||||
|
|
||||||
const CDN_URL = 'https://openpanel.dev/op1.js';
|
const CDN_URL = 'https://openpanel.dev/op1.js';
|
||||||
|
|
||||||
@@ -60,5 +62,5 @@ ${methods
|
|||||||
.join('\n')}`;
|
.join('\n')}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<script src={cdnUrl ?? CDN_URL} async defer />
|
<script src={scriptUrl ?? cdnUrl ?? CDN_URL} async defer />
|
||||||
<script is:inline set:html={scriptContent} />
|
<script is:inline set:html={scriptContent} />
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
// adding .js next/script import fixes an issues
|
|
||||||
// with esm and nextjs (when using pages dir)
|
|
||||||
import Script from 'next/script.js';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DecrementPayload,
|
DecrementPayload,
|
||||||
IdentifyPayload,
|
IdentifyPayload,
|
||||||
@@ -12,6 +7,11 @@ import type {
|
|||||||
TrackProperties,
|
TrackProperties,
|
||||||
} from '@openpanel/web';
|
} from '@openpanel/web';
|
||||||
import { getInitSnippet } from '@openpanel/web';
|
import { getInitSnippet } from '@openpanel/web';
|
||||||
|
// adding .js next/script import fixes an issues
|
||||||
|
// with esm and nextjs (when using pages dir)
|
||||||
|
import Script from 'next/script.js';
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: nextjs requires this
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
export * from '@openpanel/web';
|
export * from '@openpanel/web';
|
||||||
|
|
||||||
@@ -19,7 +19,9 @@ const CDN_URL = 'https://openpanel.dev/op1.js';
|
|||||||
|
|
||||||
type OpenPanelComponentProps = Omit<OpenPanelOptions, 'filter'> & {
|
type OpenPanelComponentProps = Omit<OpenPanelOptions, 'filter'> & {
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
/** @deprecated Use `scriptUrl` instead. */
|
||||||
cdnUrl?: string;
|
cdnUrl?: string;
|
||||||
|
scriptUrl?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
globalProperties?: Record<string, unknown>;
|
globalProperties?: Record<string, unknown>;
|
||||||
strategy?: 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker';
|
strategy?: 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker';
|
||||||
@@ -42,6 +44,7 @@ const stringify = (obj: unknown) => {
|
|||||||
export function OpenPanelComponent({
|
export function OpenPanelComponent({
|
||||||
profileId,
|
profileId,
|
||||||
cdnUrl,
|
cdnUrl,
|
||||||
|
scriptUrl,
|
||||||
globalProperties,
|
globalProperties,
|
||||||
strategy = 'afterInteractive',
|
strategy = 'afterInteractive',
|
||||||
...options
|
...options
|
||||||
@@ -80,10 +83,8 @@ export function OpenPanelComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script src={appendVersion(cdnUrl || CDN_URL)} async defer />
|
<Script async defer src={appendVersion(scriptUrl || cdnUrl || CDN_URL)} />
|
||||||
<Script
|
<Script
|
||||||
id="openpanel-init"
|
|
||||||
strategy={strategy}
|
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `${getInitSnippet()}
|
__html: `${getInitSnippet()}
|
||||||
${methods
|
${methods
|
||||||
@@ -92,6 +93,8 @@ export function OpenPanelComponent({
|
|||||||
})
|
})
|
||||||
.join('\n')}`,
|
.join('\n')}`,
|
||||||
}}
|
}}
|
||||||
|
id="openpanel-init"
|
||||||
|
strategy={strategy}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -101,25 +104,21 @@ type IdentifyComponentProps = IdentifyPayload;
|
|||||||
|
|
||||||
export function IdentifyComponent(props: IdentifyComponentProps) {
|
export function IdentifyComponent(props: IdentifyComponentProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Script
|
||||||
<Script
|
dangerouslySetInnerHTML={{
|
||||||
dangerouslySetInnerHTML={{
|
__html: `window.op('identify', ${JSON.stringify(props)});`,
|
||||||
__html: `window.op('identify', ${JSON.stringify(props)});`,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetGlobalPropertiesComponent(props: Record<string, unknown>) {
|
export function SetGlobalPropertiesComponent(props: Record<string, unknown>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Script
|
||||||
<Script
|
dangerouslySetInnerHTML={{
|
||||||
dangerouslySetInnerHTML={{
|
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
|
||||||
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +136,7 @@ export function useOpenPanel() {
|
|||||||
clearRevenue,
|
clearRevenue,
|
||||||
pendingRevenue,
|
pendingRevenue,
|
||||||
fetchDeviceId,
|
fetchDeviceId,
|
||||||
|
getDeviceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ function screenView(properties?: TrackProperties): void;
|
|||||||
function screenView(path: string, properties?: TrackProperties): void;
|
function screenView(path: string, properties?: TrackProperties): void;
|
||||||
function screenView(
|
function screenView(
|
||||||
pathOrProperties?: string | TrackProperties,
|
pathOrProperties?: string | TrackProperties,
|
||||||
propertiesOrUndefined?: TrackProperties,
|
propertiesOrUndefined?: TrackProperties
|
||||||
) {
|
) {
|
||||||
window.op?.('screenView', pathOrProperties, propertiesOrUndefined);
|
window.op?.('screenView', pathOrProperties, propertiesOrUndefined);
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,9 @@ function decrement(payload: DecrementPayload) {
|
|||||||
function fetchDeviceId() {
|
function fetchDeviceId() {
|
||||||
return window.op.fetchDeviceId();
|
return window.op.fetchDeviceId();
|
||||||
}
|
}
|
||||||
|
function getDeviceId() {
|
||||||
|
return window.op.getDeviceId();
|
||||||
|
}
|
||||||
function clearRevenue() {
|
function clearRevenue() {
|
||||||
window.op.clearRevenue();
|
window.op.clearRevenue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export type OpenPanelOptions = {
|
|||||||
export class OpenPanel {
|
export class OpenPanel {
|
||||||
api: Api;
|
api: Api;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
sessionId?: string;
|
||||||
global?: Record<string, unknown>;
|
global?: Record<string, unknown>;
|
||||||
queue: TrackHandlerPayload[] = [];
|
queue: TrackHandlerPayload[] = [];
|
||||||
|
|
||||||
@@ -69,6 +71,16 @@ export class OpenPanel {
|
|||||||
this.flush();
|
this.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldQueue(payload: TrackHandlerPayload): boolean {
|
||||||
|
if (payload.type === 'replay' && !this.sessionId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.options.waitForProfile && !this.profileId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async send(payload: TrackHandlerPayload) {
|
async send(payload: TrackHandlerPayload) {
|
||||||
if (this.options.disabled) {
|
if (this.options.disabled) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -78,11 +90,26 @@ export class OpenPanel {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.waitForProfile && !this.profileId) {
|
if (this.shouldQueue(payload)) {
|
||||||
this.queue.push(payload);
|
this.queue.push(payload);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return this.api.fetch('/track', payload);
|
|
||||||
|
// Disable keepalive for replay since it has a hard body limit and breaks the request
|
||||||
|
const result = await this.api.fetch<
|
||||||
|
TrackHandlerPayload,
|
||||||
|
{ deviceId: string; sessionId: string }
|
||||||
|
>('/track', payload, { keepalive: payload.type !== 'replay' });
|
||||||
|
this.deviceId = result?.deviceId;
|
||||||
|
const hadSession = !!this.sessionId;
|
||||||
|
this.sessionId = result?.sessionId;
|
||||||
|
|
||||||
|
// Flush queued items (e.g. replay chunks) when sessionId first arrives
|
||||||
|
if (!hadSession && this.sessionId) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGlobalProperties(properties: Record<string, unknown>) {
|
setGlobalProperties(properties: Record<string, unknown>) {
|
||||||
@@ -149,7 +176,7 @@ export class OpenPanel {
|
|||||||
|
|
||||||
async revenue(
|
async revenue(
|
||||||
amount: number,
|
amount: number,
|
||||||
properties?: TrackProperties & { deviceId?: string },
|
properties?: TrackProperties & { deviceId?: string }
|
||||||
) {
|
) {
|
||||||
const deviceId = properties?.deviceId;
|
const deviceId = properties?.deviceId;
|
||||||
delete properties?.deviceId;
|
delete properties?.deviceId;
|
||||||
@@ -160,33 +187,47 @@ export class OpenPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchDeviceId(): Promise<string> {
|
getDeviceId(): string {
|
||||||
const result = await this.api.fetch<undefined, { deviceId: string }>(
|
return this.deviceId ?? '';
|
||||||
'/track/device-id',
|
}
|
||||||
undefined,
|
|
||||||
{ method: 'GET', keepalive: false },
|
getSessionId(): string {
|
||||||
);
|
return this.sessionId ?? '';
|
||||||
return result?.deviceId ?? '';
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `getDeviceId()` instead. This async method is no longer needed.
|
||||||
|
*/
|
||||||
|
fetchDeviceId(): Promise<string> {
|
||||||
|
return Promise.resolve(this.deviceId ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.profileId = undefined;
|
this.profileId = undefined;
|
||||||
// should we force a session end here?
|
this.deviceId = undefined;
|
||||||
|
this.sessionId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
this.queue.forEach((item) => {
|
const remaining: TrackHandlerPayload[] = [];
|
||||||
this.send({
|
for (const item of this.queue) {
|
||||||
...item,
|
if (this.shouldQueue(item)) {
|
||||||
// Not sure why ts-expect-error is needed here
|
remaining.push(item);
|
||||||
// @ts-expect-error
|
continue;
|
||||||
payload: {
|
}
|
||||||
...item.payload,
|
const payload =
|
||||||
profileId: item.payload.profileId ?? this.profileId,
|
item.type === 'replay'
|
||||||
},
|
? item.payload
|
||||||
});
|
: {
|
||||||
});
|
...item.payload,
|
||||||
this.queue = [];
|
profileId:
|
||||||
|
'profileId' in item.payload
|
||||||
|
? (item.payload.profileId ?? this.profileId)
|
||||||
|
: this.profileId,
|
||||||
|
};
|
||||||
|
this.send({ ...item, payload } as TrackHandlerPayload);
|
||||||
|
}
|
||||||
|
this.queue = remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(...args: any[]) {
|
log(...args: any[]) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openpanel/sdk": "workspace:1.0.4-local"
|
"@openpanel/sdk": "workspace:1.0.4-local",
|
||||||
|
"@rrweb/types": "2.0.0-alpha.20",
|
||||||
|
"rrweb": "2.0.0-alpha.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
@@ -18,4 +20,4 @@
|
|||||||
"tsup": "^7.2.0",
|
"tsup": "^7.2.0",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,43 @@ import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
|||||||
export type * from '@openpanel/sdk';
|
export type * from '@openpanel/sdk';
|
||||||
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||||
|
|
||||||
|
export type SessionReplayOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
sampleRate?: number;
|
||||||
|
maskAllInputs?: boolean;
|
||||||
|
maskTextSelector?: string;
|
||||||
|
blockSelector?: string;
|
||||||
|
blockClass?: string;
|
||||||
|
ignoreSelector?: string;
|
||||||
|
flushIntervalMs?: number;
|
||||||
|
maxEventsPerChunk?: number;
|
||||||
|
maxPayloadBytes?: number;
|
||||||
|
/**
|
||||||
|
* URL to the replay recorder script.
|
||||||
|
* Only used when loading the SDK via a script tag (IIFE / op1.js).
|
||||||
|
* When using the npm package with a bundler this option is ignored
|
||||||
|
* because the bundler resolves the replay module from the package.
|
||||||
|
*/
|
||||||
|
scriptUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Injected at build time only in the IIFE (tracker) build.
|
||||||
|
// In the library build this is `undefined`.
|
||||||
|
declare const __OPENPANEL_REPLAY_URL__: string | undefined;
|
||||||
|
|
||||||
|
// Capture script element synchronously; currentScript is only set during sync execution.
|
||||||
|
// Used by loadReplayModule() to derive the replay script URL in the IIFE build.
|
||||||
|
const _replayScriptRef: HTMLScriptElement | null =
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? (document.currentScript as HTMLScriptElement | null)
|
||||||
|
: null;
|
||||||
|
|
||||||
export type OpenPanelOptions = OpenPanelBaseOptions & {
|
export type OpenPanelOptions = OpenPanelBaseOptions & {
|
||||||
trackOutgoingLinks?: boolean;
|
trackOutgoingLinks?: boolean;
|
||||||
trackScreenViews?: boolean;
|
trackScreenViews?: boolean;
|
||||||
trackAttributes?: boolean;
|
trackAttributes?: boolean;
|
||||||
trackHashChanges?: boolean;
|
trackHashChanges?: boolean;
|
||||||
|
sessionReplay?: SessionReplayOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toCamelCase(str: string) {
|
function toCamelCase(str: string) {
|
||||||
@@ -66,6 +98,75 @@ export class OpenPanel extends OpenPanelBase {
|
|||||||
if (this.options.trackAttributes) {
|
if (this.options.trackAttributes) {
|
||||||
this.trackAttributes();
|
this.trackAttributes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.options.sessionReplay?.enabled) {
|
||||||
|
const sampleRate = this.options.sessionReplay.sampleRate ?? 1;
|
||||||
|
const sampled = Math.random() < sampleRate;
|
||||||
|
if (sampled) {
|
||||||
|
this.loadReplayModule().then((mod) => {
|
||||||
|
if (!mod) return;
|
||||||
|
mod.startReplayRecorder(this.options.sessionReplay!, (chunk) => {
|
||||||
|
this.send({
|
||||||
|
type: 'replay',
|
||||||
|
payload: {
|
||||||
|
...chunk,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the replay recorder module.
|
||||||
|
*
|
||||||
|
* - **IIFE build (op1.js)**: `__OPENPANEL_REPLAY_URL__` is replaced at
|
||||||
|
* build time with a CDN URL (e.g. `https://openpanel.dev/op1-replay.js`).
|
||||||
|
* The user can also override it via `sessionReplay.scriptUrl`.
|
||||||
|
* We load the IIFE replay script via a classic `<script>` tag which
|
||||||
|
* avoids CORS issues (dynamic `import(url)` uses `cors` mode).
|
||||||
|
* The IIFE exposes its exports on `window.__openpanel_replay`.
|
||||||
|
*
|
||||||
|
* - **Library build (npm)**: `__OPENPANEL_REPLAY_URL__` is `undefined`
|
||||||
|
* (never replaced). We use `import('./replay')` which the host app's
|
||||||
|
* bundler resolves and code-splits from the package source.
|
||||||
|
*/
|
||||||
|
private async loadReplayModule(): Promise<typeof import('./replay') | null> {
|
||||||
|
try {
|
||||||
|
// typeof check avoids a ReferenceError when the constant is not
|
||||||
|
// defined (library build). tsup replaces the constant with a
|
||||||
|
// string literal only in the IIFE build, so this branch is
|
||||||
|
// dead-code-eliminated in the library build.
|
||||||
|
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
|
||||||
|
const scriptEl = _replayScriptRef;
|
||||||
|
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
|
||||||
|
|
||||||
|
// Already loaded (e.g. user included the script manually)
|
||||||
|
if ((window as any).__openpanel_replay) {
|
||||||
|
return (window as any).__openpanel_replay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load via classic <script> tag — no CORS restrictions
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = url;
|
||||||
|
script.onload = () => {
|
||||||
|
resolve((window as any).__openpanel_replay ?? null);
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
console.warn('[OpenPanel] Failed to load replay script from', url);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Library / bundler context — resolved by the bundler
|
||||||
|
return await import('./replay');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[OpenPanel] Failed to load replay module', e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/sdks/web/src/replay/index.ts
Normal file
2
packages/sdks/web/src/replay/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { startReplayRecorder, stopReplayRecorder } from './recorder';
|
||||||
|
export type { ReplayChunkPayload, ReplayRecorderConfig } from './recorder';
|
||||||
160
packages/sdks/web/src/replay/recorder.ts
Normal file
160
packages/sdks/web/src/replay/recorder.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/sdks/web/src/types.d.ts
vendored
6
packages/sdks/web/src/types.d.ts
vendored
@@ -14,7 +14,9 @@ type ExposedMethodsNames =
|
|||||||
| 'clearRevenue'
|
| 'clearRevenue'
|
||||||
| 'pendingRevenue'
|
| 'pendingRevenue'
|
||||||
| 'screenView'
|
| 'screenView'
|
||||||
| 'fetchDeviceId';
|
| 'fetchDeviceId'
|
||||||
|
| 'getDeviceId'
|
||||||
|
| 'getSessionId';
|
||||||
|
|
||||||
export type ExposedMethods = {
|
export type ExposedMethods = {
|
||||||
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
|
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
|
||||||
@@ -38,7 +40,7 @@ type OpenPanelMethodSignatures = {
|
|||||||
} & {
|
} & {
|
||||||
screenView(
|
screenView(
|
||||||
pathOrProperties?: string | TrackProperties,
|
pathOrProperties?: string | TrackProperties,
|
||||||
properties?: TrackProperties,
|
properties?: TrackProperties
|
||||||
): void;
|
): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Test callable function API
|
// Test callable function API
|
||||||
|
/** biome-ignore-all lint/correctness/noUnusedVariables: test */
|
||||||
function testCallableAPI() {
|
function testCallableAPI() {
|
||||||
// ✅ Should work - correct callable syntax
|
// ✅ Should work - correct callable syntax
|
||||||
window.op('track', 'button_clicked', { location: 'header' });
|
window.op('track', 'button_clicked', { location: 'header' });
|
||||||
@@ -29,6 +30,7 @@ function testDirectMethodAPI() {
|
|||||||
window.op.flushRevenue();
|
window.op.flushRevenue();
|
||||||
window.op.clearRevenue();
|
window.op.clearRevenue();
|
||||||
window.op.fetchDeviceId();
|
window.op.fetchDeviceId();
|
||||||
|
window.op.getDeviceId();
|
||||||
|
|
||||||
// ❌ Should error - wrong arguments for track
|
// ❌ Should error - wrong arguments for track
|
||||||
// @ts-expect-error - track expects (name: string, properties?: TrackProperties)
|
// @ts-expect-error - track expects (name: string, properties?: TrackProperties)
|
||||||
|
|||||||
@@ -1,11 +1,70 @@
|
|||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig([
|
||||||
entry: ['index.ts', 'src/tracker.ts'],
|
// Library build (npm package) — cjs + esm + dts
|
||||||
format: ['cjs', 'esm', 'iife'],
|
// Dynamic import('./replay') is preserved; the host app's bundler
|
||||||
dts: true,
|
// will code-split it into a separate chunk automatically.
|
||||||
splitting: false,
|
{
|
||||||
sourcemap: false,
|
entry: ['index.ts'],
|
||||||
clean: true,
|
format: ['cjs', 'esm'],
|
||||||
minify: true,
|
dts: true,
|
||||||
});
|
splitting: false,
|
||||||
|
sourcemap: false,
|
||||||
|
clean: true,
|
||||||
|
minify: true,
|
||||||
|
},
|
||||||
|
// IIFE build (script tag: op1.js)
|
||||||
|
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
|
||||||
|
// knows to load the replay module from the CDN instead of a
|
||||||
|
// relative import (which doesn't work in a standalone script).
|
||||||
|
// The replay module is excluded via an esbuild plugin so it is
|
||||||
|
// never bundled into op1.js — it will be loaded lazily via <script>.
|
||||||
|
{
|
||||||
|
entry: { 'src/tracker': 'src/tracker.ts' },
|
||||||
|
format: ['iife'],
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: false,
|
||||||
|
minify: true,
|
||||||
|
define: {
|
||||||
|
__OPENPANEL_REPLAY_URL__: JSON.stringify(
|
||||||
|
'https://openpanel.dev/op1-replay.js'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
esbuildPlugins: [
|
||||||
|
{
|
||||||
|
name: 'exclude-replay-from-iife',
|
||||||
|
setup(build) {
|
||||||
|
// Intercept any import that resolves to the replay module and
|
||||||
|
// return an empty object. The actual loading happens at runtime
|
||||||
|
// via a <script> tag (see loadReplayModule in index.ts).
|
||||||
|
build.onResolve(
|
||||||
|
{ filter: /[/\\]replay([/\\]index)?(\.[jt]s)?$/ },
|
||||||
|
() => ({
|
||||||
|
path: 'replay-empty-stub',
|
||||||
|
namespace: 'replay-stub',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'replay-stub' }, () => ({
|
||||||
|
contents: 'module.exports = {}',
|
||||||
|
loader: 'js',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Replay module — built as both ESM (npm) and IIFE (CDN).
|
||||||
|
// ESM → consumed by the host-app's bundler via `import('./replay')`.
|
||||||
|
// IIFE → loaded at runtime via a classic <script> tag (no CORS issues).
|
||||||
|
// Exposes `window.__openpanel_replay`.
|
||||||
|
// rrweb must be bundled in (noExternal) because browsers can't resolve
|
||||||
|
// bare specifiers like "rrweb" from a standalone ES module / script.
|
||||||
|
{
|
||||||
|
entry: { 'src/replay': 'src/replay/index.ts' },
|
||||||
|
format: ['esm', 'iife'],
|
||||||
|
globalName: '__openpanel_replay',
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: false,
|
||||||
|
minify: true,
|
||||||
|
noExternal: ['rrweb', '@rrweb/types'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import {
|
||||||
|
getSessionList,
|
||||||
import { getSessionList, sessionService } from '@openpanel/db';
|
getSessionReplayChunksFrom,
|
||||||
|
sessionService,
|
||||||
|
} from '@openpanel/db';
|
||||||
import { zChartEventFilter } from '@openpanel/validation';
|
import { zChartEventFilter } from '@openpanel/validation';
|
||||||
|
import { z } from 'zod';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
export function encodeCursor(cursor: {
|
export function encodeCursor(cursor: {
|
||||||
@@ -14,7 +16,7 @@ export function encodeCursor(cursor: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decodeCursor(
|
export function decodeCursor(
|
||||||
encoded: string,
|
encoded: string
|
||||||
): { createdAt: string; id: string } | null {
|
): { createdAt: string; id: string } | null {
|
||||||
try {
|
try {
|
||||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||||
@@ -40,7 +42,7 @@ export const sessionRouter = createTRPCRouter({
|
|||||||
endDate: z.date().optional(),
|
endDate: z.date().optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
take: z.number().default(50),
|
take: z.number().default(50),
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
|
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
|
||||||
@@ -58,7 +60,19 @@ export const sessionRouter = createTRPCRouter({
|
|||||||
|
|
||||||
byId: protectedProcedure
|
byId: protectedProcedure
|
||||||
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
|
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
|
||||||
.query(async ({ input: { sessionId, projectId } }) => {
|
.query(({ input: { sessionId, projectId } }) => {
|
||||||
return sessionService.byId(sessionId, projectId);
|
return sessionService.byId(sessionId, projectId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
replayChunksFrom: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
fromIndex: z.number().int().min(0).default(0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(({ input: { sessionId, projectId, fromIndex } }) => {
|
||||||
|
return getSessionReplayChunksFrom(sessionId, projectId, fromIndex);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export const zAliasPayload = z.object({
|
|||||||
alias: z.string().min(1),
|
alias: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zReplayPayload = z.object({
|
||||||
|
chunk_index: z.number().int().min(0).max(65_535),
|
||||||
|
events_count: z.number().int().min(1),
|
||||||
|
is_full_snapshot: z.boolean(),
|
||||||
|
started_at: z.string().datetime(),
|
||||||
|
ended_at: z.string().datetime(),
|
||||||
|
payload: z.string().max(1_048_576 * 2), // 2MB max
|
||||||
|
});
|
||||||
|
|
||||||
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('track'),
|
type: z.literal('track'),
|
||||||
@@ -84,6 +93,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('alias'),
|
type: z.literal('alias'),
|
||||||
payload: zAliasPayload,
|
payload: zAliasPayload,
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('replay'),
|
||||||
|
payload: zReplayPayload,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
||||||
@@ -91,6 +104,7 @@ export type IIdentifyPayload = z.infer<typeof zIdentifyPayload>;
|
|||||||
export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
|
export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
|
||||||
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
||||||
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
||||||
|
export type IReplayPayload = z.infer<typeof zReplayPayload>;
|
||||||
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
||||||
|
|
||||||
// Deprecated types for beta version of the SDKs
|
// Deprecated types for beta version of the SDKs
|
||||||
|
|||||||
137
pnpm-lock.yaml
generated
137
pnpm-lock.yaml
generated
@@ -465,6 +465,9 @@ importers:
|
|||||||
'@openpanel/payments':
|
'@openpanel/payments':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/payments
|
version: link:../../packages/payments
|
||||||
|
'@openpanel/sdk':
|
||||||
|
specifier: ^1.0.8
|
||||||
|
version: 1.0.8
|
||||||
'@openpanel/sdk-info':
|
'@openpanel/sdk-info':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/sdks/_info
|
version: link:../../packages/sdks/_info
|
||||||
@@ -472,8 +475,8 @@ importers:
|
|||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/validation
|
version: link:../../packages/validation
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: ^1.0.1
|
specifier: ^1.0.12
|
||||||
version: 1.0.1
|
version: 1.0.12
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
specifier: ^1.2.12
|
||||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -774,6 +777,9 @@ importers:
|
|||||||
remark-rehype:
|
remark-rehype:
|
||||||
specifier: ^11.1.2
|
specifier: ^11.1.2
|
||||||
version: 11.1.2
|
version: 11.1.2
|
||||||
|
rrweb-player:
|
||||||
|
specifier: 2.0.0-alpha.20
|
||||||
|
version: 2.0.0-alpha.20
|
||||||
short-unique-id:
|
short-unique-id:
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
version: 5.0.3
|
version: 5.0.3
|
||||||
@@ -1478,7 +1484,7 @@ importers:
|
|||||||
packages/sdks/astro:
|
packages/sdks/astro:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
devDependencies:
|
devDependencies:
|
||||||
astro:
|
astro:
|
||||||
@@ -1491,7 +1497,7 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../common
|
version: link:../../common
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
express:
|
express:
|
||||||
specifier: ^4.17.0 || ^5.0.0
|
specifier: ^4.17.0 || ^5.0.0
|
||||||
@@ -1516,7 +1522,7 @@ importers:
|
|||||||
packages/sdks/nextjs:
|
packages/sdks/nextjs:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
next:
|
next:
|
||||||
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||||
@@ -1544,7 +1550,7 @@ importers:
|
|||||||
packages/sdks/nuxt:
|
packages/sdks/nuxt:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
h3:
|
h3:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
@@ -1581,7 +1587,7 @@ importers:
|
|||||||
packages/sdks/react-native:
|
packages/sdks/react-native:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
expo-application:
|
expo-application:
|
||||||
specifier: 5 - 7
|
specifier: 5 - 7
|
||||||
@@ -1627,8 +1633,14 @@ importers:
|
|||||||
packages/sdks/web:
|
packages/sdks/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
|
'@rrweb/types':
|
||||||
|
specifier: 2.0.0-alpha.20
|
||||||
|
version: 2.0.0-alpha.20
|
||||||
|
rrweb:
|
||||||
|
specifier: 2.0.0-alpha.20
|
||||||
|
version: 2.0.0-alpha.20
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@openpanel/tsconfig':
|
'@openpanel/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -5864,14 +5876,14 @@ packages:
|
|||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.0':
|
|
||||||
resolution: {integrity: sha512-FNmmfjdXoC/VHEjA+WkrQ4lyM5lxEmV7xDd57uj4E+lIS0sU3DLG2mV/dpS8AscnZbUvuMn3kPhiLCqYzuv/gg==}
|
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.2':
|
'@openpanel/sdk@1.0.2':
|
||||||
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
|
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
|
||||||
|
|
||||||
'@openpanel/web@1.0.1':
|
'@openpanel/sdk@1.0.8':
|
||||||
resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==}
|
resolution: {integrity: sha512-mr7HOZ/vqrJaATDFxcv3yyLjXcUXgsfboa0o0GlhiAYUh2B1Q0kgsm5qkfbtZhTqYP4BmNCWRkfRlpFp4pfpPQ==}
|
||||||
|
|
||||||
|
'@openpanel/web@1.0.12':
|
||||||
|
resolution: {integrity: sha512-39oL19HYrw4qAzlxbtFP/rfLOaciWJXCxPwL6bk+u4SUcrrOrwmjSg0CwQYyrd2p3wp1QnsCiyv2n0EtEpjQMA==}
|
||||||
|
|
||||||
'@openpanel/web@1.0.5':
|
'@openpanel/web@1.0.5':
|
||||||
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
|
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
|
||||||
@@ -8705,6 +8717,18 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@rrweb/packer@2.0.0-alpha.20':
|
||||||
|
resolution: {integrity: sha512-GsByg2olGZ2n3To6keFG604QAboipuXZvjYxO2wITSwARBf/sZdy6cbUEjF0RS+QnuTM5GaVXeQapNMLmpKbrA==}
|
||||||
|
|
||||||
|
'@rrweb/replay@2.0.0-alpha.20':
|
||||||
|
resolution: {integrity: sha512-VodsLb+C2bYNNVbb0U14tKLa9ctzUxYIlt9VnxPATWvfyXHLTku8BhRWptuW/iIjVjmG49LBoR1ilxw/HMiJ1w==}
|
||||||
|
|
||||||
|
'@rrweb/types@2.0.0-alpha.20':
|
||||||
|
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
|
||||||
|
|
||||||
|
'@rrweb/utils@2.0.0-alpha.20':
|
||||||
|
resolution: {integrity: sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==}
|
||||||
|
|
||||||
'@segment/loosely-validate-event@2.0.0':
|
'@segment/loosely-validate-event@2.0.0':
|
||||||
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
||||||
|
|
||||||
@@ -9656,6 +9680,9 @@ packages:
|
|||||||
'@tsconfig/node18@1.0.3':
|
'@tsconfig/node18@1.0.3':
|
||||||
resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==}
|
resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==}
|
||||||
|
|
||||||
|
'@tsconfig/svelte@1.0.13':
|
||||||
|
resolution: {integrity: sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==}
|
||||||
|
|
||||||
'@turf/boolean-point-in-polygon@6.5.0':
|
'@turf/boolean-point-in-polygon@6.5.0':
|
||||||
resolution: {integrity: sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==}
|
resolution: {integrity: sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==}
|
||||||
|
|
||||||
@@ -9722,6 +9749,9 @@ packages:
|
|||||||
'@types/cors@2.8.17':
|
'@types/cors@2.8.17':
|
||||||
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
|
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
|
||||||
|
|
||||||
|
'@types/css-font-loading-module@0.0.7':
|
||||||
|
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
|
||||||
|
|
||||||
'@types/d3-array@3.2.1':
|
'@types/d3-array@3.2.1':
|
||||||
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
|
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
|
||||||
|
|
||||||
@@ -10391,6 +10421,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
|
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
'@xstate/fsm@1.6.5':
|
||||||
|
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
||||||
|
|
||||||
abbrev@2.0.0:
|
abbrev@2.0.0:
|
||||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@@ -10782,6 +10815,10 @@ packages:
|
|||||||
base-64@1.0.0:
|
base-64@1.0.0:
|
||||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
base64-js@0.0.2:
|
base64-js@0.0.2:
|
||||||
resolution: {integrity: sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==}
|
resolution: {integrity: sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -12679,6 +12716,9 @@ packages:
|
|||||||
fetchdts@0.1.7:
|
fetchdts@0.1.7:
|
||||||
resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==}
|
resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==}
|
||||||
|
|
||||||
|
fflate@0.4.8:
|
||||||
|
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||||
|
|
||||||
fifo@2.4.1:
|
fifo@2.4.1:
|
||||||
resolution: {integrity: sha512-XTbUCNmo54Jav0hcL6VxDuY4x1eCQH61HEF80C2Oww283pfjQ2C8avZeyq4v43sW2S2403kmzssE9j4lbF66Sg==}
|
resolution: {integrity: sha512-XTbUCNmo54Jav0hcL6VxDuY4x1eCQH61HEF80C2Oww283pfjQ2C8avZeyq4v43sW2S2403kmzssE9j4lbF66Sg==}
|
||||||
|
|
||||||
@@ -16943,9 +16983,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
rrdom@2.0.0-alpha.20:
|
||||||
|
resolution: {integrity: sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==}
|
||||||
|
|
||||||
rrweb-cssom@0.8.0:
|
rrweb-cssom@0.8.0:
|
||||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
|
|
||||||
|
rrweb-player@2.0.0-alpha.20:
|
||||||
|
resolution: {integrity: sha512-3ZCv1ksUxuIOn3Vn/eWrwWs9Xy+4KVjISD+q26ZLfisZ3hZ0CPgYG3iC22pmmycIeMS2svOfvf7gPh7jExwpUA==}
|
||||||
|
|
||||||
|
rrweb-snapshot@2.0.0-alpha.20:
|
||||||
|
resolution: {integrity: sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==}
|
||||||
|
|
||||||
|
rrweb@2.0.0-alpha.20:
|
||||||
|
resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==}
|
||||||
|
|
||||||
run-applescript@7.1.0:
|
run-applescript@7.1.0:
|
||||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -24961,13 +25013,15 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.0': {}
|
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.2': {}
|
'@openpanel/sdk@1.0.2': {}
|
||||||
|
|
||||||
'@openpanel/web@1.0.1':
|
'@openpanel/sdk@1.0.8': {}
|
||||||
|
|
||||||
|
'@openpanel/web@1.0.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/sdk': 1.0.0
|
'@openpanel/sdk': 1.0.8
|
||||||
|
'@rrweb/types': 2.0.0-alpha.20
|
||||||
|
rrweb: 2.0.0-alpha.20
|
||||||
|
|
||||||
'@openpanel/web@1.0.5':
|
'@openpanel/web@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -28030,6 +28084,20 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.52.5':
|
'@rollup/rollup-win32-x64-msvc@4.52.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@rrweb/packer@2.0.0-alpha.20':
|
||||||
|
dependencies:
|
||||||
|
'@rrweb/types': 2.0.0-alpha.20
|
||||||
|
fflate: 0.4.8
|
||||||
|
|
||||||
|
'@rrweb/replay@2.0.0-alpha.20':
|
||||||
|
dependencies:
|
||||||
|
'@rrweb/types': 2.0.0-alpha.20
|
||||||
|
rrweb: 2.0.0-alpha.20
|
||||||
|
|
||||||
|
'@rrweb/types@2.0.0-alpha.20': {}
|
||||||
|
|
||||||
|
'@rrweb/utils@2.0.0-alpha.20': {}
|
||||||
|
|
||||||
'@segment/loosely-validate-event@2.0.0':
|
'@segment/loosely-validate-event@2.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
component-type: 1.2.2
|
component-type: 1.2.2
|
||||||
@@ -29384,6 +29452,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tsconfig/node18@1.0.3': {}
|
'@tsconfig/node18@1.0.3': {}
|
||||||
|
|
||||||
|
'@tsconfig/svelte@1.0.13': {}
|
||||||
|
|
||||||
'@turf/boolean-point-in-polygon@6.5.0':
|
'@turf/boolean-point-in-polygon@6.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@turf/helpers': 6.5.0
|
'@turf/helpers': 6.5.0
|
||||||
@@ -29474,6 +29544,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.24
|
'@types/node': 20.19.24
|
||||||
|
|
||||||
|
'@types/css-font-loading-module@0.0.7': {}
|
||||||
|
|
||||||
'@types/d3-array@3.2.1': {}
|
'@types/d3-array@3.2.1': {}
|
||||||
|
|
||||||
'@types/d3-axis@3.0.6':
|
'@types/d3-axis@3.0.6':
|
||||||
@@ -30364,6 +30436,8 @@ snapshots:
|
|||||||
|
|
||||||
'@xmldom/xmldom@0.8.10': {}
|
'@xmldom/xmldom@0.8.10': {}
|
||||||
|
|
||||||
|
'@xstate/fsm@1.6.5': {}
|
||||||
|
|
||||||
abbrev@2.0.0: {}
|
abbrev@2.0.0: {}
|
||||||
|
|
||||||
abbrev@3.0.1: {}
|
abbrev@3.0.1: {}
|
||||||
@@ -30909,6 +30983,8 @@ snapshots:
|
|||||||
|
|
||||||
base-64@1.0.0: {}
|
base-64@1.0.0: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
base64-js@0.0.2: {}
|
base64-js@0.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
@@ -33466,6 +33542,8 @@ snapshots:
|
|||||||
|
|
||||||
fetchdts@0.1.7: {}
|
fetchdts@0.1.7: {}
|
||||||
|
|
||||||
|
fflate@0.4.8: {}
|
||||||
|
|
||||||
fifo@2.4.1: {}
|
fifo@2.4.1: {}
|
||||||
|
|
||||||
figures@5.0.0:
|
figures@5.0.0:
|
||||||
@@ -39012,8 +39090,33 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
rrdom@2.0.0-alpha.20:
|
||||||
|
dependencies:
|
||||||
|
rrweb-snapshot: 2.0.0-alpha.20
|
||||||
|
|
||||||
rrweb-cssom@0.8.0: {}
|
rrweb-cssom@0.8.0: {}
|
||||||
|
|
||||||
|
rrweb-player@2.0.0-alpha.20:
|
||||||
|
dependencies:
|
||||||
|
'@rrweb/packer': 2.0.0-alpha.20
|
||||||
|
'@rrweb/replay': 2.0.0-alpha.20
|
||||||
|
'@tsconfig/svelte': 1.0.13
|
||||||
|
|
||||||
|
rrweb-snapshot@2.0.0-alpha.20:
|
||||||
|
dependencies:
|
||||||
|
postcss: 8.5.6
|
||||||
|
|
||||||
|
rrweb@2.0.0-alpha.20:
|
||||||
|
dependencies:
|
||||||
|
'@rrweb/types': 2.0.0-alpha.20
|
||||||
|
'@rrweb/utils': 2.0.0-alpha.20
|
||||||
|
'@types/css-font-loading-module': 0.0.7
|
||||||
|
'@xstate/fsm': 1.6.5
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
mitt: 3.0.1
|
||||||
|
rrdom: 2.0.0-alpha.20
|
||||||
|
rrweb-snapshot: 2.0.0-alpha.20
|
||||||
|
|
||||||
run-applescript@7.1.0: {}
|
run-applescript@7.1.0: {}
|
||||||
|
|
||||||
run-async@2.4.1: {}
|
run-async@2.4.1: {}
|
||||||
|
|||||||
41
test.ts
41
test.ts
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -261,6 +261,9 @@ const publishPackages = (
|
|||||||
execSync(
|
execSync(
|
||||||
`cp ${workspacePath('packages/sdks/web/dist/src/tracker.global.js')} ${workspacePath('./apps/public/public/op1.js')}`,
|
`cp ${workspacePath('packages/sdks/web/dist/src/tracker.global.js')} ${workspacePath('./apps/public/public/op1.js')}`,
|
||||||
);
|
);
|
||||||
|
execSync(
|
||||||
|
`cp ${workspacePath('packages/sdks/web/dist/src/replay.global.js')} ${workspacePath('./apps/public/public/op1-replay.js')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user