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