feat: session replay
* wip * wip * wip * wip * final fixes * comments * fix
This commit is contained in:
committed by
GitHub
parent
38d9b65ec8
commit
aa81bbfe77
@@ -0,0 +1,33 @@
|
||||
import { SessionsTable } from '@/components/sessions/table';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, profileId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
trpc.session.list.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
profileId,
|
||||
take: 50,
|
||||
search: debouncedSearch,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return <SessionsTable query={query} />;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function Component() {
|
||||
label: 'Overview',
|
||||
},
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import { ReplayShell } from '@/components/sessions/replay';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/use-event-query-filters';
|
||||
Widget,
|
||||
WidgetBody,
|
||||
WidgetHead,
|
||||
WidgetTitle,
|
||||
} from '@/components/widget';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
||||
'/_app/$organizationId/$projectId/sessions_/$sessionId'
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -24,66 +31,148 @@ export const Route = createFileRoute(
|
||||
context.trpc.session.byId.queryOptions({
|
||||
sessionId: params.sessionId,
|
||||
projectId: params.projectId,
|
||||
}),
|
||||
})
|
||||
),
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.event.events.queryOptions({
|
||||
projectId: params.projectId,
|
||||
sessionId: params.sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
})
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle('Sessions'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Session') }],
|
||||
}),
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, sessionId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
|
||||
return {
|
||||
...session,
|
||||
name: 'screen_view',
|
||||
sessionId: session.id,
|
||||
properties: {},
|
||||
path: session.exitPath,
|
||||
origin: session.exitOrigin,
|
||||
importedAt: undefined,
|
||||
meta: undefined,
|
||||
sdkName: undefined,
|
||||
sdkVersion: undefined,
|
||||
profile: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
function VisitedRoutes({ paths }: { paths: string[] }) {
|
||||
const counted = paths.reduce<Record<string, number>>((acc, p) => {
|
||||
acc[p] = (acc[p] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||
const max = sorted[0]?.[1] ?? 1;
|
||||
|
||||
const { data: session } = useSuspenseQuery(
|
||||
trpc.session.byId.queryOptions({
|
||||
sessionId,
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const columnVisibility = useReadColumnVisibility('events');
|
||||
const query = useInfiniteQuery(
|
||||
trpc.event.events.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
sessionId,
|
||||
filters,
|
||||
events: eventNames,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
columnVisibility: columnVisibility ?? {},
|
||||
},
|
||||
{
|
||||
enabled: columnVisibility !== null,
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
|
||||
>
|
||||
<div className="row gap-4 mb-6">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Visited pages</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{sorted.map(([path, count]) => (
|
||||
<div className="group relative px-3 py-2" key={path}>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||
style={{ width: `${(count / max) * 100}%` }}
|
||||
/>
|
||||
<div className="relative flex min-w-0 justify-between gap-2">
|
||||
<span className="truncate text-sm">{path}</span>
|
||||
<span className="shrink-0 font-medium text-sm">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDistribution({ events }: { events: IServiceEvent[] }) {
|
||||
const counted = events.reduce<Record<string, number>>((acc, e) => {
|
||||
acc[e.name] = (acc[e.name] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
|
||||
const max = sorted[0]?.[1] ?? 1;
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Event distribution</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{sorted.map(([name, count]) => (
|
||||
<div className="group relative px-3 py-2" key={name}>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
|
||||
style={{ width: `${(count / max) * 100}%` }}
|
||||
/>
|
||||
<div className="relative flex justify-between gap-2">
|
||||
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
|
||||
<span className="shrink-0 font-medium text-sm">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const { projectId, sessionId, organizationId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const number = useNumber();
|
||||
|
||||
const { data: session } = useSuspenseQuery(
|
||||
trpc.session.byId.queryOptions({ sessionId, projectId })
|
||||
);
|
||||
|
||||
const { data: eventsData } = useSuspenseQuery(
|
||||
trpc.event.events.queryOptions({
|
||||
projectId,
|
||||
sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
})
|
||||
);
|
||||
|
||||
const events = eventsData?.data ?? [];
|
||||
|
||||
const isIdentified =
|
||||
session.profileId && session.profileId !== session.deviceId;
|
||||
|
||||
const { data: profile } = useSuspenseQuery(
|
||||
trpc.profile.byId.queryOptions({
|
||||
profileId: session.profileId,
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
|
||||
const fakeEvent = sessionToFakeEvent(session);
|
||||
|
||||
return (
|
||||
<PageContainer className="col gap-8">
|
||||
<PageHeader title={`Session: ${session.id}`}>
|
||||
<div className="row mb-6 gap-4">
|
||||
{session.country && (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<SerieIcon name={session.country} />
|
||||
<span>
|
||||
{session.country}
|
||||
@@ -92,32 +181,195 @@ function Component() {
|
||||
</div>
|
||||
)}
|
||||
{session.device && (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<SerieIcon name={session.device} />
|
||||
<span className="capitalize">{session.device}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.os && (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<SerieIcon name={session.os} />
|
||||
<span>{session.os}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.model && (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<SerieIcon name={session.model} />
|
||||
<span>{session.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.browser && (
|
||||
<div className="row gap-2 items-center">
|
||||
<div className="row items-center gap-2">
|
||||
<SerieIcon name={session.browser} />
|
||||
<span>{session.browser}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<EventsTable query={query} />
|
||||
|
||||
{session.hasReplay && (
|
||||
<ReplayShell projectId={projectId} sessionId={sessionId} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||
{/* Left column */}
|
||||
<div className="col gap-6">
|
||||
{/* Session info */}
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Session info</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<KeyValueGrid
|
||||
className="border-0"
|
||||
columns={1}
|
||||
copyable
|
||||
data={[
|
||||
{
|
||||
name: 'duration',
|
||||
value: number.formatWithUnit(session.duration / 1000, 'min'),
|
||||
},
|
||||
{ name: 'createdAt', value: session.createdAt },
|
||||
{ name: 'endedAt', value: session.endedAt },
|
||||
{ name: 'screenViews', value: session.screenViewCount },
|
||||
{ name: 'events', value: session.eventCount },
|
||||
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
|
||||
...(session.entryPath
|
||||
? [{ name: 'entryPath', value: session.entryPath }]
|
||||
: []),
|
||||
...(session.exitPath
|
||||
? [{ name: 'exitPath', value: session.exitPath }]
|
||||
: []),
|
||||
...(session.referrerName
|
||||
? [{ name: 'referrerName', value: session.referrerName }]
|
||||
: []),
|
||||
...(session.referrer
|
||||
? [{ name: 'referrer', value: session.referrer }]
|
||||
: []),
|
||||
...(session.utmSource
|
||||
? [{ name: 'utmSource', value: session.utmSource }]
|
||||
: []),
|
||||
...(session.utmMedium
|
||||
? [{ name: 'utmMedium', value: session.utmMedium }]
|
||||
: []),
|
||||
...(session.utmCampaign
|
||||
? [{ name: 'utmCampaign', value: session.utmCampaign }]
|
||||
: []),
|
||||
...(session.revenue > 0
|
||||
? [{ name: 'revenue', value: `$${session.revenue}` }]
|
||||
: []),
|
||||
{ name: 'country', value: session.country, event: fakeEvent },
|
||||
...(session.city
|
||||
? [{ name: 'city', value: session.city, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.os
|
||||
? [{ name: 'os', value: session.os, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.browser
|
||||
? [
|
||||
{
|
||||
name: 'browser',
|
||||
value: session.browser,
|
||||
event: fakeEvent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(session.device
|
||||
? [
|
||||
{
|
||||
name: 'device',
|
||||
value: session.device,
|
||||
event: fakeEvent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(session.brand
|
||||
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
|
||||
: []),
|
||||
...(session.model
|
||||
? [{ name: 'model', value: session.model, event: fakeEvent }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
|
||||
{/* Profile card */}
|
||||
{isIdentified && profile && (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Profile</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
<Link
|
||||
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: session.profileId,
|
||||
}}
|
||||
to="/$organizationId/$projectId/profiles/$profileId"
|
||||
>
|
||||
<ProfileAvatar {...profile} size="lg" />
|
||||
<div className="col min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">
|
||||
{getProfileName(profile, false) ?? session.profileId}
|
||||
</span>
|
||||
{profile.email && (
|
||||
<span className="truncate text-muted-foreground text-sm">
|
||||
{profile.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{/* Visited pages */}
|
||||
<VisitedRoutes
|
||||
paths={events
|
||||
.filter((e) => e.name === 'screen_view' && e.path)
|
||||
.map((e) => e.path)}
|
||||
/>
|
||||
|
||||
{/* Event distribution */}
|
||||
<EventDistribution events={events} />
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="col gap-6">
|
||||
{/* Events list */}
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Events</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="divide-y">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
className="row items-center gap-3 px-4 py-2"
|
||||
key={event.id}
|
||||
>
|
||||
<EventIcon meta={event.meta} name={event.name} size="sm" />
|
||||
<div className="col min-w-0 flex-1">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{event.name === 'screen_view' && event.path
|
||||
? event.path
|
||||
: event.name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{formatDateTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user