Skip to content

Commit

Permalink
feat(replay): create a wrapper class to init rrweb player alongside v…
Browse files Browse the repository at this point in the history
…ideo replayer (#69927)

Fixes #69817

We have a `videoReplayer`, which uses video events to create the replay
playback for mobile replays. However, in order to see the gestures
(clicks, mouse movements, etc) we need to initialize an rrweb player too
(the one that web replay uses). This PR introduces a
`videoReplayerWithInteractions` classe which initializes both, so that
mobile replays can utilize both players at once.


![image](https://github.com/getsentry/sentry/assets/56095982/8a81da2d-2f8c-4bac-acf0-988c04be08ec)


Another key change we had to make was introducing a fake full snapshot
event after every meta event in order to trick the rrweb player into
thinking we had a node to map the mouse movement to. The rrweb player
essentially fails to render any gesture if it doesn't find an element
with a matching `id` to the `id` inside the `positions` array (see below
picture), so we hardcoded the event to have `id: 0` (which is what the
SDK is sending for the mobile rrweb events). This workaround should be
safe to do since the full snapshot event doesn't affect the video
playback at all.


<img width="264" alt="SCR-20240430-nqts"
src="https://github.com/getsentry/sentry/assets/56095982/45e73a66-2740-4093-94a2-75cc1f3c2954">

Adding a snapshot event after every meta event also fixes the scrubbing
bugs we were experiencing.

Fixing mousetails not showing up involved absolutely positioning the
`replayer-wrapper`.





https://github.com/getsentry/sentry/assets/56095982/4a33cae4-ae1d-43b6-91a3-bf81fb36cf8c
  • Loading branch information
michellewzhang committed May 8, 2024
1 parent c1f2d1e commit 6ed338e
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 5 deletions.
18 changes: 14 additions & 4 deletions static/app/components/replays/replayContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ReplayPrefs,
} from 'sentry/components/replays/preferences/replayPreferences';
import useReplayHighlighting from 'sentry/components/replays/useReplayHighlighting';
import {VideoReplayerWithInteractions} from 'sentry/components/replays/videoReplayerWithInteractions';
import {trackAnalytics} from 'sentry/utils/analytics';
import clamp from 'sentry/utils/number/clamp';
import type useInitialOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
Expand All @@ -26,7 +27,6 @@ import useProjectFromId from 'sentry/utils/useProjectFromId';
import {useUser} from 'sentry/utils/useUser';

import {CanvasReplayerPlugin} from './canvasReplayerPlugin';
import {VideoReplayer} from './videoReplayer';

type Dimensions = {height: number; width: number};
type RootElem = null | HTMLDivElement;
Expand Down Expand Up @@ -474,16 +474,19 @@ function ProviderNonMemo({
return null;
}

// check if this is a video replay and if we can use the video replayer
// check if this is a video replay and if we can use the video (wrapper) replayer
if (!isVideoReplay || !videoEvents || !startTimestampMs) {
return null;
}

const inst = new VideoReplayer(videoEvents, {
// This is a wrapper class that wraps both the VideoReplayer
// and the rrweb Replayer
const inst = new VideoReplayerWithInteractions({
// video specific
videoEvents,
videoApiPrefix: `/api/0/projects/${
organization.slug
}/${projectSlug}/replays/${replay?.getReplay().id}/videos/`,
root,
start: startTimestampMs,
onFinished: setReplayFinished,
onLoaded: event => {
Expand All @@ -501,6 +504,11 @@ function ProviderNonMemo({
},
clipWindow,
durationMs,
// rrweb specific
theme,
events: events ?? [],
// common to both
root,
});
// `.current` is marked as readonly, but it's safe to set the value from
// inside a `useEffect` hook.
Expand All @@ -520,6 +528,7 @@ function ProviderNonMemo({
isFetching,
isVideoReplay,
videoEvents,
events,
organization.slug,
projectSlug,
replay,
Expand All @@ -528,6 +537,7 @@ function ProviderNonMemo({
startTimeOffsetMs,
clipWindow,
durationMs,
theme,
]
);

Expand Down
14 changes: 14 additions & 0 deletions static/app/components/replays/replayPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,20 @@ const SentryPlayerRoot = styled(PlayerRoot)`
height: 10px;
}
}
/* Correctly positions the canvas for video replays and shows the purple "mousetails" */
&.video-replayer {
.replayer-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.replayer-wrapper > iframe {
opacity: 0;
}
}
`;

const Overlay = styled('div')`
Expand Down
2 changes: 1 addition & 1 deletion static/app/components/replays/videoReplayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface VideoReplayerOptions {
clipWindow?: ClipWindow;
}

interface VideoReplayerConfig {
export interface VideoReplayerConfig {
/**
* Not supported, only here to maintain compat w/ rrweb player
*/
Expand Down
152 changes: 152 additions & 0 deletions static/app/components/replays/videoReplayerWithInteractions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type {Theme} from '@emotion/react';
import {type eventWithTime, Replayer} from '@sentry-internal/rrweb';

import {
VideoReplayer,
type VideoReplayerConfig,
} from 'sentry/components/replays/videoReplayer';
import type {ClipWindow, VideoEvent} from 'sentry/utils/replays/types';

type RootElem = HTMLDivElement | null;

interface VideoReplayerWithInteractionsOptions {
durationMs: number;
events: eventWithTime[];
onBuffer: (isBuffering: boolean) => void;
onFinished: () => void;
onLoaded: (event: any) => void;
root: RootElem;
start: number;
theme: Theme;
videoApiPrefix: string;
videoEvents: VideoEvent[];
clipWindow?: ClipWindow;
}

/**
* A wrapper replayer that wraps both VideoReplayer and the rrweb Replayer.
* We need both instances in order to render the video playback alongside gestures.
*/
export class VideoReplayerWithInteractions {
public config: VideoReplayerConfig = {
skipInactive: false,
speed: 1.0,
};
private videoReplayer: VideoReplayer;
private replayer: Replayer;

constructor({
videoEvents,
events,
root,
start,
videoApiPrefix,
onBuffer,
onFinished,
onLoaded,
clipWindow,
durationMs,
theme,
}: VideoReplayerWithInteractionsOptions) {
this.videoReplayer = new VideoReplayer(videoEvents, {
videoApiPrefix,
root,
start,
onFinished,
onLoaded,
onBuffer,
clipWindow,
durationMs,
});

root?.classList.add('video-replayer');

const eventsWithSnapshots: eventWithTime[] = [];
events.forEach(e => {
eventsWithSnapshots.push(e);
if (e.type === 4) {
// Create a mock full snapshot event, in order to render rrweb gestures properly
// Need to add one for every meta event we see
// The hardcoded data.node.id here should match the ID of the data being sent
// in the `positions` arrays
const fullSnapshotEvent = {
type: 2,
data: {
node: {
type: 0,
childNodes: [
{
type: 1,
name: 'html',
publicId: '',
systemId: '',
},
{
type: 2,
tagName: 'html',
attributes: {
lang: 'en',
},
childNodes: [],
},
],
id: 0,
},
},
timestamp: e.timestamp,
};
eventsWithSnapshots.push(fullSnapshotEvent);
}
});

this.replayer = new Replayer(eventsWithSnapshots, {
root: root as Element,
blockClass: 'sentry-block',
mouseTail: {
duration: 0.75 * 1000,
lineCap: 'round',
lineWidth: 2,
strokeStyle: theme.purple200,
},
plugins: [],
skipInactive: false,
speed: this.config.speed,
});
}

public destroy() {
this.videoReplayer.destroy();
this.replayer.destroy();
}

/**
* Returns the current video time, using the video's external timer.
*/
public getCurrentTime() {
return this.videoReplayer.getCurrentTime();
}

/**
* Play both the rrweb and video player.
*/
public play(videoOffsetMs: number) {
this.videoReplayer.play(videoOffsetMs);
this.replayer.play(videoOffsetMs);
}

/**
* Pause both the rrweb and video player.
*/
public pause(videoOffsetMs: number) {
this.videoReplayer.pause(videoOffsetMs);
this.replayer.pause(videoOffsetMs);
}

/**
* Equivalent to rrweb's `setConfig()`, but here we only support the `speed` configuration.
*/
public setConfig(config: Partial<VideoReplayerConfig>): void {
this.videoReplayer.setConfig(config);
this.replayer.setConfig(config);
}
}

0 comments on commit 6ed338e

Please sign in to comment.