useExtractedCrumbHtml.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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. nextExtract: null | Extraction['html'] = null;
  122. activities: Extraction[] = [];
  123. constructor({crumbs, isFinished, onFinish}: PluginOpts) {
  124. this.crumbs = crumbs;
  125. this.isFinished = isFinished;
  126. this.onFinish = onFinish;
  127. }
  128. handler(event: eventWithTime, _isSync: boolean, {replayer}: {replayer: Replayer}) {
  129. if (event.type === EventType.IncrementalSnapshot) {
  130. this.extractCurrentCrumb(event, {replayer});
  131. this.extractNextCrumb({replayer});
  132. }
  133. if (this.isFinished(event)) {
  134. this.onFinish(this.activities);
  135. }
  136. }
  137. extractCurrentCrumb(event: eventWithTime, {replayer}: {replayer: Replayer}) {
  138. const crumb = first(this.crumbs);
  139. const crumbTimestamp = +new Date(crumb?.timestamp || '');
  140. if (!crumb || !crumbTimestamp || crumbTimestamp > event.timestamp) {
  141. return;
  142. }
  143. const truncated = extractNode(crumb, replayer) || this.nextExtract;
  144. if (truncated) {
  145. this.activities.push({
  146. crumb,
  147. html: truncated,
  148. timestamp: crumbTimestamp,
  149. });
  150. }
  151. this.nextExtract = null;
  152. this.crumbs.shift();
  153. }
  154. extractNextCrumb({replayer}: {replayer: Replayer}) {
  155. const crumb = first(this.crumbs);
  156. const crumbTimestamp = +new Date(crumb?.timestamp || '');
  157. if (!crumb || !crumbTimestamp) {
  158. return;
  159. }
  160. this.nextExtract = extractNode(crumb, replayer);
  161. }
  162. }
  163. function extractNode(crumb: Crumb, replayer: Replayer) {
  164. const mirror = replayer.getMirror();
  165. // @ts-expect-error
  166. const nodeId = crumb.data?.nodeId || '';
  167. const node = mirror.getNode(nodeId);
  168. // @ts-expect-error
  169. const html = node?.outerHTML || node?.textContent || '';
  170. // Limit document node depth to 2
  171. let truncated = removeNodesAtLevel(html, 2);
  172. // If still very long and/or removeNodesAtLevel failed, truncate
  173. if (truncated.length > 1500) {
  174. truncated = truncated.substring(0, 1500);
  175. }
  176. return truncated;
  177. }
  178. function removeNodesAtLevel(html: string, level: number) {
  179. const parser = new DOMParser();
  180. try {
  181. const doc = parser.parseFromString(html, 'text/html');
  182. const removeChildLevel = (
  183. max: number,
  184. collection: HTMLCollection,
  185. current: number = 0
  186. ) => {
  187. for (let i = 0; i < collection.length; i++) {
  188. const child = collection[i];
  189. if (child.nodeName === 'STYLE') {
  190. child.textContent = '/* Inline CSS */';
  191. }
  192. if (child.nodeName === 'svg') {
  193. child.innerHTML = '<!-- SVG -->';
  194. }
  195. if (max <= current) {
  196. if (child.childElementCount > 0) {
  197. child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
  198. }
  199. } else {
  200. removeChildLevel(max, child.children, current + 1);
  201. }
  202. }
  203. };
  204. removeChildLevel(level, doc.body.children);
  205. return doc.body.innerHTML;
  206. } catch (err) {
  207. // If we can't parse the HTML, just return the original
  208. return html;
  209. }
  210. }
  211. export default useExtractedCrumbHtml;