diff --git a/apps/start/src/components/sessions/details/session-journey.tsx b/apps/start/src/components/sessions/details/session-journey.tsx new file mode 100644 index 000000000..b0687a4f3 --- /dev/null +++ b/apps/start/src/components/sessions/details/session-journey.tsx @@ -0,0 +1,87 @@ +import { ChevronRightIcon, RouteIcon } from 'lucide-react'; +import type { SessionPageVisit } from './session-pages'; +import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { Widget } from '@/components/widget'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; + +const MS_PER_SECOND = 1000; +const MIN_BAR_PERCENT = 4; + +function StepBadge({ label, tone }: { label: string; tone: 'entry' | 'exit' }) { + return ( + + {label} + + ); +} + +export function SessionJourney({ visits }: { visits: SessionPageVisit[] }) { + const pages = visits.filter((visit) => visit.view !== null); + if (pages.length === 0) { + return null; + } + const longest = Math.max(...pages.map((page) => page.durationMs), 1); + + return ( + + + Journey + +
    + {pages.map((page, index) => { + const isFirst = index === 0; + const isLast = index === pages.length - 1; + const customEvents = page.events.length; + const barWidth = Math.max( + MIN_BAR_PERCENT, + (page.durationMs / longest) * 100 + ); + return ( +
  1. +
    +
    + + {index + 1} + + {isFirst && } + {isLast && } +
    + + {page.path} + +
    +
    +
    + + {fancyMinutes(page.durationMs / MS_PER_SECOND)} + {customEvents > 0 && + ` · ${customEvents} ${customEvents === 1 ? 'event' : 'events'}`} + +
    + {!isLast && ( + + )} +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-metrics.tsx b/apps/start/src/components/sessions/details/session-metrics.tsx new file mode 100644 index 000000000..a9b5099ea --- /dev/null +++ b/apps/start/src/components/sessions/details/session-metrics.tsx @@ -0,0 +1,87 @@ +import type { IServiceEvent, IServiceSession } from '@openpanel/core'; +import { + type MetricUnit, + OverviewMetricCard, +} from '@/components/overview/overview-metric-card'; +import { cn } from '@/utils/cn'; + +const COLUMNS_BY_COUNT: Record = { + 4: 'xl:grid-cols-4', + 5: 'xl:grid-cols-5', + 6: 'xl:grid-cols-6', +}; +const MS_PER_SECOND = 1000; + +interface SessionMetric { + key: string; + label: string; + value: number; + unit: MetricUnit; +} + +export function SessionMetrics({ + session, + events, +}: { + session: IServiceSession; + events: IServiceEvent[]; +}) { + const pageViews = events.filter((event) => event.name === 'screen_view'); + const uniquePages = new Set(pageViews.map((event) => event.path || '/')).size; + const durationSeconds = session.duration / MS_PER_SECOND; + const hasPageViews = session.screenViewCount > 0; + + // The card's 'min' unit takes seconds and renders them as a duration. + const metrics: SessionMetric[] = [ + { key: 'duration', label: 'Duration', value: durationSeconds, unit: 'min' }, + { + key: 'screenViews', + label: 'Page views', + value: session.screenViewCount, + unit: '', + }, + { key: 'uniquePages', label: 'Unique pages', value: uniquePages, unit: '' }, + { key: 'events', label: 'Events', value: session.eventCount, unit: '' }, + ...(hasPageViews + ? [ + { + key: 'timePerPage', + label: 'Time per page', + value: durationSeconds / session.screenViewCount, + unit: 'min' as const, + }, + ] + : []), + ...(session.revenue > 0 + ? [ + { + key: 'revenue', + label: 'Revenue', + value: session.revenue, + unit: 'currency' as const, + }, + ] + : []), + ]; + + return ( +
+ {metrics.map((metric) => ( + + ))} +
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-pages.ts b/apps/start/src/components/sessions/details/session-pages.ts new file mode 100644 index 000000000..5a865a9a7 --- /dev/null +++ b/apps/start/src/components/sessions/details/session-pages.ts @@ -0,0 +1,77 @@ +import type { IServiceEvent } from '@openpanel/core'; + +export interface SessionPageVisit { + /** The screen view that opened the page; null for events before the first one. */ + view: IServiceEvent | null; + path: string; + startedAt: number; + durationMs: number; + events: IServiceEvent[]; +} + +function toMs(date: Date | string): number { + return date instanceof Date ? date.getTime() : new Date(date).getTime(); +} + +function pagePath(event: IServiceEvent): string { + return event.path || '/'; +} + +/** + * Within one millisecond the query's order is arbitrary, so an event goes after + * a screen view only when it happened on that view's page. + */ +function compareEvents(a: IServiceEvent, b: IServiceEvent): number { + const difference = toMs(a.createdAt) - toMs(b.createdAt); + const aIsView = a.name === 'screen_view'; + const bIsView = b.name === 'screen_view'; + if (difference !== 0 || aIsView === bIsView) { + return difference; + } + const view = aIsView ? a : b; + const other = aIsView ? b : a; + const viewGoesFirst = pagePath(other) === pagePath(view); + return viewGoesFirst === aIsView ? -1 : 1; +} + +export function sortEventsAscending(events: IServiceEvent[]): IServiceEvent[] { + return [...events].sort(compareEvents); +} + +/** Splits a session's events into page visits, each lasting until the next page. */ +export function groupEventsByPage( + events: IServiceEvent[], + sessionEndedAt: Date +): SessionPageVisit[] { + const visits: SessionPageVisit[] = []; + for (const event of sortEventsAscending(events)) { + const isPageView = event.name === 'screen_view'; + const current = visits.at(-1); + if (isPageView || !current) { + visits.push({ + view: isPageView ? event : null, + path: isPageView ? pagePath(event) : '', + startedAt: toMs(event.createdAt), + durationMs: 0, + events: isPageView ? [] : [event], + }); + continue; + } + current.events.push(event); + } + + const endedAt = toMs(sessionEndedAt); + for (const [index, visit] of visits.entries()) { + const next = visits[index + 1]; + const lastEvent = visit.events.at(-1); + const end = + next?.startedAt ?? + Math.max(endedAt, lastEvent ? toMs(lastEvent.createdAt) : 0); + visit.durationMs = Math.max(0, end - visit.startedAt); + } + return visits; +} + +export function getElapsedMs(event: IServiceEvent, sessionStart: Date): number { + return Math.max(0, toMs(event.createdAt) - toMs(sessionStart)); +} diff --git a/apps/start/src/components/sessions/details/session-properties.tsx b/apps/start/src/components/sessions/details/session-properties.tsx new file mode 100644 index 000000000..e3d1563bc --- /dev/null +++ b/apps/start/src/components/sessions/details/session-properties.tsx @@ -0,0 +1,123 @@ +import type { IServiceEvent, IServiceSession } from '@openpanel/core'; +import { parseAsStringEnum, useQueryState } from 'nuqs'; +import { + WidgetButtons, + WidgetHead, +} from '@/components/overview/overview-widget'; +import { + KeyValueGrid, + type KeyValueItem, +} from '@/components/ui/key-value-grid'; +import { Widget } from '@/components/widget'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; + +const TABS = [ + { key: 'session', label: 'Session' }, + { key: 'source', label: 'Source' }, + { key: 'device', label: 'Device' }, +] as const; +type TabKey = (typeof TABS)[number]['key']; +const MS_PER_SECOND = 1000; + +/** Lets the grid's filter links treat session fields like event properties. */ +function sessionAsEvent(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, + }; +} + +function withVersion(name: string, version: string): string { + return version ? `${name} ${version}` : name; +} + +function getItems(session: IServiceSession, tab: TabKey): KeyValueItem[] { + const event = sessionAsEvent(session); + const items: Record = { + session: [ + { name: 'id', value: session.id }, + { name: 'createdAt', value: session.createdAt }, + { name: 'endedAt', value: session.endedAt }, + { + name: 'duration', + value: fancyMinutes(session.duration / MS_PER_SECOND), + }, + { name: 'entryPath', value: session.entryPath }, + { name: 'exitPath', value: session.exitPath }, + { name: 'bounce', value: session.isBounce ? 'Yes' : 'No' }, + { name: 'deviceId', value: session.deviceId }, + ], + source: [ + { name: 'referrerName', value: session.referrerName, event }, + { name: 'referrerType', value: session.referrerType, event }, + { name: 'referrer', value: session.referrer, event }, + { name: 'utmSource', value: session.utmSource }, + { name: 'utmMedium', value: session.utmMedium }, + { name: 'utmCampaign', value: session.utmCampaign }, + { name: 'utmContent', value: session.utmContent }, + { name: 'utmTerm', value: session.utmTerm }, + ], + device: [ + { name: 'country', value: session.country, event }, + { name: 'region', value: session.region, event }, + { name: 'city', value: session.city, event }, + { name: 'device', value: session.device, event }, + { name: 'os', value: withVersion(session.os, session.osVersion) }, + { + name: 'browser', + value: withVersion(session.browser, session.browserVersion), + }, + { name: 'brand', value: session.brand, event }, + { name: 'model', value: session.model, event }, + ], + }; + return items[tab].filter( + (item) => + item.value !== '' && item.value !== null && item.value !== undefined + ); +} + +export function SessionProperties({ session }: { session: IServiceSession }) { + const [tab, setTab] = useQueryState( + 'tab', + parseAsStringEnum(TABS.map((t) => t.key)).withDefault('session') + ); + const items = getItems(session, tab); + + return ( + + +
Details
+ + {TABS.map((t) => ( + + ))} + +
+ {items.length > 0 ? ( + + ) : ( +
+ Nothing recorded for this session. +
+ )} +
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-timeline.tsx b/apps/start/src/components/sessions/details/session-timeline.tsx new file mode 100644 index 000000000..a309f42ad --- /dev/null +++ b/apps/start/src/components/sessions/details/session-timeline.tsx @@ -0,0 +1,202 @@ +import type { IServiceEvent } from '@openpanel/core'; +import { ListTreeIcon, ZapIcon } from 'lucide-react'; +import { formatDuration } from '../replay/replay-utils'; +import { getElapsedMs, type SessionPageVisit } from './session-pages'; +import { EmptyState } from '@/components/empty-state'; +import { EventIcon } from '@/components/events/event-icon'; +import { useEventDrawer } from '@/components/events/use-event-drawer'; +import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { Widget } from '@/components/widget'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; +import { formatTime } from '@/utils/date'; + +const MS_PER_SECOND = 1000; +const MAX_PROPERTY_CHIPS = 3; +const MAX_CHIP_VALUE_LENGTH = 32; + +function getPropertyChips(event: IServiceEvent): [string, string][] { + return Object.entries(event.properties ?? {}) + .filter( + ([key, value]) => + !key.startsWith('__') && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) + .slice(0, MAX_PROPERTY_CHIPS) + .map(([key, value]) => { + const text = String(value); + return [ + key, + text.length > MAX_CHIP_VALUE_LENGTH + ? `${text.slice(0, MAX_CHIP_VALUE_LENGTH)}…` + : text, + ]; + }); +} + +function Elapsed({ ms }: { ms: number }) { + return ( + + +{formatDuration(ms)} + + ); +} + +function EventRow({ + event, + sessionStart, + onOpen, +}: { + event: IServiceEvent; + sessionStart: Date; + onOpen: () => void; +}) { + const chips = getPropertyChips(event); + return ( +
  • + + +
  • + ); +} + +function PageHeader({ + visit, + index, + sessionStart, + onOpen, +}: { + visit: SessionPageVisit; + index: number; + sessionStart: Date; + onOpen?: () => void; +}) { + const view = visit.view; + const content = ( + <> + {view ? ( + + {index} + + ) : ( + + )} + + + {view ? visit.path : 'Session started'} + + + {formatTime(new Date(visit.startedAt))} + {view && + ` · ${fancyMinutes(visit.durationMs / MS_PER_SECOND)} on page`} + + + {view && } + + ); + + if (!onOpen) { + return ( +
    {content}
    + ); + } + return ( + + ); +} + +export function SessionTimeline({ + visits, + sessionStart, +}: { + visits: SessionPageVisit[]; + sessionStart: Date; +}) { + const { openEvent } = useEventDrawer(); + const open = (event: IServiceEvent) => + openEvent({ id: event.id, createdAt: event.createdAt }); + let pageNumber = 0; + + return ( + + + Timeline + + {visits.length === 0 ? ( + + ) : ( +
      + {visits.map((visit, index) => { + const view = visit.view; + if (view) { + pageNumber++; + } + return ( +
    1. + open(view) : undefined} + sessionStart={sessionStart} + visit={visit} + /> + {visit.events.length > 0 && ( +
        + {visit.events.map((event) => ( + open(event)} + sessionStart={sessionStart} + /> + ))} +
      + )} +
    2. + ); + })} +
    + )} +
    + ); +} diff --git a/apps/start/src/components/sessions/replay/browser-chrome.tsx b/apps/start/src/components/sessions/replay/browser-chrome.tsx deleted file mode 100644 index 6162322f0..000000000 --- a/apps/start/src/components/sessions/replay/browser-chrome.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { ReactNode } from 'react'; -import { cn } from '@/utils/cn'; - -export function BrowserChrome({ - url, - children, - right, - controls = ( -
    -
    -
    -
    -
    - ), - className, -}: { - url?: ReactNode; - children: ReactNode; - right?: ReactNode; - controls?: ReactNode; - className?: string; -}) { - return ( -
    -
    - {controls} - {url !== false && ( -
    - {url} -
    - )} - {right} -
    - {children} -
    - ); -} diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index fb26c8067..7b7b7193f 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -1,54 +1,30 @@ import type { IServiceEvent } from '@openpanel/core'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Maximize2, Minimize2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BrowserChrome } from './browser-chrome'; -import { ReplayTime } from './replay-controls'; -import { ReplayTimeline } from './replay-timeline'; -import { getEventOffsetMs } from './replay-utils'; +import { FastForwardIcon, VideoOffIcon } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ReplayProvider, useCurrentTime, useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed'; -import { ReplayPlayer } from '@/components/sessions/replay/replay-player'; +} from './replay-context'; +import { ReplayControls, ReplayPlayPauseButton } from './replay-controls'; +import { ReplayEventFeed } from './replay-event-feed'; +import { getStageFitHeight, ReplayPlayer } from './replay-player'; +import { ReplayTimeline } from './replay-timeline'; +import { + type EventWithOffset, + findLastIndexAtTime, + getPageSegments, + type PageSegment, + type ReplayRecordEvent, + sortEventsByOffset, +} from './replay-utils'; import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; -function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { - const { startTime } = useReplayContext(); - const currentTime = useCurrentTime(250); +const FEED_TICK_MS = 100; - const currentUrl = useMemo(() => { - if (startTime == null || !events.length) { - return ''; - } - - const withOffset = events - .map((ev) => ({ - event: ev, - offsetMs: getEventOffsetMs(ev, startTime), - })) - .filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime) - .sort((a, b) => a.offsetMs - b.offsetMs); - - const latest = withOffset.at(-1); - if (!latest) { - return ''; - } - - const { origin = '', path = '/' } = latest.event; - return `${origin}${path}`; - }, [events, currentTime, startTime]); - - return {currentUrl}; -} - -/** - * Feeds remaining chunks into the player after it's ready. - * Receives already-fetched chunks from the initial batch, then pages - * through the rest using replayChunksFrom. - */ +/** Pages through the chunks after the first batch and feeds them to the player. */ function ReplayChunkLoader({ sessionId, projectId, @@ -60,9 +36,12 @@ function ReplayChunkLoader({ }) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const { addEvent, refreshDuration } = useReplayContext(); + const { addEvent, refreshDuration, isReady } = useReplayContext(); useEffect(() => { + if (!isReady) { + return; + } let cancelled = false; function loadFrom(index: number) { @@ -84,13 +63,12 @@ function ReplayChunkLoader({ } } refreshDuration(); - const advanced = res.nextIndex > index; - if (res.hasMore && advanced) { + if (res.hasMore && res.nextIndex > index) { loadFrom(res.nextIndex); } }) .catch(() => { - // chunk loading failed — replay may be incomplete + // A failed page leaves the replay shorter, not broken. }); } @@ -98,72 +76,211 @@ function ReplayChunkLoader({ return () => { cancelled = true; }; - }, []); + }, [ + isReady, + queryClient, + trpc, + sessionId, + projectId, + fromIndex, + addEvent, + refreshDuration, + ]); return null; } -function FullscreenButton({ - containerRef, -}: { - containerRef: React.RefObject; -}) { - const [isFullscreen, setIsFullscreen] = useState(false); +function CurrentUrl({ segment }: { segment: PageSegment | undefined }) { + if (!segment) { + return about:blank; + } + const host = segment.origin.replace(/^https?:\/\//, ''); + return ( + + {host} + {segment.path} + + ); +} - useEffect(() => { - const onChange = () => setIsFullscreen(!!document.fullscreenElement); - document.addEventListener('fullscreenchange', onChange); - return () => document.removeEventListener('fullscreenchange', onChange); - }, []); +/** A big play button until the viewer starts the replay for the first time. */ +function StartOverlay() { + const { isPlaying, isReady } = useReplayContext(); + const [hasStarted, setHasStarted] = useState(false); - const toggle = useCallback(() => { - if (!containerRef.current) { - return; + useEffect(() => { + if (isPlaying) { + setHasStarted(true); } - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - containerRef.current.requestFullscreen(); + }, [isPlaying]); + + if (!isReady || hasStarted) { + return null; + } + + return ( +
    + +
    + ); +} + +function SkippingBadge() { + const { isSkipping, isPlaying } = useReplayContext(); + if (!(isSkipping && isPlaying)) { + return null; + } + return ( +
    + + Skipping idle time +
    + ); +} + +type StageStatus = 'loading' | 'error' | 'empty'; + +function StagePlaceholder({ status }: { status: StageStatus }) { + if (status === 'loading') { + return ( +
    +
    +
    +
    +
    +
    + Loading recording… +
    +
    +
    +
    + ); + } + return ( +
    + + + {status === 'error' + ? 'The recording could not be loaded. Reload the page to try again.' + : 'No recording was captured for this session.'} + +
    + ); +} + +function getStageStatus(isLoading: boolean, isError: boolean): StageStatus { + if (isLoading) { + return 'loading'; + } + return isError ? 'error' : 'empty'; +} + +function ReplayStage({ + events, + playerEvents, + isLoading, + isError, + fullscreenTarget, +}: { + events: IServiceEvent[]; + playerEvents: ReplayRecordEvent[]; + isLoading: boolean; + isError: boolean; + fullscreenTarget: React.RefObject; +}) { + const { startTime, duration } = useReplayContext(); + const currentTime = useCurrentTime(FEED_TICK_MS); + + // Until the duration is known every event is kept; after that, only what + // the recording covers can be seeked to. + const sorted = useMemo(() => { + if (startTime == null) { + return []; } - }, [containerRef]); + const all = sortEventsByOffset(events, startTime); + return duration > 0 + ? all.filter(({ offsetMs }) => offsetMs <= duration) + : all; + }, [events, startTime, duration]); + const segments = useMemo( + () => getPageSegments(sorted, duration), + [sorted, duration] + ); + const currentIndex = findLastIndexAtTime(sorted, currentTime); + const currentSegment = + segments.filter((segment) => segment.startMs <= currentTime).at(-1) ?? + segments[0]; + const hasRecording = playerEvents.length > 0; + const stageFitHeight = useMemo( + () => getStageFitHeight(playerEvents), + [playerEvents] + ); return ( - +
    +
    +
    +
    + {hasRecording ? ( + } + url={} + /> + ) : ( + + )} + +
    +
    + + +
    +
    + +
    ); } -function ReplayContent({ +export function ReplayShell({ sessionId, projectId, + events, }: { sessionId: string; projectId: string; + events: IServiceEvent[]; }) { const trpc = useTRPC(); - const containerRef = useRef(null); + const rootRef = useRef(null); - const { data: eventsData } = useQuery( - trpc.event.events.queryOptions({ - projectId, - sessionId, - filters: [], - columnVisibility: {}, - }) - ); - - // Fetch first batch of chunks (includes chunk 0 for player init + more) - const { data: firstBatch, isLoading: replayLoading } = useQuery( + // The first batch carries the full snapshot the player needs to start. + const { + data: firstBatch, + isLoading, + isError, + } = useQuery( trpc.session.replayChunksFrom.queryOptions({ sessionId, projectId, @@ -171,67 +288,30 @@ function ReplayContent({ }) ); - const events = eventsData?.data ?? []; - const playerEvents = - firstBatch?.data.flatMap((row) => row?.events ?? []) ?? []; - const hasMore = firstBatch?.hasMore ?? false; - const hasReplay = playerEvents.length !== 0; - - function renderReplay() { - if (replayLoading) { - return ( -
    -
    -
    Loading session replay
    -
    - ); - } - if (hasReplay) { - return ; - } - return ( -
    - No replay data available for this session. -
    - ); - } + const playerEvents = useMemo( + () => firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [], + [firstBatch] + ); return ( - + // Keyed so playback state (e.g. the start overlay) resets per session. +
    -
    - - {hasReplay && } - -
    - } - url={ - hasReplay ? ( - - ) : ( - about:blank - ) - } - > - {renderReplay()} - {hasReplay && } - -
    -
    -
    - -
    -
    +
    - {hasReplay && hasMore && ( + {firstBatch?.hasMore && ( @@ -239,13 +319,3 @@ function ReplayContent({ ); } - -export function ReplayShell({ - sessionId, - projectId, -}: { - sessionId: string; - projectId: string; -}) { - return ; -} diff --git a/apps/start/src/components/sessions/replay/replay-context.tsx b/apps/start/src/components/sessions/replay/replay-context.tsx index cba34b19a..d70bc52cf 100644 --- a/apps/start/src/components/sessions/replay/replay-context.tsx +++ b/apps/start/src/components/sessions/replay/replay-context.tsx @@ -7,6 +7,11 @@ import { useRef, useState, } from 'react'; +import { + getClick, + isUserInteraction, + type ReplayRecordEvent, +} from './replay-utils'; export interface ReplayPlayerInstance { play: () => void; @@ -14,9 +19,10 @@ export interface ReplayPlayerInstance { toggle: () => void; goto: (timeOffset: number, play?: boolean) => void; setSpeed: (speed: number) => void; + toggleSkipInactive: () => void; + triggerResize: () => void; getMetaData: () => { startTime: number; endTime: number; totalTime: number }; - getReplayer: () => { getCurrentTime: () => number }; - addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + addEvent: (event: ReplayRecordEvent) => void; addEventListener: ( event: string, handler: (e: { payload: unknown }) => void @@ -27,41 +33,49 @@ export interface ReplayPlayerInstance { type CurrentTimeListener = (t: number) => void; +export interface RecordingActivity { + interactions: number[]; + clicks: { timestamp: number; x: number; y: number }[]; +} + +export const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const; +export type ReplaySpeed = (typeof SPEED_OPTIONS)[number]; +const DEFAULT_SPEED: ReplaySpeed = 1; +export const DEFAULT_SKIP_INACTIVE = true; + interface ReplayContextValue { // High-frequency value — read via ref, not state. Use subscribeToCurrentTime // or useCurrentTime() to get updates without causing 60fps re-renders. currentTimeRef: React.MutableRefObject; subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void; - // Low-frequency state (safe to consume directly) isPlaying: boolean; + isSkipping: boolean; duration: number; startTime: number | null; isReady: boolean; - // Playback controls + speed: ReplaySpeed; + skipInactive: boolean; + activity: RecordingActivity; play: () => void; pause: () => void; toggle: () => void; seek: (timeMs: number) => void; - setSpeed: (speed: number) => void; - // Lazy chunk loading - addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + setSpeed: (speed: ReplaySpeed) => void; + toggleSkipInactive: () => void; + addEvent: (event: ReplayRecordEvent) => void; refreshDuration: () => void; - // Called by ReplayPlayer to register/unregister the rrweb instance onPlayerReady: ( player: ReplayPlayerInstance, - playerStartTime: number + initialEvents: ReplayRecordEvent[] ) => void; onPlayerDestroy: () => void; - // State setters exposed so ReplayPlayer can wire rrweb event listeners setCurrentTime: (t: number) => void; setIsPlaying: (p: boolean) => void; - setDuration: (d: number) => void; + setIsSkipping: (s: boolean) => void; } const ReplayContext = createContext(null); -const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const; - export function useReplayContext() { const ctx = useContext(ReplayContext); if (!ctx) { @@ -97,16 +111,28 @@ export function useCurrentTime(intervalMs = 0): number { return time; } +const EMPTY_ACTIVITY: RecordingActivity = { interactions: [], clicks: [] }; + export function ReplayProvider({ children }: { children: ReactNode }) { const playerRef = useRef(null); const isPlayingRef = useRef(false); const currentTimeRef = useRef(0); const listenersRef = useRef>(new Set()); + // Chunks stream in after the player starts; collect what they tell us about + // the user's activity here and publish it in one go per batch. + const activityRef = useRef({ + interactions: [], + clicks: [], + }); const [isPlaying, setIsPlaying] = useState(false); + const [isSkipping, setIsSkipping] = useState(false); const [duration, setDuration] = useState(0); const [startTime, setStartTime] = useState(null); const [isReady, setIsReady] = useState(false); + const [speed, setSpeedState] = useState(DEFAULT_SPEED); + const [skipInactive, setSkipInactive] = useState(DEFAULT_SKIP_INACTIVE); + const [activity, setActivity] = useState(EMPTY_ACTIVITY); const setIsPlayingWithRef = useCallback((playing: boolean) => { isPlayingRef.current = playing; @@ -129,15 +155,52 @@ export function ReplayProvider({ children }: { children: ReactNode }) { } }, []); + const collectActivity = useCallback((event: ReplayRecordEvent) => { + if (!isUserInteraction(event)) { + return; + } + activityRef.current.interactions.push(event.timestamp); + const click = getClick(event); + if (click) { + activityRef.current.clicks.push(click); + } + }, []); + + const publishActivity = useCallback(() => { + setActivity({ + interactions: [...activityRef.current.interactions], + clicks: [...activityRef.current.clicks], + }); + }, []); + + // rrweb queues each added event in a microtask; reading the metadata in the + // next one sees the whole batch instead of the length before it. + const refreshDuration = useCallback(() => { + queueMicrotask(() => { + const total = playerRef.current?.getMetaData().totalTime ?? 0; + if (total > 0) { + setDuration(total); + } + publishActivity(); + }); + }, [publishActivity]); + const onPlayerReady = useCallback( - (player: ReplayPlayerInstance, playerStartTime: number) => { + (player: ReplayPlayerInstance, initialEvents: ReplayRecordEvent[]) => { playerRef.current = player; - setStartTime(playerStartTime); + activityRef.current = { interactions: [], clicks: [] }; + for (const event of initialEvents) { + collectActivity(event); + } + setStartTime(player.getMetaData().startTime); currentTimeRef.current = 0; setIsPlayingWithRef(false); + setSpeedState(DEFAULT_SPEED); + setSkipInactive(DEFAULT_SKIP_INACTIVE); + refreshDuration(); setIsReady(true); }, - [setIsPlayingWithRef] + [collectActivity, refreshDuration, setIsPlayingWithRef] ); const onPlayerDestroy = useCallback(() => { @@ -147,6 +210,8 @@ export function ReplayProvider({ children }: { children: ReactNode }) { setDuration(0); setStartTime(null); setIsPlayingWithRef(false); + setIsSkipping(false); + setActivity(EMPTY_ACTIVITY); }, [setIsPlayingWithRef]); const play = useCallback(() => { @@ -162,54 +227,54 @@ export function ReplayProvider({ children }: { children: ReactNode }) { }, []); const seek = useCallback((timeMs: number) => { - playerRef.current?.goto(timeMs, isPlayingRef.current); + playerRef.current?.goto(Math.max(0, timeMs), isPlayingRef.current); }, []); - const setSpeed = useCallback((s: number) => { - if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) { - return; - } - playerRef.current?.setSpeed(s); + const setSpeed = useCallback((next: ReplaySpeed) => { + playerRef.current?.setSpeed(next); + setSpeedState(next); + }, []); + + const toggleSkipInactive = useCallback(() => { + playerRef.current?.toggleSkipInactive(); + setSkipInactive((current) => !current); }, []); const addEvent = useCallback( - (event: { type: number; data: unknown; timestamp: number }) => { + (event: ReplayRecordEvent) => { playerRef.current?.addEvent(event); + collectActivity(event); }, - [] + [collectActivity] ); - const refreshDuration = useCallback(() => { - const total = playerRef.current?.getMetaData().totalTime ?? 0; - if (total > 0) { - setDuration(total); - } - }, []); - const value: ReplayContextValue = { currentTimeRef, subscribeToCurrentTime, isPlaying, + isSkipping, duration, startTime, isReady, + speed, + skipInactive, + activity, play, pause, toggle, seek, setSpeed, + toggleSkipInactive, addEvent, refreshDuration, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying: setIsPlayingWithRef, - setDuration, + setIsSkipping, }; return ( {children} ); } - -export { SPEED_OPTIONS }; diff --git a/apps/start/src/components/sessions/replay/replay-controls.tsx b/apps/start/src/components/sessions/replay/replay-controls.tsx index d3edb07d3..4a0d57644 100644 --- a/apps/start/src/components/sessions/replay/replay-controls.tsx +++ b/apps/start/src/components/sessions/replay/replay-controls.tsx @@ -1,38 +1,347 @@ -import { Pause, Play } from 'lucide-react'; -import { formatDuration } from './replay-utils'; import { + FastForwardIcon, + Maximize2Icon, + Minimize2Icon, + PauseIcon, + PlayIcon, + RotateCcwIcon, + RotateCwIcon, + SkipBackIcon, + SkipForwardIcon, +} from 'lucide-react'; +import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { + SPEED_OPTIONS, useCurrentTime, useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { Button } from '@/components/ui/button'; +} from './replay-context'; +import { type EventWithOffset, formatDuration } from './replay-utils'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { cn } from '@/utils/cn'; -export function ReplayTime() { - const { duration } = useReplayContext(); - const currentTime = useCurrentTime(250); +const JUMP_MS = 10_000; +// Jumping to "previous event" right after one fired should go to the one before. +const PREVIOUS_EVENT_GRACE_MS = 1500; +function ControlButton({ + label, + shortcut, + onClick, + children, + active, + className, +}: { + label: string; + shortcut?: string; + onClick: () => void; + children: ReactNode; + active?: boolean; + className?: string; +}) { return ( - - {formatDuration(currentTime)} / {formatDuration(duration)} - + + {label} + {shortcut && ( + + {shortcut} + + )} + + } + > + + ); } -export function ReplayPlayPauseButton() { +export function ReplayPlayPauseButton({ size = 'md' }: { size?: 'md' | 'lg' }) { const { isPlaying, isReady, toggle } = useReplayContext(); - - if (!isReady) { - return null; - } + const isLarge = size === 'lg'; return ( - + {isPlaying ? ( + + ) : ( + + )} + + ); +} + +function ReplayTime() { + const { duration } = useReplayContext(); + const currentTime = useCurrentTime(250); + + return ( + + {formatDuration(currentTime)} + + {' '} + / {formatDuration(duration)} + + + ); +} + +function useEventJumps(events: EventWithOffset[]) { + const { currentTimeRef, seek } = useReplayContext(); + const next = useCallback(() => { + const target = events.find( + ({ offsetMs }) => offsetMs > currentTimeRef.current + 1 + ); + if (target) { + seek(target.offsetMs); + } + }, [events, currentTimeRef, seek]); + const previous = useCallback(() => { + const target = events + .filter( + ({ offsetMs }) => + offsetMs < currentTimeRef.current - PREVIOUS_EVENT_GRACE_MS + ) + .at(-1); + seek(target ? target.offsetMs : 0); + }, [events, currentTimeRef, seek]); + return { next, previous }; +} + +function useFullscreen(target: React.RefObject) { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => + setIsFullscreen(document.fullscreenElement === target.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, [target]); + + const toggle = useCallback(() => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + target.current?.requestFullscreen(); + } + }, [target]); + + return { isFullscreen, toggle }; +} + +function nextSpeed(current: number, direction: 1 | -1) { + const index = SPEED_OPTIONS.indexOf( + current as (typeof SPEED_OPTIONS)[number] + ); + const nextIndex = Math.max( + 0, + Math.min(SPEED_OPTIONS.length - 1, index + direction) + ); + return SPEED_OPTIONS[nextIndex] ?? SPEED_OPTIONS[1]; +} + +/** Player shortcuts; ignored while the user is typing anywhere on the page. */ +function useReplayHotkeys({ + events, + scope, + onFullscreen, +}: { + events: EventWithOffset[]; + scope: React.RefObject; + onFullscreen: () => void; +}) { + const { + isReady, + toggle, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + toggleSkipInactive, + } = useReplayContext(); + const jumps = useEventJumps(events); + + useEffect(() => { + if (!isReady) { + return; + } + const onKeyDown = (e: KeyboardEvent) => { + const hasModifier = e.metaKey || e.ctrlKey || e.altKey; + // Only while nothing else on the page has focus, or focus is in the player. + const isInScope = + e.target === document.body || + (e.target instanceof Node && scope.current?.contains(e.target)); + if (hasModifier || !isInScope) { + return; + } + const actions: Record void> = { + ' ': toggle, + k: toggle, + j: () => seek(currentTimeRef.current - JUMP_MS), + l: () => seek(Math.min(duration, currentTimeRef.current + JUMP_MS)), + ',': jumps.previous, + '.': jumps.next, + '<': () => setSpeed(nextSpeed(speed, -1)), + '>': () => setSpeed(nextSpeed(speed, 1)), + i: toggleSkipInactive, + f: onFullscreen, + }; + const action = actions[e.key]; + if (!action) { + return; + } + // Space on a focused button would also click it. + const isButton = e.target instanceof HTMLButtonElement; + if (e.key === ' ' && isButton) { + return; + } + e.preventDefault(); + action(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [ + isReady, + toggle, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + toggleSkipInactive, + jumps, + scope, + onFullscreen, + ]); +} + +export function ReplayControls({ + events, + fullscreenTarget, +}: { + events: EventWithOffset[]; + fullscreenTarget: React.RefObject; +}) { + const { + isReady, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + skipInactive, + toggleSkipInactive, + } = useReplayContext(); + const jumps = useEventJumps(events); + const fullscreen = useFullscreen(fullscreenTarget); + useReplayHotkeys({ + events, + scope: fullscreenTarget, + onFullscreen: fullscreen.toggle, + }); + + return ( +
    + +
    + + + + seek(currentTimeRef.current - JUMP_MS)} + shortcut="J" + > + + + + seek(Math.min(duration, currentTimeRef.current + JUMP_MS)) + } + shortcut="L" + > + + + + + +
    +
    + +
    + +
    + + + Skip idle + + { + const index = SPEED_OPTIONS.indexOf(speed); + setSpeed(SPEED_OPTIONS[(index + 1) % SPEED_OPTIONS.length]!); + }} + shortcut="< >" + > + {speed}× + + + {fullscreen.isFullscreen ? ( + + ) : ( + + )} + +
    +
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-event-feed.tsx b/apps/start/src/components/sessions/replay/replay-event-feed.tsx index d2fbd9958..ea98a4764 100644 --- a/apps/start/src/components/sessions/replay/replay-event-feed.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-feed.tsx @@ -1,119 +1,97 @@ -import type { IServiceEvent } from '@openpanel/core'; -import { useEffect, useMemo, useRef } from 'react'; -import { BrowserChrome } from './browser-chrome'; -import { getEventOffsetMs } from './replay-utils'; -import { - useCurrentTime, - useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item'; +import { useEffect, useRef } from 'react'; +import { useReplayContext } from './replay-context'; +import { ReplayEventItem, type ReplayItemState } from './replay-event-item'; +import type { EventWithOffset } from './replay-utils'; import { ScrollArea } from '@/components/ui/scroll-area'; -type EventWithOffset = { event: IServiceEvent; offsetMs: number }; +// After the user scrolls the feed by hand, leave it alone for a moment. +const MANUAL_SCROLL_PAUSE_MS = 4000; +const SKELETON_ROWS = Array.from({ length: 6 }, (_, index) => ({ + id: `skeleton-${index}`, + width: 40 + ((index * 17) % 45), +})); + +function getState(index: number, currentIndex: number): ReplayItemState { + if (index === currentIndex) { + return 'current'; + } + return index < currentIndex ? 'past' : 'future'; +} export function ReplayEventFeed({ events, - replayLoading, + currentIndex, + isLoading, }: { - events: IServiceEvent[]; - replayLoading: boolean; + events: EventWithOffset[]; + currentIndex: number; + isLoading: boolean; }) { - const { startTime, isReady, seek } = useReplayContext(); - const currentTime = useCurrentTime(100); + const { seek } = useReplayContext(); const viewportRef = useRef(null); - const prevCountRef = useRef(0); - - // Pre-sort events by offset once when events/startTime changes. - // This is the expensive part — done once, not on every tick. - const sortedEvents = useMemo(() => { - if (startTime == null || !isReady) { - return []; - } - return events - .map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) })) - .filter(({ offsetMs }) => offsetMs >= -10_000) - .sort((a, b) => a.offsetMs - b.offsetMs); - }, [events, startTime, isReady]); - - // Binary search to find how many events are visible at currentTime. - // O(log n) instead of O(n) filter on every tick. - const visibleCount = useMemo(() => { - let lo = 0; - let hi = sortedEvents.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) { - lo = mid + 1; - } else { - hi = mid; - } - } - return lo; - }, [sortedEvents, currentTime]); - - const visibleEvents = sortedEvents.slice(0, visibleCount); - const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null; + const lastManualScrollRef = useRef(0); useEffect(() => { const viewport = viewportRef.current; - if (!viewport || visibleEvents.length === 0) { + const isUserScrolling = + performance.now() - lastManualScrollRef.current < MANUAL_SCROLL_PAUSE_MS; + if (!viewport || isUserScrolling) { return; } - - const isNewItem = visibleEvents.length > prevCountRef.current; - prevCountRef.current = visibleEvents.length; - - requestAnimationFrame(() => { - viewport.scrollTo({ - top: viewport.scrollHeight, - behavior: isNewItem ? 'smooth' : 'instant', - }); - }); - }, [visibleEvents.length]); + const current = viewport.querySelector( + '[data-replay-state="current"]' + ); + if (!current) { + return; + } + const top = + current.offsetTop - viewport.clientHeight / 2 + current.clientHeight / 2; + viewport.scrollTo({ top, behavior: 'smooth' }); + }, [currentIndex]); return ( - Timeline} - url={false} - > - -
    - {visibleEvents.map(({ event, offsetMs }) => ( -
    +
    +
    + Activity + + {Math.max(0, currentIndex + 1)} / {events.length} + +
    + { + lastManualScrollRef.current = performance.now(); + }} + ref={viewportRef} + > +
    + {isLoading && + SKELETON_ROWS.map((row) => ( +
    +
    +
    +
    + ))} + {!isLoading && + events.map(({ event, offsetMs }, index) => ( seek(Math.max(0, offsetMs))} + key={event.id} + offsetMs={offsetMs} + onClick={() => seek(offsetMs)} + state={getState(index, currentIndex)} /> -
    - ))} - {!replayLoading && visibleEvents.length === 0 && ( -
    - Events will appear as the replay plays. + ))} + {!isLoading && events.length === 0 && ( +
    + No events were tracked during this recording.
    )} - {replayLoading && - Array.from({ length: 5 }).map((_, i) => ( -
    -
    -
    -
    -
    -
    -
    - ))}
    - +
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-event-item.tsx b/apps/start/src/components/sessions/replay/replay-event-item.tsx index cd23da7ef..a25312b33 100644 --- a/apps/start/src/components/sessions/replay/replay-event-item.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-item.tsx @@ -1,51 +1,101 @@ import type { IServiceEvent } from '@openpanel/core'; +import { formatDuration, getEventLabel } from './replay-utils'; import { EventIcon } from '@/components/events/event-icon'; -import { cn } from '@/lib/utils'; +import { cn } from '@/utils/cn'; -function formatTime(date: Date | string): string { - const d = date instanceof Date ? date : new Date(date); - const h = d.getHours().toString().padStart(2, '0'); - const m = d.getMinutes().toString().padStart(2, '0'); - const s = d.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; +const MAX_PROPERTY_CHIPS = 2; +const MAX_CHIP_VALUE_LENGTH = 24; + +export type ReplayItemState = 'past' | 'current' | 'future'; + +function getPropertyChips(event: IServiceEvent): string[] { + return Object.entries(event.properties ?? {}) + .filter( + ([key, value]) => + !key.startsWith('__') && + (typeof value === 'string' || typeof value === 'number') + ) + .slice(0, MAX_PROPERTY_CHIPS) + .map(([key, value]) => { + const text = String(value); + const shortened = + text.length > MAX_CHIP_VALUE_LENGTH + ? `${text.slice(0, MAX_CHIP_VALUE_LENGTH)}…` + : text; + return `${key}: ${shortened}`; + }); } export function ReplayEventItem({ event, - isCurrent, + offsetMs, + state, onClick, }: { event: IServiceEvent; - isCurrent: boolean; + offsetMs: number; + state: ReplayItemState; onClick: () => void; }) { - const displayName = - event.name === 'screen_view' && event.path - ? event.path - : event.name.replace(/_/g, ' '); + const isPageView = event.name === 'screen_view'; + const chips = isPageView ? [] : getPropertyChips(event); return ( ); } diff --git a/apps/start/src/components/sessions/replay/replay-player.tsx b/apps/start/src/components/sessions/replay/replay-player.tsx index 056da91aa..d25405528 100644 --- a/apps/start/src/components/sessions/replay/replay-player.tsx +++ b/apps/start/src/components/sessions/replay/replay-player.tsx @@ -1,44 +1,149 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context'; -import { useReplayContext } from '@/components/sessions/replay/replay-context'; +import { LockIcon } from 'lucide-react'; +import { + type ReactNode, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + DEFAULT_SKIP_INACTIVE, + type ReplayPlayerInstance, + SPEED_OPTIONS, + useReplayContext, +} from './replay-context'; +import { getRecordedDimensions, type ReplayRecordEvent } from './replay-utils'; +import { cn } from '@/utils/cn'; import 'rrweb-player/dist/style.css'; -/** rrweb meta event (type 4) carries the recorded viewport size */ -function getRecordedDimensions( - events: Array<{ type: number; data: unknown }> -): { width: number; height: number } | null { - const meta = events.find((e) => e.type === 4); - if ( - meta && - typeof meta.data === 'object' && - meta.data !== null && - 'width' in meta.data && - 'height' in meta.data - ) { - const { width, height } = meta.data as { width: number; height: number }; - if (width > 0 && height > 0) { - return { width, height }; - } +const FALLBACK_ASPECT_RATIO = 16 / 9; +const STAGE_PADDING = 24; +const BROWSER_BAR_HEIGHT = 36; +const PHONE_BEZEL = 10; +const PHONE_URL_HEIGHT = 36; +const MOUSE_TAIL = { + duration: 600, + lineCap: 'round', + lineWidth: 3, + strokeStyle: 'rgba(34, 102, 236, 0.45)', +}; + +type FrameKind = 'browser' | 'phone'; + +interface Size { + width: number; + height: number; +} + +function fitViewport(area: Size, aspectRatio: number, kind: FrameKind): Size { + const chromeWidth = kind === 'phone' ? PHONE_BEZEL * 2 : 0; + const chromeHeight = + kind === 'phone' ? PHONE_BEZEL * 2 + PHONE_URL_HEIGHT : BROWSER_BAR_HEIGHT; + const maxWidth = Math.max(0, area.width - STAGE_PADDING * 2 - chromeWidth); + const maxHeight = Math.max(0, area.height - STAGE_PADDING * 2 - chromeHeight); + const height = Math.min(maxHeight, maxWidth / aspectRatio); + return { + width: Math.round(height * aspectRatio), + height: Math.round(height), + }; +} + +/** + * Height a stage needs to show a wide recording edge to edge, as CSS against + * the stage's container width; tall recordings are bounded by the stage's max. + */ +export function getStageFitHeight(events: ReplayRecordEvent[]): string { + const recorded = getRecordedDimensions(events); + const aspectRatio = recorded + ? recorded.width / recorded.height + : FALLBACK_ASPECT_RATIO; + if (aspectRatio < 1) { + return '100vh'; } - return null; + const verticalChrome = STAGE_PADDING * 2 + BROWSER_BAR_HEIGHT; + return `calc((100cqw - ${STAGE_PADDING * 2}px) / ${aspectRatio.toFixed(4)} + ${verticalChrome}px)`; +} + +function BrowserFrame({ + url, + viewport, + children, + width, +}: { + url: ReactNode; + viewport: string | null; + children: ReactNode; + width: number; +}) { + return ( +
    +
    +
    + + + +
    +
    + + {url} +
    + {viewport && ( + + {viewport} + + )} +
    + {children} +
    + ); } -function calcDimensions( - containerWidth: number, - aspectRatio: number -): { width: number; height: number } { - const maxHeight = window.innerHeight * 0.7; - const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight); - const width = Math.min(containerWidth, Math.round(height * aspectRatio)); - return { width, height }; +function PhoneFrame({ + url, + children, +}: { + url: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    +
    + + {url} +
    +
    +
    +
    {children}
    +
    +
    + ); } export function ReplayPlayer({ events, + url, + overlay, }: { - events: Array<{ type: number; data: unknown; timestamp: number }>; + events: ReplayRecordEvent[]; + url: ReactNode; + overlay?: ReactNode; }) { + const areaRef = useRef(null); const containerRef = useRef(null); const playerRef = useRef(null); const { @@ -46,78 +151,84 @@ export function ReplayPlayer({ onPlayerDestroy, setCurrentTime, setIsPlaying, - setDuration, + setIsSkipping, } = useReplayContext(); const [importError, setImportError] = useState(false); + const [size, setSize] = useState(null); + const sizeRef = useRef(null); - const recordedDimensions = useMemo( - () => getRecordedDimensions(events), - [events] - ); + const recorded = useMemo(() => getRecordedDimensions(events), [events]); + const aspectRatio = recorded + ? recorded.width / recorded.height + : FALLBACK_ASPECT_RATIO; + const frameKind: FrameKind = aspectRatio < 1 ? 'phone' : 'browser'; - useEffect(() => { - if (!(events.length && containerRef.current)) { + useLayoutEffect(() => { + const area = areaRef.current; + if (!area) { return; } + const measure = () => { + const next = fitViewport( + { width: area.clientWidth, height: area.clientHeight }, + aspectRatio, + frameKind + ); + sizeRef.current = next; + setSize(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(area); + return () => observer.disconnect(); + }, [aspectRatio, frameKind]); - // Clear any previous player DOM - containerRef.current.innerHTML = ''; + const hasSize = size !== null && size.width > 0; + useEffect(() => { + const container = containerRef.current; + const initialSize = sizeRef.current; + if (!(events.length && container && hasSize && initialSize)) { + return; + } + + container.innerHTML = ''; let mounted = true; let player: ReplayPlayerInstance | null = null; let handleVisibilityChange: (() => void) | null = null; - const aspectRatio = recordedDimensions - ? recordedDimensions.width / recordedDimensions.height - : 16 / 9; - - const { width, height } = calcDimensions( - containerRef.current.offsetWidth, - aspectRatio - ); - import('rrweb-player') .then((module) => { - if (!(containerRef.current && mounted)) { + if (!mounted) { return; } - - const PlayerConstructor = module.default; - player = new PlayerConstructor({ - target: containerRef.current, + player = new module.default({ + target: container, props: { events, - width, - height, + width: initialSize.width, + height: initialSize.height, autoPlay: false, showController: false, - speedOption: [0.5, 1, 2, 4, 8], - skipInactive: false, + speedOption: [...SPEED_OPTIONS], + skipInactive: DEFAULT_SKIP_INACTIVE, + mouseTail: MOUSE_TAIL, }, - }) as ReplayPlayerInstance; - + }) as unknown as ReplayPlayerInstance; playerRef.current = player; - // Track play state from replayer (getMetaData() does not expose isPlaying) let playingState = false; - - // Wire rrweb's built-in event emitter — no RAF loop needed. - // Note: rrweb-player does NOT emit ui-update-duration; duration is - // read from getMetaData() on init and after each addEvent batch. player.addEventListener('ui-update-current-time', (e) => { - const t = e.payload as number; - setCurrentTime(t); + setCurrentTime(e.payload as number); }); - player.addEventListener('ui-update-player-state', (e) => { - const playing = e.payload === 'playing'; - playingState = playing; - setIsPlaying(playing); + playingState = e.payload === 'playing'; + setIsPlaying(playingState); }); + player.addEventListener('skip-start', () => setIsSkipping(true)); + player.addEventListener('skip-end', () => setIsSkipping(false)); - // Pause on tab hide; resume on show (prevents timer drift). - // getMetaData() does not expose isPlaying, so we use playingState - // kept in sync by ui-update-player-state above. + // Pause while the tab is hidden so the replay clock does not drift. let wasPlaying = false; handleVisibilityChange = () => { if (!player) { @@ -135,12 +246,7 @@ export function ReplayPlayer({ }; document.addEventListener('visibilitychange', handleVisibilityChange); - // Notify context — marks isReady = true and sets initial duration - const meta = player.getMetaData(); - if (meta.totalTime > 0) { - setDuration(meta.totalTime); - } - onPlayerReady(player, meta.startTime); + onPlayerReady(player, events); }) .catch(() => { if (mounted) { @@ -148,61 +254,76 @@ export function ReplayPlayer({ } }); - const onWindowResize = () => { - if (!(containerRef.current && mounted && playerRef.current?.$set)) { - return; - } - const { width: w, height: h } = calcDimensions( - containerRef.current.offsetWidth, - aspectRatio - ); - playerRef.current.$set({ width: w, height: h }); - }; - window.addEventListener('resize', onWindowResize); - return () => { mounted = false; - window.removeEventListener('resize', onWindowResize); if (handleVisibilityChange) { document.removeEventListener( 'visibilitychange', handleVisibilityChange ); } - if (player) { - player.pause(); - } - if (containerRef.current) { - containerRef.current.innerHTML = ''; - } + player?.pause(); + container.innerHTML = ''; playerRef.current = null; onPlayerDestroy(); }; }, [ events, - recordedDimensions, + hasSize, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, - setDuration, + setIsSkipping, ]); + useEffect(() => { + if (!(size && playerRef.current?.$set)) { + return; + } + playerRef.current.$set({ width: size.width, height: size.height }); + playerRef.current.triggerResize(); + }, [size]); + if (importError) { return ( -
    +
    Failed to load replay player.
    ); } + const viewport = ( +
    +
    + {overlay} +
    + ); + + const viewportLabel = recorded + ? `${recorded.width} × ${recorded.height}` + : null; + return ( -
    -
    +
    + {hasSize && + (frameKind === 'phone' ? ( + {viewport} + ) : ( + + {viewport} + + ))}
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index 3e84a96f5..6132f8637 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -1,21 +1,213 @@ -import type { IServiceEvent } from '@openpanel/core'; -import { AnimatePresence, motion } from 'framer-motion'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ReplayPlayPauseButton } from './replay-controls'; -import { formatDuration, getEventOffsetMs } from './replay-utils'; -import { EventIcon } from '@/components/events/event-icon'; +import { useReplayContext } from './replay-context'; import { - useCurrentTime, - useReplayContext, -} from '@/components/sessions/replay/replay-context'; + detectRageClicks, + type EventWithOffset, + formatDuration, + getActivityHistogram, + getEventLabel, + type PageSegment, +} from './replay-utils'; +import { EventIcon } from '@/components/events/event-icon'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { cn } from '@/utils/cn'; + +const HISTOGRAM_BUCKETS = 140; +const MARKER_GROUP_PX = 22; +const MIN_LABEL_WIDTH_PX = 48; +const FALLBACK_TRACK_WIDTH = 600; +const MIN_BAR_HEIGHT = 0.08; + +function percent(timeMs: number, duration: number): number { + return Math.max(0, Math.min(100, (timeMs / duration) * 100)); +} -export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { +function groupMarkers( + events: EventWithOffset[], + duration: number, + trackWidth: number +) { + const thresholdMs = (MARKER_GROUP_PX / trackWidth) * duration; + const groups: EventWithOffset[][] = []; + for (const item of events) { + const last = groups.at(-1); + // Measured from the group's first event, which is where its marker sits. + const anchor = last?.[0]; + if (last && anchor && item.offsetMs - anchor.offsetMs <= thresholdMs) { + last.push(item); + } else { + groups.push([item]); + } + } + return groups; +} + +function useElementWidth(ref: React.RefObject) { + const [width, setWidth] = useState(FALLBACK_TRACK_WIDTH); + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + const observer = new ResizeObserver(() => { + setWidth(element.clientWidth || FALLBACK_TRACK_WIDTH); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return width; +} + +function EventMarkers({ + events, + duration, + trackWidth, +}: { + events: EventWithOffset[]; + duration: number; + trackWidth: number; +}) { + const { seek } = useReplayContext(); + // Screen views already have their own band; markers are for what happened on a page. + const markers = useMemo( + () => + groupMarkers( + events.filter( + ({ event, offsetMs }) => + event.name !== 'screen_view' && + offsetMs >= 0 && + offsetMs <= duration + ), + duration, + trackWidth + ), + [events, duration, trackWidth] + ); + + return ( +
    + {markers.map((group) => { + const first = group[0]!; + return ( + + + + + + {group.map(({ event, offsetMs }) => ( +
    + + {getEventLabel(event)} + + {formatDuration(offsetMs)} + +
    + ))} +
    +
    + ); + })} +
    + ); +} + +function PageBands({ + segments, + duration, + trackWidth, + currentSegmentId, +}: { + segments: PageSegment[]; + duration: number; + trackWidth: number; + currentSegmentId: string | null; +}) { + const { seek } = useReplayContext(); + return ( +
    + {segments.map((segment) => { + const left = percent(segment.startMs, duration); + const width = percent(segment.endMs, duration) - left; + const isCurrent = segment.id === currentSegmentId; + const showLabel = (width / 100) * trackWidth >= MIN_LABEL_WIDTH_PX; + return ( + + + + + + {segment.path} + + {formatDuration(segment.endMs - segment.startMs)} + + + + ); + })} +
    + ); +} + +function Waveform({ bars, className }: { bars: number[]; className: string }) { + return ( +
    + {bars.map((value, index) => ( +
    + ))} +
    + ); +} + +export function ReplayTimeline({ + events, + segments, + currentSegmentId, +}: { + events: EventWithOffset[]; + segments: PageSegment[]; + currentSegmentId: string | null; +}) { const { currentTimeRef, duration, @@ -23,276 +215,203 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { isReady, seek, subscribeToCurrentTime, + activity, } = useReplayContext(); - // currentTime as React state is only needed for keyboard seeks (low frequency). - // The progress bar and thumb are updated directly via DOM refs to avoid re-renders. - const currentTime = useCurrentTime(250); const trackRef = useRef(null); - const progressBarRef = useRef(null); - const thumbRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [hoverInfo, setHoverInfo] = useState<{ - pct: number; - timeMs: number; - } | null>(null); - const dragCleanupRef = useRef<(() => void) | null>(null); - const rafDragRef = useRef(null); + const scrubPointerRef = useRef(null); + const playedRef = useRef(null); + const playheadRef = useRef(null); + const [hover, setHover] = useState<{ pct: number; timeMs: number } | null>( + null + ); + const trackWidth = useElementWidth(trackRef); - // Clean up any in-progress drag listeners when the component unmounts - useEffect(() => { - return () => { - dragCleanupRef.current?.(); - }; - }, []); + const bars = useMemo( + () => + startTime == null + ? [] + : getActivityHistogram( + activity.interactions, + startTime, + duration, + HISTOGRAM_BUCKETS + ), + [activity.interactions, startTime, duration] + ); - // Update progress bar and thumb directly via DOM on every tick — no React re-render. + const rageClicks = useMemo( + () => + startTime == null + ? [] + : detectRageClicks(activity.clicks, startTime).filter( + (rage) => rage.offsetMs >= 0 && rage.offsetMs <= duration + ), + [activity.clicks, startTime, duration] + ); + + // The playhead moves on every tick; write to the DOM instead of re-rendering. useEffect(() => { if (duration <= 0) { return; } - return subscribeToCurrentTime((t) => { - const pct = Math.max(0, Math.min(100, (t / duration) * 100)); - if (progressBarRef.current) { - progressBarRef.current.style.width = `${pct}%`; + const paint = (t: number) => { + const pct = `${percent(t, duration)}%`; + if (playedRef.current) { + playedRef.current.style.clipPath = `inset(0 calc(100% - ${pct}) 0 0)`; } - if (thumbRef.current) { - thumbRef.current.style.left = `calc(${pct}% - 8px)`; + if (playheadRef.current) { + playheadRef.current.style.left = pct; } - }); - }, [subscribeToCurrentTime, duration]); + }; + paint(currentTimeRef.current); + return subscribeToCurrentTime(paint); + }, [subscribeToCurrentTime, duration, currentTimeRef]); const getTimeFromClientX = useCallback( (clientX: number) => { - if (!trackRef.current || duration <= 0) { - return null; - } - const rect = trackRef.current.getBoundingClientRect(); - if (rect.width <= 0 || !Number.isFinite(rect.width)) { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || duration <= 0) { return null; } - const x = clientX - rect.left; - const pct = Math.max(0, Math.min(1, x / rect.width)); + const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return { pct, timeMs: pct * duration }; }, [duration] ); - const handleTrackMouseMove = useCallback( - (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('[data-timeline-event]')) { - setHoverInfo(null); - return; - } - const info = getTimeFromClientX(e.clientX); - if (info) { - setHoverInfo(info); - } - }, - [getTimeFromClientX] - ); - - const handleTrackMouseLeave = useCallback(() => { - if (!isDragging) { - setHoverInfo(null); - } - }, [isDragging]); - - const handleTrackMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle direct clicks on the track, not on child elements like the thumb - if ( - e.target !== trackRef.current && - !(e.target as HTMLElement).closest('.replay-track-bg') - ) { + const startScrub = useCallback( + (e: React.PointerEvent) => { + // A pen and a finger can both be primary; only one scrub at a time. + if (!e.isPrimary || scrubPointerRef.current !== null) { return; } - const info = getTimeFromClientX(e.clientX); - if (info) { - seek(info.timeMs); + const first = getTimeFromClientX(e.clientX); + if (first) { + seek(first.timeMs); } + const { pointerId } = e; + scrubPointerRef.current = pointerId; + const onMove = (move: PointerEvent) => { + if (move.pointerId !== pointerId) { + return; + } + const info = getTimeFromClientX(move.clientX); + if (info) { + setHover(info); + seek(info.timeMs); + } + }; + const onEnd = (end: PointerEvent) => { + if (end.pointerId !== pointerId) { + return; + } + scrubPointerRef.current = null; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onEnd); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onEnd); }, [getTimeFromClientX, seek] ); - const eventsWithOffset = useMemo( - () => - events - .map((ev) => ({ - event: ev, - offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0, - })) - .filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration), - [events, startTime, duration] - ); - - // Group events that are within 24px of each other on the track. - // We need the track width for pixel math — use a stable ref-based calculation. - const groupedEvents = useMemo(() => { - if (!eventsWithOffset.length || duration <= 0) { - return []; - } - - // Sort by offsetMs so we sweep left-to-right - const sorted = [...eventsWithOffset].sort( - (a, b) => a.offsetMs - b.offsetMs - ); - - // 24px in ms — recalculated from container width; fall back to 2% of duration - const trackWidth = trackRef.current?.offsetWidth ?? 600; - const thresholdMs = (24 / trackWidth) * duration; - - const groups: { items: typeof sorted; pct: number }[] = []; - for (const item of sorted) { - const last = groups[groups.length - 1]; - const lastPct = last - ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 - : Number.NEGATIVE_INFINITY; - const thisPct = (item.offsetMs / duration) * 100; - - if ( - last && - item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= - thresholdMs - ) { - last.items.push(item); - // Anchor the group at its first item's position - } else { - groups.push({ items: [item], pct: thisPct }); - } - // keep pct pointing at the first item (already set on push) - void lastPct; - } - - return groups; - }, [eventsWithOffset, duration]); - if (!isReady || duration <= 0) { - return null; + return
    ; } - const progressPct = Math.max( - 0, - Math.min(100, (currentTimeRef.current / duration) * 100) - ); + const hoverSegment = hover + ? segments.find( + (segment) => + hover.timeMs >= segment.startMs && hover.timeMs < segment.endMs + ) + : undefined; return ( - -
    - -
    + +
    + +
    { + const step = 5000; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + seek(currentTimeRef.current - step); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + seek(Math.min(duration, currentTimeRef.current + step)); + } + }} + onPointerDown={startScrub} + onPointerLeave={() => setHover(null)} + onPointerMove={(e) => setHover(getTimeFromClientX(e.clientX))} + ref={trackRef} + role="slider" + tabIndex={0} + > + +
    + +
    + + {rageClicks.map((rage) => ( + + +
    + +
    +
    + + Rage click + + {rage.clicks} clicks at {formatDuration(rage.offsetMs)} + + +
    + ))} + + {hover && ( +
    +
    + + {formatDuration(hover.timeMs)} + + {hoverSegment && ( + {hoverSegment.path} + )} +
    +
    + )} +
    { - const step = 5000; - if (e.key === 'ArrowLeft') { - e.preventDefault(); - seek(Math.max(0, currentTime - step)); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - seek(Math.min(duration, currentTime + step)); - } - }} - onMouseDown={handleTrackMouseDown} - onMouseLeave={handleTrackMouseLeave} - onMouseMove={handleTrackMouseMove} - ref={trackRef} - role="slider" - tabIndex={0} + className="pointer-events-none absolute -top-1 -bottom-1 z-30 w-0.5 -translate-x-1/2 rounded-full bg-foreground" + ref={playheadRef} > -
    -
    -
    -
    - - {hoverInfo && ( - -
    - - {formatDuration(hoverInfo.timeMs)} - - - )} - - {groupedEvents.map((group) => { - const first = group.items[0]!; - const isGroup = group.items.length > 1; - return ( - - - - - - {group.items.map(({ event: ev, offsetMs }) => ( -
    - - - {ev.name === 'screen_view' ? ev.path : ev.name} - - - {formatDuration(offsetMs)} - -
    - ))} -
    -
    - ); - })} +
    +
    ); diff --git a/apps/start/src/components/sessions/replay/replay-utils.ts b/apps/start/src/components/sessions/replay/replay-utils.ts index 9e0edd608..22115b7b6 100644 --- a/apps/start/src/components/sessions/replay/replay-utils.ts +++ b/apps/start/src/components/sessions/replay/replay-utils.ts @@ -1,14 +1,235 @@ import type { IServiceEvent } from '@openpanel/core'; +import { sortEventsAscending } from '../details/session-pages'; + +export interface ReplayRecordEvent { + type: number; + data: unknown; + timestamp: number; +} + +export interface EventWithOffset { + event: IServiceEvent; + offsetMs: number; +} + +export interface PageSegment { + id: string; + path: string; + origin: string; + startMs: number; + endMs: number; +} + +export interface RageClick { + offsetMs: number; + clicks: number; +} + +// rrweb's numeric enums (EventType, IncrementalSource, MouseInteractions). +const RRWEB_META = 4; +const RRWEB_INCREMENTAL = 3; +const SOURCE_MOUSE_MOVE = 1; +const SOURCE_MOUSE_INTERACTION = 2; +const SOURCE_SCROLL = 3; +const SOURCE_INPUT = 5; +const SOURCE_TOUCH_MOVE = 6; +const INTERACTION_CLICK = 2; +const USER_SOURCES = new Set([ + SOURCE_MOUSE_MOVE, + SOURCE_MOUSE_INTERACTION, + SOURCE_SCROLL, + SOURCE_INPUT, + SOURCE_TOUCH_MOVE, +]); + +// Three clicks inside a second on roughly the same spot is the usual +// definition of a rage click (frustration with something that does not respond). +const RAGE_CLICK_MIN_CLICKS = 3; +const RAGE_CLICK_WINDOW_MS = 1000; +const RAGE_CLICK_RADIUS_PX = 30; + +// Server events can land slightly before the recorder's first frame. +const EARLY_EVENT_TOLERANCE_MS = 10_000; + +function toMs(date: Date | string): number { + return date instanceof Date ? date.getTime() : new Date(date).getTime(); +} export function getEventOffsetMs( event: IServiceEvent, startTime: number ): number { - const t = - typeof event.createdAt === 'object' && event.createdAt instanceof Date - ? event.createdAt.getTime() - : new Date(event.createdAt).getTime(); - return t - startTime; + return toMs(event.createdAt) - startTime; +} + +export function sortEventsByOffset( + events: IServiceEvent[], + startTime: number +): EventWithOffset[] { + const withOffsets = sortEventsAscending(events).map((event) => ({ + event, + offsetMs: getEventOffsetMs(event, startTime), + })); + const isEarly = ({ offsetMs }: EventWithOffset) => + offsetMs < -EARLY_EVENT_TOLERANCE_MS; + // The page the recording opened on can be viewed long before the recorder starts. + const openingView = withOffsets + .filter((item) => isEarly(item) && item.event.name === 'screen_view') + .at(-1); + return withOffsets.filter((item) => !isEarly(item) || item === openingView); +} + +/** Index of the last item at or before `timeMs`, or -1 when none has happened yet. */ +export function findLastIndexAtTime( + sorted: EventWithOffset[], + timeMs: number +): number { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if ((sorted[mid]?.offsetMs ?? 0) <= timeMs) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo - 1; +} + +/** Each screen view owns the stretch of the recording until the next one. */ +export function getPageSegments( + sorted: EventWithOffset[], + durationMs: number +): PageSegment[] { + const views = sorted.filter(({ event }) => event.name === 'screen_view'); + return views.map(({ event, offsetMs }, index) => { + const next = views[index + 1]; + return { + id: event.id, + path: event.path || '/', + origin: event.origin ?? '', + startMs: Math.max(0, offsetMs), + endMs: Math.min(durationMs, next ? next.offsetMs : durationMs), + }; + }); +} + +/** Viewport the recording started with, carried by rrweb's meta event. */ +export function getRecordedDimensions( + events: ReplayRecordEvent[] +): { width: number; height: number } | null { + const meta = events.find((event) => event.type === RRWEB_META); + if ( + meta && + typeof meta.data === 'object' && + meta.data !== null && + 'width' in meta.data && + 'height' in meta.data + ) { + const { width, height } = meta.data as { width: number; height: number }; + if (width > 0 && height > 0) { + return { width, height }; + } + } + return null; +} + +interface IncrementalData { + source?: number; + type?: number; + x?: number; + y?: number; +} + +function getIncrementalData(event: ReplayRecordEvent): IncrementalData | null { + if (event.type !== RRWEB_INCREMENTAL) { + return null; + } + if (typeof event.data !== 'object' || event.data === null) { + return null; + } + return event.data as IncrementalData; +} + +export function isUserInteraction(event: ReplayRecordEvent): boolean { + const data = getIncrementalData(event); + return data?.source !== undefined && USER_SOURCES.has(data.source); +} + +export function getClick( + event: ReplayRecordEvent +): { timestamp: number; x: number; y: number } | null { + const data = getIncrementalData(event); + const isClick = + data?.source === SOURCE_MOUSE_INTERACTION && + data.type === INTERACTION_CLICK; + if (!isClick) { + return null; + } + return { timestamp: event.timestamp, x: data.x ?? 0, y: data.y ?? 0 }; +} + +/** Clicks elsewhere in between do not break a burst on one spot. */ +export function detectRageClicks( + clicks: { timestamp: number; x: number; y: number }[], + startTime: number +): RageClick[] { + const sorted = [...clicks].sort((a, b) => a.timestamp - b.timestamp); + const counted = new Set(); + const rageClicks: RageClick[] = []; + for (const [index, first] of sorted.entries()) { + if (counted.has(index)) { + continue; + } + const burst = [index]; + for (let next = index + 1; next < sorted.length; next++) { + const click = sorted[next]!; + if (click.timestamp - first.timestamp > RAGE_CLICK_WINDOW_MS) { + break; + } + const samePlace = + Math.hypot(click.x - first.x, click.y - first.y) <= + RAGE_CLICK_RADIUS_PX; + if (samePlace && !counted.has(next)) { + burst.push(next); + } + } + if (burst.length >= RAGE_CLICK_MIN_CLICKS) { + for (const member of burst) { + counted.add(member); + } + rageClicks.push({ + offsetMs: first.timestamp - startTime, + clicks: burst.length, + }); + } + } + return rageClicks; +} + +/** Interaction counts per bucket, normalised to 0..1 for drawing. */ +export function getActivityHistogram( + timestamps: number[], + startTime: number, + durationMs: number, + buckets: number +): number[] { + const counts = new Array(buckets).fill(0); + if (durationMs <= 0) { + return counts; + } + for (const timestamp of timestamps) { + const ratio = (timestamp - startTime) / durationMs; + if (ratio < 0 || ratio > 1) { + continue; + } + const bucket = Math.min(buckets - 1, Math.floor(ratio * buckets)); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + const max = Math.max(...counts, 1); + // Square root keeps a single burst of mouse moves from flattening the rest. + return counts.map((count) => Math.sqrt(count / max)); } /** Format a duration in milliseconds as M:SS */ @@ -18,3 +239,10 @@ export function formatDuration(ms: number): string { const s = totalSeconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } + +export function getEventLabel(event: IServiceEvent): string { + if (event.name === 'screen_view' && event.path) { + return event.path; + } + return event.name.replace(/_/g, ' '); +} diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts new file mode 100644 index 000000000..c0ea62f85 --- /dev/null +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -0,0 +1,98 @@ +import type { IServiceEvent } from '@openpanel/core'; +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; +import type { useTRPC } from '@/integrations/trpc/react'; + +// The list endpoint pages 50 events at a time; a session past this many pages +// is a bot or a tab left open, and the page would not be readable anyway. +const MAX_SESSION_EVENT_PAGES = 40; +const EVENT_PAGE_SIZE = 50; +export const MAX_SESSION_EVENTS = MAX_SESSION_EVENT_PAGES * EVENT_PAGE_SIZE; + +type Trpc = ReturnType; + +export function sessionEventsQueryOptions( + trpc: Trpc, + { projectId, sessionId }: { projectId: string; sessionId: string } +) { + return trpc.event.events.infiniteQueryOptions( + { projectId, sessionId, filters: [], columnVisibility: {} }, + { + getNextPageParam: (lastPage, pages) => { + const isLastPage = + lastPage.data.length === 0 || pages.length >= MAX_SESSION_EVENT_PAGES; + return isLastPage ? undefined : lastPage.meta.next; + }, + } + ); +} + +/** + * `loading`: later pages are still coming. `failed`: a page failed and can be + * retried. `capped`: the session has more events than the page will load. + */ +export type SessionEventsStatus = 'complete' | 'loading' | 'failed' | 'capped'; + +interface SessionEvents { + events: IServiceEvent[]; + status: SessionEventsStatus; + retry: () => void; +} + +function getStatus({ + isFetchNextPageError, + hasNextPage, + isCapped, +}: { + isFetchNextPageError: boolean; + hasNextPage: boolean; + isCapped: boolean; +}): SessionEventsStatus { + if (isFetchNextPageError) { + return 'failed'; + } + if (hasNextPage) { + return 'loading'; + } + return isCapped ? 'capped' : 'complete'; +} + +/** Every event in the session, loading the remaining pages in the background. */ +export function useSessionEvents( + trpc: Trpc, + params: { projectId: string; sessionId: string } +): SessionEvents { + const query = useSuspenseInfiniteQuery( + sessionEventsQueryOptions(trpc, params) + ); + const { + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = query; + + // A failed page stops the chain; the page shows what loaded rather than retrying forever. + useEffect(() => { + if (hasNextPage && !(isFetchingNextPage || isFetchNextPageError)) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); + + const events = useMemo( + () => query.data.pages.flatMap((page) => page.data), + [query.data] + ); + const pageCount = query.data.pages.length; + // A short last page means the session ended inside the limit. + const isLastPageFull = + (query.data.pages.at(-1)?.data.length ?? 0) >= EVENT_PAGE_SIZE; + const isCapped = + !hasNextPage && pageCount >= MAX_SESSION_EVENT_PAGES && isLastPageFull; + + return { + events, + status: getStatus({ isFetchNextPageError, hasNextPage, isCapped }), + retry: () => fetchNextPage(), + }; +} 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 0f066c4a7..cf5490176 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -1,32 +1,52 @@ -import type { IServiceEvent, IServiceSession } from '@openpanel/core'; +import type { IServiceProfile, IServiceSession } from '@openpanel/core'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; -import { ZapIcon } from 'lucide-react'; -import { formatCost, formatTokens } from '@/components/ai/ai-format'; +import { + ArrowRightIcon, + CalendarIcon, + ClockIcon, + CopyIcon, + Loader2Icon, + VideoIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; import { AiUsageWidget } from '@/components/ai/ai-usage-widget'; -import { EmptyState } from '@/components/empty-state'; -import { EventIcon } from '@/components/events/event-icon'; -import { useEventDrawer } from '@/components/events/use-event-drawer'; import FullPageLoadingState from '@/components/full-page-loading-state'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; +import { MostEvents } from '@/components/profiles/most-events'; import { ProfileAvatar } from '@/components/profiles/profile-avatar'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { SessionJourney } from '@/components/sessions/details/session-journey'; +import { SessionMetrics } from '@/components/sessions/details/session-metrics'; +import { groupEventsByPage } from '@/components/sessions/details/session-pages'; +import { SessionProperties } from '@/components/sessions/details/session-properties'; +import { SessionTimeline } from '@/components/sessions/details/session-timeline'; import { ReplayShell } from '@/components/sessions/replay'; -import { KeyValueGrid } from '@/components/ui/key-value-grid'; +import { + MAX_SESSION_EVENTS, + type SessionEventsStatus, + sessionEventsQueryOptions, + useSessionEvents, +} from '@/components/sessions/use-session-events'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; import { Widget, WidgetBody, WidgetHead, WidgetTitle, } from '@/components/widget'; -import { useNumber } from '@/hooks/use-numer-formatter'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; import { useEntityPageContext } from '@/hooks/use-page-context-helpers'; import { useTRPC } from '@/integrations/trpc/react'; +import { clipboard } from '@/utils/clipboard'; import { formatDateTime } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; import { createProjectTitle } from '@/utils/title'; +const MS_PER_SECOND = 1000; + export const Route = createFileRoute( '/_app/$organizationId/$projectId/sessions_/$sessionId' )({ @@ -39,12 +59,10 @@ export const Route = createFileRoute( projectId: params.projectId, }) ), - context.queryClient.prefetchQuery( - context.trpc.event.events.queryOptions({ + context.queryClient.prefetchInfiniteQuery( + sessionEventsQueryOptions(context.trpc, { projectId: params.projectId, sessionId: params.sessionId, - filters: [], - columnVisibility: {}, }) ), ]); @@ -55,100 +73,181 @@ export const Route = createFileRoute( pendingComponent: FullPageLoadingState, }); -/** `model · tokens · cost` from the flat `ai_*` properties an AI event carries. */ -function aiEventSummary(properties: Record): string { - const parts = [ - properties.ai_name, - properties.ai_model, - properties.ai_total_tokens - ? `${formatTokens(Number(properties.ai_total_tokens))} tokens` - : null, - properties.ai_cost ? formatCost(Number(properties.ai_cost)) : null, - properties.ai_is_error === 'true' ? 'error' : null, - ].filter((part): part is string => typeof part === 'string' && part !== ''); - return parts.join(' · '); +function HeaderFact({ + icon, + children, +}: { + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
    + {icon} + {children} +
    + ); } -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, - }; +function SessionFacts({ session }: { session: IServiceSession }) { + const location = [session.city, session.country].filter(Boolean).join(', '); + return ( +
    + }> + {formatDateTime(session.createdAt)} + + }> + {fancyMinutes(session.duration / MS_PER_SECOND)} + + {location && ( + }> + {location} + + )} + {session.referrerName && ( + }> + {session.referrerName} + + )} + {session.device && ( + }> + {session.device} + + )} + {session.os && ( + }> + {session.os} + + )} + {session.browser && ( + }> + {session.browser} + + )} +
    + ); } -function VisitedRoutes({ paths }: { paths: string[] }) { - const counted = paths.reduce>((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; +// Everything below is built from the loaded events, except the metric totals. +const EVENT_DERIVED_SECTIONS = + 'the journey, timeline, unique pages, popular events and replay activity'; - if (sorted.length === 0) { +function SessionEventsNotice({ + status, + onRetry, +}: { + status: SessionEventsStatus; + onRetry: () => void; +}) { + if (status === 'complete') { return null; } + if (status === 'loading') { + return ( +
    + + Loading the rest of this session's events… +
    + ); + } + return ( + + + {status === 'failed' + ? `Some of this session's events could not be loaded, so ${EVENT_DERIVED_SECTIONS} are incomplete.` + : `This session has more than ${MAX_SESSION_EVENTS.toLocaleString()} events. Only the latest ${MAX_SESSION_EVENTS.toLocaleString()} are used for ${EVENT_DERIVED_SECTIONS}.`} + + {status === 'failed' && ( + + )} + + ); +} + +function VisitorCard({ + profile, + session, + organizationId, + projectId, +}: { + profile: IServiceProfile | null | undefined; + session: IServiceSession; + organizationId: string; + projectId: string; +}) { + const isIdentified = session.profileId !== session.deviceId; + const name = profile && isIdentified ? getProfileName(profile, false) : null; return ( - + - Visited pages + Visitor -
    - {sorted.map(([path, count]) => ( -
    -
    -
    - {path} - {count} -
    + + + +
    + + {name ?? 'Anonymous visitor'} + + + {profile?.email || session.profileId} +
    - ))} -
    + + + ); } -function EventDistribution({ events }: { events: IServiceEvent[] }) { - const counted = events.reduce>((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; +function SessionGroups({ + groupIds, + organizationId, + projectId, +}: { + groupIds: string[]; + organizationId: string; + projectId: string; +}) { + const trpc = useTRPC(); + const { data: groups } = useQuery({ + ...trpc.group.listByIds.queryOptions({ projectId, ids: groupIds }), + enabled: groupIds.length > 0, + }); - if (sorted.length === 0) { + if (!groups?.length) { return null; } return ( - Event distribution + Groups -
    - {sorted.map(([name, count]) => ( -
    -
    -
    - {name.replace(/_/g, ' ')} - {count} +
    + {groups.map((group) => ( + +
    + {group.name} + + {group.id} +
    -
    + + {group.type} + + ))}
    @@ -158,8 +257,6 @@ function EventDistribution({ events }: { events: IServiceEvent[] }) { function Component() { const { projectId, sessionId, organizationId } = Route.useParams(); const trpc = useTRPC(); - const { openEvent } = useEventDrawer(); - const number = useNumber(); const { data: session } = useSuspenseQuery( trpc.session.byId.queryOptions({ sessionId, projectId }) @@ -167,19 +264,11 @@ function Component() { useEntityPageContext('session', { sessionId }); - 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 { + events, + status: eventsStatus, + retry: retryEvents, + } = useSessionEvents(trpc, { projectId, sessionId }); const { data: profile } = useSuspenseQuery( trpc.profile.byId.queryOptions({ @@ -188,261 +277,103 @@ function Component() { }) ); - const { data: sessionGroups } = useQuery({ - ...trpc.group.listByIds.queryOptions({ - projectId, - ids: session.groups ?? [], - }), - enabled: (session.groups?.length ?? 0) > 0, - }); + const visits = useMemo( + () => groupEventsByPage(events, session.endedAt), + [events, session.endedAt] + ); + const eventCounts = useMemo(() => { + const counts = new Map(); + for (const event of events) { + counts.set(event.name, (counts.get(event.name) ?? 0) + 1); + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); + }, [events]); - const fakeEvent = sessionToFakeEvent(session); + const isIdentified = session.profileId !== session.deviceId; + const title = + profile && isIdentified + ? getProfileName(profile, false) + : 'Anonymous visitor'; 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.hasReplay && ( + + )} + + + } + title={ +
    + + {title} + + Session + +
    + } + > +
    - {session.hasReplay && ( - - )} + -
    -
    - - - Session info - - 0 - ? [ - { - name: 'revenue', - value: number.currency(session.revenue / 100), - }, - ] - : []), - { 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 }] - : []), - ]} - /> - + - {isIdentified && profile && ( - - - Profile - - - - -
    - - {getProfileName(profile, false) ?? session.profileId} - - {profile.email && ( - - {profile.email} - - )} -
    - -
    -
    - )} - - {sessionGroups && sessionGroups.length > 0 && ( - - - Groups - - - {sessionGroups.map((group) => ( - -
    - {group.name} - - {group.id} - -
    - - {group.type} - - - ))} -
    -
    - )} + {session.hasReplay && ( + + )} +
    + - e.name === 'screen_view' && e.path) - .map((e) => e.path)} - /> - - -
    - -
    - - - Events - -
    - {events.map((event) => ( - - ))} - {events.length === 0 && ( - - )} -
    -
    +
    + +
    diff --git a/apps/start/src/types/rrweb-player.d.ts b/apps/start/src/types/rrweb-player.d.ts index 1d404666b..eb0eac778 100644 --- a/apps/start/src/types/rrweb-player.d.ts +++ b/apps/start/src/types/rrweb-player.d.ts @@ -7,6 +7,14 @@ declare module 'rrweb-player' { showController?: boolean; speedOption?: number[]; skipInactive?: boolean; + mouseTail?: + | boolean + | { + duration?: number; + lineCap?: string; + lineWidth?: number; + strokeStyle?: string; + }; } interface RrwebPlayerOptions { @@ -30,6 +38,8 @@ declare module 'rrweb-player' { toggle: () => void; goto: (timeOffset: number, play?: boolean) => void; setSpeed: (speed: number) => void; + toggleSkipInactive: () => void; + triggerResize: () => void; getMetaData: () => RrwebPlayerMetaData; getReplayer: () => RrwebReplayer; addEvent: (event: { diff --git a/packages/core/src/modules/event/src/sql.test.ts b/packages/core/src/modules/event/src/sql.test.ts index 0c3b7738f..081aec704 100644 --- a/packages/core/src/modules/event/src/sql.test.ts +++ b/packages/core/src/modules/event/src/sql.test.ts @@ -81,12 +81,14 @@ describe('eventListQuery', () => { }).toStatement(); expect(query).toContain( - 'WHERE created_at >= toDateTime64({p1:String}, 3) - INTERVAL {p2:Float64} DAY AND created_at < {p3:String} AND project_id = {p4:String}' + 'WHERE created_at >= toDateTime64({p1:String}, 3) - INTERVAL {p2:Float64} DAY AND created_at < toDateTime64({p3:String}, 3) AND project_id = {p4:String}' ); + // The upper bound keeps the millisecond: a whole-second bound would skip + // the unread events that share the last row's second. expect(query_params).toMatchObject({ p1: '2026-09-01 12:34:56', p2: 7, - p3: '2026-09-01 12:34:56', + p3: '2026-09-01 12:34:56.789', p4: PROJECT_ID, }); }); diff --git a/packages/core/src/modules/event/src/sql.ts b/packages/core/src/modules/event/src/sql.ts index a6c660523..f112a2a09 100644 --- a/packages/core/src/modules/event/src/sql.ts +++ b/packages/core/src/modules/event/src/sql.ts @@ -154,7 +154,7 @@ function eventListConditions(query: EventListQuery): SqlFragment[] { } if (query.cursor) { conditions.push( - sql`created_at < ${sql.string(formatClickhouseDate(query.cursor))}` + sql`created_at < toDateTime64(${sql.string(formatClickhouseDateTime64(query.cursor))}, 3)` ); } conditions.push(sql`project_id = ${projectId}`);