extractDomNodes.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import {Replayer} from '@sentry-internal/rrweb';
  2. import type {Mirror} from '@sentry-internal/rrweb-snapshot';
  3. import type {RecordingFrame, ReplayFrame} from 'sentry/utils/replays/types';
  4. export type Extraction = {
  5. frame: ReplayFrame;
  6. html: string | null;
  7. timestamp: number;
  8. };
  9. type Args = {
  10. frames: ReplayFrame[] | undefined;
  11. rrwebEvents: RecordingFrame[] | undefined;
  12. };
  13. export default function extractDomNodes({
  14. frames = [],
  15. rrwebEvents,
  16. }: Args): Promise<Extraction[]> {
  17. return new Promise(resolve => {
  18. if (!frames.length) {
  19. resolve([]);
  20. return;
  21. }
  22. const extractions = new Map<ReplayFrame, Extraction>();
  23. const player = createPlayer(rrwebEvents);
  24. const mirror = player.getMirror();
  25. const nextFrame = (function () {
  26. let i = 0;
  27. return () => frames[i++];
  28. })();
  29. const onDone = () => {
  30. resolve(Array.from(extractions.values()));
  31. };
  32. const nextOrDone = () => {
  33. const next = nextFrame();
  34. if (next) {
  35. matchFrame(next);
  36. } else {
  37. onDone();
  38. }
  39. };
  40. type FrameRef = {
  41. frame: undefined | ReplayFrame;
  42. nodeId: undefined | number;
  43. };
  44. const nodeIdRef: FrameRef = {
  45. frame: undefined,
  46. nodeId: undefined,
  47. };
  48. const handlePause = () => {
  49. if (!nodeIdRef.nodeId && !nodeIdRef.frame) {
  50. return;
  51. }
  52. const frame = nodeIdRef.frame as ReplayFrame;
  53. const nodeId = nodeIdRef.nodeId as number;
  54. const html = extractHtml(nodeId as number, mirror);
  55. extractions.set(frame as ReplayFrame, {
  56. frame,
  57. html,
  58. timestamp: frame.timestampMs,
  59. });
  60. nextOrDone();
  61. };
  62. const matchFrame = frame => {
  63. nodeIdRef.frame = frame;
  64. nodeIdRef.nodeId =
  65. frame.data && 'nodeId' in frame.data ? frame.data.nodeId : undefined;
  66. if (nodeIdRef.nodeId === undefined || nodeIdRef.nodeId === -1) {
  67. nextOrDone();
  68. return;
  69. }
  70. window.setTimeout(() => {
  71. player.pause(frame.offsetMs);
  72. }, 0);
  73. };
  74. player.on('pause', handlePause);
  75. matchFrame(nextFrame());
  76. });
  77. }
  78. function createPlayer(rrwebEvents): Replayer {
  79. const domRoot = document.createElement('div');
  80. domRoot.className = 'sentry-block';
  81. const {style} = domRoot;
  82. style.position = 'fixed';
  83. style.inset = '0';
  84. style.width = '0';
  85. style.height = '0';
  86. style.overflow = 'hidden';
  87. document.body.appendChild(domRoot);
  88. const replayerRef = new Replayer(rrwebEvents, {
  89. root: domRoot,
  90. loadTimeout: 1,
  91. showWarning: false,
  92. blockClass: 'sentry-block',
  93. speed: 99999,
  94. skipInactive: true,
  95. triggerFocus: false,
  96. mouseTail: false,
  97. });
  98. return replayerRef;
  99. }
  100. function extractHtml(nodeId: number, mirror: Mirror): string | null {
  101. const node = mirror.getNode(nodeId);
  102. const html =
  103. (node && 'outerHTML' in node ? (node.outerHTML as string) : node?.textContent) || '';
  104. // Limit document node depth to 2
  105. let truncated = removeNodesAtLevel(html, 2);
  106. // If still very long and/or removeNodesAtLevel failed, truncate
  107. if (truncated.length > 1500) {
  108. truncated = truncated.substring(0, 1500);
  109. }
  110. return truncated ? truncated : null;
  111. }
  112. function removeChildLevel(max: number, collection: HTMLCollection, current: number = 0) {
  113. for (let i = 0; i < collection.length; i++) {
  114. const child = collection[i];
  115. if (child.nodeName === 'STYLE') {
  116. child.textContent = '/* Inline CSS */';
  117. }
  118. if (child.nodeName === 'svg') {
  119. child.innerHTML = '<!-- SVG -->';
  120. }
  121. if (max <= current) {
  122. if (child.childElementCount > 0) {
  123. child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
  124. }
  125. } else {
  126. removeChildLevel(max, child.children, current + 1);
  127. }
  128. }
  129. }
  130. function removeNodesAtLevel(html: string, level: number): string {
  131. const parser = new DOMParser();
  132. try {
  133. const doc = parser.parseFromString(html, 'text/html');
  134. removeChildLevel(level, doc.body.children);
  135. return doc.body.innerHTML;
  136. } catch (err) {
  137. // If we can't parse the HTML, just return the original
  138. return html;
  139. }
  140. }