diff --git a/apps/api/src/controllers/event.controller.ts b/apps/api/src/controllers/event.controller.ts index 28d060d8..acea9861 100644 --- a/apps/api/src/controllers/event.controller.ts +++ b/apps/api/src/controllers/event.controller.ts @@ -1,26 +1,25 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; - -import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; -import { getSalts } from '@openpanel/db'; -import { getEventsGroupQueueShard } from '@openpanel/queue'; - import { generateId, slug } from '@openpanel/common'; +import { parseUserAgent } from '@openpanel/common/server'; +import { getSalts } from '@openpanel/db'; import { getGeoLocation } from '@openpanel/geo'; +import { getEventsGroupQueueShard } from '@openpanel/queue'; import type { DeprecatedPostEventPayload } from '@openpanel/validation'; +import type { FastifyReply, FastifyRequest } from 'fastify'; import { getStringHeaders, getTimestamp } from './track.controller'; +import { getDeviceId } from '@/utils/ids'; export async function postEvent( request: FastifyRequest<{ Body: DeprecatedPostEventPayload; }>, - reply: FastifyReply, + reply: FastifyReply ) { const { timestamp, isTimestampFromThePast } = getTimestamp( request.timestamp, - request.body, + request.body ); const ip = request.clientIp; - const ua = request.headers['user-agent']; + const ua = request.headers['user-agent'] ?? 'unknown/1.0'; const projectId = request.client?.projectId; const headers = getStringHeaders(request.headers); @@ -30,34 +29,22 @@ export async function postEvent( } const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); - const currentDeviceId = ua - ? generateDeviceId({ - salt: salts.current, - origin: projectId, - ip, - ua, - }) - : ''; - const previousDeviceId = ua - ? generateDeviceId({ - salt: salts.previous, - origin: projectId, - ip, - ua, - }) - : ''; + const { deviceId, sessionId } = await getDeviceId({ + projectId, + ip, + ua, + salts, + }); const uaInfo = parseUserAgent(ua, request.body?.properties); const groupId = uaInfo.isServer - ? request.body?.profileId - ? `${projectId}:${request.body?.profileId}` - : `${projectId}:${generateId()}` - : currentDeviceId; + ? `${projectId}:${request.body?.profileId ?? generateId()}` + : deviceId; const jobId = [ slug(request.body.name), timestamp, projectId, - currentDeviceId, + deviceId, groupId, ] .filter(Boolean) @@ -74,9 +61,10 @@ export async function postEvent( }, uaInfo, geo, - currentDeviceId, - previousDeviceId, - deviceId: '', + currentDeviceId: '', + previousDeviceId: '', + deviceId, + sessionId: sessionId ?? '', }, groupId, jobId, diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 0cebec05..d94bec58 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -1,7 +1,3 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; -import { assocPath, pathOr, pick } from 'ramda'; - -import { HttpError } from '@/utils/errors'; import { generateId, slug } from '@openpanel/common'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { @@ -13,8 +9,6 @@ import { import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { getEventsGroupQueueShard } from '@openpanel/queue'; import { getRedisCache } from '@openpanel/redis'; - -import { getDeviceId } from '@/utils/ids'; import { type IDecrementPayload, type IIdentifyPayload, @@ -24,6 +18,10 @@ import { type ITrackPayload, zTrackHandlerPayload, } from '@openpanel/validation'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { assocPath, pathOr, pick } from 'ramda'; +import { HttpError } from '@/utils/errors'; +import { getDeviceId } from '@/utils/ids'; export function getStringHeaders(headers: FastifyRequest['headers']) { return Object.entries( @@ -35,14 +33,14 @@ export function getStringHeaders(headers: FastifyRequest['headers']) { 'openpanel-client-id', 'request-id', ], - headers, - ), + headers + ) ).reduce( (acc, [key, value]) => ({ ...acc, [key]: value ? String(value) : undefined, }), - {}, + {} ); } @@ -68,7 +66,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined { export function getTimestamp( timestamp: FastifyRequest['timestamp'], - payload: ITrackHandlerPayload['payload'], + payload: ITrackHandlerPayload['payload'] ) { const safeTimestamp = timestamp || Date.now(); const userDefinedTimestamp = @@ -121,7 +119,7 @@ async function buildContext( request: FastifyRequest<{ Body: ITrackHandlerPayload; }>, - validatedBody: ITrackHandlerPayload, + validatedBody: ITrackHandlerPayload ): Promise { const projectId = request.client?.projectId; if (!projectId) { @@ -176,7 +174,7 @@ async function buildContext( async function handleTrack( payload: ITrackPayload, - context: TrackContext, + context: TrackContext ): Promise { const { projectId, deviceId, geo, headers, timestamp, sessionId } = context; @@ -224,7 +222,7 @@ async function handleTrack( }, groupId, jobId, - }), + }) ); await Promise.all(promises); @@ -232,7 +230,7 @@ async function handleTrack( async function handleIdentify( payload: IIdentifyPayload, - context: TrackContext, + context: TrackContext ): Promise { const { projectId, geo, ua } = context; const uaInfo = parseUserAgent(ua, payload.properties); @@ -262,7 +260,7 @@ async function handleIdentify( async function adjustProfileProperty( payload: IIncrementPayload | IDecrementPayload, projectId: string, - direction: 1 | -1, + direction: 1 | -1 ): Promise { const { profileId, property, value } = payload; const profile = await getProfileById(profileId, projectId); @@ -272,7 +270,7 @@ async function adjustProfileProperty( const parsed = Number.parseInt( pathOr('0', property.split('.'), profile.properties), - 10, + 10 ); if (Number.isNaN(parsed)) { @@ -282,7 +280,7 @@ async function adjustProfileProperty( profile.properties = assocPath( property.split('.'), parsed + direction * (value || 1), - profile.properties, + profile.properties ); await upsertProfile({ @@ -295,21 +293,21 @@ async function adjustProfileProperty( async function handleIncrement( payload: IIncrementPayload, - context: TrackContext, + context: TrackContext ): Promise { await adjustProfileProperty(payload, context.projectId, 1); } async function handleDecrement( payload: IDecrementPayload, - context: TrackContext, + context: TrackContext ): Promise { await adjustProfileProperty(payload, context.projectId, -1); } async function handleReplay( payload: IReplayPayload, - context: TrackContext, + context: TrackContext ): Promise { if (!context.sessionId) { throw new HttpError('Session ID is required for replay', { status: 400 }); @@ -318,7 +316,6 @@ async function handleReplay( const row = { project_id: context.projectId, session_id: context.sessionId, - profile_id: '', // TODO: remove chunk_index: payload.chunk_index, started_at: payload.started_at, ended_at: payload.ended_at, @@ -333,7 +330,7 @@ export async function handler( request: FastifyRequest<{ Body: ITrackHandlerPayload; }>, - reply: FastifyReply, + reply: FastifyReply ) { // Validate request body with Zod const validationResult = zTrackHandlerPayload.safeParse(request.body); @@ -393,7 +390,7 @@ export async function handler( export async function fetchDeviceId( request: FastifyRequest, - reply: FastifyReply, + reply: FastifyReply ) { const salts = await getSalts(); const projectId = request.client?.projectId; @@ -428,11 +425,11 @@ export async function fetchDeviceId( const multi = getRedisCache().multi(); multi.hget( `bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`, - 'data', + 'data' ); multi.hget( `bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`, - 'data', + 'data' ); const res = await multi.exec(); if (res?.[0]?.[1]) { diff --git a/apps/api/src/hooks/duplicate.hook.ts b/apps/api/src/hooks/duplicate.hook.ts index f3e1c7af..7b7655b5 100644 --- a/apps/api/src/hooks/duplicate.hook.ts +++ b/apps/api/src/hooks/duplicate.hook.ts @@ -14,7 +14,8 @@ export async function duplicateHook( const ip = req.clientIp; const origin = req.headers.origin; const clientId = req.headers['openpanel-client-id']; - const shouldCheck = ip && origin && clientId && req.body.type !== 'replay'; + const isReplay = 'type' in req.body && req.body.type === 'replay'; + const shouldCheck = ip && origin && clientId && !isReplay; const isDuplicate = shouldCheck ? await isDuplicatedEvent({ @@ -25,7 +26,6 @@ export async function duplicateHook( }) : false; - console.log('Duplicate event', isDuplicate); if (isDuplicate) { return reply.status(200).send('Duplicate event'); } diff --git a/apps/api/src/hooks/request-logging.hook.ts b/apps/api/src/hooks/request-logging.hook.ts index 2921e9ab..7b9644ba 100644 --- a/apps/api/src/hooks/request-logging.hook.ts +++ b/apps/api/src/hooks/request-logging.hook.ts @@ -41,7 +41,6 @@ export async function requestLoggingHook( ['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'], request.headers ), - // body: request.body, }); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b872738b..d55ceec6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -116,7 +116,7 @@ const startServer = async () => { return callback(null, { origin: '*', - maxAge: 86_400 * 7, // cache preflight for 24h + maxAge: 86_400 * 7, // cache preflight for 7 days }); }; }); @@ -125,11 +125,6 @@ const startServer = async () => { global: false, }); - fastify.addHook('onRequest', async (req) => { - if (req.method === 'POST') { - console.log('Incoming req', req.method, req.url); - } - }); fastify.addHook('onRequest', requestIdHook); fastify.addHook('onRequest', timestampHook); fastify.addHook('onRequest', ipHook); diff --git a/apps/public/content/docs/(tracking)/sdks/astro.mdx b/apps/public/content/docs/(tracking)/sdks/astro.mdx index 3826000d..12dac9d5 100644 --- a/apps/public/content/docs/(tracking)/sdks/astro.mdx +++ b/apps/public/content/docs/(tracking)/sdks/astro.mdx @@ -54,7 +54,8 @@ import { OpenPanelComponent } from '@openpanel/astro'; ##### Astro options - `profileId` - If you have a user id, you can pass it here to identify the user -- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) +- `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) +- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `globalProperties` - This is an object of properties that will be sent with every event. diff --git a/apps/public/content/docs/(tracking)/sdks/nextjs.mdx b/apps/public/content/docs/(tracking)/sdks/nextjs.mdx index 6f317326..3877d1b6 100644 --- a/apps/public/content/docs/(tracking)/sdks/nextjs.mdx +++ b/apps/public/content/docs/(tracking)/sdks/nextjs.mdx @@ -62,7 +62,8 @@ export default function RootLayout({ children }) { ##### NextJS options - `profileId` - If you have a user id, you can pass it here to identify the user -- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) +- `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) +- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `globalProperties` - This is an object of properties that will be sent with every event. @@ -286,12 +287,12 @@ import { createRouteHandler } from '@openpanel/nextjs/server'; export const { GET, POST } = createRouteHandler(); ``` -Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server. +Remember to change the `apiUrl` and `scriptUrl` in the `OpenPanelComponent` to your own server. ```tsx diff --git a/apps/public/content/guides/nextjs-analytics.mdx b/apps/public/content/guides/nextjs-analytics.mdx index 808b7cdd..4b85f20c 100644 --- a/apps/public/content/guides/nextjs-analytics.mdx +++ b/apps/public/content/guides/nextjs-analytics.mdx @@ -225,7 +225,7 @@ Then update your OpenPanelComponent to use the proxy endpoint. ```tsx diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index 0aaa78bc..70fec16d 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -4,6 +4,7 @@ import { Maximize2, Minimize2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BrowserChrome } from './browser-chrome'; import { ReplayTime } from './replay-controls'; +import { ReplayTimeline } from './replay-timeline'; import { getEventOffsetMs } from './replay-utils'; import { ReplayProvider, @@ -12,7 +13,6 @@ import { } from '@/components/sessions/replay/replay-context'; import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed'; import { ReplayPlayer } from '@/components/sessions/replay/replay-player'; -import { ReplayTimeline } from '@/components/sessions/replay/replay-timeline'; import { useTRPC } from '@/integrations/trpc/react'; function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { @@ -32,7 +32,7 @@ function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { .filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime) .sort((a, b) => a.offsetMs - b.offsetMs); - const latest = withOffset[withOffset.length - 1]; + const latest = withOffset.at(-1); if (!latest) { return ''; } @@ -74,7 +74,7 @@ function ReplayChunkLoader({ ) .then((res) => { res.data.forEach((row) => { - row.events.forEach((event) => { + row?.events?.forEach((event) => { addEvent(event); }); }); @@ -82,6 +82,9 @@ function ReplayChunkLoader({ if (res.hasMore) { recursive(fromIndex + res.data.length); } + }) + .catch(() => { + // chunk loading failed — replay may be incomplete }); } @@ -160,10 +163,30 @@ function ReplayContent({ ); const events = eventsData?.data ?? []; - const playerEvents = firstBatch?.data.flatMap((row) => row.events) ?? []; + const playerEvents = + firstBatch?.data.flatMap((row) => row?.events ?? []) ?? []; const hasMore = firstBatch?.hasMore ?? false; const hasReplay = playerEvents.length !== 0; + function renderReplay() { + if (replayLoading) { + return ( +
+
+
Loading session replay
+
+ ); + } + if (hasReplay) { + return ; + } + return ( +
+ No replay data available for this session. +
+ ); + } + return (
- {replayLoading ? ( -
-
-
Loading session replay
-
- ) : hasReplay ? ( - - ) : ( -
- No replay data available for this session. -
- )} + {renderReplay()} {hasReplay && }
diff --git a/apps/start/src/components/sessions/table/columns.tsx b/apps/start/src/components/sessions/table/columns.tsx index 7f28eadf..cd267afe 100644 --- a/apps/start/src/components/sessions/table/columns.tsx +++ b/apps/start/src/components/sessions/table/columns.tsx @@ -1,14 +1,12 @@ -import { ProjectLink } from '@/components/links'; -import { SerieIcon } from '@/components/report-chart/common/serie-icon'; -import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; -import type { ColumnDef } from '@tanstack/react-table'; -import { Video } from 'lucide-react'; - -import { ColumnCreatedAt } from '@/components/column-created-at'; -import { ProfileAvatar } from '@/components/profiles/profile-avatar'; -import { getProfileName } from '@/utils/getters'; import { round } from '@openpanel/common'; import type { IServiceSession } from '@openpanel/db'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Video } from 'lucide-react'; +import { ColumnCreatedAt } from '@/components/column-created-at'; +import { ProjectLink } from '@/components/links'; +import { ProfileAvatar } from '@/components/profiles/profile-avatar'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { getProfileName } from '@/utils/getters'; function formatDuration(milliseconds: number): string { const seconds = milliseconds / 1000; @@ -45,20 +43,20 @@ export function useColumns() { cell: ({ row }) => { const session = row.original; return ( -
+
{session.id.slice(0, 8)}... {session.hasReplay && ( @@ -76,8 +74,8 @@ export function useColumns() { if (session.profile) { return ( {getProfileName(session.profile)} @@ -86,8 +84,8 @@ export function useColumns() { } return ( {session.profileId} diff --git a/apps/start/src/modals/event-details.tsx b/apps/start/src/modals/event-details.tsx index 6a97f8b6..dc344fc0 100644 --- a/apps/start/src/modals/event-details.tsx +++ b/apps/start/src/modals/event-details.tsx @@ -1,22 +1,3 @@ -import { ReportChartShortcut } from '@/components/report-chart/shortcut'; -import { - useEventQueryFilters, - useEventQueryNamesFilter, -} from '@/hooks/use-event-query-filters'; - -import { ProjectLink } from '@/components/links'; -import { - WidgetButtons, - WidgetHead, -} from '@/components/overview/overview-widget'; -import { SerieIcon } from '@/components/report-chart/common/serie-icon'; -import { Button } from '@/components/ui/button'; -import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid'; -import { Widget, WidgetBody } from '@/components/widget'; -import { fancyMinutes } from '@/hooks/use-numer-formatter'; -import { useTRPC } from '@/integrations/trpc/react'; -import { cn } from '@/utils/cn'; -import { getProfileName } from '@/utils/getters'; import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db'; import { useSuspenseQuery } from '@tanstack/react-query'; import { FilterIcon, XIcon } from 'lucide-react'; @@ -24,6 +5,24 @@ import { omit } from 'ramda'; import { Suspense, useState } from 'react'; import { popModal } from '.'; import { ModalContent } from './Modal/Container'; +import { ProjectLink } from '@/components/links'; +import { + WidgetButtons, + WidgetHead, +} from '@/components/overview/overview-widget'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { ReportChartShortcut } from '@/components/report-chart/shortcut'; +import { Button } from '@/components/ui/button'; +import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid'; +import { Widget, WidgetBody } from '@/components/widget'; +import { + useEventQueryFilters, + useEventQueryNamesFilter, +} from '@/hooks/use-event-query-filters'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; +import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; +import { getProfileName } from '@/utils/getters'; interface Props { id: string; @@ -55,7 +54,7 @@ const filterable: Partial> = export default function EventDetails(props: Props) { return ( - + }> @@ -84,7 +83,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { id, projectId, createdAt, - }), + }) ); const { event, session } = query.data; @@ -158,7 +157,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { event, }); } - }, + } ); } @@ -209,7 +208,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { > */} -
@@ -218,10 +217,10 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { {Object.entries(TABS).map(([, tab]) => ( @@ -231,29 +230,29 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { {profile && ( popModal()} + className="card col gap-2 p-4 py-2 hover:bg-def-100" href={`/profiles/${encodeURIComponent(profile.id)}`} - className="card p-4 py-2 col gap-2 hover:bg-def-100" + onClick={() => popModal()} > -
-
+
+
{profile.avatar && ( )} -
+
{getProfileName(profile, false)}
-
-
+
+
-
+
{event.referrerName || event.referrer}
@@ -276,16 +275,16 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { { + popModal(); + setFilter(`properties.${item.name}`, item.value as any); + }} renderValue={(item) => (
{String(item.value)}
)} - onItemClick={(item) => { - popModal(); - setFilter(`properties.${item.name}`, item.value as any); - }} /> )} @@ -296,25 +295,6 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { { - const isFilterable = item.value && (filterable as any)[item.name]; - if (isFilterable) { - return ( -
- - -
- ); - } - - return ( - - ); - }} onItemClick={(item) => { const isFilterable = item.value && (filterable as any)[item.name]; if (isFilterable) { @@ -322,26 +302,45 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) { setFilter(item.name as keyof IServiceEvent, item.value); } }} + renderValue={(item) => { + const isFilterable = item.value && (filterable as any)[item.name]; + if (isFilterable) { + return ( +
+ + +
+ ); + } + + return ( + + ); + }} />
All events for {event.name}
-
+
-
-
-
+
+
+
-
-
+
+
{/* Profile skeleton */} -
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
{/* Properties skeleton */}
-
+
{Array.from({ length: 3 }).map((_, i) => (
-
-
+
+
))}
@@ -419,16 +418,16 @@ function EventDetailsSkeleton() { {/* Information skeleton */}
-
+
{Array.from({ length: 6 }).map((_, i) => (
-
-
+
+
))}
@@ -437,11 +436,11 @@ function EventDetailsSkeleton() { {/* Chart skeleton */}
-
-
+
+
-
+
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index 56800022..32b420db 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -1,3 +1,6 @@ +import type { IServiceEvent, IServiceSession } from '@openpanel/db'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, Link } from '@tanstack/react-router'; import { EventIcon } from '@/components/events/event-icon'; import FullPageLoadingState from '@/components/full-page-loading-state'; import { PageContainer } from '@/components/page-container'; @@ -6,18 +9,20 @@ import { ProfileAvatar } from '@/components/profiles/profile-avatar'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { ReplayShell } from '@/components/sessions/replay'; import { KeyValueGrid } from '@/components/ui/key-value-grid'; -import { Widget, WidgetBody, WidgetHead, WidgetTitle } from '@/components/widget'; +import { + Widget, + WidgetBody, + WidgetHead, + WidgetTitle, +} from '@/components/widget'; +import { useNumber } from '@/hooks/use-numer-formatter'; import { useTRPC } from '@/integrations/trpc/react'; import { formatDateTime } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; -import { useNumber } from '@/hooks/use-numer-formatter'; import { createProjectTitle } from '@/utils/title'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { Link, createFileRoute } from '@tanstack/react-router'; -import type { IServiceEvent, IServiceSession } from '@openpanel/db'; export const Route = createFileRoute( - '/_app/$organizationId/$projectId/sessions_/$sessionId', + '/_app/$organizationId/$projectId/sessions_/$sessionId' )({ component: Component, loader: async ({ context, params }) => { @@ -26,7 +31,7 @@ export const Route = createFileRoute( context.trpc.session.byId.queryOptions({ sessionId: params.sessionId, projectId: params.projectId, - }), + }) ), context.queryClient.prefetchQuery( context.trpc.event.events.queryOptions({ @@ -34,7 +39,7 @@ export const Route = createFileRoute( sessionId: params.sessionId, filters: [], columnVisibility: {}, - }), + }) ), ]); }, @@ -45,7 +50,19 @@ export const Route = createFileRoute( }); function sessionToFakeEvent(session: IServiceSession): IServiceEvent { - return session as unknown as IServiceEvent; + return { + ...session, + name: 'screen_view', + sessionId: session.id, + properties: {}, + path: session.exitPath, + origin: session.exitOrigin, + importedAt: undefined, + meta: undefined, + sdkName: undefined, + sdkVersion: undefined, + profile: undefined, + }; } function VisitedRoutes({ paths }: { paths: string[] }) { @@ -56,7 +73,9 @@ function VisitedRoutes({ paths }: { paths: string[] }) { const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]); const max = sorted[0]?.[1] ?? 1; - if (sorted.length === 0) return null; + if (sorted.length === 0) { + return null; + } return ( @@ -65,14 +84,14 @@ function VisitedRoutes({ paths }: { paths: string[] }) {
{sorted.map(([path, count]) => ( -
+
-
+
{path} - {count} + {count}
))} @@ -89,7 +108,9 @@ function EventDistribution({ events }: { events: IServiceEvent[] }) { const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]); const max = sorted[0]?.[1] ?? 1; - if (sorted.length === 0) return null; + if (sorted.length === 0) { + return null; + } return ( @@ -98,14 +119,14 @@ function EventDistribution({ events }: { events: IServiceEvent[] }) {
{sorted.map(([name, count]) => ( -
+
{name.replace(/_/g, ' ')} - {count} + {count}
))} @@ -117,10 +138,10 @@ function EventDistribution({ events }: { events: IServiceEvent[] }) { function Component() { const { projectId, sessionId, organizationId } = Route.useParams(); const trpc = useTRPC(); - const number = useNumber() + const number = useNumber(); const { data: session } = useSuspenseQuery( - trpc.session.byId.queryOptions({ sessionId, projectId }), + trpc.session.byId.queryOptions({ sessionId, projectId }) ); const { data: eventsData } = useSuspenseQuery( @@ -129,7 +150,7 @@ function Component() { sessionId, filters: [], columnVisibility: {}, - }), + }) ); const events = eventsData?.data ?? []; @@ -141,83 +162,54 @@ function Component() { trpc.profile.byId.queryOptions({ profileId: session.profileId, projectId, - }), + }) ); const fakeEvent = sessionToFakeEvent(session); return ( - -
- {session.country && ( -
- - - {session.country} - {session.city && ` / ${session.city}`} - - -
- )} - {session.device && ( -
- - {session.device} -
- - - - )} - {session.os && ( -
- - {session.os} - - - - - - -
- )} - {session.model && ( -
- - {session.model} - - - - - - - - - - - - - - - - -
- )} - {session.browser && ( -
- - {session.browser} - - - + +
+ {session.country && ( +
+ + + {session.country} + {session.city && ` / ${session.city}`} +
)} + {session.device && ( +
+ + {session.device}
+ )} + {session.os && ( +
+ + {session.os} +
+ )} + {session.model && ( +
+ + {session.model} +
+ )} + {session.browser && ( +
+ + {session.browser} +
+ )} +
- {session.hasReplay && } + {session.hasReplay && ( + + )}
{/* Left column */} @@ -232,7 +224,10 @@ function Component() { columns={1} copyable data={[ - { name: 'duration', value: number.formatWithUnit(session.duration/1000, 'min') }, + { + name: 'duration', + value: number.formatWithUnit(session.duration / 1000, 'min'), + }, { name: 'createdAt', value: session.createdAt }, { name: 'endedAt', value: session.endedAt }, { name: 'screenViews', value: session.screenViewCount }, @@ -305,13 +300,13 @@ function Component() {
@@ -319,7 +314,7 @@ function Component() { {getProfileName(profile, false) ?? session.profileId} {profile.email && ( - + {profile.email} )} @@ -350,24 +345,24 @@ function Component() {
{events.map((event) => (
- +
- + {event.name === 'screen_view' && event.path ? event.path : event.name.replace(/_/g, ' ')}
- + {formatDateTime(event.createdAt)}
))} {events.length === 0 && ( -
+
No events found
)} diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts index 0c7b89cc..a0af841f 100644 --- a/apps/worker/src/jobs/events.incoming-event.ts +++ b/apps/worker/src/jobs/events.incoming-event.ts @@ -52,7 +52,7 @@ async function createEventAndNotify( logger.info('Creating event', { event: payload }); const [event] = await Promise.all([ createEvent(payload), - checkNotificationRulesForEvent(payload).catch(() => {}), + checkNotificationRulesForEvent(payload).catch(() => null), ]); return event; diff --git a/apps/worker/src/jobs/events.incoming-events.test.ts b/apps/worker/src/jobs/events.incoming-events.test.ts index aef05511..b5e5226b 100644 --- a/apps/worker/src/jobs/events.incoming-events.test.ts +++ b/apps/worker/src/jobs/events.incoming-events.test.ts @@ -32,6 +32,8 @@ const SESSION_TIMEOUT = 30 * 60 * 1000; const projectId = 'test-project'; const currentDeviceId = 'device-123'; const previousDeviceId = 'device-456'; +// Valid UUID used when creating a new session in tests +const newSessionId = 'a1b2c3d4-e5f6-4789-a012-345678901234'; const geo = { country: 'US', city: 'New York', @@ -67,7 +69,7 @@ describe('incomingEvent', () => { vi.clearAllMocks(); }); - it.only('should create a session start and an event', async () => { + it('should create a session start and an event', async () => { const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add'); const timestamp = new Date(); // Mock job data @@ -90,12 +92,15 @@ describe('incomingEvent', () => { projectId, currentDeviceId, previousDeviceId, + deviceId: currentDeviceId, + sessionId: newSessionId, }; const event = { name: 'test_event', deviceId: currentDeviceId, profileId: '', sessionId: expect.stringMatching( + // biome-ignore lint/performance/useTopLevelRegex: test /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i ), projectId, @@ -182,6 +187,8 @@ describe('incomingEvent', () => { projectId, currentDeviceId, previousDeviceId, + deviceId: currentDeviceId, + sessionId: 'session-123', }; const changeDelay = vi.fn(); @@ -263,6 +270,8 @@ describe('incomingEvent', () => { projectId, currentDeviceId: '', previousDeviceId: '', + deviceId: '', + sessionId: '', uaInfo: uaInfoServer, }; @@ -367,6 +376,8 @@ describe('incomingEvent', () => { projectId, currentDeviceId: '', previousDeviceId: '', + deviceId: '', + sessionId: '', uaInfo: uaInfoServer, }; diff --git a/packages/db/src/buffers/session-buffer.ts b/packages/db/src/buffers/session-buffer.ts index fad5ee09..35939bd1 100644 --- a/packages/db/src/buffers/session-buffer.ts +++ b/packages/db/src/buffers/session-buffer.ts @@ -1,8 +1,7 @@ -import { type Redis, getRedisCache } from '@openpanel/redis'; - import { getSafeJson } from '@openpanel/json'; +import { getRedisCache, type Redis } from '@openpanel/redis'; import { assocPath, clone } from 'ramda'; -import { TABLE_NAMES, ch } from '../clickhouse/client'; +import { ch, TABLE_NAMES } from '../clickhouse/client'; import type { IClickhouseEvent } from '../services/event.service'; import type { IClickhouseSession } from '../services/session.service'; import { BaseBuffer } from './base-buffer'; @@ -35,14 +34,14 @@ export class SessionBuffer extends BaseBuffer { | { projectId: string; profileId: string; - }, + } ) { let hit: string | null = null; if ('sessionId' in options) { hit = await this.redis.get(`session:${options.sessionId}`); } else { hit = await this.redis.get( - `session:${options.projectId}:${options.profileId}`, + `session:${options.projectId}:${options.profileId}` ); } @@ -54,7 +53,7 @@ export class SessionBuffer extends BaseBuffer { } async getSession( - event: IClickhouseEvent, + event: IClickhouseEvent ): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> { const existingSession = await this.getExistingSession({ sessionId: event.session_id, @@ -186,14 +185,14 @@ export class SessionBuffer extends BaseBuffer { `session:${newSession.id}`, JSON.stringify(newSession), 'EX', - 60 * 60, + 60 * 60 ); if (newSession.profile_id) { multi.set( `session:${newSession.project_id}:${newSession.profile_id}`, JSON.stringify(newSession), 'EX', - 60 * 60, + 60 * 60 ); } for (const session of sessions) { @@ -220,10 +219,12 @@ export class SessionBuffer extends BaseBuffer { const events = await this.redis.lrange( this.redisKey, 0, - this.batchSize - 1, + this.batchSize - 1 ); - if (events.length === 0) return; + if (events.length === 0) { + return; + } const sessions = events .map((e) => getSafeJson(e)) @@ -258,7 +259,7 @@ export class SessionBuffer extends BaseBuffer { } } - async getBufferSize() { + getBufferSize() { return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey)); } } diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index 7ba2c783..c7681a91 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -1,3 +1,4 @@ +import { getSafeJson } from '@openpanel/json'; import { cacheable } from '@openpanel/redis'; import type { IChartEventFilter } from '@openpanel/validation'; import sqlstring from 'sqlstring'; @@ -14,7 +15,7 @@ import { getEventFiltersWhereClause } from './chart.service'; import { getOrganizationByProjectIdCached } from './organization.service'; import { getProfilesCached, type IServiceProfile } from './profile.service'; -export type IClickhouseSession = { +export interface IClickhouseSession { id: string; profile_id: string; event_count: number; @@ -53,8 +54,9 @@ export type IClickhouseSession = { revenue: number; sign: 1 | 0; version: number; - has_replay: boolean; -}; + // Dynamically added + has_replay?: boolean; +} export interface IServiceSession { id: string; @@ -92,8 +94,8 @@ export interface IServiceSession { utmContent: string; utmTerm: string; revenue: number; - hasReplay: boolean; profile?: IServiceProfile; + hasReplay?: boolean; } export interface GetSessionListOptions { @@ -144,21 +146,19 @@ export function transformSession(session: IClickhouseSession): IServiceSession { utmContent: session.utm_content, utmTerm: session.utm_term, revenue: session.revenue, - hasReplay: session.has_replay, profile: undefined, + hasReplay: session.has_replay, }; } -type Direction = 'initial' | 'next' | 'prev'; - -type PageInfo = { +interface PageInfo { next?: Cursor; // use last row -}; +} -type Cursor = { +interface Cursor { createdAt: string; // ISO 8601 with ms id: string; -}; +} export async function getSessionList({ cursor, @@ -238,13 +238,14 @@ export async function getSessionList({ sb.select[column] = column; }); - sb.select.has_replay = `toBool(src.session_id != '') as has_replay`; + sb.select.has_replay = `toBool(src.session_id != '') as hasReplay`; sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`; const sql = getSql(); const data = await chQuery< IClickhouseSession & { latestCreatedAt: string; + hasReplay: boolean; } >(sql); @@ -347,20 +348,24 @@ export async function getSessionReplayChunksFrom( FROM ${TABLE_NAMES.session_replay_chunks} WHERE session_id = ${sqlstring.escape(sessionId)} AND project_id = ${sqlstring.escape(projectId)} - ORDER BY started_at, ended_at + ORDER BY started_at, ended_at, chunk_index LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1} OFFSET ${fromIndex}` ); return { - data: rows.slice(0, REPLAY_CHUNKS_PAGE_SIZE).map((row, index) => ({ - chunkIndex: index + fromIndex, - events: JSON.parse(row.payload) as { - type: number; - data: unknown; - timestamp: number; - }[], - })), + data: rows + .slice(0, REPLAY_CHUNKS_PAGE_SIZE) + .map((row, index) => { + const events = getSafeJson< + { type: number; data: unknown; timestamp: number }[] + >(row.payload); + if (!events) { + return null; + } + return { chunkIndex: index + fromIndex, events }; + }) + .filter(Boolean), hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE, }; } @@ -369,19 +374,33 @@ class SessionService { constructor(private client: typeof ch) {} async byId(sessionId: string, projectId: string) { - const result = await clix(this.client) - .select(['*']) - .from(TABLE_NAMES.sessions) - .where('id', '=', sessionId) - .where('project_id', '=', projectId) - .where('sign', '=', 1) - .execute(); + const [sessionRows, hasReplayRows] = await Promise.all([ + clix(this.client) + .select(['*']) + .from(TABLE_NAMES.sessions, true) + .where('id', '=', sessionId) + .where('project_id', '=', projectId) + .where('sign', '=', 1) + .execute(), + chQuery<{ n: number }>( + `SELECT 1 AS n + FROM ${TABLE_NAMES.session_replay_chunks} + WHERE session_id = ${sqlstring.escape(sessionId)} + AND project_id = ${sqlstring.escape(projectId)} + LIMIT 1` + ), + ]); - if (!result[0]) { + if (!sessionRows[0]) { throw new Error('Session not found'); } - return transformSession(result[0]); + const session = transformSession(sessionRows[0]); + + return { + ...session, + hasReplay: hasReplayRows.length > 0, + }; } } diff --git a/packages/sdks/astro/src/OpenPanelComponent.astro b/packages/sdks/astro/src/OpenPanelComponent.astro index 6e95c6af..20248aa0 100644 --- a/packages/sdks/astro/src/OpenPanelComponent.astro +++ b/packages/sdks/astro/src/OpenPanelComponent.astro @@ -4,12 +4,14 @@ import { getInitSnippet } from '@openpanel/web'; type Props = Omit & { profileId?: string; + /** @deprecated Use `scriptUrl` instead. */ cdnUrl?: string; + scriptUrl?: string; filter?: string; globalProperties?: Record; }; -const { profileId, cdnUrl, globalProperties, ...options } = Astro.props; +const { profileId, cdnUrl, scriptUrl, globalProperties, ...options } = Astro.props; const CDN_URL = 'https://openpanel.dev/op1.js'; @@ -60,5 +62,5 @@ ${methods .join('\n')}`; --- -