import {useEffect, useState} from 'react'; import * as Sentry from '@sentry/react'; import first from 'lodash/first'; import {Replayer} from 'rrweb'; import {eventWithTime} from 'rrweb/typings/types'; import type {Crumb} from 'sentry/types/breadcrumbs'; import type ReplayReader from 'sentry/utils/replays/replayReader'; // Copied from `node_modules/rrweb/typings/types.d.ts` enum EventType { DomContentLoaded = 0, Load = 1, FullSnapshot = 2, IncrementalSnapshot = 3, Meta = 4, Custom = 5, Plugin = 6, } export type Extraction = { crumb: Crumb; html: string; timestamp: number; }; type HookOpts = { replay: ReplayReader; }; const requestIdleCallback = window.requestIdleCallback || function requestIdleCallbackPolyfill(cb) { const start = Date.now(); return setTimeout(function () { cb({ didTimeout: false, timeRemaining: function () { return Math.max(0, 50 - (Date.now() - start)); }, }); }, 1); }; function useExtractedCrumbHtml({replay}: HookOpts) { const [isLoading, setIsLoading] = useState(true); const [breadcrumbRefs, setBreadcrumbReferences] = useState([]); useEffect(() => { requestIdleCallback( () => { let isMounted = true; const domRoot = document.createElement('div'); domRoot.className = 'sentry-block'; const {style} = domRoot; style.position = 'fixed'; style.inset = '0'; style.width = '0'; style.height = '0'; style.overflow = 'hidden'; document.body.appendChild(domRoot); // Get a list of the breadcrumbs that relate directly to the DOM, for each // crumb we will extract the referenced HTML. const crumbs = replay .getRawCrumbs() .filter(crumb => crumb.data && 'nodeId' in crumb.data); const rrwebEvents = replay.getRRWebEvents(); // Grab the last event, but skip the synthetic `replay-end` event that the // ReplayerReader added. RRWeb will skip that event when it comes time to render const lastEvent = rrwebEvents[rrwebEvents.length - 2]; const isLastRRWebEvent = (event: eventWithTime) => lastEvent === event; const replayerRef = new Replayer(rrwebEvents, { root: domRoot, loadTimeout: 1, showWarning: false, blockClass: 'sentry-block', speed: 99999, skipInactive: true, triggerFocus: false, plugins: [ new BreadcrumbReferencesPlugin({ crumbs, isFinished: isLastRRWebEvent, onFinish: rows => { if (isMounted) { setBreadcrumbReferences(rows); } setTimeout(() => { if (document.body.contains(domRoot)) { document.body.removeChild(domRoot); } }, 0); }, }), ], mouseTail: false, }); try { // Run the replay to the end, we will capture data as it streams into the plugin replayerRef.pause(replay.getReplay().finishedAt.getTime()); } catch (error) { Sentry.captureException(error); } setIsLoading(false); return () => { isMounted = false; }; }, { timeout: 2500, } ); }, [replay]); return { isLoading, actions: breadcrumbRefs, }; } type PluginOpts = { crumbs: Crumb[]; isFinished: (event: eventWithTime) => boolean; onFinish: (mutations: Extraction[]) => void; }; class BreadcrumbReferencesPlugin { crumbs: Crumb[]; isFinished: (event: eventWithTime) => boolean; onFinish: (mutations: Extraction[]) => void; activities: Extraction[] = []; constructor({crumbs, isFinished, onFinish}: PluginOpts) { this.crumbs = crumbs; this.isFinished = isFinished; this.onFinish = onFinish; } handler(event: eventWithTime, _isSync: boolean, {replayer}: {replayer: Replayer}) { if (event.type === EventType.IncrementalSnapshot) { const crumb = first(this.crumbs); const nextTimestamp = +new Date(crumb?.timestamp || ''); if (crumb && nextTimestamp && nextTimestamp <= event.timestamp) { // we passed the next one, grab the dom, and pop the timestamp off const mirror = replayer.getMirror(); // @ts-expect-error const node = mirror.getNode(crumb.data?.nodeId || ''); // @ts-expect-error const html = node?.outerHTML || node?.textContent || ''; // Limit document node depth to 2 let truncated = removeNodesAtLevel(html, 2); // If still very long and/or removeNodesAtLevel failed, truncate if (truncated.length > 1500) { truncated = truncated.substring(0, 1500); } if (truncated) { this.activities.push({ crumb, html: truncated, timestamp: nextTimestamp, }); } this.crumbs.shift(); } } if (this.isFinished(event)) { this.onFinish(this.activities); } } } function removeNodesAtLevel(html: string, level: number) { const parser = new DOMParser(); try { const doc = parser.parseFromString(html, 'text/html'); const removeChildLevel = ( max: number, collection: HTMLCollection, current: number = 0 ) => { for (let i = 0; i < collection.length; i++) { const child = collection[i]; if (child.nodeName === 'STYLE') { child.textContent = '/* Inline CSS */'; } if (child.nodeName === 'svg') { child.innerHTML = ''; } if (max <= current) { if (child.childElementCount > 0) { child.innerHTML = ``; } } else { removeChildLevel(max, child.children, current + 1); } } }; removeChildLevel(level, doc.body.children); return doc.body.innerHTML; } catch (err) { // If we can't parse the HTML, just return the original return html; } } export default useExtractedCrumbHtml;