Normalize replay events for rrweb-player playback
This commit is contained in:
1 parent
a5f78e3e3d
commit
95ece5ba56
7 files changed
+278
-56
No files matched your search
+1
-1
@@ -113,7 +113,7 @@
|
||||
"redis": "^5.12.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"rrweb": "^2.0.1",
|
||||
"rrweb-player": "^2.0.1",
|
||||
"rrweb-player": "2.0.0-alpha.20",
|
||||
"semver": "^7.8.4",
|
||||
"serialize-error": "^13.0.1",
|
||||
"thenby": "^1.4.1",
|
||||
|
||||
Generated
+5
-5
@@ -189,8 +189,8 @@ importers:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
rrweb-player:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
specifier: 2.0.0-alpha.20
|
||||
version: 2.0.0-alpha.20
|
||||
semver:
|
||||
specifier: ^7.8.4
|
||||
version: 7.8.4
|
||||
@@ -5610,8 +5610,8 @@ packages:
|
||||
rrdom@2.0.1:
|
||||
resolution: {integrity: sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==}
|
||||
|
||||
rrweb-player@2.0.1:
|
||||
resolution: {integrity: sha512-v8PVnOy7Q3kXqWriGuzZo4ndGEV06nCMRkH/jSHweJfTLwpLqb2evgMj0S5FwQLsN2X1VM+L2/i7QWGJfgj9zw==}
|
||||
rrweb-player@2.0.0-alpha.20:
|
||||
resolution: {integrity: sha512-3ZCv1ksUxuIOn3Vn/eWrwWs9Xy+4KVjISD+q26ZLfisZ3hZ0CPgYG3iC22pmmycIeMS2svOfvf7gPh7jExwpUA==}
|
||||
|
||||
rrweb-snapshot@2.0.1:
|
||||
resolution: {integrity: sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==}
|
||||
@@ -11638,7 +11638,7 @@ snapshots:
|
||||
dependencies:
|
||||
rrweb-snapshot: 2.0.1
|
||||
|
||||
rrweb-player@2.0.1:
|
||||
rrweb-player@2.0.0-alpha.20:
|
||||
dependencies:
|
||||
'@rrweb/packer': 2.0.1
|
||||
'@rrweb/replay': 2.0.1
|
||||
|
||||
@@ -7,5 +7,5 @@ export default async function ({
|
||||
}) {
|
||||
const { websiteId, sessionId } = await params;
|
||||
|
||||
return <ReplayModal websiteId={websiteId} sessionId={sessionId} />;
|
||||
return <ReplayModal websiteId={websiteId} replayId={sessionId} />;
|
||||
}
|
||||
@@ -2,21 +2,32 @@
|
||||
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
|
||||
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
import { buildPath } from '@/lib/url';
|
||||
|
||||
export interface ReplayModalProps extends ModalProps {
|
||||
websiteId: string;
|
||||
replayId?: string;
|
||||
}
|
||||
|
||||
export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
|
||||
export function ReplayModal({ websiteId, replayId, ...props }: ReplayModalProps) {
|
||||
const {
|
||||
router,
|
||||
query: { replay },
|
||||
searchParams,
|
||||
updateParams,
|
||||
} = useNavigation();
|
||||
const activeReplayId = replayId || replay;
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
router.push(updateParams({ replay: undefined }));
|
||||
if (replayId) {
|
||||
const query = Object.fromEntries(searchParams.entries());
|
||||
delete query.replay;
|
||||
|
||||
router.push(buildPath(`/websites/${websiteId}/replays`, query));
|
||||
} else {
|
||||
router.push(updateParams({ replay: undefined }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +35,7 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
|
||||
<Modal
|
||||
placement="bottom"
|
||||
offset="80px"
|
||||
isOpen={!!replay}
|
||||
isOpen={!!activeReplayId}
|
||||
onOpenChange={handleOpenChange}
|
||||
isDismissable
|
||||
{...props}
|
||||
@@ -33,7 +44,9 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
|
||||
<Dialog variant="sheet">
|
||||
{({ close }) => (
|
||||
<Column padding="6">
|
||||
<ReplayPlayback websiteId={websiteId} replayId={replay} onClose={close} />
|
||||
{activeReplayId && (
|
||||
<ReplayPlayback websiteId={websiteId} replayId={activeReplayId} onClose={close} />
|
||||
)}
|
||||
</Column>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -1,71 +1,124 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useMobile } from '@/components/hooks';
|
||||
import { hasReplayFullSnapshot } from '@/lib/replay';
|
||||
import { getReplayPlayerEvents, getReplayViewport } from '@/lib/replay';
|
||||
import 'rrweb-player/dist/style.css';
|
||||
|
||||
const DEFAULT_REPLAY_ASPECT_RATIO = 9 / 16;
|
||||
|
||||
export function ReplayPlayer({ events }: { events: any[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRootRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<any>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [playerError, setPlayerError] = useState(false);
|
||||
const { isMobile, isPhone } = useMobile();
|
||||
const canReplay = hasReplayFullSnapshot(events);
|
||||
const showUnavailable = !events?.length || !canReplay;
|
||||
const replayEvents = useMemo(() => getReplayPlayerEvents(events), [events]);
|
||||
const replayViewport = useMemo(() => getReplayViewport(replayEvents), [replayEvents]);
|
||||
const canReplay = replayEvents.length >= 2;
|
||||
const showUnavailable = !events?.length || !canReplay || playerError;
|
||||
|
||||
const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
|
||||
const playerHeight = isPhone ? 202 : isMobile ? 360 : 576;
|
||||
const replayAspectRatio = replayViewport
|
||||
? replayViewport.height / replayViewport.width
|
||||
: DEFAULT_REPLAY_ASPECT_RATIO;
|
||||
const playerHeight = Math.round(
|
||||
playerWidth * Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 1.5),
|
||||
);
|
||||
const playerOuterHeight = playerHeight + 80;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !events?.length || !canReplay) return;
|
||||
let cancelled = false;
|
||||
const playerRoot = playerRootRef.current;
|
||||
|
||||
import('rrweb-player').then(mod => {
|
||||
const RRWebPlayer = mod.default;
|
||||
setPlayerError(false);
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
if (playerRef.current) {
|
||||
playerRef.current.$destroy?.();
|
||||
playerRef.current = null;
|
||||
}
|
||||
|
||||
playerRef.current = new RRWebPlayer({
|
||||
target: containerRef.current,
|
||||
props: {
|
||||
events: events,
|
||||
width: playerWidth,
|
||||
height: playerHeight,
|
||||
autoPlay: false,
|
||||
showController: true,
|
||||
speedOption: [1, 2, 4, 8],
|
||||
useVirtualDom: false,
|
||||
showWarning: false,
|
||||
},
|
||||
playerRoot?.replaceChildren();
|
||||
|
||||
if (!playerRoot || !canReplay) return;
|
||||
|
||||
import('rrweb-player')
|
||||
.then(mod => {
|
||||
if (cancelled) return;
|
||||
|
||||
const RRWebPlayer = mod.default;
|
||||
|
||||
playerRoot.replaceChildren();
|
||||
|
||||
try {
|
||||
const player = new RRWebPlayer({
|
||||
target: playerRoot,
|
||||
props: {
|
||||
events: replayEvents,
|
||||
width: playerWidth,
|
||||
height: playerHeight,
|
||||
autoPlay: false,
|
||||
showController: true,
|
||||
speedOption: [1, 2, 4, 8],
|
||||
useVirtualDom: false,
|
||||
showWarning: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
(player as any).$destroy?.();
|
||||
return;
|
||||
}
|
||||
|
||||
playerRef.current = player;
|
||||
} catch {
|
||||
playerRoot.replaceChildren();
|
||||
|
||||
if (!cancelled) {
|
||||
setPlayerError(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPlayerError(true);
|
||||
}
|
||||
});
|
||||
|
||||
setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.$destroy?.();
|
||||
playerRef.current = null;
|
||||
}
|
||||
|
||||
playerRoot.replaceChildren();
|
||||
};
|
||||
}, [canReplay, events, playerWidth, playerHeight]);
|
||||
}, [canReplay, replayEvents, playerWidth, playerHeight]);
|
||||
|
||||
return (
|
||||
<Column alignItems="center">
|
||||
<Column alignItems="center" width="100%">
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: playerWidth,
|
||||
minHeight: loaded && canReplay ? undefined : playerHeight,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: playerWidth,
|
||||
height: showUnavailable ? playerHeight : playerOuterHeight,
|
||||
overflowX: showUnavailable ? 'hidden' : 'auto',
|
||||
overflowY: 'hidden',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--base300)',
|
||||
background: 'var(--base75)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={playerRootRef}
|
||||
style={{
|
||||
display: showUnavailable ? 'none' : 'block',
|
||||
width: playerWidth,
|
||||
height: playerOuterHeight,
|
||||
}}
|
||||
/>
|
||||
{showUnavailable && <Empty message="Replay unavailable." />}
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
+63
-10
@@ -1,15 +1,78 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import {
|
||||
canReplayEvents,
|
||||
getReplayEventCount,
|
||||
getReplayPlayerEvents,
|
||||
getReplayViewport,
|
||||
hasReplayableFullSnapshot,
|
||||
hasReplayFullSnapshot,
|
||||
hasReplayMeta,
|
||||
REPLAY_EVENT_FRAGMENT_TYPE,
|
||||
restoreReplayEventFragments,
|
||||
} from './replay';
|
||||
|
||||
const fullSnapshot = {
|
||||
type: 2,
|
||||
timestamp: 1781553116151,
|
||||
data: {
|
||||
node: {
|
||||
type: 0,
|
||||
id: 1,
|
||||
childNodes: [{ type: 2, id: 2, tagName: 'html', attributes: {}, childNodes: [] }],
|
||||
},
|
||||
initialOffset: { left: 0, top: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
test('hasReplayFullSnapshot returns true when a full snapshot is present', () => {
|
||||
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
|
||||
});
|
||||
|
||||
test('hasReplayMeta returns true when a meta event is present', () => {
|
||||
expect(hasReplayMeta([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
|
||||
});
|
||||
|
||||
test('getReplayViewport returns the recorded viewport dimensions', () => {
|
||||
expect(getReplayViewport(null)).toBe(null);
|
||||
expect(getReplayViewport([{ type: 4, data: { width: 0, height: 768 } }])).toBe(null);
|
||||
expect(getReplayViewport([{ type: 4, data: { width: '1024', height: '1365' } }])).toEqual({
|
||||
width: 1024,
|
||||
height: 1365,
|
||||
});
|
||||
});
|
||||
|
||||
test('hasReplayableFullSnapshot returns true when a usable full snapshot is present', () => {
|
||||
expect(hasReplayableFullSnapshot([{ type: 4 }, fullSnapshot, { type: 3 }])).toBe(true);
|
||||
});
|
||||
|
||||
test('canReplayEvents requires events that can be normalized with a full snapshot', () => {
|
||||
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, fullSnapshot])).toBe(true);
|
||||
expect(canReplayEvents([fullSnapshot])).toBe(true);
|
||||
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 2 }])).toBe(false);
|
||||
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 3 }])).toBe(false);
|
||||
expect(canReplayEvents([{ ...fullSnapshot, timestamp: undefined }])).toBe(true);
|
||||
});
|
||||
|
||||
test('getReplayPlayerEvents normalizes events for rrweb-player', () => {
|
||||
expect(getReplayPlayerEvents(null)).toEqual([]);
|
||||
expect(getReplayPlayerEvents([{ type: 3, timestamp: 1781553116160 }])).toEqual([]);
|
||||
|
||||
expect(getReplayPlayerEvents([fullSnapshot])).toEqual([
|
||||
fullSnapshot,
|
||||
{ ...fullSnapshot, timestamp: fullSnapshot.timestamp + 1 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
getReplayPlayerEvents([
|
||||
{ type: 0, timestamp: '1781553116149' },
|
||||
{ ...fullSnapshot, timestamp: undefined },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: 0, timestamp: 1781553116149 },
|
||||
{ ...fullSnapshot, timestamp: 1781553116150 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hasReplayFullSnapshot returns false when only incremental events are present', () => {
|
||||
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 3 }, { type: 3 }])).toBe(false);
|
||||
});
|
||||
@@ -20,16 +83,6 @@ test('hasReplayFullSnapshot handles missing events', () => {
|
||||
});
|
||||
|
||||
test('restoreReplayEventFragments restores fragmented events', () => {
|
||||
const fullSnapshot = {
|
||||
type: 2,
|
||||
timestamp: 1781553116151,
|
||||
data: {
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [{ type: 2, tagName: 'html' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const serialized = JSON.stringify(fullSnapshot);
|
||||
const splitAt = Math.floor(serialized.length / 2);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const RRWEB_EVENT_TYPE = {
|
||||
Meta: 4,
|
||||
FullSnapshot: 2,
|
||||
} as const;
|
||||
|
||||
@@ -106,3 +107,105 @@ export function hasReplayFullSnapshot(events: any[] | null | undefined) {
|
||||
Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.FullSnapshot)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasReplayableFullSnapshot(events: any[] | null | undefined) {
|
||||
return (
|
||||
Array.isArray(events) &&
|
||||
events.some(event => {
|
||||
const { data } = event || {};
|
||||
const { node } = data || {};
|
||||
|
||||
return (
|
||||
event?.type === RRWEB_EVENT_TYPE.FullSnapshot &&
|
||||
Number.isInteger(node?.type) &&
|
||||
Array.isArray(node?.childNodes)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function hasReplayMeta(events: any[] | null | undefined) {
|
||||
return Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.Meta);
|
||||
}
|
||||
|
||||
function getReplayDimension(value: any) {
|
||||
const dimension = Number(value);
|
||||
|
||||
return Number.isFinite(dimension) && dimension > 0 ? dimension : null;
|
||||
}
|
||||
|
||||
export function getReplayViewport(events: any[] | null | undefined) {
|
||||
if (!Array.isArray(events)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = events.find(event => {
|
||||
const width = getReplayDimension(event?.data?.width);
|
||||
const height = getReplayDimension(event?.data?.height);
|
||||
|
||||
return event?.type === RRWEB_EVENT_TYPE.Meta && width && height;
|
||||
});
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
width: getReplayDimension(meta.data.width) as number,
|
||||
height: getReplayDimension(meta.data.height) as number,
|
||||
};
|
||||
}
|
||||
|
||||
function getReplayTimestamp(event: any) {
|
||||
const timestamp = Number(event?.timestamp);
|
||||
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
export function getReplayPlayerEvents(events: any[] | null | undefined) {
|
||||
if (!Array.isArray(events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const replayEvents = events.filter(
|
||||
event => event && typeof event === 'object' && event.type !== undefined && event.type !== null,
|
||||
);
|
||||
|
||||
if (!hasReplayableFullSnapshot(replayEvents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstTimestamp =
|
||||
replayEvents.map(getReplayTimestamp).find(timestamp => timestamp !== null) ?? Date.now();
|
||||
let lastTimestamp = firstTimestamp - 1;
|
||||
|
||||
const normalizedEvents = replayEvents.map(event => {
|
||||
const timestamp = getReplayTimestamp(event);
|
||||
|
||||
if (timestamp !== null) {
|
||||
lastTimestamp = timestamp;
|
||||
|
||||
return typeof event.timestamp === 'number' ? event : { ...event, timestamp };
|
||||
}
|
||||
|
||||
lastTimestamp += 1;
|
||||
|
||||
return { ...event, timestamp: lastTimestamp };
|
||||
});
|
||||
|
||||
if (normalizedEvents.length === 1) {
|
||||
return [
|
||||
normalizedEvents[0],
|
||||
{
|
||||
...normalizedEvents[0],
|
||||
timestamp: normalizedEvents[0].timestamp + 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return normalizedEvents;
|
||||
}
|
||||
|
||||
export function canReplayEvents(events: any[] | null | undefined) {
|
||||
return getReplayPlayerEvents(events).length >= 2;
|
||||
}
|
||||
Reference in new issue
Block a user