Compare commits
6 Commits
feature/op
...
feature/gs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a96e7b038 | ||
|
|
fc256124b5 | ||
|
|
df0258f532 | ||
|
|
0f9e5f6e93 | ||
|
|
c9cf7901ad | ||
|
|
2981638893 |
@@ -30,6 +30,7 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
@@ -39,7 +40,6 @@
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "catalog:",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
// Pre-compile regex patterns at module load time
|
||||
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheable(
|
||||
export const isBot = cacheableLru(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
@@ -40,5 +40,8 @@ export const isBot = cacheable(
|
||||
|
||||
return null;
|
||||
},
|
||||
60 * 5
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
import { chQuery, db } from '@openpanel/db';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
|
||||
// For docker compose healthcheck
|
||||
export async function healthcheck(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
const redisRes = await getRedisCache().ping();
|
||||
@@ -21,7 +21,6 @@ export async function healthcheck(
|
||||
ch: chRes && chRes.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.warn('healthcheck failed', { error });
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
@@ -42,22 +41,18 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
// Perform lightweight dependency checks for readiness
|
||||
const redisRes = await getRedisCache().ping();
|
||||
const dbRes = await db.$executeRaw`SELECT 1`;
|
||||
const dbRes = await db.project.findFirst();
|
||||
const chRes = await chQuery('SELECT 1');
|
||||
|
||||
const isReady = redisRes;
|
||||
const isReady = redisRes && dbRes && chRes;
|
||||
|
||||
if (!isReady) {
|
||||
const res = {
|
||||
redis: redisRes === 'PONG',
|
||||
db: !!dbRes,
|
||||
ch: chRes && chRes.length > 0,
|
||||
};
|
||||
request.log.warn('dependencies not ready', res);
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
...res,
|
||||
redis: redisRes === 'PONG',
|
||||
db: !!dbRes,
|
||||
ch: chRes && chRes.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
@@ -7,7 +14,10 @@ import {
|
||||
} from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
|
||||
export function wsVisitors(
|
||||
socket: WebSocket,
|
||||
@@ -15,38 +25,27 @@ export function wsVisitors(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const { params } = req;
|
||||
const sendCount = () => {
|
||||
eventBuffer
|
||||
.getActiveVisitorCount(params.projectId)
|
||||
.then((count) => {
|
||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
socket.send(String(count));
|
||||
})
|
||||
.catch(() => {
|
||||
socket.send('0');
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
'batch',
|
||||
({ projectId }) => {
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [, , projectId] = key.split(':');
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
const [projectId] = getLiveEventInfo(key);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
socket.send(String(count));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on('close', () => {
|
||||
@@ -63,10 +62,18 @@ export async function wsProjectEvents(
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
type?: 'saved' | 'received';
|
||||
};
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const { params } = req;
|
||||
const { params, query } = req;
|
||||
const type = query.type || 'saved';
|
||||
|
||||
if (!['saved', 'received'].includes(type)) {
|
||||
socket.send('Invalid type');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
@@ -80,20 +87,24 @@ export async function wsProjectEvents(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
socket.send('No access');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
'batch',
|
||||
({ projectId, count }) => {
|
||||
if (projectId === params.projectId) {
|
||||
socket.send(setSuperJson({ count }));
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
? {
|
||||
...event,
|
||||
profile,
|
||||
}
|
||||
: transformMinimalEvent(event),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -105,7 +116,7 @@ export async function wsProjectNotifications(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -132,9 +143,9 @@ export async function wsProjectNotifications(
|
||||
'created',
|
||||
(notification) => {
|
||||
if (notification.projectId === params.projectId) {
|
||||
socket.send(setSuperJson(notification));
|
||||
socket.send(superjson.stringify(notification));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -146,7 +157,7 @@ export async function wsOrganizationEvents(
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -173,7 +184,7 @@ export async function wsOrganizationEvents(
|
||||
'subscription_updated',
|
||||
(message) => {
|
||||
socket.send(setSuperJson(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
@@ -74,7 +74,7 @@ export async function listProjects(
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -92,7 +92,7 @@ export async function getProject(
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
@@ -139,9 +139,12 @@ export async function createProject(
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
@@ -162,7 +165,7 @@ export async function updateProject(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
@@ -220,9 +223,12 @@ export async function updateProject(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
@@ -230,7 +236,7 @@ export async function updateProject(
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -260,7 +266,7 @@ export async function deleteProject(
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
@@ -294,7 +300,7 @@ export async function listClients(
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -312,7 +318,7 @@ export async function getClient(
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
@@ -368,7 +374,7 @@ export async function updateClient(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
@@ -411,7 +417,7 @@ export async function updateClient(
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -438,7 +444,7 @@ export async function deleteClient(
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
@@ -482,7 +488,7 @@ export async function listReferences(
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
@@ -510,7 +516,7 @@ export async function getReference(
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
@@ -553,7 +559,7 @@ export async function updateReference(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
@@ -610,7 +616,7 @@ export async function updateReference(
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import {
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
getEventsGroupQueueShard,
|
||||
} from '@openpanel/queue';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
@@ -115,7 +112,6 @@ interface TrackContext {
|
||||
identity?: IIdentifyPayload;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
@@ -145,21 +141,19 @@ async function buildContext(
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
const overrideDeviceId =
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
// Get geo location (needed for track and identify)
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
|
||||
const deviceIdResult = await getDeviceId({
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId,
|
||||
overrideDeviceId:
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -172,9 +166,8 @@ async function buildContext(
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
deviceId: deviceIdResult.deviceId,
|
||||
sessionId: deviceIdResult.sessionId,
|
||||
session: deviceIdResult.session,
|
||||
deviceId,
|
||||
sessionId,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
@@ -183,14 +176,13 @@ async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
|
||||
context;
|
||||
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}`
|
||||
: undefined
|
||||
: `${projectId}:${generateId()}`
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
@@ -211,7 +203,7 @@ async function handleTrack(
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId || generateId()).add({
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
@@ -225,7 +217,6 @@ async function handleTrack(
|
||||
geo,
|
||||
deviceId,
|
||||
sessionId,
|
||||
session,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isBot } from '@/bots';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const bot = req.headers['user-agent']
|
||||
? await isBot(req.headers['user-agent'])
|
||||
? isBot(req.headers['user-agent'])
|
||||
: null;
|
||||
|
||||
if (bot && req.client?.projectId) {
|
||||
@@ -43,6 +44,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send({ bot });
|
||||
return reply.status(202).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
@@ -12,7 +13,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler,
|
||||
handler: handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { generateDeviceId } from '@openpanel/common/server';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type {
|
||||
EventsQueuePayloadCreateSessionEnd,
|
||||
EventsQueuePayloadIncomingEvent,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import { pick } from 'ramda';
|
||||
|
||||
export async function getDeviceId({
|
||||
projectId,
|
||||
@@ -42,20 +37,14 @@ export async function getDeviceId({
|
||||
ua,
|
||||
});
|
||||
|
||||
return await getInfoFromSession({
|
||||
return await getDeviceIdFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
interface DeviceIdResult {
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
}
|
||||
|
||||
async function getInfoFromSession({
|
||||
async function getDeviceIdFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
@@ -63,7 +52,7 @@ async function getInfoFromSession({
|
||||
projectId: string;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
}): Promise<DeviceIdResult> {
|
||||
}) {
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.hget(
|
||||
@@ -76,33 +65,21 @@ async function getInfoFromSession({
|
||||
);
|
||||
const res = await multi.exec();
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
(res?.[0]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: currentDeviceId, sessionId };
|
||||
}
|
||||
}
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
(res?.[1]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
return {
|
||||
deviceId: previousDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: previousDeviceId, sessionId };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -59,7 +59,7 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
|
||||
|
||||
## Insights
|
||||
|
||||
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
|
||||
If you have configured [Insights](/features/insights) for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,38 +1,56 @@
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
ChevronRightIcon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
PlayCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { NotificationsIllustration } from '@/components/illustrations/notifications';
|
||||
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
|
||||
import { RetentionIllustration } from '@/components/illustrations/retention';
|
||||
import { SessionReplayIllustration } from '@/components/illustrations/session-replay';
|
||||
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
|
||||
function wrap(child: React.ReactNode) {
|
||||
return <div className="h-48 overflow-hidden">{child}</div>;
|
||||
}
|
||||
|
||||
const mediumFeatures = [
|
||||
const features = [
|
||||
{
|
||||
title: 'Retention',
|
||||
title: 'Revenue tracking',
|
||||
description:
|
||||
'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
|
||||
illustration: wrap(<RetentionIllustration />),
|
||||
link: { href: '/features/retention', children: 'View retention' },
|
||||
'Track revenue from your payments and get insights into your revenue sources.',
|
||||
icon: DollarSignIcon,
|
||||
link: {
|
||||
href: '/features/revenue-tracking',
|
||||
children: 'More about revenue',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Profiles & Sessions',
|
||||
description:
|
||||
'Track individual users and their complete journey across your platform.',
|
||||
icon: GlobeIcon,
|
||||
link: {
|
||||
href: '/features/identify-users',
|
||||
children: 'Identify your users',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Event Tracking',
|
||||
description:
|
||||
'Capture every important interaction with flexible event tracking.',
|
||||
icon: BarChart3Icon,
|
||||
link: {
|
||||
href: '/features/event-tracking',
|
||||
children: 'All about tracking',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Session Replay',
|
||||
description:
|
||||
'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
|
||||
illustration: wrap(<SessionReplayIllustration />),
|
||||
link: { href: '/features/session-replay', children: 'See session replay' },
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description:
|
||||
'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
|
||||
illustration: wrap(<NotificationsIllustration />),
|
||||
link: { href: '/features/notifications', children: 'Set up notifications' },
|
||||
'Watch real user sessions to see exactly what happened. Privacy controls built in, loads async.',
|
||||
icon: PlayCircleIcon,
|
||||
link: {
|
||||
href: '/features/session-replay',
|
||||
children: 'See session replay',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,39 +59,37 @@ export function AnalyticsInsights() {
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
description="From first page view to long-term retention — every touchpoint in one platform. No sampling, no data limits, no guesswork."
|
||||
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
|
||||
label="ANALYTICS & INSIGHTS"
|
||||
title="Everything you need to understand your users"
|
||||
title="See the full picture of your users and product performance"
|
||||
/>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FeatureCard
|
||||
className="px-0 **:data-content:px-6"
|
||||
description="Understand your website performance with privacy-first analytics. Track visitors, referrers, and page views without touching user cookies."
|
||||
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
|
||||
illustration={<WebAnalyticsIllustration />}
|
||||
title="Web Analytics"
|
||||
variant="large"
|
||||
/>
|
||||
<FeatureCard
|
||||
className="px-0 **:data-content:px-6"
|
||||
description="Go beyond page views. Track custom events, understand user flows, and explore exactly how people use your product."
|
||||
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
|
||||
illustration={<ProductAnalyticsIllustration />}
|
||||
title="Product Analytics"
|
||||
variant="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{mediumFeatures.map((feature) => (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
className="px-0 pt-0 **:data-content:px-6"
|
||||
description={feature.description}
|
||||
illustration={feature.illustration}
|
||||
icon={feature.icon}
|
||||
key={feature.title}
|
||||
link={feature.link}
|
||||
title={feature.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
|
||||
@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Flexible data visualization',
|
||||
title: 'Visualize your data',
|
||||
description:
|
||||
'Build line charts, bar charts, sankey flows, and custom dashboards. Combine metrics from any event into a single view.',
|
||||
'See your data in a visual way. You can create advanced reports and more to understand',
|
||||
icon: ChartBarIcon,
|
||||
slug: 'data-visualization',
|
||||
},
|
||||
{
|
||||
title: 'Share & Collaborate',
|
||||
description:
|
||||
'Invite unlimited team members with org-wide or project-level access. Share dashboards publicly or lock them behind a password.',
|
||||
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.',
|
||||
icon: LayoutDashboardIcon,
|
||||
slug: 'share-and-collaborate',
|
||||
},
|
||||
{
|
||||
title: 'Integrations & Webhooks',
|
||||
title: 'Integrations',
|
||||
description:
|
||||
'Forward events to your own systems or third-party tools. Connect OpenPanel to Slack, your data warehouse, or any webhook endpoint.',
|
||||
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.',
|
||||
icon: WorkflowIcon,
|
||||
slug: 'integrations',
|
||||
},
|
||||
|
||||
@@ -43,9 +43,9 @@ export function DataPrivacy() {
|
||||
/>
|
||||
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FeatureCard
|
||||
description="GDPR compliant and privacy-friendly analytics without cookies or invasive tracking. Data is EU hosted, and a Data Processing Agreement (DPA) is available to sign."
|
||||
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
|
||||
illustration={<PrivacyIllustration />}
|
||||
title="GDPR compliant"
|
||||
title="Privacy-first"
|
||||
variant="large"
|
||||
/>
|
||||
<FeatureCard
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { ConversionsIllustration } from '@/components/illustrations/conversions';
|
||||
import { GoogleSearchConsoleIllustration } from '@/components/illustrations/google-search-console';
|
||||
import { RevenueIllustration } from '@/components/illustrations/revenue';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
|
||||
function wrap(child: React.ReactNode) {
|
||||
return <div className="h-48 overflow-hidden">{child}</div>;
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Revenue Tracking',
|
||||
description:
|
||||
'Connect payment events to track MRR and see which referrers drive the most revenue.',
|
||||
illustration: wrap(<RevenueIllustration />),
|
||||
link: {
|
||||
href: '/features/revenue-tracking',
|
||||
children: 'Track revenue',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Conversion Tracking',
|
||||
description:
|
||||
'Monitor conversion rates over time and break down by A/B variant, country, or device. Catch regressions before they cost you.',
|
||||
illustration: wrap(<ConversionsIllustration />),
|
||||
link: {
|
||||
href: '/features/conversion',
|
||||
children: 'Track conversions',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Google Search Console',
|
||||
description:
|
||||
'See which search queries bring organic traffic and how visitors convert after landing. Your SEO and product data, in one place.',
|
||||
illustration: wrap(<GoogleSearchConsoleIllustration />),
|
||||
link: {
|
||||
href: '/features/integrations',
|
||||
children: 'View integrations',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function FeatureSpotlight() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
description="OpenPanel goes beyond page views. Track revenue, monitor conversions, and connect your SEO data — all without switching tools."
|
||||
label="GROWTH TOOLS"
|
||||
title="Built for teams who ship and measure"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
className="px-0 pt-0 **:data-content:px-6"
|
||||
description={feature.description}
|
||||
illustration={feature.illustration}
|
||||
key={feature.title}
|
||||
link={feature.link}
|
||||
title={feature.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
CalendarIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
GithubIcon,
|
||||
ShieldCheckIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Competition } from '@/components/competition';
|
||||
import { EuFlag } from '@/components/eu-flag';
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Perks } from '@/components/perks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
|
||||
{ text: 'EU hosted', icon: EuFlag },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
const aspectRatio = 2946 / 1329;
|
||||
@@ -90,7 +90,7 @@ export function Hero() {
|
||||
TRUSTED BY 1,000+ PROJECTS
|
||||
</div>
|
||||
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
|
||||
The open-source alternative to <Competition />
|
||||
OpenPanel - The open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines the
|
||||
|
||||
@@ -55,9 +55,6 @@ export function Pricing() {
|
||||
<div className="col mt-8 w-full items-baseline md:mt-auto">
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
|
||||
30-day free trial
|
||||
</span>
|
||||
<div className="row items-end gap-3">
|
||||
<NumberFlow
|
||||
className="font-bold text-5xl"
|
||||
@@ -70,6 +67,9 @@ export function Pricing() {
|
||||
locales={'en-US'}
|
||||
value={selected.price}
|
||||
/>
|
||||
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
|
||||
30-day free trial
|
||||
</span>
|
||||
</div>
|
||||
<div className="row w-full justify-between">
|
||||
<span className="-mt-2 text-muted-foreground/80 text-sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { QuoteIcon, StarIcon } from 'lucide-react';
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Markdown from 'react-markdown';
|
||||
import { FeatureCardBackground } from '@/components/feature-card';
|
||||
@@ -94,22 +94,13 @@ export function WhyOpenPanel() {
|
||||
))}
|
||||
</div>
|
||||
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
|
||||
{quotes.slice(0, 2).map((quote) => (
|
||||
{quotes.map((quote) => (
|
||||
<figure
|
||||
className="group px-4 py-4 md:odd:border-r"
|
||||
key={quote.author}
|
||||
>
|
||||
<div className="row items-center justify-between">
|
||||
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
|
||||
<div className="row gap-1">
|
||||
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
|
||||
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
|
||||
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
|
||||
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
|
||||
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<blockquote className="prose text-justify text-xl">
|
||||
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
|
||||
<blockquote className="prose text-xl">
|
||||
<Markdown>{quote.quote}</Markdown>
|
||||
</blockquote>
|
||||
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnalyticsInsights } from './_sections/analytics-insights';
|
||||
import { Collaboration } from './_sections/collaboration';
|
||||
import { FeatureSpotlight } from './_sections/feature-spotlight';
|
||||
import { CtaBanner } from './_sections/cta-banner';
|
||||
import { DataPrivacy } from './_sections/data-privacy';
|
||||
import { Faq } from './_sections/faq';
|
||||
@@ -58,7 +57,6 @@ export default function HomePage() {
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<AnalyticsInsights />
|
||||
<FeatureSpotlight />
|
||||
<Collaboration />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
function star(cx: number, cy: number, outerR: number, innerR: number) {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR;
|
||||
const angle = (i * Math.PI) / 5 - Math.PI / 2;
|
||||
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
const STARS = Array.from({ length: 12 }, (_, i) => {
|
||||
const angle = (i * 30 - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 12 + 5 * Math.cos(angle),
|
||||
y: 8 + 5 * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
|
||||
export function EuFlag({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect fill="#003399" height="16" rx="1.5" width="24" />
|
||||
{STARS.map((s, i) => (
|
||||
<polygon
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static data
|
||||
key={i}
|
||||
fill="#FFCC00"
|
||||
points={star(s.x, s.y, 1.1, 0.45)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
|
||||
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
|
||||
const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
|
||||
|
||||
export function ConversionsIllustration() {
|
||||
return (
|
||||
<div className="h-full col gap-3 px-4 pb-3 pt-5">
|
||||
{/* A/B variant cards */}
|
||||
<div className="row gap-3">
|
||||
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
Variant A
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-xl">28.4%</span>
|
||||
<SimpleChart
|
||||
height={24}
|
||||
points={variantA}
|
||||
strokeColor="var(--foreground)"
|
||||
width={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
|
||||
Variant B ↑
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-xl text-emerald-500">
|
||||
41.2%
|
||||
</span>
|
||||
<SimpleChart
|
||||
height={24}
|
||||
points={variantB}
|
||||
strokeColor="rgb(34, 197, 94)"
|
||||
width={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown label */}
|
||||
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
Breakdown by experiment variant
|
||||
</span>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-foreground/50"
|
||||
style={{ width: '57%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">A: 57%</span>
|
||||
</div>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-emerald-500"
|
||||
style={{ width: '82%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">B: 82%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
const queries = [
|
||||
{
|
||||
query: 'openpanel analytics',
|
||||
clicks: 312,
|
||||
impressions: '4.1k',
|
||||
pos: 1.2,
|
||||
},
|
||||
{
|
||||
query: 'open source mixpanel alternative',
|
||||
clicks: 187,
|
||||
impressions: '3.8k',
|
||||
pos: 2.4,
|
||||
},
|
||||
{
|
||||
query: 'web analytics without cookies',
|
||||
clicks: 98,
|
||||
impressions: '2.2k',
|
||||
pos: 4.7,
|
||||
},
|
||||
];
|
||||
|
||||
export function GoogleSearchConsoleIllustration() {
|
||||
return (
|
||||
<div className="col h-full gap-2 px-4 pt-5 pb-3">
|
||||
{/* Top stats */}
|
||||
<div className="row mb-1 gap-2">
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Clicks
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">740</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Impr.
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">13k</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Avg. CTR
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">5.7%</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Avg. Pos
|
||||
</span>
|
||||
<span className="font-bold font-mono text-sm">2.8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query table */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="row border-border border-b px-3 py-1.5">
|
||||
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Query
|
||||
</span>
|
||||
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
|
||||
Pos
|
||||
</span>
|
||||
</div>
|
||||
{queries.map((q, i) => (
|
||||
<div
|
||||
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
|
||||
key={q.query}
|
||||
style={{ opacity: 1 - i * 0.18 }}
|
||||
>
|
||||
<span className="flex-1 truncate text-[9px]">{q.query}</span>
|
||||
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
|
||||
{q.pos}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { CheckCircleIcon } from 'lucide-react';
|
||||
|
||||
export function NotificationsIllustration() {
|
||||
return (
|
||||
<div className="col h-full justify-center gap-3 px-6 py-4">
|
||||
{/* Funnel completion notification */}
|
||||
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
|
||||
<div className="row items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
|
||||
<span className="font-semibold text-xs">Funnel completed</span>
|
||||
<span className="ml-auto text-[9px] text-muted-foreground">
|
||||
just now
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm">Signup Flow — 142 today</p>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-emerald-500"
|
||||
style={{ width: '71%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
71% conversion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification rule */}
|
||||
<div className="col gap-1.5 px-3 opacity-80">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
Notification rule
|
||||
</span>
|
||||
<div className="row flex-wrap items-center gap-1.5">
|
||||
<span className="text-[9px] text-muted-foreground">When</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
Signup Flow
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">completes →</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
|
||||
#growth
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client';
|
||||
|
||||
const cohorts = [
|
||||
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
|
||||
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
|
||||
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
|
||||
{ label: 'Week 4', values: [100, 70, null, null, null] },
|
||||
];
|
||||
|
||||
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
|
||||
|
||||
function cellStyle(v: number | null) {
|
||||
if (v === null) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--muted-foreground)',
|
||||
};
|
||||
}
|
||||
const opacity = 0.12 + (v / 100) * 0.7;
|
||||
return {
|
||||
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
|
||||
borderColor: `rgba(34, 197, 94, 0.3)`,
|
||||
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
|
||||
};
|
||||
}
|
||||
|
||||
export function RetentionIllustration() {
|
||||
return (
|
||||
<div className="h-full px-4 pb-3 pt-5">
|
||||
<div className="col h-full gap-1.5">
|
||||
<div className="row gap-1">
|
||||
<div className="w-12 shrink-0" />
|
||||
{headers.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex-1 text-center text-[9px] text-muted-foreground"
|
||||
>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cohorts.map(({ label, values }) => (
|
||||
<div key={label} className="row flex-1 gap-1">
|
||||
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static data
|
||||
key={i}
|
||||
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
|
||||
style={cellStyle(v)}
|
||||
>
|
||||
{v !== null ? `${v}%` : '—'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
|
||||
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
|
||||
|
||||
const referrers = [
|
||||
{ name: 'google.com', amount: '$3,840', pct: 46 },
|
||||
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
|
||||
{ name: 'github.com', amount: '$1,260', pct: 15 },
|
||||
{ name: 'direct', amount: '$1,400', pct: 16 },
|
||||
];
|
||||
|
||||
export function RevenueIllustration() {
|
||||
return (
|
||||
<div className="h-full col gap-3 px-4 pb-3 pt-5">
|
||||
{/* MRR stat + chart */}
|
||||
<div className="row gap-3">
|
||||
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
MRR
|
||||
</span>
|
||||
<span className="font-bold font-mono text-xl text-emerald-500">
|
||||
$8,420
|
||||
</span>
|
||||
<span className="text-[9px] text-emerald-500">↑ 12% this month</span>
|
||||
</div>
|
||||
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
|
||||
<span className="text-[9px] text-muted-foreground">MRR over time</span>
|
||||
<SimpleChart
|
||||
className="mt-1 flex-1"
|
||||
height={36}
|
||||
points={revenuePoints}
|
||||
strokeColor="rgb(34, 197, 94)"
|
||||
width={400}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue by referrer */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="row border-b border-border px-3 py-1.5">
|
||||
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
|
||||
Referrer
|
||||
</span>
|
||||
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
|
||||
Revenue
|
||||
</span>
|
||||
</div>
|
||||
{referrers.map((r) => (
|
||||
<div
|
||||
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
|
||||
key={r.name}
|
||||
>
|
||||
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
|
||||
{r.name}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-1 rounded-full bg-emerald-500/70"
|
||||
style={{ width: `${r.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] text-emerald-500 flex-none">
|
||||
{r.amount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { PlayIcon } from 'lucide-react';
|
||||
|
||||
export function SessionReplayIllustration() {
|
||||
return (
|
||||
<div className="h-full px-6 pb-3 pt-4">
|
||||
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
|
||||
{/* Browser chrome */}
|
||||
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-400" />
|
||||
<div className="h-2 w-2 rounded-full bg-green-400" />
|
||||
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
|
||||
app.example.com/pricing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="relative flex-1 overflow-hidden p-3">
|
||||
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
|
||||
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
|
||||
<div className="row mb-3 gap-2">
|
||||
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
|
||||
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
|
||||
</div>
|
||||
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
|
||||
<div className="h-2 w-24 rounded-full bg-muted/20" />
|
||||
|
||||
{/* Click heatspot */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: '62%', top: '48%' }}
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: '25%', top: '32%' }}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
|
||||
</div>
|
||||
|
||||
{/* Cursor trail */}
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<path
|
||||
d="M 18% 22% Q 42% 28% 62% 48%"
|
||||
fill="none"
|
||||
stroke="rgb(59 130 246 / 0.35)"
|
||||
strokeDasharray="3 2"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Cursor */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: 'calc(62% + 8px)',
|
||||
top: 'calc(48% + 6px)',
|
||||
}}
|
||||
>
|
||||
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
|
||||
<path
|
||||
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback bar */}
|
||||
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
|
||||
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
|
||||
style={{ width: '42%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[8px] text-muted-foreground">
|
||||
0:52 / 2:05
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowUpIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const STATS = [
|
||||
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
|
||||
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
|
||||
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
|
||||
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
|
||||
];
|
||||
|
||||
const SOURCES = [
|
||||
const TRAFFIC_SOURCES = [
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
|
||||
name: 'google.com',
|
||||
pct: 49,
|
||||
name: 'Google',
|
||||
percentage: 49,
|
||||
value: 2039,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
|
||||
name: 'Instagram',
|
||||
percentage: 23,
|
||||
value: 920,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
|
||||
name: 'Facebook',
|
||||
percentage: 18,
|
||||
value: 750,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
|
||||
name: 'twitter.com',
|
||||
pct: 21,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
|
||||
name: 'github.com',
|
||||
pct: 14,
|
||||
name: 'Twitter',
|
||||
percentage: 10,
|
||||
value: 412,
|
||||
},
|
||||
];
|
||||
|
||||
function AreaChart({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data);
|
||||
const w = 400;
|
||||
const h = 64;
|
||||
const xStep = w / (data.length - 1);
|
||||
const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
|
||||
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
|
||||
const area = `${line} L ${w},${h} L 0,${h} Z`;
|
||||
const last = pts[pts.length - 1];
|
||||
|
||||
return (
|
||||
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
|
||||
<defs>
|
||||
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#wa-fill)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="rgb(59 130 246)"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
|
||||
<circle
|
||||
cx={last.x}
|
||||
cy={last.y}
|
||||
fill="none"
|
||||
r="6"
|
||||
stroke="rgb(59 130 246)"
|
||||
strokeOpacity="0.3"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const COUNTRIES = [
|
||||
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
|
||||
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
|
||||
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
|
||||
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
|
||||
];
|
||||
|
||||
export function WebAnalyticsIllustration() {
|
||||
const [liveVisitors, setLiveVisitors] = useState(47);
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const values = [47, 51, 44, 53, 49, 56];
|
||||
let i = 0;
|
||||
const id = setInterval(() => {
|
||||
i = (i + 1) % values.length;
|
||||
setLiveVisitors(values[i]);
|
||||
}, 2500);
|
||||
return () => clearInterval(id);
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="aspect-video col gap-2.5 p-5">
|
||||
{/* Header */}
|
||||
<div className="row items-center justify-between">
|
||||
<div className="row items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
<NumberFlow value={liveVisitors} /> online now
|
||||
</span>
|
||||
<div className="px-12 group aspect-video">
|
||||
<div className="relative h-full col">
|
||||
<MetricCard
|
||||
title="Session duration"
|
||||
value="3m 23s"
|
||||
change="3%"
|
||||
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Bounce rate"
|
||||
value="46%"
|
||||
change="3%"
|
||||
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
|
||||
/>
|
||||
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[currentSourceIndex]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[
|
||||
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
|
||||
]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
chartPoints,
|
||||
color,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
chartPoints: number[];
|
||||
color: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
|
||||
<div className="row items-end justify-between">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm">{title}</div>
|
||||
<div className="text-2xl font-semibold font-mono">{value}</div>
|
||||
</div>
|
||||
<div className="row gap-2 items-center font-mono font-medium">
|
||||
<ArrowUpIcon className="size-3" strokeWidth={3} />
|
||||
<div>{change}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleChart
|
||||
width={400}
|
||||
height={30}
|
||||
points={chartPoints}
|
||||
strokeColor={color}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarCell({
|
||||
icon,
|
||||
name,
|
||||
percentage,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
percentage: number;
|
||||
value: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative row justify-between ">
|
||||
<div className="row gap-2 items-center font-medium text-sm">
|
||||
{icon.startsWith('http') ? (
|
||||
<Image
|
||||
alt="serie icon"
|
||||
className="max-h-4 rounded-[2px] object-contain"
|
||||
src={icon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-2xl">{icon}</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={name}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{name}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="row gap-3 font-mono text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={percentage} />%
|
||||
</span>
|
||||
<NumberFlow value={value} locales={'en-US'} />
|
||||
</div>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
Last 7 days
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* KPI tiles */}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STATS.map((stat) => (
|
||||
<div
|
||||
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
|
||||
key={stat.label}
|
||||
>
|
||||
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
|
||||
<span className="font-mono font-semibold text-xs leading-tight">
|
||||
{stat.formatted ??
|
||||
(stat.value !== null ? (
|
||||
<NumberFlow locales="en-US" value={stat.value} />
|
||||
) : null)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
|
||||
>
|
||||
{stat.up ? '↑' : '↓'} {stat.change}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Area chart */}
|
||||
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
|
||||
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
|
||||
<AreaChart data={VISITOR_DATA} />
|
||||
<div className="row justify-between px-0.5">
|
||||
{DAYS.map((d) => (
|
||||
<span className="text-[7px] text-muted-foreground" key={d}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic sources */}
|
||||
<div className="row gap-1.5">
|
||||
{SOURCES.map((src) => (
|
||||
<div
|
||||
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
|
||||
key={src.name}
|
||||
>
|
||||
<Image
|
||||
alt={src.name}
|
||||
className="rounded-[2px] object-contain"
|
||||
height={10}
|
||||
src={src.icon}
|
||||
width={10}
|
||||
/>
|
||||
<span className="flex-1 truncate text-[9px]">{src.name}</span>
|
||||
<span className="font-mono text-[9px] text-muted-foreground">
|
||||
{src.pct}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
|
||||
|
||||
export function Perks({
|
||||
perks,
|
||||
className,
|
||||
}: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
|
||||
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
|
||||
return (
|
||||
<ul className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
{perks.map((perk) => (
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { EventIcon } from './event-icon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
|
||||
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { createdAt, name, path, meta } = props;
|
||||
const { createdAt, name, path, duration, meta } = props;
|
||||
const profile = 'profile' in props ? props.profile : null;
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
@@ -27,65 +32,83 @@ export function EventListItem(props: EventListItemProps) {
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isMinimal = 'minimal' in props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'card flex w-full items-center justify-between rounded-lg p-4 transition-colors hover:bg-light-background',
|
||||
meta?.conversion &&
|
||||
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left">
|
||||
<EventIcon meta={meta} name={name} size="sm" />
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
</div>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
|
||||
meta?.conversion &&
|
||||
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left ">
|
||||
<EventIcon size="sm" name={name} meta={meta} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className=" text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -9,53 +8,71 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
|
||||
export default function EventListener({
|
||||
onRefresh,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const params = useParams({
|
||||
strict: false,
|
||||
});
|
||||
const { projectId } = useAppParams();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
useWS<{ count: number }>(
|
||||
useWS<IServiceEventMinimal | IServiceEvent>(
|
||||
`/live/events/${projectId}`,
|
||||
({ count }) => {
|
||||
counter.set((prev) => prev + count);
|
||||
(event) => {
|
||||
if (event) {
|
||||
const isProfilePage = !!params?.profileId;
|
||||
if (isProfilePage) {
|
||||
const profile = 'profile' in event ? event.profile : null;
|
||||
if (profile?.id === params?.profileId) {
|
||||
counter.set((prev) => prev + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
counter.set((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
counter.set(0);
|
||||
onRefresh();
|
||||
}}
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all'
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all'
|
||||
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{counter.debounced === 0 ? (
|
||||
'Listening'
|
||||
) : (
|
||||
<AnimatedNumber suffix=" new events" value={counter.debounced} />
|
||||
<AnimatedNumber value={counter.debounced} suffix=" new events" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export function useColumns() {
|
||||
const number = useNumber();
|
||||
@@ -27,24 +28,17 @@ export function useColumns() {
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell({ row }) {
|
||||
const { name, path, revenue } = row.original;
|
||||
const fullTitle =
|
||||
name === 'screen_view'
|
||||
? path
|
||||
: name === 'revenue' && revenue
|
||||
? `${name} (${number.currency(revenue / 100)})`
|
||||
: name.replace(/_/g, ' ');
|
||||
|
||||
const { name, path, duration, properties, revenue } = row.original;
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return path;
|
||||
return <span className="max-w-md truncate">{path}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">Screen: </span>
|
||||
{path}
|
||||
<span className="max-w-md truncate">{path}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,27 +50,38 @@ export function useColumns() {
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="shrink-0 transition-transform hover:scale-105"
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
onClick={() => {
|
||||
pushModal('EditEvent', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<EventIcon
|
||||
meta={row.original.meta}
|
||||
name={row.original.name}
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
/>
|
||||
</button>
|
||||
<span className="flex min-w-0 flex-1 gap-2">
|
||||
<span className="flex gap-2">
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
|
||||
title={fullTitle}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
@@ -84,10 +89,11 @@ export function useColumns() {
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
<span className="block truncate">{renderName()}</span>
|
||||
{renderName()}
|
||||
</button>
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -101,8 +107,8 @@ export function useColumns() {
|
||||
if (profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="group row items-center gap-2 whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
@@ -113,8 +119,8 @@ export function useColumns() {
|
||||
if (profileId && profileId !== deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
Unknown
|
||||
</ProjectLink>
|
||||
@@ -124,8 +130,8 @@ export function useColumns() {
|
||||
if (deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
Anonymous
|
||||
</ProjectLink>
|
||||
@@ -146,10 +152,10 @@ export function useColumns() {
|
||||
const { sessionId } = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
{sessionId.slice(0, 6)}
|
||||
{sessionId.slice(0,6)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
@@ -169,7 +175,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
@@ -183,7 +189,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
@@ -197,7 +203,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
@@ -215,14 +221,14 @@ export function useColumns() {
|
||||
const { properties } = row.original;
|
||||
const filteredProperties = Object.fromEntries(
|
||||
Object.entries(properties || {}).filter(
|
||||
([key]) => !key.startsWith('__')
|
||||
)
|
||||
([key]) => !key.startsWith('__'),
|
||||
),
|
||||
);
|
||||
const items = Object.entries(filteredProperties);
|
||||
const limit = 2;
|
||||
const data = items.slice(0, limit).map(([key, value]) => ({
|
||||
name: key,
|
||||
value,
|
||||
value: value,
|
||||
}));
|
||||
if (items.length > limit) {
|
||||
data.push({
|
||||
|
||||
@@ -35,7 +35,6 @@ type Props = {
|
||||
>,
|
||||
unknown
|
||||
>;
|
||||
showEventListener?: boolean;
|
||||
};
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
||||
@@ -216,7 +215,7 @@ const VirtualizedEventsTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query, showEventListener = false }: Props) => {
|
||||
export const EventsTable = ({ query }: Props) => {
|
||||
const { isLoading } = query;
|
||||
const columns = useColumns();
|
||||
|
||||
@@ -273,7 +272,7 @@ export const EventsTable = ({ query, showEventListener = false }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
|
||||
<EventsTableToolbar query={query} table={table} />
|
||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
@@ -292,11 +291,9 @@ export const EventsTable = ({ query, showEventListener = false }: Props) => {
|
||||
function EventsTableToolbar({
|
||||
query,
|
||||
table,
|
||||
showEventListener,
|
||||
}: {
|
||||
query: Props['query'];
|
||||
table: Table<IServiceEvent>;
|
||||
showEventListener: boolean;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
@@ -308,7 +305,7 @@ function EventsTableToolbar({
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{showEventListener && <EventListener onRefresh={() => query.refetch()} />}
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type {
|
||||
IServiceClient,
|
||||
IServiceEvent,
|
||||
IServiceProject,
|
||||
} from '@openpanel/db';
|
||||
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeAgo } from '@/utils/date';
|
||||
|
||||
interface Props {
|
||||
project: IServiceProject;
|
||||
client: IServiceClient | null;
|
||||
events: IServiceEvent[];
|
||||
onVerified: (verified: boolean) => void;
|
||||
}
|
||||
|
||||
const VerifyListener = ({ events }: Props) => {
|
||||
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
|
||||
useWS<IServiceEvent>(
|
||||
`/live/events/${client?.projectId}?type=received`,
|
||||
(data) => {
|
||||
setEvents((prev) => [...prev, data]);
|
||||
onVerified(true);
|
||||
}
|
||||
);
|
||||
|
||||
const isConnected = events.length > 0;
|
||||
|
||||
const renderIcon = () => {
|
||||
@@ -31,18 +49,16 @@ const VerifyListener = ({ events }: Props) => {
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-6 rounded-xl p-4 md:p-6',
|
||||
isConnected
|
||||
? 'bg-emerald-100 dark:bg-emerald-700/10'
|
||||
: 'bg-blue-500/10'
|
||||
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10'
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
||||
{isConnected ? 'Successfully connected' : 'Waiting for events'}
|
||||
{isConnected ? 'Success' : 'Waiting for events'}
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<div className="mt-2 flex flex-col-reverse gap-1">
|
||||
<div className="flex flex-col-reverse">
|
||||
{events.length > 5 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckIcon size={14} />{' '}
|
||||
@@ -53,7 +69,7 @@ const VerifyListener = ({ events }: Props) => {
|
||||
<div className="flex items-center gap-2" key={event.id}>
|
||||
<CheckIcon size={14} />{' '}
|
||||
<span className="font-medium">{event.name}</span>{' '}
|
||||
<span className="ml-auto text-foreground/50 text-sm">
|
||||
<span className="ml-auto text-emerald-800">
|
||||
{timeAgo(event.createdAt, 'round')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircleIcon, ChevronsUpDownIcon, SearchIcon } from 'lucide-react';
|
||||
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
@@ -28,7 +27,6 @@ export function GscCannibalization({
|
||||
const { apiUrl } = useAppContext();
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const pageSize = 15;
|
||||
|
||||
const query = useQuery(
|
||||
@@ -56,19 +54,7 @@ export function GscCannibalization({
|
||||
});
|
||||
};
|
||||
|
||||
const allItems = query.data ?? [];
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!search.trim()) {
|
||||
return allItems;
|
||||
}
|
||||
const q = search.toLowerCase();
|
||||
return allItems.filter(
|
||||
(item) =>
|
||||
item.query.toLowerCase().includes(q) ||
|
||||
item.pages.some((p) => p.page.toLowerCase().includes(q))
|
||||
);
|
||||
}, [allItems, search]);
|
||||
const items = query.data ?? [];
|
||||
|
||||
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
||||
useEffect(() => {
|
||||
@@ -81,52 +67,37 @@ export function GscCannibalization({
|
||||
const rangeStart = items.length ? page * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
|
||||
|
||||
if (!(query.isLoading || allItems.length)) {
|
||||
if (!(query.isLoading || items.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||
{items.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||
{items.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{items.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search keywords"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{items.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{query.isLoading &&
|
||||
|
||||
@@ -3,13 +3,11 @@ import {
|
||||
AlertTriangleIcon,
|
||||
EyeIcon,
|
||||
MousePointerClickIcon,
|
||||
SearchIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
@@ -71,7 +69,6 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||
const { apiUrl } = useAppContext();
|
||||
const [page, setPage] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const pageSize = 8;
|
||||
|
||||
const dateInput = {
|
||||
@@ -220,71 +217,45 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
|
||||
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
|
||||
|
||||
const filteredInsights = useMemo(() => {
|
||||
if (!search.trim()) {
|
||||
return insights;
|
||||
}
|
||||
const q = search.toLowerCase();
|
||||
return insights.filter(
|
||||
(i) =>
|
||||
i.path.toLowerCase().includes(q) || i.page.toLowerCase().includes(q)
|
||||
);
|
||||
}, [insights, search]);
|
||||
|
||||
const pageCount = Math.ceil(filteredInsights.length / pageSize) || 1;
|
||||
const pageCount = Math.ceil(insights.length / pageSize) || 1;
|
||||
const paginatedInsights = useMemo(
|
||||
() => filteredInsights.slice(page * pageSize, (page + 1) * pageSize),
|
||||
[filteredInsights, page, pageSize]
|
||||
() => insights.slice(page * pageSize, (page + 1) * pageSize),
|
||||
[insights, page, pageSize]
|
||||
);
|
||||
const rangeStart = filteredInsights.length ? page * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, filteredInsights.length);
|
||||
const rangeStart = insights.length ? page * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
|
||||
|
||||
if (!(isLoading || insights.length)) {
|
||||
if (!isLoading && !insights.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Opportunities</h3>
|
||||
{filteredInsights.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{filteredInsights.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredInsights.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{filteredInsights.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${filteredInsights.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Opportunities</h3>
|
||||
{insights.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{insights.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search pages"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
{insights.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{insights.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{isLoading &&
|
||||
@@ -316,16 +287,6 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
type="button"
|
||||
>
|
||||
<div className="col min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'row shrink-0 items-center gap-1 self-start rounded-md px-1 py-0.5 font-medium text-xs',
|
||||
config.color,
|
||||
config.bg
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt=""
|
||||
@@ -340,8 +301,15 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
{insight.path || insight.page}
|
||||
</span>
|
||||
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||
{insight.metrics}
|
||||
<span
|
||||
className={cn(
|
||||
'row shrink-0 items-center gap-1 rounded-md px-1 py-0.5 font-medium text-xs',
|
||||
config.color,
|
||||
config.bg
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
@@ -351,6 +319,10 @@ export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
{insight.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||
{insight.metrics}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ProjectLink } from '../links';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { EventItem } from '../events/table/item';
|
||||
|
||||
interface RealtimeActiveSessionsProps {
|
||||
projectId: string;
|
||||
@@ -15,52 +17,64 @@ export function RealtimeActiveSessions({
|
||||
limit = 10,
|
||||
}: RealtimeActiveSessionsProps) {
|
||||
const trpc = useTRPC();
|
||||
const { data: sessions = [] } = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions(
|
||||
{ projectId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
const activeSessionsQuery = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
const [state, setState] = useState<IServiceEvent[]>([]);
|
||||
|
||||
// Update state when initial data loads
|
||||
useEffect(() => {
|
||||
if (activeSessionsQuery.data && state.length === 0) {
|
||||
setState(activeSessionsQuery.data);
|
||||
}
|
||||
}, [activeSessionsQuery.data, state]);
|
||||
|
||||
// Set up WebSocket connection for real-time updates
|
||||
useWS<IServiceEvent>(
|
||||
`/live/events/${projectId}`,
|
||||
(session) => {
|
||||
setState((prev) => {
|
||||
// Add new session and remove duplicates, keeping most recent
|
||||
const filtered = prev.filter((s) => s.id !== session.id);
|
||||
return [session, ...filtered].slice(0, limit);
|
||||
});
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
|
||||
|
||||
return (
|
||||
<div className="col card h-full max-md:hidden">
|
||||
<div className="hide-scrollbar h-full overflow-y-auto">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<div className="col divide-y">
|
||||
{sessions.slice(0, limit).map((session) => (
|
||||
<div className="col h-full max-md:hidden">
|
||||
<div className="hide-scrollbar h-full overflow-y-auto pb-10">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<div className="col gap-4">
|
||||
{sessions.map((session) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
||||
key={session.id}
|
||||
layout
|
||||
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
||||
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<ProjectLink
|
||||
className="relative block p-4 py-3 pr-14"
|
||||
href={`/sessions/${session.sessionId}`}
|
||||
>
|
||||
<div className="col flex-1 gap-1">
|
||||
{session.name === 'screen_view' && (
|
||||
<span className="text-muted-foreground text-xs leading-normal/80">
|
||||
{session.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-sm leading-normal">
|
||||
{session.name === 'screen_view'
|
||||
? session.path
|
||||
: session.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row absolute top-1/2 right-4 origin-right -translate-y-1/2 scale-50 gap-2">
|
||||
<SerieIcon name={session.referrerName} />
|
||||
<SerieIcon name={session.os} />
|
||||
<SerieIcon name={session.browser} />
|
||||
<SerieIcon name={session.device} />
|
||||
</div>
|
||||
</ProjectLink>
|
||||
<EventItem
|
||||
event={session}
|
||||
viewOptions={{
|
||||
properties: false,
|
||||
origin: false,
|
||||
queryString: false,
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -42,5 +42,5 @@ function Component() {
|
||||
),
|
||||
);
|
||||
|
||||
return <EventsTable query={query} showEventListener />;
|
||||
return <EventsTable query={query} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
|
||||
import RealtimeMap from '@/components/realtime/map';
|
||||
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
|
||||
@@ -9,10 +7,12 @@ import { RealtimePaths } from '@/components/realtime/realtime-paths';
|
||||
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
|
||||
import RealtimeReloader from '@/components/realtime/realtime-reloader';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/realtime'
|
||||
'/_app/$organizationId/$projectId/realtime',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -36,8 +36,8 @@ function Component() {
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ function Component() {
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
|
||||
<div className="row relative">
|
||||
<div className="aspect-[4/2] w-full overflow-hidden">
|
||||
<div className="overflow-hidden aspect-[4/2] w-full">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
sidebarConfig={{
|
||||
@@ -56,17 +56,18 @@ function Component() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col absolute top-8 bottom-4 left-8 gap-4">
|
||||
<div className="card w-72 bg-background/90 p-4">
|
||||
<div className="absolute top-8 left-8 bottom-0 col gap-4">
|
||||
<div className="card p-4 w-72 bg-background/90">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="relative min-h-0 w-72 flex-1">
|
||||
<div className="w-72 flex-1 min-h-0 relative">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-4 pt-4 md:grid-cols-2 md:p-8 md:pt-0 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 pt-4 md:p-8 md:pt-0">
|
||||
<div>
|
||||
<RealtimeGeo projectId={projectId} />
|
||||
</div>
|
||||
|
||||
@@ -732,10 +732,10 @@ function GscTable({
|
||||
)}
|
||||
</div>
|
||||
{onSearchChange != null && (
|
||||
<div className="relative">
|
||||
<div className="relative border-t">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder ?? 'Search'}
|
||||
type="search"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
|
||||
import { BoxSelectIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
@@ -32,21 +33,22 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { data: events } = useQuery(
|
||||
trpc.event.events.queryOptions(
|
||||
{ projectId },
|
||||
{
|
||||
refetchInterval: 2500,
|
||||
}
|
||||
)
|
||||
const { data: events, refetch } = useQuery(
|
||||
trpc.event.events.queryOptions({ projectId })
|
||||
);
|
||||
const isVerified = events?.data && events.data.length > 0;
|
||||
const { data: project } = useQuery(
|
||||
trpc.project.getProjectWithClients.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.data.length > 0) {
|
||||
setIsVerified(true);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<FullPageEmptyState icon={BoxSelectIcon} title="Project not found" />
|
||||
@@ -62,7 +64,15 @@ function Component() {
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div className="col gap-8 p-4">
|
||||
<VerifyListener events={events?.data ?? []} />
|
||||
<VerifyListener
|
||||
client={client}
|
||||
events={events?.data ?? []}
|
||||
onVerified={() => {
|
||||
refetch();
|
||||
setIsVerified(true);
|
||||
}}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
<VerifyFaq project={project} />
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/email": "workspace:*",
|
||||
"@openpanel/importer": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/js-runtime": "workspace:*",
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/importer": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import type { Queue, WorkerOptions } from 'bullmq';
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
import {
|
||||
cronQueue,
|
||||
EVENTS_GROUP_QUEUES_SHARDS,
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
cronQueue,
|
||||
eventsGroupQueues,
|
||||
gscQueue,
|
||||
importQueue,
|
||||
@@ -14,12 +15,14 @@ import {
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisQueue } from '@openpanel/redis';
|
||||
import type { Queue, WorkerOptions } from 'bullmq';
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { Worker as GroupWorker } from 'groupmq';
|
||||
|
||||
import { cronJob } from './jobs/cron';
|
||||
import { incomingEvent } from './jobs/events.incoming-event';
|
||||
import { gscJob } from './jobs/gsc';
|
||||
import { incomingEvent } from './jobs/events.incoming-event';
|
||||
import { importJob } from './jobs/import';
|
||||
import { insightsProjectJob } from './jobs/insights';
|
||||
import { miscJob } from './jobs/misc';
|
||||
@@ -92,7 +95,7 @@ function getConcurrencyFor(queueName: string, defaultValue = 1): number {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function bootWorkers() {
|
||||
export async function bootWorkers() {
|
||||
const enabledQueues = getEnabledQueues();
|
||||
|
||||
const workers: (Worker | GroupWorker<any>)[] = [];
|
||||
@@ -116,14 +119,12 @@ export function bootWorkers() {
|
||||
|
||||
for (const index of eventQueuesToStart) {
|
||||
const queue = eventsGroupQueues[index];
|
||||
if (!queue) {
|
||||
continue;
|
||||
}
|
||||
if (!queue) continue;
|
||||
|
||||
const queueName = `events_${index}`;
|
||||
const concurrency = getConcurrencyFor(
|
||||
queueName,
|
||||
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10)
|
||||
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10),
|
||||
);
|
||||
|
||||
const worker = new GroupWorker<EventsQueuePayloadIncomingEvent['payload']>({
|
||||
@@ -131,7 +132,7 @@ export function bootWorkers() {
|
||||
concurrency,
|
||||
logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined,
|
||||
blockingTimeoutSec: Number.parseFloat(
|
||||
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1'
|
||||
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1',
|
||||
),
|
||||
handler: async (job) => {
|
||||
return await incomingEvent(job.data);
|
||||
@@ -171,7 +172,7 @@ export function bootWorkers() {
|
||||
const notificationWorker = new Worker(
|
||||
notificationQueue.name,
|
||||
notificationJob,
|
||||
{ ...workerOptions, concurrency }
|
||||
{ ...workerOptions, concurrency },
|
||||
);
|
||||
workers.push(notificationWorker);
|
||||
logger.info('Started worker for notification', { concurrency });
|
||||
@@ -223,7 +224,7 @@ export function bootWorkers() {
|
||||
|
||||
if (workers.length === 0) {
|
||||
logger.warn(
|
||||
'No workers started. Check ENABLED_QUEUES environment variable.'
|
||||
'No workers started. Check ENABLED_QUEUES environment variable.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,7 +254,7 @@ export function bootWorkers() {
|
||||
const elapsed = job.finishedOn - job.processedOn;
|
||||
eventsGroupJobDuration.observe(
|
||||
{ name: worker.name, status: 'failed' },
|
||||
elapsed
|
||||
elapsed,
|
||||
);
|
||||
}
|
||||
logger.error('job failed', {
|
||||
@@ -266,6 +267,23 @@ export function bootWorkers() {
|
||||
}
|
||||
});
|
||||
|
||||
(worker as Worker).on('completed', (job) => {
|
||||
if (job) {
|
||||
if (job.processedOn && job.finishedOn) {
|
||||
const elapsed = job.finishedOn - job.processedOn;
|
||||
logger.info('job completed', {
|
||||
jobId: job.id,
|
||||
worker: worker.name,
|
||||
elapsed,
|
||||
});
|
||||
eventsGroupJobDuration.observe(
|
||||
{ name: worker.name, status: 'success' },
|
||||
elapsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(worker as Worker).on('ioredis:close', () => {
|
||||
logger.error('worker closed due to ioredis:close', {
|
||||
worker: worker.name,
|
||||
@@ -275,7 +293,7 @@ export function bootWorkers() {
|
||||
|
||||
async function exitHandler(
|
||||
eventName: string,
|
||||
evtOrExitCodeOrError: number | string | Error
|
||||
evtOrExitCodeOrError: number | string | Error,
|
||||
) {
|
||||
// Log the actual error details for unhandled rejections/exceptions
|
||||
if (evtOrExitCodeOrError instanceof Error) {
|
||||
@@ -321,7 +339,7 @@ export function bootWorkers() {
|
||||
process.on(evt, (code) => {
|
||||
exitHandler(evt, code);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return workers;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createInitialSalts } from '@openpanel/db';
|
||||
import {
|
||||
cronQueue,
|
||||
eventsGroupQueues,
|
||||
gscQueue,
|
||||
importQueue,
|
||||
insightsQueue,
|
||||
miscQueue,
|
||||
@@ -13,8 +12,9 @@ import {
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import express from 'express';
|
||||
import { BullBoardGroupMQAdapter } from 'groupmq';
|
||||
import client from 'prom-client';
|
||||
|
||||
import { BullBoardGroupMQAdapter } from 'groupmq';
|
||||
import sourceMapSupport from 'source-map-support';
|
||||
import { bootCron } from './boot-cron';
|
||||
import { bootWorkers } from './boot-workers';
|
||||
@@ -39,7 +39,7 @@ async function start() {
|
||||
createBullBoard({
|
||||
queues: [
|
||||
...eventsGroupQueues.map(
|
||||
(queue) => new BullBoardGroupMQAdapter(queue) as any
|
||||
(queue) => new BullBoardGroupMQAdapter(queue) as any,
|
||||
),
|
||||
new BullMQAdapter(sessionsQueue),
|
||||
new BullMQAdapter(cronQueue),
|
||||
@@ -47,9 +47,8 @@ async function start() {
|
||||
new BullMQAdapter(miscQueue),
|
||||
new BullMQAdapter(importQueue),
|
||||
new BullMQAdapter(insightsQueue),
|
||||
new BullMQAdapter(gscQueue),
|
||||
],
|
||||
serverAdapter,
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
app.use('/', serverAdapter.getRouter());
|
||||
|
||||
@@ -33,7 +33,7 @@ async function generateNewSalt() {
|
||||
return created;
|
||||
});
|
||||
|
||||
await getSalts.clear();
|
||||
getSalts.clear();
|
||||
|
||||
return newSalt;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
|
||||
import { getReferrerWithQuery, parseReferrer } from '@openpanel/common/server';
|
||||
import {
|
||||
getReferrerWithQuery,
|
||||
parseReferrer,
|
||||
parseUserAgent,
|
||||
} from '@openpanel/common/server';
|
||||
import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
|
||||
import {
|
||||
checkNotificationRulesForEvent,
|
||||
@@ -10,12 +14,10 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import type { ILogger } from '@openpanel/logger';
|
||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import { anyPass, isEmpty, isNil, mergeDeepRight, omit, reject } from 'ramda';
|
||||
import { logger as baseLogger } from '@/utils/logger';
|
||||
import {
|
||||
createSessionEndJob,
|
||||
extendSessionEndJob,
|
||||
} from '@/utils/session-handler';
|
||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
||||
|
||||
const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue'];
|
||||
|
||||
@@ -91,8 +93,7 @@ export async function incomingEvent(
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId,
|
||||
uaInfo,
|
||||
session,
|
||||
uaInfo: _uaInfo,
|
||||
} = jobPayload;
|
||||
const properties = body.properties ?? {};
|
||||
const reqId = headers['request-id'] ?? 'unknown';
|
||||
@@ -120,15 +121,16 @@ export async function incomingEvent(
|
||||
? null
|
||||
: parseReferrer(getProperty('__referrer'));
|
||||
const utmReferrer = getReferrerWithQuery(query);
|
||||
const userAgent = headers['user-agent'];
|
||||
const sdkName = headers['openpanel-sdk-name'];
|
||||
const sdkVersion = headers['openpanel-sdk-version'];
|
||||
// TODO: Remove both user-agent and parseUserAgent
|
||||
const uaInfo = _uaInfo ?? parseUserAgent(userAgent, properties);
|
||||
|
||||
const baseEvent: IServiceCreateEventPayload = {
|
||||
const baseEvent = {
|
||||
name: body.name,
|
||||
profileId,
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId,
|
||||
properties: omit(GLOBAL_PROPERTIES, {
|
||||
...properties,
|
||||
__hash: hash,
|
||||
@@ -147,7 +149,7 @@ export async function incomingEvent(
|
||||
origin,
|
||||
referrer: referrer?.url || '',
|
||||
referrerName: utmReferrer?.name || referrer?.name || referrer?.url,
|
||||
referrerType: utmReferrer?.type || referrer?.type || '',
|
||||
referrerType: referrer?.type || utmReferrer?.type || '',
|
||||
os: uaInfo.os,
|
||||
osVersion: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
@@ -159,17 +161,16 @@ export async function incomingEvent(
|
||||
body.name === 'revenue' && '__revenue' in properties
|
||||
? parseRevenue(properties.__revenue)
|
||||
: undefined,
|
||||
};
|
||||
} as const;
|
||||
|
||||
// if timestamp is from the past we dont want to create a new session
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
const session =
|
||||
profileId && !isTimestampFromThePast
|
||||
? await sessionBuffer.getExistingSession({
|
||||
profileId,
|
||||
projectId,
|
||||
})
|
||||
: null;
|
||||
const session = profileId
|
||||
? await sessionBuffer.getExistingSession({
|
||||
profileId,
|
||||
projectId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const payload = {
|
||||
...baseEvent,
|
||||
@@ -197,48 +198,82 @@ export async function incomingEvent(
|
||||
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
|
||||
}
|
||||
|
||||
const sessionEnd = await getSessionEnd({
|
||||
projectId,
|
||||
deviceId,
|
||||
profileId,
|
||||
});
|
||||
const activeSession = sessionEnd
|
||||
? await sessionBuffer.getExistingSession({
|
||||
sessionId: sessionEnd.sessionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const payload: IServiceCreateEventPayload = merge(baseEvent, {
|
||||
referrer: session?.referrer ?? baseEvent.referrer,
|
||||
referrerName: session?.referrerName ?? baseEvent.referrerName,
|
||||
referrerType: session?.referrerType ?? baseEvent.referrerType,
|
||||
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 || activeSession?.exit_path || '',
|
||||
origin: baseEvent.origin || activeSession?.exit_origin || '',
|
||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||
|
||||
// If the triggering event is filtered, do not create session_start or the event (issue #2)
|
||||
const isExcluded = await isEventExcludedByProjectFilter(payload, projectId);
|
||||
if (isExcluded) {
|
||||
logger.info(
|
||||
'Skipping session_start and event (excluded by project filter)',
|
||||
{ event: payload.name, projectId }
|
||||
{
|
||||
event: payload.name,
|
||||
projectId,
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
await extendSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
}).catch((error) => {
|
||||
logger.error('Error finding and extending session end job', { error });
|
||||
throw error;
|
||||
});
|
||||
} else {
|
||||
await createEventAndNotify(
|
||||
{
|
||||
...payload,
|
||||
name: 'session_start',
|
||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||
},
|
||||
logger,
|
||||
projectId
|
||||
).catch((error) => {
|
||||
logger.error('Error creating session start event', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
if (!sessionEnd) {
|
||||
const locked = await getLock(
|
||||
`session_start:${projectId}:${sessionId}`,
|
||||
'1',
|
||||
1000
|
||||
);
|
||||
if (locked) {
|
||||
logger.info('Creating session start event', { event: payload });
|
||||
await createEventAndNotify(
|
||||
{
|
||||
...payload,
|
||||
name: 'session_start',
|
||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||
},
|
||||
logger,
|
||||
projectId
|
||||
).catch((error) => {
|
||||
logger.error('Error creating session start event', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
} else {
|
||||
logger.info('Session start already claimed by another worker', {
|
||||
event: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const event = await createEventAndNotify(payload, logger, projectId);
|
||||
|
||||
if (!event) {
|
||||
// Skip creating session end when event was excluded
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionEnd) {
|
||||
logger.info('Creating session end job', { event: payload });
|
||||
await createSessionEndJob({ payload }).catch((error) => {
|
||||
logger.error('Error creating session end job', { event: payload });
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return createEventAndNotify(payload, logger, projectId);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -186,11 +186,6 @@ describe('incomingEvent', () => {
|
||||
projectId,
|
||||
deviceId,
|
||||
sessionId: 'session-123',
|
||||
session: {
|
||||
referrer: '',
|
||||
referrerName: '',
|
||||
referrerType: '',
|
||||
},
|
||||
};
|
||||
|
||||
const changeDelay = vi.fn();
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { db, syncGscData } from '@openpanel/db';
|
||||
import type { GscQueuePayload } from '@openpanel/queue';
|
||||
import { gscQueue } from '@openpanel/queue';
|
||||
import type { GscQueuePayload } from '@openpanel/queue';
|
||||
import type { Job } from 'bullmq';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const BACKFILL_MONTHS = 6;
|
||||
const CHUNK_DAYS = 14;
|
||||
|
||||
export function gscJob(job: Job<GscQueuePayload>) {
|
||||
export async function gscJob(job: Job<GscQueuePayload>) {
|
||||
switch (job.data.type) {
|
||||
case 'gscProjectSync':
|
||||
return gscProjectSyncJob(job.data.payload.projectId);
|
||||
case 'gscProjectBackfill':
|
||||
return gscProjectBackfillJob(job.data.payload.projectId);
|
||||
default:
|
||||
throw new Error('Unknown GSC job type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +59,7 @@ async function gscProjectSyncJob(projectId: string) {
|
||||
async function gscProjectBackfillJob(projectId: string) {
|
||||
const conn = await db.gscConnection.findUnique({ where: { projectId } });
|
||||
if (!conn?.siteUrl) {
|
||||
logger.warn('GSC backfill skipped: no connection or siteUrl', {
|
||||
projectId,
|
||||
});
|
||||
logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import client from 'prom-client';
|
||||
|
||||
import {
|
||||
botBuffer,
|
||||
eventBuffer,
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
sessionBuffer,
|
||||
} from '@openpanel/db';
|
||||
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
||||
import client from 'prom-client';
|
||||
|
||||
const Registry = client.Registry;
|
||||
|
||||
@@ -19,7 +20,7 @@ export const eventsGroupJobDuration = new client.Histogram({
|
||||
name: 'job_duration_ms',
|
||||
help: 'Duration of job processing (in ms)',
|
||||
labelNames: ['name', 'status'],
|
||||
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10_000, 30_000], // 10ms to 30s
|
||||
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10000, 30000], // 10ms to 30s
|
||||
});
|
||||
|
||||
register.registerMetric(eventsGroupJobDuration);
|
||||
@@ -27,61 +28,57 @@ register.registerMetric(eventsGroupJobDuration);
|
||||
queues.forEach((queue) => {
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_active_count`,
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_active_count`,
|
||||
help: 'Active count',
|
||||
async collect() {
|
||||
const metric = await queue.getActiveCount();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_delayed_count`,
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_delayed_count`,
|
||||
help: 'Delayed count',
|
||||
async collect() {
|
||||
if ('getDelayedCount' in queue) {
|
||||
const metric = await queue.getDelayedCount();
|
||||
this.set(metric);
|
||||
} else {
|
||||
this.set(0);
|
||||
}
|
||||
const metric = await queue.getDelayedCount();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_failed_count`,
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_failed_count`,
|
||||
help: 'Failed count',
|
||||
async collect() {
|
||||
const metric = await queue.getFailedCount();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_completed_count`,
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_completed_count`,
|
||||
help: 'Completed count',
|
||||
async collect() {
|
||||
const metric = await queue.getCompletedCount();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
new client.Gauge({
|
||||
name: `${queue.name.replace(/[{}]/g, '')}_waiting_count`,
|
||||
name: `${queue.name.replace(/[\{\}]/g, '')}_waiting_count`,
|
||||
help: 'Waiting count',
|
||||
async collect() {
|
||||
const metric = await queue.getWaitingCount();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +90,7 @@ register.registerMetric(
|
||||
const metric = await eventBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -104,7 +101,7 @@ register.registerMetric(
|
||||
const metric = await profileBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -115,7 +112,7 @@ register.registerMetric(
|
||||
const metric = await botBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -126,7 +123,7 @@ register.registerMetric(
|
||||
const metric = await sessionBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
register.registerMetric(
|
||||
@@ -137,5 +134,5 @@ register.registerMetric(
|
||||
const metric = await replayBuffer.getBufferSize();
|
||||
this.set(metric);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,39 +1,13 @@
|
||||
import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
import { sessionsQueue } from '@openpanel/queue';
|
||||
import {
|
||||
type EventsQueuePayloadCreateSessionEnd,
|
||||
sessionsQueue,
|
||||
} from '@openpanel/queue';
|
||||
import type { Job } from 'bullmq';
|
||||
import { logger } from './logger';
|
||||
|
||||
export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
||||
|
||||
const CHANGE_DELAY_THROTTLE_MS = process.env.CHANGE_DELAY_THROTTLE_MS
|
||||
? Number.parseInt(process.env.CHANGE_DELAY_THROTTLE_MS, 10)
|
||||
: 60_000; // 1 minute
|
||||
|
||||
const CHANGE_DELAY_THROTTLE_MAP = new Map<string, number>();
|
||||
|
||||
export async function extendSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
}) {
|
||||
const last = CHANGE_DELAY_THROTTLE_MAP.get(`${projectId}:${deviceId}`) ?? 0;
|
||||
const isThrottled = Date.now() - last < CHANGE_DELAY_THROTTLE_MS;
|
||||
|
||||
if (isThrottled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = getSessionEndJobId(projectId, deviceId);
|
||||
const job = await sessionsQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
await job.changeDelay(SESSION_TIMEOUT);
|
||||
CHANGE_DELAY_THROTTLE_MAP.set(`${projectId}:${deviceId}`, Date.now());
|
||||
}
|
||||
|
||||
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
||||
`sessionEnd:${projectId}:${deviceId}`;
|
||||
|
||||
@@ -59,3 +33,106 @@ export function createSessionEndJob({
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSessionEnd({
|
||||
projectId,
|
||||
deviceId,
|
||||
profileId,
|
||||
}: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
profileId: string;
|
||||
}) {
|
||||
const sessionEnd = await getSessionEndJob({
|
||||
projectId,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
if (sessionEnd) {
|
||||
const existingSessionIsAnonymous =
|
||||
sessionEnd.job.data.payload.profileId ===
|
||||
sessionEnd.job.data.payload.deviceId;
|
||||
|
||||
const eventIsIdentified =
|
||||
profileId && sessionEnd.job.data.payload.profileId !== profileId;
|
||||
|
||||
if (existingSessionIsAnonymous && eventIsIdentified) {
|
||||
await sessionEnd.job.updateData({
|
||||
...sessionEnd.job.data,
|
||||
payload: {
|
||||
...sessionEnd.job.data.payload,
|
||||
profileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sessionEnd.job.changeDelay(SESSION_TIMEOUT);
|
||||
return sessionEnd.job.data.payload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSessionEndJob(args: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
retryCount?: number;
|
||||
}): Promise<{
|
||||
deviceId: string;
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||
} | null> {
|
||||
const { retryCount = 0 } = args;
|
||||
|
||||
if (retryCount >= 6) {
|
||||
throw new Error('Failed to get session end');
|
||||
}
|
||||
|
||||
async function handleJobStates(
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>,
|
||||
deviceId: string
|
||||
): Promise<{
|
||||
deviceId: string;
|
||||
job: Job<EventsQueuePayloadCreateSessionEnd>;
|
||||
} | null> {
|
||||
const state = await job.getState();
|
||||
if (state !== 'delayed') {
|
||||
logger.debug(`[session-handler] Session end job is in "${state}" state`, {
|
||||
state,
|
||||
retryCount,
|
||||
jobTimestamp: new Date(job.timestamp).toISOString(),
|
||||
jobDelta: Date.now() - job.timestamp,
|
||||
jobId: job.id,
|
||||
payload: job.data.payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (state === 'delayed' || state === 'waiting') {
|
||||
return { deviceId, job };
|
||||
}
|
||||
|
||||
if (state === 'active') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return getSessionEndJob({
|
||||
...args,
|
||||
retryCount: retryCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
await job.remove();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"useSemanticElements": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNestedTernary": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
@@ -71,8 +70,7 @@
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noExcessiveCognitiveComplexity": "off"
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,42 @@ import { getRedisCache } from '@openpanel/redis';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ch } from '../clickhouse/client';
|
||||
|
||||
// Break circular dep: event-buffer -> event.service -> buffers/index -> EventBuffer
|
||||
vi.mock('../services/event.service', () => ({}));
|
||||
// Mock transformEvent to avoid circular dependency with buffers -> services -> buffers
|
||||
vi.mock('../services/event.service', () => ({
|
||||
transformEvent: (event: any) => ({
|
||||
id: event.id ?? 'id',
|
||||
name: event.name,
|
||||
deviceId: event.device_id,
|
||||
profileId: event.profile_id,
|
||||
projectId: event.project_id,
|
||||
sessionId: event.session_id,
|
||||
properties: event.properties ?? {},
|
||||
createdAt: new Date(event.created_at ?? Date.now()),
|
||||
country: event.country,
|
||||
city: event.city,
|
||||
region: event.region,
|
||||
longitude: event.longitude,
|
||||
latitude: event.latitude,
|
||||
os: event.os,
|
||||
osVersion: event.os_version,
|
||||
browser: event.browser,
|
||||
browserVersion: event.browser_version,
|
||||
device: event.device,
|
||||
brand: event.brand,
|
||||
model: event.model,
|
||||
duration: event.duration ?? 0,
|
||||
path: event.path ?? '',
|
||||
origin: event.origin ?? '',
|
||||
referrer: event.referrer,
|
||||
referrerName: event.referrer_name,
|
||||
referrerType: event.referrer_type,
|
||||
meta: event.meta,
|
||||
importedAt: undefined,
|
||||
sdkName: event.sdk_name,
|
||||
sdkVersion: event.sdk_version,
|
||||
profile: event.profile,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { EventBuffer } from './event-buffer';
|
||||
|
||||
@@ -34,16 +68,18 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
// Get initial count
|
||||
const initialCount = await eventBuffer.getBufferSize();
|
||||
|
||||
eventBuffer.add(event);
|
||||
await eventBuffer.flush();
|
||||
// Add event
|
||||
await eventBuffer.add(event);
|
||||
|
||||
// Buffer counter should increase by 1
|
||||
const newCount = await eventBuffer.getBufferSize();
|
||||
expect(newCount).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
it('adds screen_view directly to buffer queue', async () => {
|
||||
it('adds multiple screen_views - moves previous to buffer with duration', async () => {
|
||||
const t0 = Date.now();
|
||||
const sessionId = 'session_1';
|
||||
|
||||
@@ -63,23 +99,60 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view3 = {
|
||||
project_id: 'p1',
|
||||
profile_id: 'u1',
|
||||
session_id: sessionId,
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 3000).toISOString(),
|
||||
} as any;
|
||||
|
||||
// Add first screen_view
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view1);
|
||||
|
||||
eventBuffer.add(view1);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// screen_view goes directly to buffer
|
||||
// Should be stored as "last" but NOT in queue yet
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 1);
|
||||
expect(count2).toBe(count1); // No change in buffer
|
||||
|
||||
eventBuffer.add(view2);
|
||||
await eventBuffer.flush();
|
||||
// Last screen_view should be retrievable
|
||||
const last1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last1).not.toBeNull();
|
||||
expect(last1!.createdAt.toISOString()).toBe(view1.created_at);
|
||||
|
||||
// Add second screen_view
|
||||
await eventBuffer.add(view2);
|
||||
|
||||
// Now view1 should be in buffer
|
||||
const count3 = await eventBuffer.getBufferSize();
|
||||
expect(count3).toBe(count1 + 2);
|
||||
expect(count3).toBe(count1 + 1);
|
||||
|
||||
// view2 should now be the "last"
|
||||
const last2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last2!.createdAt.toISOString()).toBe(view2.created_at);
|
||||
|
||||
// Add third screen_view
|
||||
await eventBuffer.add(view3);
|
||||
|
||||
// Now view2 should also be in buffer
|
||||
const count4 = await eventBuffer.getBufferSize();
|
||||
expect(count4).toBe(count1 + 2);
|
||||
|
||||
// view3 should now be the "last"
|
||||
const last3 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p1',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last3!.createdAt.toISOString()).toBe(view3.created_at);
|
||||
});
|
||||
|
||||
it('adds session_end directly to buffer queue', async () => {
|
||||
it('adds session_end - moves last screen_view and session_end to buffer', async () => {
|
||||
const t0 = Date.now();
|
||||
const sessionId = 'session_2';
|
||||
|
||||
@@ -99,44 +172,148 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(t0 + 5000).toISOString(),
|
||||
} as any;
|
||||
|
||||
// Add screen_view
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view);
|
||||
|
||||
eventBuffer.add(view);
|
||||
eventBuffer.add(sessionEnd);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// Should be stored as "last", not in buffer yet
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 2);
|
||||
expect(count2).toBe(count1);
|
||||
|
||||
// Add session_end
|
||||
await eventBuffer.add(sessionEnd);
|
||||
|
||||
// Both should now be in buffer (+2)
|
||||
const count3 = await eventBuffer.getBufferSize();
|
||||
expect(count3).toBe(count1 + 2);
|
||||
|
||||
// Last screen_view should be cleared
|
||||
const last = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p2',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
expect(last).toBeNull();
|
||||
});
|
||||
|
||||
it('session_end with no previous screen_view - only adds session_end to buffer', async () => {
|
||||
const sessionId = 'session_3';
|
||||
|
||||
const sessionEnd = {
|
||||
project_id: 'p3',
|
||||
profile_id: 'u3',
|
||||
session_id: sessionId,
|
||||
name: 'session_end',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(sessionEnd);
|
||||
|
||||
// Only session_end should be in buffer (+1)
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 1);
|
||||
});
|
||||
|
||||
it('gets last screen_view by profileId', async () => {
|
||||
const view = {
|
||||
project_id: 'p4',
|
||||
profile_id: 'u4',
|
||||
session_id: 'session_4',
|
||||
name: 'screen_view',
|
||||
path: '/home',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view);
|
||||
|
||||
// Query by profileId
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p4',
|
||||
profileId: 'u4',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('screen_view');
|
||||
expect(result!.path).toBe('/home');
|
||||
});
|
||||
|
||||
it('gets last screen_view by sessionId', async () => {
|
||||
const sessionId = 'session_5';
|
||||
const view = {
|
||||
project_id: 'p5',
|
||||
profile_id: 'u5',
|
||||
session_id: sessionId,
|
||||
name: 'screen_view',
|
||||
path: '/about',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view);
|
||||
|
||||
// Query by sessionId
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p5',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('screen_view');
|
||||
expect(result!.path).toBe('/about');
|
||||
});
|
||||
|
||||
it('returns null for non-existent last screen_view', async () => {
|
||||
const result = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p_nonexistent',
|
||||
profileId: 'u_nonexistent',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('gets buffer count correctly', async () => {
|
||||
// Initially 0
|
||||
expect(await eventBuffer.getBufferSize()).toBe(0);
|
||||
|
||||
eventBuffer.add({
|
||||
// Add regular event
|
||||
await eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
name: 'event1',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
await eventBuffer.flush();
|
||||
|
||||
expect(await eventBuffer.getBufferSize()).toBe(1);
|
||||
|
||||
eventBuffer.add({
|
||||
// Add another regular event
|
||||
await eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
name: 'event2',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
await eventBuffer.flush();
|
||||
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// screen_view also goes directly to buffer
|
||||
eventBuffer.add({
|
||||
// Add screen_view (not counted until flushed)
|
||||
await eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
profile_id: 'u6',
|
||||
session_id: 'session_6',
|
||||
name: 'screen_view',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any);
|
||||
await eventBuffer.flush();
|
||||
|
||||
// Still 2 (screen_view is pending)
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// Add another screen_view (first one gets flushed)
|
||||
await eventBuffer.add({
|
||||
project_id: 'p6',
|
||||
profile_id: 'u6',
|
||||
session_id: 'session_6',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(Date.now() + 1000).toISOString(),
|
||||
} as any);
|
||||
|
||||
// Now 3 (2 regular + 1 flushed screen_view)
|
||||
expect(await eventBuffer.getBufferSize()).toBe(3);
|
||||
});
|
||||
|
||||
@@ -153,9 +330,8 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date(Date.now() + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
eventBuffer.add(event1);
|
||||
eventBuffer.add(event2);
|
||||
await eventBuffer.flush();
|
||||
await eventBuffer.add(event1);
|
||||
await eventBuffer.add(event2);
|
||||
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
@@ -165,12 +341,14 @@ describe('EventBuffer', () => {
|
||||
|
||||
await eventBuffer.processBuffer();
|
||||
|
||||
// Should insert both events
|
||||
expect(insertSpy).toHaveBeenCalled();
|
||||
const callArgs = insertSpy.mock.calls[0]![0];
|
||||
expect(callArgs.format).toBe('JSONEachRow');
|
||||
expect(callArgs.table).toBe('events');
|
||||
expect(Array.isArray(callArgs.values)).toBe(true);
|
||||
|
||||
// Buffer should be empty after processing
|
||||
expect(await eventBuffer.getBufferSize()).toBe(0);
|
||||
|
||||
insertSpy.mockRestore();
|
||||
@@ -181,14 +359,14 @@ describe('EventBuffer', () => {
|
||||
process.env.EVENT_BUFFER_CHUNK_SIZE = '2';
|
||||
const eb = new EventBuffer();
|
||||
|
||||
// Add 4 events
|
||||
for (let i = 0; i < 4; i++) {
|
||||
eb.add({
|
||||
await eb.add({
|
||||
project_id: 'p8',
|
||||
name: `event${i}`,
|
||||
created_at: new Date(Date.now() + i).toISOString(),
|
||||
} as any);
|
||||
}
|
||||
await eb.flush();
|
||||
|
||||
const insertSpy = vi
|
||||
.spyOn(ch, 'insert')
|
||||
@@ -196,12 +374,14 @@ describe('EventBuffer', () => {
|
||||
|
||||
await eb.processBuffer();
|
||||
|
||||
// With chunk size 2 and 4 events, should be called twice
|
||||
expect(insertSpy).toHaveBeenCalledTimes(2);
|
||||
const call1Values = insertSpy.mock.calls[0]![0].values as any[];
|
||||
const call2Values = insertSpy.mock.calls[1]![0].values as any[];
|
||||
expect(call1Values.length).toBe(2);
|
||||
expect(call2Values.length).toBe(2);
|
||||
|
||||
// Restore
|
||||
if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE;
|
||||
else process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
|
||||
|
||||
@@ -216,61 +396,129 @@ describe('EventBuffer', () => {
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
eventBuffer.add(event);
|
||||
await eventBuffer.flush();
|
||||
await eventBuffer.add(event);
|
||||
|
||||
const count = await eventBuffer.getActiveVisitorCount('p9');
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('handles multiple sessions independently — all events go to buffer', async () => {
|
||||
it('handles multiple sessions independently', async () => {
|
||||
const t0 = Date.now();
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
|
||||
eventBuffer.add({
|
||||
// Session 1
|
||||
const view1a = {
|
||||
project_id: 'p10',
|
||||
profile_id: 'u10',
|
||||
session_id: 'session_10a',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
project_id: 'p10',
|
||||
profile_id: 'u11',
|
||||
session_id: 'session_10b',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
} as any;
|
||||
|
||||
const view1b = {
|
||||
project_id: 'p10',
|
||||
profile_id: 'u10',
|
||||
session_id: 'session_10a',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any);
|
||||
eventBuffer.add({
|
||||
} as any;
|
||||
|
||||
// Session 2
|
||||
const view2a = {
|
||||
project_id: 'p10',
|
||||
profile_id: 'u11',
|
||||
session_id: 'session_10b',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view2b = {
|
||||
project_id: 'p10',
|
||||
profile_id: 'u11',
|
||||
session_id: 'session_10b',
|
||||
name: 'screen_view',
|
||||
created_at: new Date(t0 + 2000).toISOString(),
|
||||
} as any);
|
||||
await eventBuffer.flush();
|
||||
} as any;
|
||||
|
||||
// All 4 events are in buffer directly
|
||||
expect(await eventBuffer.getBufferSize()).toBe(count1 + 4);
|
||||
await eventBuffer.add(view1a);
|
||||
await eventBuffer.add(view2a);
|
||||
await eventBuffer.add(view1b); // Flushes view1a
|
||||
await eventBuffer.add(view2b); // Flushes view2a
|
||||
|
||||
// Should have 2 events in buffer (one from each session)
|
||||
expect(await eventBuffer.getBufferSize()).toBe(2);
|
||||
|
||||
// Each session should have its own "last" screen_view
|
||||
const last1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p10',
|
||||
sessionId: 'session_10a',
|
||||
});
|
||||
expect(last1!.createdAt.toISOString()).toBe(view1b.created_at);
|
||||
|
||||
const last2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p10',
|
||||
sessionId: 'session_10b',
|
||||
});
|
||||
expect(last2!.createdAt.toISOString()).toBe(view2b.created_at);
|
||||
});
|
||||
|
||||
it('bulk adds events to buffer', async () => {
|
||||
const events = Array.from({ length: 5 }, (_, i) => ({
|
||||
it('screen_view without session_id goes directly to buffer', async () => {
|
||||
const view = {
|
||||
project_id: 'p11',
|
||||
name: `event${i}`,
|
||||
created_at: new Date(Date.now() + i).toISOString(),
|
||||
})) as any[];
|
||||
profile_id: 'u11',
|
||||
name: 'screen_view',
|
||||
created_at: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
eventBuffer.bulkAdd(events);
|
||||
await eventBuffer.flush();
|
||||
const count1 = await eventBuffer.getBufferSize();
|
||||
await eventBuffer.add(view);
|
||||
|
||||
expect(await eventBuffer.getBufferSize()).toBe(5);
|
||||
// Should go directly to buffer (no session_id)
|
||||
const count2 = await eventBuffer.getBufferSize();
|
||||
expect(count2).toBe(count1 + 1);
|
||||
});
|
||||
|
||||
it('updates last screen_view when new one arrives from same profile but different session', async () => {
|
||||
const t0 = Date.now();
|
||||
|
||||
const view1 = {
|
||||
project_id: 'p12',
|
||||
profile_id: 'u12',
|
||||
session_id: 'session_12a',
|
||||
name: 'screen_view',
|
||||
path: '/page1',
|
||||
created_at: new Date(t0).toISOString(),
|
||||
} as any;
|
||||
|
||||
const view2 = {
|
||||
project_id: 'p12',
|
||||
profile_id: 'u12',
|
||||
session_id: 'session_12b', // Different session!
|
||||
name: 'screen_view',
|
||||
path: '/page2',
|
||||
created_at: new Date(t0 + 1000).toISOString(),
|
||||
} as any;
|
||||
|
||||
await eventBuffer.add(view1);
|
||||
await eventBuffer.add(view2);
|
||||
|
||||
// Both sessions should have their own "last"
|
||||
const lastSession1 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
sessionId: 'session_12a',
|
||||
});
|
||||
expect(lastSession1!.path).toBe('/page1');
|
||||
|
||||
const lastSession2 = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
sessionId: 'session_12b',
|
||||
});
|
||||
expect(lastSession2!.path).toBe('/page2');
|
||||
|
||||
// Profile should have the latest one
|
||||
const lastProfile = await eventBuffer.getLastScreenView({
|
||||
projectId: 'p12',
|
||||
profileId: 'u12',
|
||||
});
|
||||
expect(lastProfile!.path).toBe('/page2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,33 @@ import { getSafeJson } from '@openpanel/json';
|
||||
import {
|
||||
type Redis,
|
||||
getRedisCache,
|
||||
getRedisPub,
|
||||
publishEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { ch } from '../clickhouse/client';
|
||||
import { type IClickhouseEvent } from '../services/event.service';
|
||||
import {
|
||||
type IClickhouseEvent,
|
||||
type IServiceEvent,
|
||||
transformEvent,
|
||||
} from '../services/event.service';
|
||||
import { BaseBuffer } from './base-buffer';
|
||||
|
||||
/**
|
||||
* Simplified Event Buffer
|
||||
*
|
||||
* Rules:
|
||||
* 1. All events go into a single list buffer (event_buffer:queue)
|
||||
* 2. screen_view events are handled specially:
|
||||
* - Store current screen_view as "last" for the session
|
||||
* - When a new screen_view arrives, flush the previous one with calculated duration
|
||||
* 3. session_end events:
|
||||
* - Retrieve the last screen_view (don't modify it)
|
||||
* - Push both screen_view and session_end to buffer
|
||||
* 4. Flush: Simply process all events from the list buffer
|
||||
*/
|
||||
|
||||
export class EventBuffer extends BaseBuffer {
|
||||
// Configurable limits
|
||||
private batchSize = process.env.EVENT_BUFFER_BATCH_SIZE
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_BATCH_SIZE, 10)
|
||||
: 4000;
|
||||
@@ -16,26 +36,124 @@ export class EventBuffer extends BaseBuffer {
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_CHUNK_SIZE, 10)
|
||||
: 1000;
|
||||
|
||||
private microBatchIntervalMs = process.env.EVENT_BUFFER_MICRO_BATCH_MS
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_MS, 10)
|
||||
: 10;
|
||||
private microBatchMaxSize = process.env.EVENT_BUFFER_MICRO_BATCH_SIZE
|
||||
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_SIZE, 10)
|
||||
: 100;
|
||||
|
||||
private pendingEvents: IClickhouseEvent[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isFlushing = false;
|
||||
/** Tracks consecutive flush failures for observability; reset on success. */
|
||||
private flushRetryCount = 0;
|
||||
|
||||
private activeVisitorsExpiration = 60 * 5; // 5 minutes
|
||||
/** How often (ms) we refresh the heartbeat key + zadd per visitor. */
|
||||
private heartbeatRefreshMs = 60_000; // 1 minute
|
||||
private lastHeartbeat = new Map<string, number>();
|
||||
|
||||
// LIST - Stores all events ready to be flushed
|
||||
private queueKey = 'event_buffer:queue';
|
||||
|
||||
// STRING - Tracks total buffer size incrementally
|
||||
protected bufferCounterKey = 'event_buffer:total_count';
|
||||
|
||||
// Script SHAs for loaded Lua scripts
|
||||
private scriptShas: {
|
||||
addScreenView?: string;
|
||||
addSessionEnd?: string;
|
||||
} = {};
|
||||
|
||||
// Hash key for storing last screen_view per session
|
||||
private getLastScreenViewKeyBySession(sessionId: string) {
|
||||
return `event_buffer:last_screen_view:session:${sessionId}`;
|
||||
}
|
||||
|
||||
// Hash key for storing last screen_view per profile
|
||||
private getLastScreenViewKeyByProfile(projectId: string, profileId: string) {
|
||||
return `event_buffer:last_screen_view:profile:${projectId}:${profileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lua script for handling screen_view addition - RACE-CONDITION SAFE without GroupMQ
|
||||
*
|
||||
* Strategy: Use Redis GETDEL (atomic get-and-delete) to ensure only ONE thread
|
||||
* can process the "last" screen_view at a time.
|
||||
*
|
||||
* KEYS[1] = last screen_view key (by session) - stores both event and timestamp as JSON
|
||||
* KEYS[2] = last screen_view key (by profile, may be empty)
|
||||
* KEYS[3] = queue key
|
||||
* KEYS[4] = buffer counter key
|
||||
* ARGV[1] = new event with timestamp as JSON: {"event": {...}, "ts": 123456}
|
||||
* ARGV[2] = TTL for last screen_view (1 hour)
|
||||
*/
|
||||
private readonly addScreenViewScript = `
|
||||
local sessionKey = KEYS[1]
|
||||
local profileKey = KEYS[2]
|
||||
local queueKey = KEYS[3]
|
||||
local counterKey = KEYS[4]
|
||||
local newEventData = ARGV[1]
|
||||
local ttl = tonumber(ARGV[2])
|
||||
|
||||
-- GETDEL is atomic: get previous and delete in one operation
|
||||
-- This ensures only ONE thread gets the previous event
|
||||
local previousEventData = redis.call("GETDEL", sessionKey)
|
||||
|
||||
-- Store new screen_view as last for session
|
||||
redis.call("SET", sessionKey, newEventData, "EX", ttl)
|
||||
|
||||
-- Store new screen_view as last for profile (if key provided)
|
||||
if profileKey and profileKey ~= "" then
|
||||
redis.call("SET", profileKey, newEventData, "EX", ttl)
|
||||
end
|
||||
|
||||
-- If there was a previous screen_view, add it to queue with calculated duration
|
||||
if previousEventData then
|
||||
local prev = cjson.decode(previousEventData)
|
||||
local curr = cjson.decode(newEventData)
|
||||
|
||||
-- Calculate duration (ensure non-negative to handle clock skew)
|
||||
if prev.ts and curr.ts then
|
||||
prev.event.duration = math.max(0, curr.ts - prev.ts)
|
||||
end
|
||||
|
||||
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
|
||||
redis.call("INCR", counterKey)
|
||||
return 1
|
||||
end
|
||||
|
||||
return 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Lua script for handling session_end - RACE-CONDITION SAFE
|
||||
*
|
||||
* Uses GETDEL to atomically retrieve and delete the last screen_view
|
||||
*
|
||||
* KEYS[1] = last screen_view key (by session)
|
||||
* KEYS[2] = last screen_view key (by profile, may be empty)
|
||||
* KEYS[3] = queue key
|
||||
* KEYS[4] = buffer counter key
|
||||
* ARGV[1] = session_end event JSON
|
||||
*/
|
||||
private readonly addSessionEndScript = `
|
||||
local sessionKey = KEYS[1]
|
||||
local profileKey = KEYS[2]
|
||||
local queueKey = KEYS[3]
|
||||
local counterKey = KEYS[4]
|
||||
local sessionEndJson = ARGV[1]
|
||||
|
||||
-- GETDEL is atomic: only ONE thread gets the last screen_view
|
||||
local previousEventData = redis.call("GETDEL", sessionKey)
|
||||
local added = 0
|
||||
|
||||
-- If there was a previous screen_view, add it to queue
|
||||
if previousEventData then
|
||||
local prev = cjson.decode(previousEventData)
|
||||
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
|
||||
redis.call("INCR", counterKey)
|
||||
added = added + 1
|
||||
end
|
||||
|
||||
-- Add session_end to queue
|
||||
redis.call("RPUSH", queueKey, sessionEndJson)
|
||||
redis.call("INCR", counterKey)
|
||||
added = added + 1
|
||||
|
||||
-- Delete profile key
|
||||
if profileKey and profileKey ~= "" then
|
||||
redis.call("DEL", profileKey)
|
||||
end
|
||||
|
||||
return added
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
name: 'event',
|
||||
@@ -43,97 +161,170 @@ export class EventBuffer extends BaseBuffer {
|
||||
await this.processBuffer();
|
||||
},
|
||||
});
|
||||
// Load Lua scripts into Redis on startup
|
||||
this.loadScripts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Lua scripts into Redis and cache their SHAs.
|
||||
* This avoids sending the entire script on every call.
|
||||
*/
|
||||
private async loadScripts() {
|
||||
try {
|
||||
const redis = getRedisCache();
|
||||
const [screenViewSha, sessionEndSha] = await Promise.all([
|
||||
redis.script('LOAD', this.addScreenViewScript),
|
||||
redis.script('LOAD', this.addSessionEndScript),
|
||||
]);
|
||||
|
||||
this.scriptShas.addScreenView = screenViewSha as string;
|
||||
this.scriptShas.addSessionEnd = sessionEndSha as string;
|
||||
|
||||
this.logger.info('Loaded Lua scripts into Redis', {
|
||||
addScreenView: this.scriptShas.addScreenView,
|
||||
addSessionEnd: this.scriptShas.addSessionEnd,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load Lua scripts', { error });
|
||||
}
|
||||
}
|
||||
|
||||
bulkAdd(events: IClickhouseEvent[]) {
|
||||
const redis = getRedisCache();
|
||||
const multi = redis.multi();
|
||||
for (const event of events) {
|
||||
this.add(event);
|
||||
this.add(event, multi);
|
||||
}
|
||||
return multi.exec();
|
||||
}
|
||||
|
||||
add(event: IClickhouseEvent) {
|
||||
this.pendingEvents.push(event);
|
||||
|
||||
if (this.pendingEvents.length >= this.microBatchMaxSize) {
|
||||
this.flushLocalBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushLocalBuffer();
|
||||
}, this.microBatchIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
public async flush() {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
await this.flushLocalBuffer();
|
||||
}
|
||||
|
||||
private async flushLocalBuffer() {
|
||||
if (this.isFlushing || this.pendingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFlushing = true;
|
||||
|
||||
const eventsToFlush = this.pendingEvents;
|
||||
this.pendingEvents = [];
|
||||
|
||||
/**
|
||||
* Add an event into Redis buffer.
|
||||
*
|
||||
* Logic:
|
||||
* - screen_view: Store as "last" for session, flush previous if exists
|
||||
* - session_end: Flush last screen_view + session_end
|
||||
* - Other events: Add directly to queue
|
||||
*/
|
||||
async add(event: IClickhouseEvent, _multi?: ReturnType<Redis['multi']>) {
|
||||
try {
|
||||
const redis = getRedisCache();
|
||||
const multi = redis.multi();
|
||||
const eventJson = JSON.stringify(event);
|
||||
const multi = _multi || redis.multi();
|
||||
|
||||
for (const event of eventsToFlush) {
|
||||
multi.rpush(this.queueKey, JSON.stringify(event));
|
||||
if (event.profile_id) {
|
||||
this.incrementActiveVisitorCount(
|
||||
multi,
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
);
|
||||
}
|
||||
if (event.session_id && event.name === 'screen_view') {
|
||||
// Handle screen_view
|
||||
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
|
||||
const profileKey = event.profile_id
|
||||
? this.getLastScreenViewKeyByProfile(
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
)
|
||||
: '';
|
||||
const timestamp = new Date(event.created_at || Date.now()).getTime();
|
||||
|
||||
// Combine event and timestamp into single JSON for atomic operations
|
||||
const eventWithTimestamp = JSON.stringify({
|
||||
event: event,
|
||||
ts: timestamp,
|
||||
});
|
||||
|
||||
this.evalScript(
|
||||
multi,
|
||||
'addScreenView',
|
||||
this.addScreenViewScript,
|
||||
4,
|
||||
sessionKey,
|
||||
profileKey,
|
||||
this.queueKey,
|
||||
this.bufferCounterKey,
|
||||
eventWithTimestamp,
|
||||
'3600', // 1 hour TTL
|
||||
);
|
||||
} else if (event.session_id && event.name === 'session_end') {
|
||||
// Handle session_end
|
||||
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
|
||||
const profileKey = event.profile_id
|
||||
? this.getLastScreenViewKeyByProfile(
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
)
|
||||
: '';
|
||||
|
||||
this.evalScript(
|
||||
multi,
|
||||
'addSessionEnd',
|
||||
this.addSessionEndScript,
|
||||
4,
|
||||
sessionKey,
|
||||
profileKey,
|
||||
this.queueKey,
|
||||
this.bufferCounterKey,
|
||||
eventJson,
|
||||
);
|
||||
} else {
|
||||
// All other events go directly to queue
|
||||
multi.rpush(this.queueKey, eventJson).incr(this.bufferCounterKey);
|
||||
}
|
||||
multi.incrby(this.bufferCounterKey, eventsToFlush.length);
|
||||
|
||||
await multi.exec();
|
||||
if (event.profile_id) {
|
||||
this.incrementActiveVisitorCount(
|
||||
multi,
|
||||
event.project_id,
|
||||
event.profile_id,
|
||||
);
|
||||
}
|
||||
|
||||
this.flushRetryCount = 0;
|
||||
this.pruneHeartbeatMap();
|
||||
if (!_multi) {
|
||||
await multi.exec();
|
||||
}
|
||||
|
||||
await publishEvent('events', 'received', transformEvent(event));
|
||||
} catch (error) {
|
||||
// Re-queue failed events at the front to preserve order and avoid data loss
|
||||
this.pendingEvents = eventsToFlush.concat(this.pendingEvents);
|
||||
|
||||
this.flushRetryCount += 1;
|
||||
this.logger.warn(
|
||||
'Failed to flush local buffer to Redis; events re-queued',
|
||||
{
|
||||
error,
|
||||
eventCount: eventsToFlush.length,
|
||||
flushRetryCount: this.flushRetryCount,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
// Events may have accumulated while we were flushing; schedule another flush if needed
|
||||
if (this.pendingEvents.length > 0 && !this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushLocalBuffer();
|
||||
}, this.microBatchIntervalMs);
|
||||
}
|
||||
this.logger.error('Failed to add event to Redis buffer', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Lua script using EVALSHA (cached) or fallback to EVAL.
|
||||
* This avoids sending the entire script on every call.
|
||||
*/
|
||||
private evalScript(
|
||||
multi: ReturnType<Redis['multi']>,
|
||||
scriptName: keyof typeof this.scriptShas,
|
||||
scriptContent: string,
|
||||
numKeys: number,
|
||||
...args: (string | number)[]
|
||||
) {
|
||||
const sha = this.scriptShas[scriptName];
|
||||
|
||||
if (sha) {
|
||||
// Use EVALSHA with cached SHA
|
||||
multi.evalsha(sha, numKeys, ...args);
|
||||
} else {
|
||||
// Fallback to EVAL and try to reload script
|
||||
multi.eval(scriptContent, numKeys, ...args);
|
||||
this.logger.warn(`Script ${scriptName} not loaded, using EVAL fallback`);
|
||||
// Attempt to reload scripts in background
|
||||
this.loadScripts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the Redis buffer - simplified version.
|
||||
*
|
||||
* Simply:
|
||||
* 1. Fetch events from the queue (up to batchSize)
|
||||
* 2. Parse and sort them
|
||||
* 3. Insert into ClickHouse in chunks
|
||||
* 4. Publish saved events
|
||||
* 5. Clean up processed events from queue
|
||||
*/
|
||||
async processBuffer() {
|
||||
const redis = getRedisCache();
|
||||
|
||||
try {
|
||||
// Fetch events from queue
|
||||
const queueEvents = await redis.lrange(
|
||||
this.queueKey,
|
||||
0,
|
||||
@@ -145,6 +336,7 @@ export class EventBuffer extends BaseBuffer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse events
|
||||
const eventsToClickhouse: IClickhouseEvent[] = [];
|
||||
for (const eventStr of queueEvents) {
|
||||
const event = getSafeJson<IClickhouseEvent>(eventStr);
|
||||
@@ -158,12 +350,14 @@ export class EventBuffer extends BaseBuffer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort events by creation time
|
||||
eventsToClickhouse.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at || 0).getTime() -
|
||||
new Date(b.created_at || 0).getTime(),
|
||||
);
|
||||
|
||||
// Insert events into ClickHouse in chunks
|
||||
this.logger.info('Inserting events into ClickHouse', {
|
||||
totalEvents: eventsToClickhouse.length,
|
||||
chunks: Math.ceil(eventsToClickhouse.length / this.chunkSize),
|
||||
@@ -177,17 +371,14 @@ export class EventBuffer extends BaseBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
const countByProject = new Map<string, number>();
|
||||
// Publish "saved" events
|
||||
const pubMulti = getRedisPub().multi();
|
||||
for (const event of eventsToClickhouse) {
|
||||
countByProject.set(
|
||||
event.project_id,
|
||||
(countByProject.get(event.project_id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
for (const [projectId, count] of countByProject) {
|
||||
publishEvent('events', 'batch', { projectId, count });
|
||||
await publishEvent('events', 'saved', transformEvent(event), pubMulti);
|
||||
}
|
||||
await pubMulti.exec();
|
||||
|
||||
// Clean up processed events from queue
|
||||
await redis
|
||||
.multi()
|
||||
.ltrim(this.queueKey, queueEvents.length, -1)
|
||||
@@ -203,6 +394,45 @@ export class EventBuffer extends BaseBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the latest screen_view event for a given session or profile
|
||||
*/
|
||||
public async getLastScreenView(
|
||||
params:
|
||||
| {
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
},
|
||||
): Promise<IServiceEvent | null> {
|
||||
const redis = getRedisCache();
|
||||
|
||||
let lastScreenViewKey: string;
|
||||
if ('sessionId' in params) {
|
||||
lastScreenViewKey = this.getLastScreenViewKeyBySession(params.sessionId);
|
||||
} else {
|
||||
lastScreenViewKey = this.getLastScreenViewKeyByProfile(
|
||||
params.projectId,
|
||||
params.profileId,
|
||||
);
|
||||
}
|
||||
|
||||
const eventDataStr = await redis.get(lastScreenViewKey);
|
||||
|
||||
if (eventDataStr) {
|
||||
const eventData = getSafeJson<{ event: IClickhouseEvent; ts: number }>(
|
||||
eventDataStr,
|
||||
);
|
||||
if (eventData?.event) {
|
||||
return transformEvent(eventData.event);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getBufferSize() {
|
||||
return this.getBufferSizeWithCounter(async () => {
|
||||
const redis = getRedisCache();
|
||||
@@ -210,32 +440,16 @@ export class EventBuffer extends BaseBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
private pruneHeartbeatMap() {
|
||||
const cutoff = Date.now() - this.activeVisitorsExpiration * 1000;
|
||||
for (const [key, ts] of this.lastHeartbeat) {
|
||||
if (ts < cutoff) {
|
||||
this.lastHeartbeat.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private incrementActiveVisitorCount(
|
||||
private async incrementActiveVisitorCount(
|
||||
multi: ReturnType<Redis['multi']>,
|
||||
projectId: string,
|
||||
profileId: string,
|
||||
) {
|
||||
const key = `${projectId}:${profileId}`;
|
||||
// Track active visitors and emit expiry events when inactive for TTL
|
||||
const now = Date.now();
|
||||
const last = this.lastHeartbeat.get(key) ?? 0;
|
||||
|
||||
if (now - last < this.heartbeatRefreshMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastHeartbeat.set(key, now);
|
||||
const zsetKey = `live:visitors:${projectId}`;
|
||||
const heartbeatKey = `live:visitor:${projectId}:${profileId}`;
|
||||
multi
|
||||
return multi
|
||||
.zadd(zsetKey, now, profileId)
|
||||
.set(heartbeatKey, '1', 'EX', this.activeVisitorsExpiration);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { deepMergeObjects } from '@openpanel/common';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type { ILogger } from '@openpanel/logger';
|
||||
import { getRedisCache, type Redis } from '@openpanel/redis';
|
||||
import { type Redis, getRedisCache } from '@openpanel/redis';
|
||||
import shallowEqual from 'fast-deep-equal';
|
||||
import { omit } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client';
|
||||
import { TABLE_NAMES, ch, chQuery } from '../clickhouse/client';
|
||||
import type { IClickhouseProfile } from '../services/profile.service';
|
||||
import { BaseBuffer } from './base-buffer';
|
||||
|
||||
@@ -89,7 +89,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
'os_version',
|
||||
'browser_version',
|
||||
],
|
||||
profile.properties
|
||||
profile.properties,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,16 +97,16 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
? deepMergeObjects(existingProfile, omit(['created_at'], profile))
|
||||
: profile;
|
||||
|
||||
if (
|
||||
profile &&
|
||||
existingProfile &&
|
||||
shallowEqual(
|
||||
omit(['created_at'], existingProfile),
|
||||
omit(['created_at'], mergedProfile)
|
||||
)
|
||||
) {
|
||||
this.logger.debug('Profile not changed, skipping');
|
||||
return;
|
||||
if (profile && existingProfile) {
|
||||
if (
|
||||
shallowEqual(
|
||||
omit(['created_at'], existingProfile),
|
||||
omit(['created_at'], mergedProfile),
|
||||
)
|
||||
) {
|
||||
this.logger.debug('Profile not changed, skipping');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Merged profile will be inserted', {
|
||||
@@ -151,11 +151,11 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
private async fetchProfile(
|
||||
profile: IClickhouseProfile,
|
||||
logger: ILogger
|
||||
logger: ILogger,
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
const existingProfile = await this.fetchFromCache(
|
||||
profile.id,
|
||||
profile.project_id
|
||||
profile.project_id,
|
||||
);
|
||||
if (existingProfile) {
|
||||
logger.debug('Profile found in Redis');
|
||||
@@ -167,7 +167,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
public async fetchFromCache(
|
||||
profileId: string,
|
||||
projectId: string
|
||||
projectId: string,
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
const cacheKey = this.getProfileCacheKey({
|
||||
profileId,
|
||||
@@ -182,7 +182,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
private async fetchFromClickhouse(
|
||||
profile: IClickhouseProfile,
|
||||
logger: ILogger
|
||||
logger: ILogger,
|
||||
): Promise<IClickhouseProfile | null> {
|
||||
logger.debug('Fetching profile from Clickhouse');
|
||||
const result = await chQuery<IClickhouseProfile>(
|
||||
@@ -207,7 +207,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
}
|
||||
GROUP BY id, project_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`
|
||||
LIMIT 1`,
|
||||
);
|
||||
logger.debug('Clickhouse fetch result', {
|
||||
found: !!result[0],
|
||||
@@ -221,7 +221,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
const profiles = await this.redis.lrange(
|
||||
this.redisKey,
|
||||
0,
|
||||
this.batchSize - 1
|
||||
this.batchSize - 1,
|
||||
);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
@@ -231,7 +231,7 @@ export class ProfileBuffer extends BaseBuffer {
|
||||
|
||||
this.logger.debug(`Processing ${profiles.length} profiles in buffer`);
|
||||
const parsedProfiles = profiles.map((p) =>
|
||||
getSafeJson<IClickhouseProfile>(p)
|
||||
getSafeJson<IClickhouseProfile>(p),
|
||||
);
|
||||
|
||||
for (const chunk of this.chunks(parsedProfiles, this.chunkSize)) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { originalCh } from './clickhouse/client';
|
||||
import { decrypt, encrypt } from './encryption';
|
||||
import { createLogger } from '@openpanel/logger';
|
||||
import { db } from './prisma-client';
|
||||
|
||||
const logger = createLogger({ name: 'db:gsc' });
|
||||
|
||||
export interface GscSite {
|
||||
siteUrl: string;
|
||||
permissionLevel: string;
|
||||
@@ -14,15 +11,9 @@ export interface GscSite {
|
||||
async function refreshGscToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; expiresAt: Date }> {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
throw new Error(
|
||||
'GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set in this environment'
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.GOOGLE_CLIENT_ID,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
@@ -55,19 +46,9 @@ export async function getGscAccessToken(projectId: string): Promise<string> {
|
||||
conn.accessTokenExpiresAt &&
|
||||
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
|
||||
) {
|
||||
logger.info('GSC using cached access token', {
|
||||
projectId,
|
||||
expiresAt: conn.accessTokenExpiresAt,
|
||||
});
|
||||
return decrypt(conn.accessToken);
|
||||
}
|
||||
|
||||
logger.info('GSC access token expired, attempting refresh', {
|
||||
projectId,
|
||||
expiresAt: conn.accessTokenExpiresAt,
|
||||
hasRefreshToken: !!conn.refreshToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const { accessToken, expiresAt } = await refreshGscToken(
|
||||
decrypt(conn.refreshToken)
|
||||
@@ -76,21 +57,18 @@ export async function getGscAccessToken(projectId: string): Promise<string> {
|
||||
where: { projectId },
|
||||
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
|
||||
});
|
||||
logger.info('GSC token refreshed successfully', { projectId, expiresAt });
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to refresh token';
|
||||
logger.error('GSC token refresh failed', { projectId, error: errorMessage });
|
||||
await db.gscConnection.update({
|
||||
where: { projectId },
|
||||
data: {
|
||||
lastSyncStatus: 'token_expired',
|
||||
lastSyncError: errorMessage,
|
||||
lastSyncError:
|
||||
error instanceof Error ? error.message : 'Failed to refresh token',
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`GSC token refresh failed for project ${projectId}: ${errorMessage}`
|
||||
'GSC token has expired or been revoked. Please reconnect Google Search Console.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import type { Client, Prisma } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
@@ -34,4 +34,7 @@ export async function getClientById(
|
||||
});
|
||||
}
|
||||
|
||||
export const getClientByIdCached = cacheable(getClientById, 60 * 5);
|
||||
export const getClientByIdCached = cacheableLru(getClientById, {
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
device: event.device,
|
||||
brand: event.brand,
|
||||
model: event.model,
|
||||
duration: event.duration,
|
||||
path: event.path,
|
||||
origin: event.origin,
|
||||
referrer: event.referrer,
|
||||
@@ -215,7 +216,7 @@ export interface IServiceEvent {
|
||||
device?: string | undefined;
|
||||
brand?: string | undefined;
|
||||
model?: string | undefined;
|
||||
duration?: number;
|
||||
duration: number;
|
||||
path: string;
|
||||
origin: string;
|
||||
referrer: string | undefined;
|
||||
@@ -246,7 +247,7 @@ export interface IServiceEventMinimal {
|
||||
browser?: string | undefined;
|
||||
device?: string | undefined;
|
||||
brand?: string | undefined;
|
||||
duration?: number;
|
||||
duration: number;
|
||||
path: string;
|
||||
origin: string;
|
||||
referrer: string | undefined;
|
||||
@@ -378,7 +379,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
device: payload.device ?? '',
|
||||
brand: payload.brand ?? '',
|
||||
model: payload.model ?? '',
|
||||
duration: payload.duration ?? 0,
|
||||
duration: payload.duration,
|
||||
referrer: payload.referrer ?? '',
|
||||
referrer_name: payload.referrerName ?? '',
|
||||
referrer_type: payload.referrerType ?? '',
|
||||
@@ -476,7 +477,7 @@ export async function getEventList(options: GetEventListOptions) {
|
||||
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
|
||||
}
|
||||
|
||||
if (!(cursor || (startDate && endDate))) {
|
||||
if (!cursor && !(startDate && endDate)) {
|
||||
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
|
||||
}
|
||||
|
||||
@@ -561,6 +562,9 @@ export async function getEventList(options: GetEventListOptions) {
|
||||
if (select.model) {
|
||||
sb.select.model = 'model';
|
||||
}
|
||||
if (select.duration) {
|
||||
sb.select.duration = 'duration';
|
||||
}
|
||||
if (select.path) {
|
||||
sb.select.path = 'path';
|
||||
}
|
||||
@@ -767,6 +771,7 @@ class EventService {
|
||||
where,
|
||||
select,
|
||||
limit,
|
||||
orderBy,
|
||||
filters,
|
||||
}: {
|
||||
projectId: string;
|
||||
@@ -806,6 +811,7 @@ class EventService {
|
||||
select.event.deviceId && 'e.device_id as device_id',
|
||||
select.event.name && 'e.name as name',
|
||||
select.event.path && 'e.path as path',
|
||||
select.event.duration && 'e.duration as duration',
|
||||
select.event.country && 'e.country as country',
|
||||
select.event.city && 'e.city as city',
|
||||
select.event.os && 'e.os as os',
|
||||
@@ -890,6 +896,7 @@ class EventService {
|
||||
select.event.deviceId && 'e.device_id as device_id',
|
||||
select.event.name && 'e.name as name',
|
||||
select.event.path && 'e.path as path',
|
||||
select.event.duration && 'e.duration as duration',
|
||||
select.event.country && 'e.country as country',
|
||||
select.event.city && 'e.city as city',
|
||||
select.event.os && 'e.os as os',
|
||||
@@ -1025,6 +1032,7 @@ class EventService {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
duration: true,
|
||||
country: true,
|
||||
city: true,
|
||||
os: true,
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
|
||||
},
|
||||
});
|
||||
},
|
||||
60 * 24,
|
||||
60 * 24
|
||||
);
|
||||
|
||||
function getIntegration(integrationId: string | null) {
|
||||
|
||||
@@ -416,30 +416,6 @@ export class OverviewService {
|
||||
const where = this.getRawWhereClause('sessions', filters);
|
||||
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
||||
|
||||
// CTE: per-event screen_view durations via window function
|
||||
const rawScreenViewDurationsQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
|
||||
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', 'screen_view')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters));
|
||||
|
||||
// CTE: avg duration per date bucket
|
||||
const avgDurationByDateQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
'date',
|
||||
'round(avgIf(duration, duration > 0), 2) / 1000 AS avg_session_duration',
|
||||
])
|
||||
.from('raw_screen_view_durations')
|
||||
.groupBy(['date']);
|
||||
|
||||
// Session aggregation with bounce rates
|
||||
const sessionAggQuery = clix(this.client, timezone)
|
||||
.select([
|
||||
@@ -497,8 +473,6 @@ export class OverviewService {
|
||||
.where('date', '!=', rollupDate)
|
||||
)
|
||||
.with('overall_unique_visitors', overallUniqueVisitorsQuery)
|
||||
.with('raw_screen_view_durations', rawScreenViewDurationsQuery)
|
||||
.with('avg_duration_by_date', avgDurationByDateQuery)
|
||||
.select<{
|
||||
date: string;
|
||||
bounce_rate: number;
|
||||
@@ -515,7 +489,8 @@ export class OverviewService {
|
||||
'dss.bounce_rate as bounce_rate',
|
||||
'uniq(e.profile_id) AS unique_visitors',
|
||||
'uniq(e.session_id) AS total_sessions',
|
||||
'coalesce(dur.avg_session_duration, 0) AS avg_session_duration',
|
||||
'round(avgIf(duration, duration > 0), 2) / 1000 AS _avg_session_duration',
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'count(*) AS total_screen_views',
|
||||
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
|
||||
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
|
||||
@@ -527,10 +502,6 @@ export class OverviewService {
|
||||
'daily_session_stats AS dss',
|
||||
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`
|
||||
)
|
||||
.leftJoin(
|
||||
'avg_duration_by_date AS dur',
|
||||
`${clix.toStartOf('e.created_at', interval as any)} = dur.date`
|
||||
)
|
||||
.where('e.project_id', '=', projectId)
|
||||
.where('e.name', '=', 'screen_view')
|
||||
.where('e.created_at', 'BETWEEN', [
|
||||
@@ -538,7 +509,7 @@ export class OverviewService {
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters))
|
||||
.groupBy(['date', 'dss.bounce_rate', 'dur.avg_session_duration'])
|
||||
.groupBy(['date', 'dss.bounce_rate'])
|
||||
.orderBy('date', 'ASC')
|
||||
.fill(fillConfig.from, fillConfig.to, fillConfig.step)
|
||||
.transform({
|
||||
|
||||
@@ -52,24 +52,6 @@ export class PagesService {
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'))
|
||||
.groupBy(['origin', 'path']);
|
||||
|
||||
// CTE: compute screen_view durations via window function (leadInFrame gives next event's timestamp)
|
||||
const screenViewDurationsCte = clix(this.client, timezone)
|
||||
.select([
|
||||
'project_id',
|
||||
'session_id',
|
||||
'path',
|
||||
'origin',
|
||||
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
|
||||
])
|
||||
.from(TABLE_NAMES.events, false)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', 'screen_view')
|
||||
.where('path', '!=', '')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
]);
|
||||
|
||||
// Pre-filtered sessions subquery for better performance
|
||||
const sessionsSubquery = clix(this.client, timezone)
|
||||
.select(['id', 'project_id', 'is_bounce'])
|
||||
@@ -84,7 +66,6 @@ export class PagesService {
|
||||
// Main query: aggregate events and calculate bounce rate from pre-filtered sessions
|
||||
const query = clix(this.client, timezone)
|
||||
.with('page_titles', titlesCte)
|
||||
.with('screen_view_durations', screenViewDurationsCte)
|
||||
.select<ITopPage>([
|
||||
'e.origin as origin',
|
||||
'e.path as path',
|
||||
@@ -93,18 +74,25 @@ export class PagesService {
|
||||
'count() as pageviews',
|
||||
'round(avg(e.duration) / 1000 / 60, 2) as avg_duration',
|
||||
`round(
|
||||
(uniqIf(e.session_id, s.is_bounce = 1) * 100.0) /
|
||||
nullIf(uniq(e.session_id), 0),
|
||||
(uniqIf(e.session_id, s.is_bounce = 1) * 100.0) /
|
||||
nullIf(uniq(e.session_id), 0),
|
||||
2
|
||||
) as bounce_rate`,
|
||||
])
|
||||
.from('screen_view_durations e', false)
|
||||
.from(`${TABLE_NAMES.events} e`, false)
|
||||
.leftJoin(
|
||||
sessionsSubquery,
|
||||
'e.session_id = s.id AND e.project_id = s.project_id',
|
||||
's'
|
||||
)
|
||||
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
|
||||
.where('e.project_id', '=', projectId)
|
||||
.where('e.name', '=', 'screen_view')
|
||||
.where('e.path', '!=', '')
|
||||
.where('e.created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.when(!!search, (q) => {
|
||||
const term = `%${search}%`;
|
||||
q.whereGroup()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { chQuery, TABLE_NAMES } from '../clickhouse/client';
|
||||
import { TABLE_NAMES, chQuery } from '../clickhouse/client';
|
||||
import type { Prisma, Project } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function getProjectById(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/** L1 LRU (60s) + L2 Redis. clear() invalidates Redis + local LRU; other nodes may serve stale from LRU for up to 60s. */
|
||||
export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24);
|
||||
|
||||
export async function getProjectWithClients(id: string) {
|
||||
@@ -45,7 +44,7 @@ export async function getProjectWithClients(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getProjectsByOrganizationId(organizationId: string) {
|
||||
export async function getProjectsByOrganizationId(organizationId: string) {
|
||||
return db.project.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
@@ -96,7 +95,7 @@ export async function getProjects({
|
||||
|
||||
if (access.length > 0) {
|
||||
return projects.filter((project) =>
|
||||
access.some((a) => a.projectId === project.id)
|
||||
access.some((a) => a.projectId === project.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +104,7 @@ export async function getProjects({
|
||||
|
||||
export const getProjectEventsCount = async (projectId: string) => {
|
||||
const res = await chQuery<{ count: number }>(
|
||||
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`
|
||||
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`,
|
||||
);
|
||||
return res[0]?.count;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { generateSalt } from '@openpanel/common/server';
|
||||
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import { cacheableLru } from '@openpanel/redis';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export const getSalts = cacheable(
|
||||
export const getSalts = cacheableLru(
|
||||
'op:salt',
|
||||
async () => {
|
||||
const [curr, prev] = await db.salt.findMany({
|
||||
@@ -24,7 +24,10 @@ export const getSalts = cacheable(
|
||||
|
||||
return salts;
|
||||
},
|
||||
60 * 5,
|
||||
{
|
||||
maxSize: 2,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
export async function createInitialSalts() {
|
||||
|
||||
221
packages/payments/scripts/assign-product-to-org.ts
Normal file
221
packages/payments/scripts/assign-product-to-org.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import inquirer from 'inquirer';
|
||||
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { getSuccessUrl } from '..';
|
||||
|
||||
// Register the autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
|
||||
|
||||
interface Answers {
|
||||
isProduction: boolean;
|
||||
polarApiKey: string;
|
||||
productId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
async function promptForInput() {
|
||||
// Get all organizations first
|
||||
const organizations = await db.organization.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 1: Collect Polar credentials first
|
||||
const polarCredentials = await inquirer.prompt<{
|
||||
isProduction: boolean;
|
||||
polarApiKey: string;
|
||||
polarOrganizationId: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'isProduction',
|
||||
message: 'Is this for production?',
|
||||
choices: [
|
||||
{ name: 'Yes', value: true },
|
||||
{ name: 'No', value: false },
|
||||
],
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarApiKey',
|
||||
message: 'Enter your Polar API key:',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'API key is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Step 2: Initialize Polar client and fetch products
|
||||
const polar = new Polar({
|
||||
accessToken: polarCredentials.polarApiKey,
|
||||
server: polarCredentials.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
console.log('Fetching products from Polar...');
|
||||
const productsResponse = await polar.products.list({
|
||||
limit: 100,
|
||||
isArchived: false,
|
||||
sorting: ['price_amount'],
|
||||
});
|
||||
|
||||
const products = productsResponse.result.items;
|
||||
|
||||
if (products.length === 0) {
|
||||
throw new Error('No products found in Polar');
|
||||
}
|
||||
|
||||
// Step 3: Continue with product selection and organization selection
|
||||
const restOfAnswers = await inquirer.prompt<{
|
||||
productId: string;
|
||||
organizationId: string;
|
||||
}>([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'productId',
|
||||
message: 'Select product:',
|
||||
source: (answersSoFar: any, input = '') => {
|
||||
return products
|
||||
.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(input.toLowerCase()) ||
|
||||
product.id.toLowerCase().includes(input.toLowerCase()),
|
||||
)
|
||||
.map((product) => {
|
||||
const price = product.prices?.[0];
|
||||
const priceStr =
|
||||
price && 'priceAmount' in price && price.priceAmount
|
||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
||||
: 'No price';
|
||||
return {
|
||||
name: `${product.name} (${priceStr})`,
|
||||
value: product.id,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'organizationId',
|
||||
message: 'Select organization:',
|
||||
source: (answersSoFar: any, input = '') => {
|
||||
return organizations
|
||||
.filter(
|
||||
(org) =>
|
||||
org.name.toLowerCase().includes(input.toLowerCase()) ||
|
||||
org.id.toLowerCase().includes(input.toLowerCase()),
|
||||
)
|
||||
.map((org) => ({
|
||||
name: `${org.name} (${org.id})`,
|
||||
value: org.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
...polarCredentials,
|
||||
...restOfAnswers,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Assigning existing product to organization...');
|
||||
const input = await promptForInput();
|
||||
|
||||
const polar = new Polar({
|
||||
accessToken: input.polarApiKey,
|
||||
server: input.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization.createdBy) {
|
||||
throw new Error(
|
||||
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = organization.createdBy;
|
||||
|
||||
// Fetch product details for review
|
||||
const product = await polar.products.get({ id: input.productId });
|
||||
const price = product.prices?.[0];
|
||||
const priceStr =
|
||||
price && 'priceAmount' in price && price.priceAmount
|
||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
||||
: 'No price';
|
||||
|
||||
console.log('\nReview the following settings:');
|
||||
console.table({
|
||||
product: product.name,
|
||||
price: priceStr,
|
||||
organization: organization.name,
|
||||
email: user.email,
|
||||
name:
|
||||
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
|
||||
});
|
||||
|
||||
const { confirmed } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmed',
|
||||
message: 'Do you want to proceed?',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Operation canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutLink = await polar.checkoutLinks.create({
|
||||
paymentProcessor: 'stripe',
|
||||
productId: input.productId,
|
||||
allowDiscountCodes: false,
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
userId: user.id,
|
||||
},
|
||||
successUrl: getSuccessUrl(
|
||||
input.isProduction
|
||||
? 'https://dashboard.openpanel.dev'
|
||||
: 'http://localhost:3000',
|
||||
organization.id,
|
||||
),
|
||||
});
|
||||
|
||||
console.log('\nCheckout link created:');
|
||||
console.table(checkoutLink);
|
||||
console.log('\nProduct assigned successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => db.$disconnect());
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@openpanel/db';
|
||||
import { createLogger } from '@openpanel/logger';
|
||||
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Queue, QueueEvents } from 'bullmq';
|
||||
import { Queue as GroupQueue } from 'groupmq';
|
||||
import type { ITrackPayload } from '../../validation';
|
||||
|
||||
@@ -66,10 +66,6 @@ export interface EventsQueuePayloadIncomingEvent {
|
||||
headers: Record<string, string | undefined>;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: Pick<
|
||||
IServiceCreateEventPayload,
|
||||
'referrer' | 'referrerName' | 'referrerType'
|
||||
>;
|
||||
};
|
||||
}
|
||||
export interface EventsQueuePayloadCreateEvent {
|
||||
@@ -210,6 +206,9 @@ export const sessionsQueue = new Queue<SessionsQueuePayload>(
|
||||
},
|
||||
}
|
||||
);
|
||||
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
|
||||
connection: getRedisQueue(),
|
||||
});
|
||||
|
||||
export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), {
|
||||
connection: getRedisQueue(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { getRedisCache } from './redis';
|
||||
|
||||
export const deleteCache = (key: string) => {
|
||||
export const deleteCache = async (key: string) => {
|
||||
return getRedisCache().del(key);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getCache<T>(
|
||||
key: string,
|
||||
expireInSec: number,
|
||||
fn: () => Promise<T>,
|
||||
useLruCache?: boolean
|
||||
useLruCache?: boolean,
|
||||
): Promise<T> {
|
||||
// L1 Cache: Check global LRU cache first (in-memory, instant)
|
||||
if (useLruCache) {
|
||||
@@ -28,7 +28,15 @@ export async function getCache<T>(
|
||||
// L2 Cache: Check Redis cache (shared across instances)
|
||||
const hit = await getRedisCache().get(key);
|
||||
if (hit) {
|
||||
const parsed = parseCache(hit);
|
||||
const parsed = JSON.parse(hit, (_, value) => {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
|
||||
) {
|
||||
return new Date(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
// Store in LRU cache for next time
|
||||
if (useLruCache) {
|
||||
@@ -73,24 +81,12 @@ export function getGlobalLruCacheStats() {
|
||||
}
|
||||
|
||||
function stringify(obj: unknown): string {
|
||||
if (obj === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (obj === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
if (typeof obj === 'boolean') {
|
||||
return obj ? 'true' : 'false';
|
||||
}
|
||||
if (typeof obj === 'number') {
|
||||
return String(obj);
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
return obj;
|
||||
}
|
||||
if (typeof obj === 'function') {
|
||||
return obj.toString();
|
||||
}
|
||||
if (obj === null) return 'null';
|
||||
if (obj === undefined) return 'undefined';
|
||||
if (typeof obj === 'boolean') return obj ? 'true' : 'false';
|
||||
if (typeof obj === 'number') return String(obj);
|
||||
if (typeof obj === 'string') return obj;
|
||||
if (typeof obj === 'function') return obj.toString();
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return `[${obj.map(stringify).join(',')}]`;
|
||||
@@ -132,29 +128,17 @@ function hasResult(result: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/;
|
||||
const parseCache = (cached: string) => {
|
||||
try {
|
||||
return JSON.parse(cached, (_, value) => {
|
||||
if (typeof value === 'string' && DATE_REGEX.test(value)) {
|
||||
return new Date(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse cache', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// L1 cache: short TTL to offload Redis; clear() invalidates Redis, other nodes may serve stale from LRU for up to this long
|
||||
const CACHEABLE_LRU_TTL_MS = 60 * 1000; // 60 seconds
|
||||
const CACHEABLE_LRU_MAX = 1000;
|
||||
export interface CacheableLruOptions {
|
||||
/** TTL in seconds for LRU cache */
|
||||
ttl: number;
|
||||
/** Maximum number of entries in LRU cache */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
// Overload 1: cacheable(fn, expireInSec)
|
||||
export function cacheable<T extends (...args: any) => any>(
|
||||
fn: T,
|
||||
expireInSec: number
|
||||
expireInSec: number,
|
||||
): T & {
|
||||
getKey: (...args: Parameters<T>) => string;
|
||||
clear: (...args: Parameters<T>) => Promise<number>;
|
||||
@@ -167,7 +151,7 @@ export function cacheable<T extends (...args: any) => any>(
|
||||
export function cacheable<T extends (...args: any) => any>(
|
||||
name: string,
|
||||
fn: T,
|
||||
expireInSec: number
|
||||
expireInSec: number,
|
||||
): T & {
|
||||
getKey: (...args: Parameters<T>) => string;
|
||||
clear: (...args: Parameters<T>) => Promise<number>;
|
||||
@@ -180,7 +164,7 @@ export function cacheable<T extends (...args: any) => any>(
|
||||
export function cacheable<T extends (...args: any) => any>(
|
||||
fnOrName: T | string,
|
||||
fnOrExpireInSec: number | T,
|
||||
_expireInSec?: number
|
||||
_expireInSec?: number,
|
||||
) {
|
||||
const name = typeof fnOrName === 'string' ? fnOrName : fnOrName.name;
|
||||
const fn =
|
||||
@@ -211,67 +195,184 @@ export function cacheable<T extends (...args: any) => any>(
|
||||
|
||||
const cachePrefix = `cachable:${name}`;
|
||||
const getKey = (...args: Parameters<T>) =>
|
||||
`${cachePrefix}:${stringify(args)}`.replaceAll(/\s/g, '');
|
||||
`${cachePrefix}:${stringify(args)}`;
|
||||
|
||||
const lruCache = new LRUCache<string, any>({
|
||||
max: CACHEABLE_LRU_MAX,
|
||||
ttl: CACHEABLE_LRU_TTL_MS,
|
||||
});
|
||||
|
||||
// L1 LRU (60s) + L2 Redis. clear() deletes Redis + local LRU; other nodes may serve stale from LRU for up to 60s.
|
||||
// Redis-only mode: asynchronous implementation
|
||||
const cachedFn = async (
|
||||
...args: Parameters<T>
|
||||
): Promise<Awaited<ReturnType<T>>> => {
|
||||
const key = getKey(...args);
|
||||
|
||||
// L1: in-memory LRU first (offloads Redis on hot keys)
|
||||
const lruHit = lruCache.get(key);
|
||||
if (lruHit !== undefined && hasResult(lruHit)) {
|
||||
return lruHit as Awaited<ReturnType<T>>;
|
||||
}
|
||||
|
||||
// L2: Redis (shared across instances)
|
||||
// Check Redis cache (shared across instances)
|
||||
const cached = await getRedisCache().get(key);
|
||||
if (cached) {
|
||||
const parsed = parseCache(cached);
|
||||
if (hasResult(parsed)) {
|
||||
lruCache.set(key, parsed);
|
||||
return parsed;
|
||||
try {
|
||||
const parsed = JSON.parse(cached, (_, value) => {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
|
||||
) {
|
||||
return new Date(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (hasResult(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse cache', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss: execute function
|
||||
// Cache miss: Execute function
|
||||
const result = await fn(...(args as any));
|
||||
|
||||
if (hasResult(result)) {
|
||||
lruCache.set(key, result);
|
||||
// Don't await Redis write - fire and forget for better performance
|
||||
getRedisCache()
|
||||
.setex(key, expireInSec, JSON.stringify(result))
|
||||
.catch(() => {
|
||||
// ignore error
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
cachedFn.getKey = getKey;
|
||||
cachedFn.clear = (...args: Parameters<T>) => {
|
||||
cachedFn.clear = async (...args: Parameters<T>) => {
|
||||
const key = getKey(...args);
|
||||
lruCache.delete(key);
|
||||
return getRedisCache().del(key);
|
||||
};
|
||||
cachedFn.set =
|
||||
(...args: Parameters<T>) =>
|
||||
(payload: Awaited<ReturnType<T>>) => {
|
||||
async (payload: Awaited<ReturnType<T>>) => {
|
||||
const key = getKey(...args);
|
||||
return getRedisCache()
|
||||
.setex(key, expireInSec, JSON.stringify(payload))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return cachedFn;
|
||||
}
|
||||
|
||||
// Overload 1: cacheableLru(fn, options)
|
||||
export function cacheableLru<T extends (...args: any) => any>(
|
||||
fn: T,
|
||||
options: CacheableLruOptions,
|
||||
): T & {
|
||||
getKey: (...args: Parameters<T>) => string;
|
||||
clear: (...args: Parameters<T>) => boolean;
|
||||
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
|
||||
};
|
||||
|
||||
// Overload 2: cacheableLru(name, fn, options)
|
||||
export function cacheableLru<T extends (...args: any) => any>(
|
||||
name: string,
|
||||
fn: T,
|
||||
options: CacheableLruOptions,
|
||||
): T & {
|
||||
getKey: (...args: Parameters<T>) => string;
|
||||
clear: (...args: Parameters<T>) => boolean;
|
||||
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
|
||||
};
|
||||
|
||||
// Implementation for cacheableLru (LRU-only - synchronous)
|
||||
export function cacheableLru<T extends (...args: any) => any>(
|
||||
fnOrName: T | string,
|
||||
fnOrOptions: T | CacheableLruOptions,
|
||||
_options?: CacheableLruOptions,
|
||||
) {
|
||||
const name = typeof fnOrName === 'string' ? fnOrName : fnOrName.name;
|
||||
const fn =
|
||||
typeof fnOrName === 'function'
|
||||
? fnOrName
|
||||
: typeof fnOrOptions === 'function'
|
||||
? fnOrOptions
|
||||
: null;
|
||||
|
||||
let options: CacheableLruOptions;
|
||||
|
||||
// Parse parameters based on function signature
|
||||
if (typeof fnOrName === 'function') {
|
||||
// Overload 1: cacheableLru(fn, options)
|
||||
options =
|
||||
typeof fnOrOptions === 'object' && fnOrOptions !== null
|
||||
? fnOrOptions
|
||||
: ({} as CacheableLruOptions);
|
||||
} else {
|
||||
// Overload 2: cacheableLru(name, fn, options)
|
||||
options =
|
||||
typeof _options === 'object' && _options !== null
|
||||
? _options
|
||||
: ({} as CacheableLruOptions);
|
||||
}
|
||||
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error('fn is not a function');
|
||||
}
|
||||
|
||||
if (typeof options.ttl !== 'number') {
|
||||
throw new Error('options.ttl is required and must be a number');
|
||||
}
|
||||
|
||||
const cachePrefix = `cachable:${name}`;
|
||||
const getKey = (...args: Parameters<T>) =>
|
||||
`${cachePrefix}:${stringify(args)}`;
|
||||
|
||||
const maxSize = options.maxSize ?? 1000;
|
||||
const ttl = options.ttl;
|
||||
|
||||
// Create function-specific LRU cache
|
||||
const functionLruCache = new LRUCache<string, any>({
|
||||
max: maxSize,
|
||||
ttl: ttl * 1000, // Convert seconds to milliseconds for LRU
|
||||
});
|
||||
|
||||
// LRU-only mode: synchronous implementation (or returns promise if fn is async)
|
||||
const cachedFn = ((...args: Parameters<T>): ReturnType<T> => {
|
||||
const key = getKey(...args);
|
||||
|
||||
// Check LRU cache
|
||||
const lruHit = functionLruCache.get(key);
|
||||
if (lruHit !== undefined && hasResult(lruHit)) {
|
||||
return lruHit as ReturnType<T>;
|
||||
}
|
||||
|
||||
// Cache miss: Execute function
|
||||
const result = fn(...(args as any)) as ReturnType<T>;
|
||||
|
||||
// If result is a Promise, handle it asynchronously but cache the resolved value
|
||||
if (result && typeof (result as any).then === 'function') {
|
||||
return (result as Promise<any>).then((resolved: any) => {
|
||||
if (hasResult(resolved)) {
|
||||
functionLruCache.set(key, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}) as ReturnType<T>;
|
||||
}
|
||||
|
||||
// Synchronous result: cache and return
|
||||
if (hasResult(result)) {
|
||||
functionLruCache.set(key, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as T & {
|
||||
getKey: (...args: Parameters<T>) => string;
|
||||
clear: (...args: Parameters<T>) => boolean;
|
||||
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
|
||||
};
|
||||
|
||||
cachedFn.getKey = getKey;
|
||||
cachedFn.clear = (...args: Parameters<T>) => {
|
||||
const key = getKey(...args);
|
||||
return functionLruCache.delete(key);
|
||||
};
|
||||
cachedFn.set =
|
||||
(...args: Parameters<T>) =>
|
||||
(payload: ReturnType<T>) => {
|
||||
const key = getKey(...args);
|
||||
if (hasResult(payload)) {
|
||||
lruCache.set(key, payload);
|
||||
return getRedisCache()
|
||||
.setex(key, expireInSec, JSON.stringify(payload))
|
||||
.catch(() => {
|
||||
// ignore error
|
||||
});
|
||||
functionLruCache.set(key, payload);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export type IPublishChannels = {
|
||||
};
|
||||
};
|
||||
events: {
|
||||
batch: { projectId: string; count: number };
|
||||
received: IServiceEvent;
|
||||
saved: IServiceEvent;
|
||||
};
|
||||
notification: {
|
||||
created: Prisma.NotificationUncheckedCreateInput;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { OpenPanelOptions, TrackProperties } from '@openpanel/sdk';
|
||||
import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||
import * as Application from 'expo-application';
|
||||
import Constants from 'expo-constants';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
|
||||
import type { OpenPanelOptions, TrackProperties } from '@openpanel/sdk';
|
||||
import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||
|
||||
export * from '@openpanel/sdk';
|
||||
|
||||
export class OpenPanel extends OpenPanelBase {
|
||||
private lastPath = '';
|
||||
constructor(public options: OpenPanelOptions) {
|
||||
super({
|
||||
...options,
|
||||
@@ -37,12 +37,7 @@ export class OpenPanel extends OpenPanelBase {
|
||||
});
|
||||
}
|
||||
|
||||
track(name: string, properties?: TrackProperties) {
|
||||
return super.track(name, { ...properties, __path: this.lastPath });
|
||||
}
|
||||
|
||||
screenView(route: string, properties?: TrackProperties): void {
|
||||
this.lastPath = route;
|
||||
public screenView(route: string, properties?: TrackProperties): void {
|
||||
super.track('screen_view', {
|
||||
...properties,
|
||||
__path: route,
|
||||
|
||||
@@ -58,7 +58,7 @@ export type OpenPanelOptions = OpenPanelBaseOptions & {
|
||||
|
||||
function toCamelCase(str: string) {
|
||||
return str.replace(/([-_][a-z])/gi, ($1) =>
|
||||
$1.toUpperCase().replace('-', '').replace('_', '')
|
||||
$1.toUpperCase().replace('-', '').replace('_', ''),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,9 +114,7 @@ export class OpenPanel extends OpenPanelBase {
|
||||
const sampled = Math.random() < sampleRate;
|
||||
if (sampled) {
|
||||
this.loadReplayModule().then((mod) => {
|
||||
if (!mod) {
|
||||
return;
|
||||
}
|
||||
if (!mod) return;
|
||||
mod.startReplayRecorder(this.options.sessionReplay!, (chunk) => {
|
||||
// Replay chunks go through send() and are queued when disabled or waitForProfile
|
||||
// until ready() is called (base SDK also queues replay until sessionId is set).
|
||||
@@ -155,10 +153,7 @@ export class OpenPanel extends OpenPanelBase {
|
||||
// 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';
|
||||
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) {
|
||||
@@ -292,15 +287,11 @@ export class OpenPanel extends OpenPanelBase {
|
||||
});
|
||||
}
|
||||
|
||||
track(name: string, properties?: TrackProperties) {
|
||||
return super.track(name, { ...properties, __path: this.lastPath });
|
||||
}
|
||||
|
||||
screenView(properties?: TrackProperties): void;
|
||||
screenView(path: string, properties?: TrackProperties): void;
|
||||
screenView(
|
||||
pathOrProperties?: string | TrackProperties,
|
||||
propertiesOrUndefined?: TrackProperties
|
||||
propertiesOrUndefined?: TrackProperties,
|
||||
): void {
|
||||
if (this.isServer()) {
|
||||
return;
|
||||
@@ -331,7 +322,7 @@ export class OpenPanel extends OpenPanelBase {
|
||||
|
||||
async flushRevenue() {
|
||||
const promises = this.pendingRevenues.map((pending) =>
|
||||
super.revenue(pending.amount, pending.properties)
|
||||
super.revenue(pending.amount, pending.properties),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
this.clearRevenue();
|
||||
@@ -352,7 +343,7 @@ export class OpenPanel extends OpenPanelBase {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'openpanel-pending-revenues',
|
||||
JSON.stringify(this.pendingRevenues)
|
||||
JSON.stringify(this.pendingRevenues),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { round } from '@openpanel/common';
|
||||
import { flatten, map, pipe, prop, range, sort, uniq } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AggregateChartEngine,
|
||||
ChartEngine,
|
||||
type IClickhouseProfile,
|
||||
type IServiceProfile,
|
||||
TABLE_NAMES,
|
||||
ch,
|
||||
chQuery,
|
||||
clix,
|
||||
@@ -17,11 +21,8 @@ import {
|
||||
getReportById,
|
||||
getSelectPropertyKey,
|
||||
getSettingsForProject,
|
||||
type IClickhouseProfile,
|
||||
type IServiceProfile,
|
||||
onlyReportEvents,
|
||||
sankeyService,
|
||||
TABLE_NAMES,
|
||||
validateShareAccess,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
@@ -32,15 +33,15 @@ import {
|
||||
zReportInput,
|
||||
zTimeInterval,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import { round } from '@openpanel/common';
|
||||
import { AggregateChartEngine, ChartEngine } from '@openpanel/db';
|
||||
import {
|
||||
differenceInDays,
|
||||
differenceInMonths,
|
||||
differenceInWeeks,
|
||||
formatISO,
|
||||
} from 'date-fns';
|
||||
import { flatten, map, pipe, prop, range, sort, uniq } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import {
|
||||
@@ -82,7 +83,7 @@ const chartProcedure = publicProcedure.use(
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
@@ -118,7 +119,7 @@ const chartProcedure = publicProcedure.use(
|
||||
report: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const chartRouter = createTRPCRouter({
|
||||
@@ -127,7 +128,7 @@ export const chartRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
@@ -150,7 +151,7 @@ export const chartRouter = createTRPCRouter({
|
||||
TO toStartOfDay(now())
|
||||
STEP INTERVAL 1 day
|
||||
SETTINGS session_timezone = '${timezone}'
|
||||
`
|
||||
`,
|
||||
);
|
||||
|
||||
const metricsPromise = clix(ch, timezone)
|
||||
@@ -184,7 +185,7 @@ export const chartRouter = createTRPCRouter({
|
||||
? Math.round(
|
||||
((metrics.months_3 - metrics.months_3_prev) /
|
||||
metrics.months_3_prev) *
|
||||
100
|
||||
100,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -208,12 +209,12 @@ export const chartRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const [events, meta] = await Promise.all([
|
||||
chQuery<{ name: string; count: number }>(
|
||||
`SELECT name, count(name) as count FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${sqlstring.escape(projectId)} GROUP BY name ORDER BY count DESC, name ASC`
|
||||
`SELECT name, count(name) as count FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${sqlstring.escape(projectId)} GROUP BY name ORDER BY count DESC, name ASC`,
|
||||
),
|
||||
getEventMetasCached(projectId),
|
||||
]);
|
||||
@@ -237,7 +238,7 @@ export const chartRouter = createTRPCRouter({
|
||||
z.object({
|
||||
event: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId, event } }) => {
|
||||
const profiles = await clix(ch, 'UTC')
|
||||
@@ -251,8 +252,8 @@ export const chartRouter = createTRPCRouter({
|
||||
const profileProperties = [
|
||||
...new Set(
|
||||
profiles.flatMap((p) =>
|
||||
Object.keys(p.properties).map((k) => `profile.properties.${k}`)
|
||||
)
|
||||
Object.keys(p.properties).map((k) => `profile.properties.${k}`),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -282,6 +283,7 @@ export const chartRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const fixedProperties = [
|
||||
'duration',
|
||||
'revenue',
|
||||
'has_profile',
|
||||
'path',
|
||||
@@ -314,7 +316,7 @@ export const chartRouter = createTRPCRouter({
|
||||
|
||||
return pipe(
|
||||
sort<string>((a, b) => a.length - b.length),
|
||||
uniq
|
||||
uniq,
|
||||
)(properties);
|
||||
}),
|
||||
|
||||
@@ -324,9 +326,9 @@ export const chartRouter = createTRPCRouter({
|
||||
event: z.string(),
|
||||
property: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { event, property, projectId } }) => {
|
||||
.query(async ({ input: { event, property, projectId, ...input } }) => {
|
||||
if (property === 'has_profile') {
|
||||
return {
|
||||
values: ['true', 'false'],
|
||||
@@ -376,7 +378,7 @@ export const chartRouter = createTRPCRouter({
|
||||
.from(TABLE_NAMES.profiles)
|
||||
.where('project_id', '=', projectId),
|
||||
'profile.id = profile_id',
|
||||
'profile'
|
||||
'profile',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,8 +389,8 @@ export const chartRouter = createTRPCRouter({
|
||||
(data: typeof events) => map(prop('values'), data),
|
||||
flatten,
|
||||
uniq,
|
||||
sort((a, b) => a.length - b.length)
|
||||
)(events)
|
||||
sort((a, b) => a.length - b.length),
|
||||
)(events),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,8 +406,8 @@ export const chartRouter = createTRPCRouter({
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const chartInput = ctx.report
|
||||
@@ -446,8 +448,8 @@ export const chartRouter = createTRPCRouter({
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const chartInput = ctx.report
|
||||
@@ -534,10 +536,12 @@ export const chartRouter = createTRPCRouter({
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(({ input, ctx }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
console.log('input', input);
|
||||
|
||||
const chartInput = ctx.report
|
||||
? {
|
||||
...ctx.report,
|
||||
@@ -558,10 +562,10 @@ export const chartRouter = createTRPCRouter({
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(({ input, ctx }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
const chartInput = ctx.report
|
||||
? {
|
||||
...ctx.report,
|
||||
@@ -589,7 +593,7 @@ export const chartRouter = createTRPCRouter({
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const projectId = ctx.report?.projectId ?? input.projectId;
|
||||
@@ -643,7 +647,7 @@ export const chartRouter = createTRPCRouter({
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
timezone
|
||||
timezone,
|
||||
);
|
||||
const diffInterval = {
|
||||
minute: () => differenceInDays(dates.endDate, dates.startDate),
|
||||
@@ -673,14 +677,14 @@ export const chartRouter = createTRPCRouter({
|
||||
const usersSelect = range(0, diffInterval + 1)
|
||||
.map(
|
||||
(index) =>
|
||||
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`
|
||||
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`,
|
||||
)
|
||||
.join(',\n');
|
||||
|
||||
const countsSelect = range(0, diffInterval + 1)
|
||||
.map(
|
||||
(index) =>
|
||||
`length(interval_${index}_users) AS interval_${index}_user_count`
|
||||
`length(interval_${index}_users) AS interval_${index}_user_count`,
|
||||
)
|
||||
.join(',\n');
|
||||
|
||||
@@ -765,10 +769,12 @@ export const chartRouter = createTRPCRouter({
|
||||
interval: zTimeInterval.default('day'),
|
||||
series: zChartSeries,
|
||||
breakdowns: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { projectId, date, series } = input;
|
||||
const limit = 100;
|
||||
const serie = series[0];
|
||||
|
||||
if (!serie) {
|
||||
@@ -807,7 +813,7 @@ export const chartRouter = createTRPCRouter({
|
||||
if (profileFields.length > 0) {
|
||||
// Extract top-level field names and select only what's needed
|
||||
const fieldsToSelect = uniq(
|
||||
profileFields.map((f) => f.split('.')[0])
|
||||
profileFields.map((f) => f.split('.')[0]),
|
||||
).join(', ');
|
||||
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${fieldsToSelect} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
||||
}
|
||||
@@ -830,7 +836,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// Fetch profile details in batches to avoid exceeding ClickHouse max_query_size
|
||||
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
|
||||
const BATCH_SIZE = 200;
|
||||
const profiles: IServiceProfile[] = [];
|
||||
const profiles = [];
|
||||
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
||||
const batch = ids.slice(i, i + BATCH_SIZE);
|
||||
const batchProfiles = await getProfilesCached(batch, projectId);
|
||||
@@ -853,13 +859,13 @@ export const chartRouter = createTRPCRouter({
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
'If true, show users who dropped off at this step. If false, show users who completed at least this step.'
|
||||
'If true, show users who dropped off at this step. If false, show users who completed at least this step.',
|
||||
),
|
||||
funnelWindow: z.number().optional(),
|
||||
funnelGroup: z.string().optional(),
|
||||
breakdowns: z.array(z.object({ name: z.string() })).optional(),
|
||||
range: zRange,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
@@ -905,15 +911,15 @@ export const chartRouter = createTRPCRouter({
|
||||
|
||||
// Check for profile filters and add profile join if needed
|
||||
const profileFilters = funnelService.getProfileFilters(
|
||||
eventSeries as IChartEvent[]
|
||||
eventSeries as IChartEvent[],
|
||||
);
|
||||
if (profileFilters.length > 0) {
|
||||
const fieldsToSelect = uniq(
|
||||
profileFilters.map((f) => f.split('.')[0])
|
||||
profileFilters.map((f) => f.split('.')[0]),
|
||||
).join(', ');
|
||||
funnelCte.leftJoin(
|
||||
`(SELECT id, ${fieldsToSelect} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile`,
|
||||
'profile.id = events.profile_id'
|
||||
'profile.id = events.profile_id',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -928,7 +934,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// `max(level) AS level` alias (ILLEGAL_AGGREGATION error).
|
||||
query.with(
|
||||
'funnel',
|
||||
'SELECT profile_id, max(level) AS level FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id'
|
||||
'SELECT profile_id, max(level) AS level FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id',
|
||||
);
|
||||
} else {
|
||||
// For session grouping: filter out level = 0 inside the CTE
|
||||
@@ -963,7 +969,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// when there are many profile IDs to pass in the IN(...) clause
|
||||
const ids = profileIdsResult.map((p) => p.profile_id).filter(Boolean);
|
||||
const BATCH_SIZE = 500;
|
||||
const profiles: IServiceProfile[] = [];
|
||||
const profiles = [];
|
||||
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
||||
const batch = ids.slice(i, i + BATCH_SIZE);
|
||||
const batchProfiles = await getProfilesCached(batch, projectId);
|
||||
@@ -980,7 +986,7 @@ function processCohortData(
|
||||
total_first_event_count: number;
|
||||
[key: string]: any;
|
||||
}>,
|
||||
diffInterval: number
|
||||
diffInterval: number,
|
||||
) {
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
@@ -989,13 +995,13 @@ function processCohortData(
|
||||
const processed = data.map((row) => {
|
||||
const sum = row.total_first_event_count;
|
||||
const values = range(0, diffInterval + 1).map(
|
||||
(index) => (row[`interval_${index}_user_count`] || 0) as number
|
||||
(index) => (row[`interval_${index}_user_count`] || 0) as number,
|
||||
);
|
||||
|
||||
return {
|
||||
cohort_interval: row.cohort_interval,
|
||||
sum,
|
||||
values,
|
||||
values: values,
|
||||
percentages: values.map((value) => (sum > 0 ? round(value / sum, 2) : 0)),
|
||||
};
|
||||
});
|
||||
@@ -1035,10 +1041,10 @@ function processCohortData(
|
||||
cohort_interval: 'Weighted Average',
|
||||
sum: round(averageData.totalSum / processed.length, 0),
|
||||
percentages: averageData.percentages.map(({ sum, weightedSum }) =>
|
||||
sum > 0 ? round(weightedSum / sum, 2) : 0
|
||||
sum > 0 ? round(weightedSum / sum, 2) : 0,
|
||||
),
|
||||
values: averageData.values.map(({ sum, weightedSum }) =>
|
||||
sum > 0 ? round(weightedSum / sum, 0) : 0
|
||||
sum > 0 ? round(weightedSum / sum, 0) : 0,
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -96,7 +96,9 @@ export const projectRouter = createTRPCRouter({
|
||||
});
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(input.id),
|
||||
...res.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
res.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
return res;
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type EventMeta,
|
||||
TABLE_NAMES,
|
||||
ch,
|
||||
chQuery,
|
||||
clix,
|
||||
db,
|
||||
formatClickhouseDate,
|
||||
type IClickhouseEvent,
|
||||
TABLE_NAMES,
|
||||
transformEvent,
|
||||
getEventList,
|
||||
} from '@openpanel/db';
|
||||
|
||||
import { subMinutes } from 'date-fns';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
export const realtimeRouter = createTRPCRouter({
|
||||
@@ -22,7 +25,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
long: number;
|
||||
lat: number;
|
||||
}>(
|
||||
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`
|
||||
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`,
|
||||
);
|
||||
|
||||
return res;
|
||||
@@ -30,18 +33,25 @@ export const realtimeRouter = createTRPCRouter({
|
||||
activeSessions: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const rows = await chQuery<IClickhouseEvent>(
|
||||
`SELECT
|
||||
name, session_id, created_at, path, origin, referrer, referrer_name,
|
||||
country, city, region, os, os_version, browser, browser_version,
|
||||
device
|
||||
FROM ${TABLE_NAMES.events}
|
||||
WHERE project_id = ${sqlstring.escape(input.projectId)}
|
||||
AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
return rows.map(transformEvent);
|
||||
return getEventList({
|
||||
projectId: input.projectId,
|
||||
take: 30,
|
||||
select: {
|
||||
name: true,
|
||||
path: true,
|
||||
origin: true,
|
||||
referrer: true,
|
||||
referrerName: true,
|
||||
referrerType: true,
|
||||
country: true,
|
||||
device: true,
|
||||
os: true,
|
||||
browser: true,
|
||||
createdAt: true,
|
||||
profile: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
paths: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
@@ -66,7 +76,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
.where(
|
||||
'created_at',
|
||||
'>=',
|
||||
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
||||
)
|
||||
.groupBy(['path', 'origin'])
|
||||
.orderBy('count', 'DESC')
|
||||
@@ -96,7 +106,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
.where(
|
||||
'created_at',
|
||||
'>=',
|
||||
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
||||
)
|
||||
.groupBy(['referrer_name'])
|
||||
.orderBy('count', 'DESC')
|
||||
@@ -127,7 +137,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
.where(
|
||||
'created_at',
|
||||
'>=',
|
||||
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
||||
)
|
||||
.groupBy(['country', 'city'])
|
||||
.orderBy('count', 'DESC')
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -16,8 +16,8 @@ catalogs:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
groupmq:
|
||||
specifier: 2.0.0-next.1
|
||||
version: 2.0.0-next.1
|
||||
specifier: 1.1.1-next.2
|
||||
version: 1.1.1-next.2
|
||||
react:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
@@ -198,7 +198,7 @@ importers:
|
||||
version: 5.0.0
|
||||
groupmq:
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0-next.1(ioredis@5.8.2)
|
||||
version: 1.1.1-next.2(ioredis@5.8.2)
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -936,7 +936,7 @@ importers:
|
||||
version: 4.18.2
|
||||
groupmq:
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0-next.1(ioredis@5.8.2)
|
||||
version: 1.1.1-next.2(ioredis@5.8.2)
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
@@ -1419,7 +1419,7 @@ importers:
|
||||
version: 5.63.0
|
||||
groupmq:
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0-next.1(ioredis@5.8.2)
|
||||
version: 1.1.1-next.2(ioredis@5.8.2)
|
||||
devDependencies:
|
||||
'@openpanel/tsconfig':
|
||||
specifier: workspace:*
|
||||
@@ -13157,11 +13157,11 @@ packages:
|
||||
|
||||
glob@7.1.6:
|
||||
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
glob@9.3.5:
|
||||
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
|
||||
@@ -13221,8 +13221,8 @@ packages:
|
||||
resolution: {integrity: sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
groupmq@2.0.0-next.1:
|
||||
resolution: {integrity: sha512-xcpz29HeXXn0yP/sQTGPPNMLQAZCCrJg3x9kpOAFbtsXki5KVeBsY3mWNBt3Z+YCa9OxwkTFL6tOcrB67z127A==}
|
||||
groupmq@1.1.1-next.2:
|
||||
resolution: {integrity: sha512-5gH+P3NfSCjfCLcB2g2TAHCpmQz+rwrQkb+kAyrzB9puZuAHKQVYOUPWKVBRFjY7B9jPRGHrimDO6h9rWKGfMA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ioredis: '>=5'
|
||||
@@ -34142,7 +34142,7 @@ snapshots:
|
||||
|
||||
graphql@15.8.0: {}
|
||||
|
||||
groupmq@2.0.0-next.1(ioredis@5.8.2):
|
||||
groupmq@1.1.1-next.2(ioredis@5.8.2):
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
ioredis: 5.8.2
|
||||
|
||||
@@ -13,4 +13,4 @@ catalog:
|
||||
"@types/react-dom": ^19.2.3
|
||||
"@types/node": ^24.7.1
|
||||
typescript: ^5.9.3
|
||||
groupmq: 2.0.0-next.1
|
||||
groupmq: 1.1.1-next.2
|
||||
|
||||
Reference in New Issue
Block a user