+
+
+ 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 (
-
-
-
-
-
-
- {formatTime(event.createdAt)}
+
+
+
+
+ {getEventLabel(event)}
-
+ {chips.length > 0 && (
+
+ {chips.map((chip) => (
+
+ {chip}
+
+ ))}
+
+ )}
+
+
+ {formatDuration(Math.max(0, offsetMs))}
+
);
}
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 (
+
+ );
}
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 = (
+
+ );
+
+ 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 (
+
+
+ seek(first.offsetMs)}
+ style={{ left: `${percent(first.offsetMs, duration)}%` }}
+ type="button"
+ >
+
+ {group.length > 1 && (
+
+ {group.length}
+
+ )}
+
+
+
+ {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 (
+
+
+ seek(segment.startMs)}
+ style={{ left: `${left}%`, width: `${width}%` }}
+ type="button"
+ >
+ {showLabel ? segment.path : null}
+
+
+
+ {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 (
-
-
- {
- e.stopPropagation();
- seek(first.offsetMs);
- }}
- style={{ left: `${group.pct}%`, marginLeft: -12 }}
- type="button"
- >
-
- {isGroup && (
-
- {group.items.length}
-
- )}
-
-
-
- {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' && (
+
+ Retry
+
+ )}
+
+ );
+}
+
+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 && (
+
+ document
+ .getElementById('replay')
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ variant="outline"
+ >
+ Watch replay
+
+ )}
+ clipboard(session.id, 'Session id copied')}
+ variant="outline"
+ >
+ Copy id
+
+ >
+ }
+ 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 && (
+
+ )}
+
+
+
+
+
+ {eventCounts.length > 0 && }
+
-
e.name === 'screen_view' && e.path)
- .map((e) => e.path)}
- />
-
-
-
-
-
-
-
- Events
-
-
- {events.map((event) => (
-
- openEvent({ id: event.id, createdAt: event.createdAt })
- }
- type="button"
- >
-
-
-
- {event.name === 'screen_view' && event.path
- ? event.path
- : event.name.replace(/_/g, ' ')}
-
- {event.name.startsWith('ai_') && (
-
- {aiEventSummary(event.properties)}
-
- )}
-
-
- {formatDateTime(event.createdAt)}
-
-
- ))}
- {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}`);