fix: Update session player (#395)

* Replace react-slider with mantine's slider + custom markers for better performance
* Fix bug when opening a session replay with a ?ts param shows the first frame instead of correct frame at `ts`
* abort loading events when navigating off the page

before

https://github.com/hyperdxio/hyperdx/assets/20255948/195ce791-2d31-4ae4-9700-0ff52f021171

after

https://github.com/hyperdxio/hyperdx/assets/20255948/8ec31ff4-c3c1-4c0d-9f04-29c123e9444f
This commit is contained in:
Ernest Iliiasov 2024-05-12 10:39:00 -07:00 committed by GitHub
parent 9c4ef4320c
commit 6d99e3be9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 248 additions and 275 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---
New performant session replay playbar component

View file

@ -72,7 +72,6 @@
"react-query": "^3.39.1",
"react-reflex": "^4.0.9",
"react-select": "^5.7.0",
"react-slider": "^2.0.1",
"react-sliding-pane": "^7.3.0",
"react-sortable-hoc": "^2.0.0",
"react-syntax-highlighter": "^15.4.5",
@ -109,7 +108,6 @@
"@types/react-datepicker": "^4.10.0",
"@types/react-dom": "^18.2.18",
"@types/react-grid-layout": "^1.3.2",
"@types/react-slider": "^1.3.1",
"@types/react-syntax-highlighter": "^13.5.2",
"@types/react-table": "^7.7.14",
"@types/react-virtualized-auto-sizer": "^1.0.1",

View file

@ -476,7 +476,7 @@ function SearchInput({
placeholder={placeholder}
value={value}
onChange={e => onChange(e.currentTarget.value)}
leftSection={<i className="bi bi-search fs-8 ps-1" text-slate-400 />}
leftSection={<i className="bi bi-search fs-8 ps-1 text-slate-400" />}
onKeyDown={handleKeyDown}
rightSection={
value ? (

View file

@ -53,7 +53,7 @@ export default function DOMPlayer({
let currentRrwebEvent = '';
const { isFetching: isSearchResultsFetching } = useSearchEventStream(
const { isFetching: isSearchResultsFetching, abort } = useSearchEventStream(
{
apiUrlPath: `/sessions/${sessionId}/rrweb`,
q: '',
@ -219,21 +219,7 @@ export default function DOMPlayer({
replayer.current?.setConfig({ speed: playerSpeed, skipInactive });
}
}
}, [playerState, playerSpeed, skipInactive]);
// Set player to the correct time based on focus
useEffect(() => {
if (focus?.setBy !== 'player' && replayer.current != null) {
pause(
focus?.ts == null
? 0
: focus?.ts - replayer.current.getMetaData().startTime,
);
if (playerState === 'playing') {
play();
}
}
}, [focus, pause, setPlayerState, playerState, play]);
}, [playerState, playerSpeed, skipInactive, pause, play]);
const handleResize = useCallback(() => {
if (wrapper.current == null || playerContainer.current == null) {
@ -262,6 +248,7 @@ export default function DOMPlayer({
handleResize();
}, [resizeKey, handleResize]);
const [isReplayerInitialized, setIsReplayerInitialized] = useState(false);
// Set up player
useEffect(() => {
if (
@ -281,6 +268,7 @@ export default function DOMPlayer({
showWarning: debug,
skipInactive: true,
});
setIsReplayerInitialized(true);
if (debug) {
// @ts-ignore
@ -334,19 +322,51 @@ export default function DOMPlayer({
debug,
]);
// Set player to the correct time based on focus
useEffect(() => {
if (
!isInitialEventsLoaded ||
!isReplayerInitialized ||
lastEventTsLoaded < (focus?.ts ? focus.ts + 1000 : Infinity)
) {
return;
}
if (focus?.setBy !== 'player' && replayer.current != null) {
pause(
focus?.ts == null
? 0
: focus?.ts - replayer.current.getMetaData().startTime,
);
handleResize();
if (playerState === 'playing') {
play();
}
}
}, [
focus,
pause,
setPlayerState,
playerState,
play,
isInitialEventsLoaded,
isReplayerInitialized,
handleResize,
lastEventTsLoaded,
]);
useEffect(() => {
return () => {
if (replayer.current != null) {
replayer.current?.destroy();
replayer.current = null;
}
abort();
};
}, []);
const isLoading = isInitialEventsLoaded === false && isSearchResultsFetching;
// TODO: Handle when ts is set to a value that's outside of this session
const isBuffering =
playerState === 'playing' &&
isReplayFullyLoaded === false &&
(replayer.current?.getMetaData()?.endTime ?? 0) < (focus?.ts ?? 0);

View file

@ -1,14 +1,14 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import { format } from 'date-fns';
import throttle from 'lodash/throttle';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import uniqBy from 'lodash/uniqBy';
import Button from 'react-bootstrap/Button';
import ReactSlider from 'react-slider';
import { Group } from '@mantine/core';
import Checkbox from './Checkbox';
import type { PlaybarMarker } from './PlaybarSlider';
import { PlaybarSlider } from './PlaybarSlider';
import { useSessionEvents } from './sessionUtils';
import { getShortUrl, truncateText, useLocalStorage } from './utils';
import { getShortUrl, useLocalStorage } from './utils';
import 'react-bootstrap-range-slider/dist/react-bootstrap-range-slider.css';
@ -38,102 +38,6 @@ function formatTs({
}
}
function Track({
props,
state,
minSliderVal,
maxSliderVal,
showRelativeTime,
}: {
props: any;
state: any;
minSliderVal: number;
maxSliderVal: number;
showRelativeTime: boolean;
}) {
const thumbWidth = 18;
const container = useRef<HTMLDivElement>(null);
const tracker = useRef<HTMLDivElement>(null);
const [track0MouseXPerc, setTrack0MouseXPerc] = useState<
undefined | number
>();
const [mouseHovered, setMouseHovered] = useState(false);
const [mouseTs, setMouseTs] = useState<number | undefined>();
const debouncedMouseMove = useMemo(
() =>
throttle(e => {
const rect = container.current?.getBoundingClientRect();
if (rect == null) return;
// https://github.com/zillow/react-slider/blob/master/src/components/ReactSlider/ReactSlider.jsx#L749
// For some reason we need to subtract half of thumb width here to match
// the react slider logic
const x = e.clientX - rect.left - thumbWidth / 2;
// Subtract by thumb width as the thumb width is added to each track
const xPerc = x / (rect.width - thumbWidth);
const segmentTimespan =
state.index === 0
? state.value - minSliderVal
: maxSliderVal - state.value;
const newMouseTs =
segmentTimespan * xPerc +
(state.index === 0 ? minSliderVal : state.value);
setMouseTs(newMouseTs);
setTrack0MouseXPerc(xPerc);
}, 100),
[minSliderVal, state.value, state.index, maxSliderVal],
);
return (
<div
{...props}
className={
state.index === 0
? 'bg-success rounded-pill rounded-end top-0 bottom-0'
: 'bg-grey rounded-pill rounded-start top-0 bottom-0'
}
key={`track-${state.index}`}
onMouseMove={debouncedMouseMove}
ref={container}
onMouseEnter={() => setMouseHovered(true)}
onMouseLeave={() => setMouseHovered(false)}
>
<div
ref={tracker}
style={{
width: `${(track0MouseXPerc ?? 0) * 100}%`,
height: '100%',
position: 'relative',
top: '-15px',
}}
>
<div
className="rounded bg-black text-center text-nowrap"
style={{
display: mouseHovered ? 'block' : 'none',
position: 'absolute',
transform: 'translateX(50%)',
padding: 5,
right: 0,
top: -20,
}}
>
{formatTs({
ts: mouseTs,
minTs: minSliderVal,
showRelativeTime,
})}
</div>
</div>
</div>
);
}
export default function Playbar({
playerState,
setPlayerState,
@ -173,47 +77,50 @@ export default function Playbar({
const { events } = useSessionEvents({ config: eventsConfig });
const markers = useMemo(() => {
return (
events?.map(event => {
const spanName = event['span_name'];
const locationHref = event['location.href'];
const shortLocationHref = getShortUrl(locationHref);
const markers = useMemo<PlaybarMarker[]>(() => {
return uniqBy(
events
?.filter(
({ startOffset }) => startOffset >= minTs && startOffset <= maxTs,
)
.map(event => {
const spanName = event['span_name'];
const locationHref = event['location.href'];
const shortLocationHref = getShortUrl(locationHref);
const errorMessage = event['error.message'];
const errorMessage = event['error.message'];
const url = event['http.url'];
const statusCode = event['http.status_code'];
const method = event['http.method'];
const shortUrl = getShortUrl(url);
const url = event['http.url'];
const statusCode = event['http.status_code'];
const method = event['http.method'];
const shortUrl = getShortUrl(url);
const isNavigation =
spanName === 'routeChange' || spanName === 'documentLoad';
const isNavigation =
spanName === 'routeChange' || spanName === 'documentLoad';
const isError = event.severity_text === 'error' || statusCode >= 399;
const isError = event.severity_text === 'error' || statusCode >= 399;
return {
id: event.id,
ts: event.startOffset,
description: isNavigation
? `Navigated to ${shortLocationHref}`
: url.length > 0
? `${statusCode} ${method}${url.length > 0 ? ` ${shortUrl}` : ''}`
: errorMessage != null && errorMessage.length > 0
? errorMessage
: spanName === 'intercom.onShow'
? 'Intercom Chat Opened'
: event.body,
className: isError ? 'bg-danger' : 'bg-primary',
};
}) ?? []
return {
id: event.id,
ts: event.startOffset,
percentage: Math.round(
((event.startOffset - minTs) / (maxTs - minTs)) * 100,
),
description: isNavigation
? `Navigated to ${shortLocationHref}`
: url.length > 0
? `${statusCode} ${method}${url.length > 0 ? ` ${shortUrl}` : ''}`
: errorMessage != null && errorMessage.length > 0
? errorMessage
: spanName === 'intercom.onShow'
? 'Intercom Chat Opened'
: event.body,
isError,
};
}) ?? [],
'percentage',
);
}, [events]);
const marks = useMemo(
() => Array.from(new Set(markers.map(m => m.ts).filter(ts => ts >= minTs))),
[markers, minTs],
);
}, [events, maxTs, minTs]);
const [showRelativeTime, setShowRelativeTime] = useLocalStorage(
'hdx-session-subpanel-show-relative-time',
@ -280,71 +187,17 @@ export default function Playbar({
>
{formatTs({ ts: focus?.ts, minTs, showRelativeTime })}
</div>
<div className="PlaybarSliderParent w-100 d-flex align-self-stretch align-items-center me-3">
<ReactSlider
className="PlaybarSlider w-100"
thumbClassName="thumb"
value={focus?.ts ?? minSliderVal}
<div className="w-100 d-flex align-self-stretch align-items-center me-3">
<PlaybarSlider
markers={markers}
min={minSliderVal}
max={maxSliderVal}
step={1000}
marks={marks}
renderMark={props => {
const mark = markers.find(marker => marker.ts === props.key);
const description = truncateText(
mark?.description ?? '',
240,
'...',
/\n/,
);
return (
<OverlayTrigger
key={`${props.key}`}
overlay={<Tooltip id={`tooltip`}>{description}</Tooltip>}
>
<div
{...props}
className={`${mark?.className ?? ''} rounded-circle mark`}
/>
</OverlayTrigger>
);
value={focus?.ts}
onChange={ts => {
setFocus({ ts, setBy: 'slider' });
}}
renderThumb={(props, state) => (
<OverlayTrigger
key="thumb"
overlay={
<Tooltip id={`tooltip`} className="mono fs-7 text-nowrap">
{(() => {
const value = Math.max(state.value - minTs, 0);
const minutes = Math.floor(value / 1000 / 60);
const seconds = Math.floor((value / 1000) % 60);
return `${minutes}m:${seconds < 10 ? '0' : ''}${seconds}s`;
})()}{' '}
at{' '}
{(() => {
return format(new Date(state.value), 'hh:mm:ss a');
})()}
</Tooltip>
}
>
<div
{...props}
className="bg-success cursor-grab rounded-circle shadow thumb"
key="thumb"
/>
</OverlayTrigger>
)}
renderTrack={(props, state) => (
<Track
key={state.index}
props={props}
state={state}
minSliderVal={minSliderVal}
maxSliderVal={maxSliderVal}
showRelativeTime={showRelativeTime}
/>
)}
onChange={value => setFocus({ ts: value as number, setBy: 'slider' })}
setPlayerState={setPlayerState}
playerState={playerState}
/>
</div>
<Checkbox

View file

@ -0,0 +1,98 @@
import * as React from 'react';
import { format } from 'date-fns';
import { Slider, Tooltip } from '@mantine/core';
import { truncateText } from './utils';
import styles from '../styles/PlaybarSlider.module.scss';
export type PlaybarMarker = {
id: string;
ts: number;
description: string;
isError: boolean;
};
type PlaybarSliderProps = {
value?: number;
min: number;
max: number;
markers?: PlaybarMarker[];
playerState: 'playing' | 'paused';
onChange: (value: number) => void;
setPlayerState: (playerState: 'playing' | 'paused') => void;
};
export const PlaybarSlider = ({
min,
max,
value,
markers,
playerState,
onChange,
setPlayerState,
}: PlaybarSliderProps) => {
const valueLabelFormat = React.useCallback(
(ts: number) => {
const value = Math.max(ts - min, 0);
const minutes = Math.floor(value / 1000 / 60);
const seconds = Math.floor((value / 1000) % 60);
const timestamp = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
const time = format(new Date(ts), 'hh:mm:ss a');
return `${timestamp} at ${time}`;
},
[min],
);
const markersContent = React.useMemo(
() =>
markers?.map(mark => (
<Tooltip
color={mark.isError ? 'red' : 'gray'}
key={mark.id}
label={truncateText(mark?.description ?? '', 240, '...', /\n/)}
position="top"
withArrow
>
<div
className={styles.markerDot}
style={{
backgroundColor: mark.isError
? 'var(--mantine-color-red-6)'
: 'var(--mantine-color-gray-6)',
left: `${((mark.ts - min) / (max - min)) * 100}%`,
}}
onClick={() => onChange(mark.ts)}
/>
</Tooltip>
)),
[markers, max, min, onChange],
);
const [prevPlayerState, setPrevPlayerState] = React.useState(playerState);
const handleMouseDown = React.useCallback(() => {
setPrevPlayerState(playerState);
setPlayerState('paused');
}, [playerState, setPlayerState]);
const handleMouseUp = React.useCallback(() => {
setPlayerState(prevPlayerState);
}, [prevPlayerState, setPlayerState]);
return (
<div className={styles.wrapper}>
<div className={styles.markers}>{markersContent}</div>
<Slider
color="gray.5"
size="sm"
min={min}
max={max}
value={value || min}
step={1000}
label={valueLabelFormat}
onChange={onChange}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>
</div>
);
};

View file

@ -326,11 +326,13 @@ export default function SessionSubpanel({
},
);
const prevTsQuery = usePrevious(tsQuery);
useEffect(() => {
if (prevTsQuery == null && tsQuery != null) {
_setFocus({ ts: tsQuery, setBy: 'url' });
}
}, [prevTsQuery, tsQuery]);
const debouncedSetTsQuery = useRef(
throttle(async (ts: number) => {
setTsQuery(ts, 'replaceIn');

View file

@ -292,12 +292,17 @@ function useSearchEventStream(
[fetchResults, results.data.length, hasNextPage],
);
const abort = useCallback(() => {
lastAbortController.current?.abort();
}, []);
return {
hasNextPage,
isFetching,
results: results.data,
resultsKey: results.key,
fetchNextPage,
abort,
};
}

View file

@ -0,0 +1,36 @@
.wrapper {
width: 100%;
> div {
width: 100%;
}
}
.markers {
width: auto !important;
position: relative;
margin-bottom: 10px;
margin-left: 6px;
margin-right: 6px;
&:hover {
.markerDot {
opacity: 0.2;
}
}
}
.markerDot {
cursor: pointer;
position: absolute;
top: 0;
margin-left: -1px;
width: 3px;
height: 8px;
background-color: #333;
border-radius: 1px;
&:hover {
opacity: 1 !important;
z-index: 20;
}
}

View file

@ -371,6 +371,19 @@ body {
/* Custom Components */
.player-skipping-overlay {
position: absolute;
z-index: 10;
background: rgba(50, 50, 50, 0.4);
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.player-wrapper {
position: relative;
@ -427,49 +440,6 @@ body {
}
}
.PlaybarSliderParent {
&:hover {
.PlaybarSlider {
height: 12px;
.thumb {
margin-top: -3px;
height: 18px;
width: 18px;
}
.mark {
margin-top: 0px;
width: 12px;
height: 12px;
margin-left: 6px;
}
}
}
.PlaybarSlider {
transition: height 0.2s ease-in-out;
height: 6px;
.thumb {
transition: all 0.2s ease-in-out;
margin-top: -5px;
height: 16px;
width: 16px;
}
.mark {
transition: all 0.2s ease-in-out;
width: 8px;
height: 8px;
margin-top: -1px;
margin-left: -4px;
}
}
}
// Just for the price slider
.PricingSlider {
height: 25px;

View file

@ -4406,13 +4406,6 @@
dependencies:
"@types/react" "*"
"@types/react-slider@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/react-slider/-/react-slider-1.3.1.tgz#a3816989eb4fc172e7df316930f242fec90690fc"
integrity sha512-4X2yK7RyCIy643YCFL+bc6XNmcnBtt8n88uuyihvcn5G7Lut23eNQU3q3KmwF7MWIfKfsW5NxCjw0SeDZRtgaA==
dependencies:
"@types/react" "*"
"@types/react-syntax-highlighter@^13.5.2":
version "13.5.2"
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-13.5.2.tgz#357cc03581dc434c57c3b31f70e0eecdbf7b3ab0"
@ -12188,13 +12181,6 @@ react-select@^5.7.0:
react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2"
react-slider@^2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-2.0.4.tgz#21c656ffabc3bb4481cf6b49e6d647baeda83572"
integrity sha512-sWwQD01n6v+MbeLCYthJGZPc0kzOyhQHyd0bSo0edg+IAxTVQmj3Oy4SBK65eX6gNwS9meUn6Z5sIBUVmwAd9g==
dependencies:
prop-types "^15.8.1"
react-sliding-pane@^7.3.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/react-sliding-pane/-/react-sliding-pane-7.3.0.tgz#a6a03b90db216e7ec6f746c7e649d19ba03ff4e0"