useExtractedCrumbHtml.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import {useEffect, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import first from 'lodash/first';
  4. import {Replayer} from 'rrweb';
  5. import {eventWithTime} from 'rrweb/typings/types';
  6. import type {Crumb} from 'sentry/types/breadcrumbs';
  7. import type ReplayReader from 'sentry/utils/replays/replayReader';
  8. // Copied from `node_modules/rrweb/typings/types.d.ts`
  9. enum EventType {
  10. DomContentLoaded = 0,
  11. Load = 1,
  12. FullSnapshot = 2,
  13. IncrementalSnapshot = 3,
  14. Meta = 4,
  15. Custom = 5,
  16. Plugin = 6,
  17. }
  18. type Extraction = {
  19. crumb: Crumb;
  20. html: string;
  21. timestamp: number;
  22. };
  23. type HookOpts = {
  24. replay: ReplayReader;
  25. };
  26. function useExtractedCrumbHtml({replay}: HookOpts) {
  27. const [breadcrumbRefs, setBreadcrumbReferences] = useState<Extraction[]>([]);
  28. useEffect(() => {
  29. let isMounted = true;
  30. const domRoot = document.createElement('div');
  31. domRoot.className = 'sr-block';
  32. const {style} = domRoot;
  33. style.position = 'fixed';
  34. style.inset = '0';
  35. style.width = '0';
  36. style.height = '0';
  37. style.overflow = 'hidden';
  38. document.body.appendChild(domRoot);
  39. // Get a list of the breadcrumbs that relate directly to the DOM, for each
  40. // crumb we will extract the referenced HTML.
  41. const crumbs = replay
  42. .getRawCrumbs()
  43. .filter(crumb => crumb.data && 'nodeId' in crumb.data);
  44. const rrwebEvents = replay.getRRWebEvents();
  45. // Grab the last event, but skip the synthetic `replay-end` event that the
  46. // ReplayerReader added. RRWeb will skip that event when it comes time to render
  47. const lastEvent = rrwebEvents[rrwebEvents.length - 2];
  48. const isLastRRWebEvent = (event: eventWithTime) => lastEvent === event;
  49. const replayerRef = new Replayer(rrwebEvents, {
  50. root: domRoot,
  51. loadTimeout: 1,
  52. showWarning: false,
  53. blockClass: 'sr-block',
  54. speed: 99999,
  55. skipInactive: true,
  56. triggerFocus: false,
  57. plugins: [
  58. new BreadcrumbReferencesPlugin({
  59. crumbs,
  60. isFinished: isLastRRWebEvent,
  61. onFinish: rows => {
  62. if (isMounted) {
  63. setBreadcrumbReferences(rows);
  64. }
  65. setTimeout(() => {
  66. if (document.body.contains(domRoot)) {
  67. document.body.removeChild(domRoot);
  68. }
  69. }, 0);
  70. },
  71. }),
  72. ],
  73. mouseTail: false,
  74. });
  75. try {
  76. // Run the replay to the end, we will capture data as it streams into the plugin
  77. replayerRef.pause(replay.getReplay().finishedAt.getTime());
  78. } catch (error) {
  79. Sentry.captureException(error);
  80. }
  81. return () => {
  82. isMounted = false;
  83. };
  84. }, [replay]);
  85. return {
  86. isLoading: false,
  87. actions: breadcrumbRefs,
  88. };
  89. }
  90. type PluginOpts = {
  91. crumbs: Crumb[];
  92. isFinished: (event: eventWithTime) => boolean;
  93. onFinish: (mutations: Extraction[]) => void;
  94. };
  95. class BreadcrumbReferencesPlugin {
  96. crumbs: Crumb[];
  97. isFinished: (event: eventWithTime) => boolean;
  98. onFinish: (mutations: Extraction[]) => void;
  99. activities: Extraction[] = [];
  100. constructor({crumbs, isFinished, onFinish}: PluginOpts) {
  101. this.crumbs = crumbs;
  102. this.isFinished = isFinished;
  103. this.onFinish = onFinish;
  104. }
  105. handler(event: eventWithTime, _isSync: boolean, {replayer}: {replayer: Replayer}) {
  106. if (event.type === EventType.IncrementalSnapshot) {
  107. const crumb = first(this.crumbs);
  108. const nextTimestamp = +new Date(crumb?.timestamp || '');
  109. if (crumb && nextTimestamp && nextTimestamp <= event.timestamp) {
  110. // we passed the next one, grab the dom, and pop the timestamp off
  111. const mirror = replayer.getMirror();
  112. // @ts-expect-error
  113. const node = mirror.getNode(crumb.data?.nodeId || '');
  114. // @ts-expect-error
  115. const html = node?.outerHTML || node?.textContent || '';
  116. this.activities.push({
  117. crumb,
  118. html,
  119. timestamp: nextTimestamp,
  120. });
  121. this.crumbs.shift();
  122. }
  123. }
  124. if (this.isFinished(event)) {
  125. this.onFinish(this.activities);
  126. }
  127. }
  128. }
  129. export default useExtractedCrumbHtml;