canvasReplayerPlugin.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import * as Sentry from '@sentry/react';
  2. import {
  3. canvasMutation,
  4. type canvasMutationData,
  5. type canvasMutationParam,
  6. EventType,
  7. type eventWithTime,
  8. IncrementalSource,
  9. type Replayer,
  10. type ReplayPlugin,
  11. } from '@sentry-internal/rrweb';
  12. import type {CanvasArg} from '@sentry-internal/rrweb-types';
  13. import debounce from 'lodash/debounce';
  14. import {deserializeCanvasArg} from './deserializeCanvasArgs';
  15. type CanvasEventWithTime = eventWithTime & {
  16. data: canvasMutationData;
  17. type: EventType.IncrementalSnapshot;
  18. };
  19. function isCanvasMutationEvent(e: eventWithTime): e is CanvasEventWithTime {
  20. return (
  21. e.type === EventType.IncrementalSnapshot &&
  22. e.data.source === IncrementalSource.CanvasMutation
  23. );
  24. }
  25. class InvalidCanvasNodeError extends Error {}
  26. /**
  27. * Find the lowest matching index for event
  28. */
  29. function findIndex(
  30. arr: eventWithTime[],
  31. event?: eventWithTime,
  32. optionalStart?: number,
  33. optionalEnd?: number
  34. ) {
  35. if (!event) {
  36. return -1;
  37. }
  38. const start = optionalStart ?? 0;
  39. const end = optionalEnd ?? arr.length - 1;
  40. if (start > end) {
  41. return end;
  42. }
  43. const mid = Math.floor((start + end) / 2);
  44. // Search lower half
  45. if (event.timestamp <= arr[mid]!.timestamp) {
  46. return findIndex(arr, event, start, mid - 1);
  47. }
  48. // Search top half
  49. return findIndex(arr, event, mid + 1, end);
  50. }
  51. /**
  52. * Takes sorted canvasMutationEvents and:
  53. * - preloads a small amount of canvas events to improve playback
  54. * - applies the canvas draw comands to a canvas outside of rrweb iframe
  55. * - copies outside canvas to iframe canvas
  56. * - this avoids having to remove iframe sandbox
  57. */
  58. export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
  59. const PRELOAD_SIZE = 50;
  60. const BUFFER_TIME = 20_000;
  61. const canvases = new Map<number, HTMLCanvasElement>();
  62. const containers = new Map<number, HTMLImageElement>();
  63. const imageMap = new Map<CanvasEventWithTime | string, HTMLImageElement>();
  64. // `canvasEventMap` can not be assumed to be sorted because it is ordered by
  65. // insertion order and insertions will not happen in a linear timeline due to
  66. // the ability to jump around the playback
  67. const canvasEventMap = new Map<CanvasEventWithTime, canvasMutationParam>();
  68. const canvasMutationEvents = events.filter(isCanvasMutationEvent);
  69. // `deserializeAndPreloadCanvasEvents()` is async and `preload()` can be
  70. // called before the previous call finishes, so we use this Set to determine
  71. // if a deserialization of an event is in progress so that it can be skipped if so.
  72. const preloadQueue = new Set<CanvasEventWithTime>();
  73. const eventsToPrune: eventWithTime[] = [];
  74. // In the case where replay is not started and user seeks, `handler` can be
  75. // called before the DOM is fully built. This means that nodes do not yet
  76. // exist in DOM mirror. We need to replay these events when `onBuild` is
  77. // called.
  78. const handleQueue = new Map<number, [CanvasEventWithTime, Replayer]>();
  79. // This is a pointer to the index of the next event that will need to be
  80. // preloaded. Most of the time the recording plays sequentially, so we do not
  81. // need to re-iterate through the events list.
  82. //
  83. // If this value is -1, then it means there is no next preload index and we
  84. // should search (`findIndex`) the events list for the index. This happens
  85. // when the user jumps around the recording.
  86. let nextPreloadIndex = 0;
  87. /**
  88. * Prune events that are more than <20> seconds away (both older and newer) than given event.
  89. *
  90. * 20 seconds is used as the buffer because our UI's "go back 10 seconds".
  91. */
  92. function prune(event: eventWithTime): void {
  93. while (eventsToPrune.length) {
  94. // Peek top of queue and see if event should be pruned, otherwise we can break out of the loop
  95. if (
  96. Math.abs(event.timestamp - eventsToPrune[0]!.timestamp) <= BUFFER_TIME &&
  97. eventsToPrune.length <= PRELOAD_SIZE
  98. ) {
  99. break;
  100. }
  101. const eventToPrune = eventsToPrune.shift();
  102. if (
  103. eventToPrune &&
  104. isCanvasMutationEvent(eventToPrune) &&
  105. canvasEventMap.has(eventToPrune)
  106. ) {
  107. canvasEventMap.delete(eventToPrune);
  108. }
  109. }
  110. // TODO: as a failsafe, we could apply same logic to canvasEventMap if it goes over a certain size
  111. eventsToPrune.push(event);
  112. }
  113. /**
  114. * Taken from rrweb: https://github.com/rrweb-io/rrweb/blob/8e318c44f26ac25c80d8bd0811f19f5e3fe9903b/packages/rrweb/src/replay/index.ts#L1039
  115. */
  116. async function deserializeAndPreloadCanvasEvents(
  117. data: canvasMutationData,
  118. event: CanvasEventWithTime
  119. ): Promise<void> {
  120. if (!canvasEventMap.has(event)) {
  121. const status = {
  122. isUnchanged: true,
  123. };
  124. if ('commands' in data) {
  125. const commands = await Promise.all(
  126. data.commands.map(async c => {
  127. const args = await Promise.all(
  128. (c.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status))
  129. );
  130. return {...c, args};
  131. })
  132. );
  133. if (status.isUnchanged === false) {
  134. canvasEventMap.set(event, {...data, commands});
  135. }
  136. } else {
  137. const args = await Promise.all(
  138. (data.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status))
  139. );
  140. if (status.isUnchanged === false) {
  141. canvasEventMap.set(event, {...data, args});
  142. }
  143. }
  144. }
  145. }
  146. /**
  147. * Clone canvas node, change parent document of node to current document, and
  148. * insert an image element to original node (i.e. canvas inside of iframe).
  149. *
  150. * The image element is saved to `containers` map, which will later get
  151. * written to when replay is being played.
  152. */
  153. function cloneCanvas(id: number, node: HTMLCanvasElement) {
  154. const cloneNode = node.cloneNode() as HTMLCanvasElement;
  155. canvases.set(id, cloneNode);
  156. document.adoptNode(cloneNode);
  157. return cloneNode;
  158. }
  159. async function preload(currentEvent?: eventWithTime, preloadCount = PRELOAD_SIZE) {
  160. const foundIndex =
  161. nextPreloadIndex > -1
  162. ? nextPreloadIndex
  163. : findIndex(canvasMutationEvents, currentEvent);
  164. const startIndex = foundIndex > -1 ? foundIndex : 0;
  165. const eventsToPreload = canvasMutationEvents
  166. .slice(startIndex, startIndex + preloadCount)
  167. .filter(
  168. ({timestamp}) =>
  169. !currentEvent || timestamp - currentEvent.timestamp <= BUFFER_TIME
  170. );
  171. nextPreloadIndex = nextPreloadIndex > -1 ? nextPreloadIndex + 1 : startIndex;
  172. for (const event of eventsToPreload) {
  173. if (!preloadQueue.has(event) && !canvasEventMap.has(event)) {
  174. preloadQueue.add(event);
  175. // Deserialize and preload an event serially, otherwise for large event
  176. // counts, this can crash the browser
  177. await deserializeAndPreloadCanvasEvents(event.data as canvasMutationData, event);
  178. preloadQueue.delete(event);
  179. }
  180. }
  181. }
  182. // Debounce so that `processEvent` is not called immediately. We want to only
  183. // process the most recent event, otherwise it will look like the canvas is
  184. // animating when we seek throughout replay.
  185. //
  186. // `handleQueue` is really a map of canvas id -> most recent canvas mutation
  187. // event for all canvas mutation events before the current replay time
  188. const debouncedProcessQueuedEvents = debounce(
  189. function processQueuedEvents() {
  190. const canvasIds = Array.from(canvases.keys());
  191. const queuedEventIds = Array.from(handleQueue.keys());
  192. const queuedEventIdsSet = new Set(queuedEventIds);
  193. const unusedCanvases = canvasIds.filter(id => !queuedEventIdsSet.has(id));
  194. // Compare the canvas ids from canvas mutation events against existing
  195. // canvases and remove the canvas snapshot for previously drawn to
  196. // canvases that do not currently exist in this new point of time
  197. unusedCanvases.forEach(id => {
  198. const el = containers.get(id);
  199. if (el) {
  200. el.src = '';
  201. }
  202. });
  203. // Find all canvases with an event that needs to process
  204. Array.from(handleQueue.entries()).forEach(async ([id, [e, replayer]]) => {
  205. try {
  206. await processEvent(e, {replayer});
  207. handleQueue.delete(id);
  208. } catch (err) {
  209. handleProcessEventError(err);
  210. }
  211. });
  212. },
  213. 250,
  214. {maxWait: 1000}
  215. );
  216. /**
  217. * In the case where mirror DOM is built, we only want to process the most
  218. * recent sync event, otherwise the playback will look like it's playing if
  219. * we process all events.
  220. */
  221. function processEventSync(e: eventWithTime, {replayer}: {replayer: Replayer}) {
  222. // We want to only process the most recent sync CanvasMutationEvent
  223. if (isCanvasMutationEvent(e)) {
  224. handleQueue.set(e.data.id, [e, replayer]);
  225. }
  226. debouncedProcessQueuedEvents();
  227. }
  228. /**
  229. * Processes canvas mutation events
  230. */
  231. async function processEvent(e: CanvasEventWithTime, {replayer}: {replayer: Replayer}) {
  232. preload(e);
  233. const source = replayer.getMirror().getNode(e.data.id);
  234. const target =
  235. canvases.get(e.data.id) ||
  236. (source && cloneCanvas(e.data.id, source as HTMLCanvasElement));
  237. if (!target) {
  238. throw new InvalidCanvasNodeError('No canvas found for id');
  239. }
  240. await canvasMutation({
  241. event: e,
  242. mutation: e.data,
  243. target,
  244. imageMap,
  245. canvasEventMap,
  246. errorHandler: (err: unknown) => {
  247. if (err instanceof Error) {
  248. Sentry.captureException(err);
  249. }
  250. },
  251. });
  252. const img = containers.get(e.data.id);
  253. if (img) {
  254. img.src = target.toDataURL();
  255. img.style.maxWidth = '100%';
  256. img.style.maxHeight = '100%';
  257. }
  258. prune(e);
  259. }
  260. preload();
  261. return {
  262. /**
  263. * When document is first built, we want to preload canvas events. After a
  264. * `canvas` element is built (in rrweb), insert an image element which will
  265. * be used to mirror the drawn canvas.
  266. */
  267. onBuild: (node, {id}) => {
  268. if (!node) {
  269. return;
  270. }
  271. if (node.nodeName === 'CANVAS' && node.nodeType === 1) {
  272. // Add new image container that will be written to
  273. const el = containers.get(id) || document.createElement('img');
  274. (node as HTMLCanvasElement).appendChild(el);
  275. containers.set(id, el);
  276. }
  277. // See comments at definition of `handleQueue`
  278. const queueItem = handleQueue.get(id);
  279. handleQueue.delete(id);
  280. if (!queueItem) {
  281. return;
  282. }
  283. const [event, replayer] = queueItem;
  284. processEvent(event, {replayer}).catch(handleProcessEventError);
  285. },
  286. /**
  287. * Mutate canvas outside of iframe, then export the canvas as an image, and
  288. * draw inside of the image el inside of replay canvas.
  289. */
  290. handler: (e: eventWithTime, isSync: boolean, {replayer}: {replayer: Replayer}) => {
  291. const isCanvas = isCanvasMutationEvent(e);
  292. // isSync = true means it is fast forwarding vs playing
  293. // nothing to do when fast forwarding since canvas mutations for us are
  294. // image snapshots and do not depend on past events
  295. if (isSync) {
  296. // Set this to -1 to indicate that we will need to search
  297. // `canvasMutationEvents` for starting point of preloading
  298. //
  299. // Only do this when isSync is true, meaning there was a seek, since we
  300. // don't know where next index is
  301. nextPreloadIndex = -1;
  302. processEventSync(e, {replayer});
  303. prune(e);
  304. return;
  305. }
  306. if (!isCanvas) {
  307. // Otherwise, not `isSync` and not canvas, only need to prune
  308. prune(e);
  309. return;
  310. }
  311. processEvent(e, {replayer}).catch(handleProcessEventError);
  312. },
  313. };
  314. }
  315. function handleProcessEventError(err: unknown) {
  316. if (err instanceof InvalidCanvasNodeError) {
  317. // This can throw if mirror DOM is not ready
  318. return;
  319. }
  320. Sentry.captureException(err);
  321. }