useExtractedCrumbHtml.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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. export type Extraction = {
  19. crumb: Crumb;
  20. html: string;
  21. timestamp: number;
  22. };
  23. type HookOpts = {
  24. replay: ReplayReader;
  25. };
  26. const requestIdleCallback =
  27. window.requestIdleCallback ||
  28. function requestIdleCallbackPolyfill(cb) {
  29. const start = Date.now();
  30. return setTimeout(function () {
  31. cb({
  32. didTimeout: false,
  33. timeRemaining: function () {
  34. return Math.max(0, 50 - (Date.now() - start));
  35. },
  36. });
  37. }, 1);
  38. };
  39. function useExtractedCrumbHtml({replay}: HookOpts) {
  40. const [isLoading, setIsLoading] = useState(true);
  41. const [breadcrumbRefs, setBreadcrumbReferences] = useState<Extraction[]>([]);
  42. useEffect(() => {
  43. requestIdleCallback(
  44. () => {
  45. let isMounted = true;
  46. const domRoot = document.createElement('div');
  47. domRoot.className = 'sentry-block';
  48. const {style} = domRoot;
  49. style.position = 'fixed';
  50. style.inset = '0';
  51. style.width = '0';
  52. style.height = '0';
  53. style.overflow = 'hidden';
  54. document.body.appendChild(domRoot);
  55. // Get a list of the breadcrumbs that relate directly to the DOM, for each
  56. // crumb we will extract the referenced HTML.
  57. const crumbs = replay
  58. .getRawCrumbs()
  59. .filter(crumb => crumb.data && 'nodeId' in crumb.data);
  60. const rrwebEvents = replay.getRRWebEvents();
  61. // Grab the last event, but skip the synthetic `replay-end` event that the
  62. // ReplayerReader added. RRWeb will skip that event when it comes time to render
  63. const lastEvent = rrwebEvents[rrwebEvents.length - 2];
  64. const isLastRRWebEvent = (event: eventWithTime) => lastEvent === event;
  65. const replayerRef = new Replayer(rrwebEvents, {
  66. root: domRoot,
  67. loadTimeout: 1,
  68. showWarning: false,
  69. blockClass: 'sentry-block',
  70. speed: 99999,
  71. skipInactive: true,
  72. triggerFocus: false,
  73. plugins: [
  74. new BreadcrumbReferencesPlugin({
  75. crumbs,
  76. isFinished: isLastRRWebEvent,
  77. onFinish: rows => {
  78. if (isMounted) {
  79. setBreadcrumbReferences(rows);
  80. }
  81. setTimeout(() => {
  82. if (document.body.contains(domRoot)) {
  83. document.body.removeChild(domRoot);
  84. }
  85. }, 0);
  86. },
  87. }),
  88. ],
  89. mouseTail: false,
  90. });
  91. try {
  92. // Run the replay to the end, we will capture data as it streams into the plugin
  93. replayerRef.pause(replay.getReplay().finishedAt.getTime());
  94. } catch (error) {
  95. Sentry.captureException(error);
  96. }
  97. setIsLoading(false);
  98. return () => {
  99. isMounted = false;
  100. };
  101. },
  102. {
  103. timeout: 2500,
  104. }
  105. );
  106. }, [replay]);
  107. return {
  108. isLoading,
  109. actions: breadcrumbRefs,
  110. };
  111. }
  112. type PluginOpts = {
  113. crumbs: Crumb[];
  114. isFinished: (event: eventWithTime) => boolean;
  115. onFinish: (mutations: Extraction[]) => void;
  116. };
  117. class BreadcrumbReferencesPlugin {
  118. crumbs: Crumb[];
  119. isFinished: (event: eventWithTime) => boolean;
  120. onFinish: (mutations: Extraction[]) => void;
  121. activities: Extraction[] = [];
  122. constructor({crumbs, isFinished, onFinish}: PluginOpts) {
  123. this.crumbs = crumbs;
  124. this.isFinished = isFinished;
  125. this.onFinish = onFinish;
  126. }
  127. handler(event: eventWithTime, _isSync: boolean, {replayer}: {replayer: Replayer}) {
  128. if (event.type === EventType.IncrementalSnapshot) {
  129. const crumb = first(this.crumbs);
  130. const nextTimestamp = +new Date(crumb?.timestamp || '');
  131. if (crumb && nextTimestamp && nextTimestamp <= event.timestamp) {
  132. // we passed the next one, grab the dom, and pop the timestamp off
  133. const mirror = replayer.getMirror();
  134. // @ts-expect-error
  135. const node = mirror.getNode(crumb.data?.nodeId || '');
  136. // @ts-expect-error
  137. const html = node?.outerHTML || node?.textContent || '';
  138. // Limit document node depth to 2
  139. let truncated = removeNodesAtLevel(html, 2);
  140. // If still very long and/or removeNodesAtLevel failed, truncate
  141. if (truncated.length > 1500) {
  142. truncated = truncated.substring(0, 1500);
  143. }
  144. if (truncated) {
  145. this.activities.push({
  146. crumb,
  147. html: truncated,
  148. timestamp: nextTimestamp,
  149. });
  150. }
  151. this.crumbs.shift();
  152. }
  153. }
  154. if (this.isFinished(event)) {
  155. this.onFinish(this.activities);
  156. }
  157. }
  158. }
  159. function removeNodesAtLevel(html: string, level: number) {
  160. const parser = new DOMParser();
  161. try {
  162. const doc = parser.parseFromString(html, 'text/html');
  163. const removeChildLevel = (
  164. max: number,
  165. collection: HTMLCollection,
  166. current: number = 0
  167. ) => {
  168. for (let i = 0; i < collection.length; i++) {
  169. const child = collection[i];
  170. if (child.nodeName === 'STYLE') {
  171. child.textContent = '/* Inline CSS */';
  172. }
  173. if (child.nodeName === 'svg') {
  174. child.innerHTML = '<!-- SVG -->';
  175. }
  176. if (max <= current) {
  177. if (child.childElementCount > 0) {
  178. child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
  179. }
  180. } else {
  181. removeChildLevel(max, child.children, current + 1);
  182. }
  183. }
  184. };
  185. removeChildLevel(level, doc.body.children);
  186. return doc.body.innerHTML;
  187. } catch (err) {
  188. // If we can't parse the HTML, just return the original
  189. return html;
  190. }
  191. }
  192. export default useExtractedCrumbHtml;