Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 115 additions & 43 deletions static/app/components/replays/canvasReplayerPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ReplayPlugin,
} from '@sentry-internal/rrweb';
import type {CanvasArg} from '@sentry-internal/rrweb-types';
import debounce from 'lodash/debounce';

import {deserializeCanvasArg} from './deserializeCanvasArgs';

Expand All @@ -25,6 +26,8 @@ function isCanvasMutationEvent(e: eventWithTime): e is CanvasEventWithTime {
);
}

class InvalidCanvasNodeError extends Error {}

/**
* Find the lowest matching index for event
*/
Expand Down Expand Up @@ -79,6 +82,11 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
// if a deserialization of an event is in progress so that it can be skipped if so.
const preloadQueue = new Set<CanvasEventWithTime>();
const eventsToPrune: eventWithTime[] = [];
// In the case where replay is not started and user seeks, `handler` can be
// called before the DOM is fully built. This means that nodes do not yet
// exist in DOM mirror. We need to replay these events when `onBuild` is
// called.
const handleQueue = new Map<number, [CanvasEventWithTime, Replayer]>();

// This is a pointer to the index of the next event that will need to be
// preloaded. Most of the time the recording plays sequentially, so we do not
Expand Down Expand Up @@ -193,6 +201,74 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
}
}

const debouncedProcessQueuedEvents = debounce(
function () {
Array.from(handleQueue.entries()).forEach(async ([id, [e, replayer]]) => {
try {
await processEvent(e, {replayer});
handleQueue.delete(id);
} catch (err) {
if (!(err instanceof InvalidCanvasNodeError)) {
Sentry.captureException(err);
}
}
});
},
250,
{maxWait: 1000}
);

/**
* In the case where mirror DOM is built, we only want to process the most
* recent sync event, otherwise the playback will look like it's playing if
* we process all events.
*/
function processEventSync(e: CanvasEventWithTime, {replayer}: {replayer: Replayer}) {
// We want to only process the most recent sync event
handleQueue.set(e.data.id, [e, replayer]);
debouncedProcessQueuedEvents();
}

/**
* Processes canvas mutation events
*/
async function processEvent(e: CanvasEventWithTime, {replayer}: {replayer: Replayer}) {
preload(e);

const source = replayer.getMirror().getNode(e.data.id);
const target =
canvases.get(e.data.id) ||
(source && cloneCanvas(e.data.id, source as HTMLCanvasElement));

if (!target) {
throw new InvalidCanvasNodeError('No canvas found for id');
}

await canvasMutation({
event: e,
mutation: e.data,
target,
imageMap,
canvasEventMap,
errorHandler: (err: unknown) => {
if (err instanceof Error) {
Sentry.captureException(err);
} else {
Sentry.metrics.increment('replay.canvas_player.error_canvas_mutation');
}
},
});

const img = containers.get(e.data.id);
if (img) {
img.src = target.toDataURL();
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
}

prune(e);
}

preload();

return {
Expand All @@ -212,69 +288,65 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
(node as HTMLCanvasElement).appendChild(el);
containers.set(id, el);
}

// See comments at definition of `handleQueue`
const queueItem = handleQueue.get(id);
handleQueue.delete(id);
if (!queueItem) {
return;
}
const [event, replayer] = queueItem;
try {
processEvent(event, {replayer});
} catch (err) {
Sentry.captureException(err);
}
},

/**
* Mutate canvas outside of iframe, then export the canvas as an image, and
* draw inside of the image el inside of replay canvas.
*/
handler: async (
e: eventWithTime,
isSync: boolean,
{replayer}: {replayer: Replayer}
) => {
handler: (e: eventWithTime, isSync: boolean, {replayer}: {replayer: Replayer}) => {
const isCanvas = isCanvasMutationEvent(e);

// isSync = true means it is fast forwarding vs playing
// nothing to do when fast forwarding since canvas mutations for us are
// image snapshots and do not depend on past events
if (isSync || !isCanvasMutationEvent(e)) {
if (isSync) {
// Set this to -1 to indicate that we will need to search
// `canvasMutationEvents` for starting point of preloading
//
// Only do this when isSync is true, meaning there was a seek
nextPreloadIndex = -1;
if (isSync) {
// Set this to -1 to indicate that we will need to search
// `canvasMutationEvents` for starting point of preloading
//
// Only do this when isSync is true, meaning there was a seek, since we
// don't know where next index is
nextPreloadIndex = -1;

if (isCanvas) {
processEventSync(e, {replayer});
}

prune(e);
return;
}

preload(e);

const source = replayer.getMirror().getNode(e.data.id);
const target =
canvases.get(e.data.id) ||
(source && cloneCanvas(e.data.id, source as HTMLCanvasElement));
if (!isCanvas) {
// Otherwise, not `isSync` and not canvas, only need to prune
prune(e);

// No canvas found for id... this isn't reliably reproducible and not
// exactly sure why it flakes. Saving as metric to keep an eye on it.
if (!target) {
Sentry.metrics.increment('replay.canvas_player.no_canvas_id');
return;
}

await canvasMutation({
event: e,
mutation: e.data,
target,
imageMap,
canvasEventMap,
errorHandler: (err: unknown) => {
if (err instanceof Error) {
Sentry.captureException(err);
} else {
Sentry.metrics.increment('replay.canvas_player.error_canvas_mutation');
}
},
});
try {
processEvent(e, {replayer});
} catch (err) {
if (err instanceof InvalidCanvasNodeError) {
// This can throw if mirror DOM is not ready
Sentry.metrics.increment('replay.canvas_player.no_canvas_id');
return;
}

const img = containers.get(e.data.id);
if (img) {
img.src = target.toDataURL();
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
Sentry.captureException(err);
}

prune(e);
},
};
}