123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- import * as Sentry from '@sentry/react';
- import type {eventWithTime} from '@sentry-internal/rrweb';
- import {EventType, Replayer} from '@sentry-internal/rrweb';
- import first from 'lodash/first';
- import type {Crumb} from 'sentry/types/breadcrumbs';
- import requestIdleCallback from 'sentry/utils/window/requestIdleCallback';
- export type Extraction = {
- crumb: Crumb;
- html: string;
- timestamp: number;
- };
- type Args = {
- crumbs: Crumb[] | undefined;
- finishedAt: Date | undefined;
- rrwebEvents: eventWithTime[] | undefined;
- };
- function _extractDomNodes({
- crumbs,
- rrwebEvents,
- finishedAt,
- }: Args): Promise<Extraction[]> {
- // Get a list of the breadcrumbs that relate directly to the DOM, for each
- // crumb we will extract the referenced HTML.
- if (!crumbs || !rrwebEvents || rrwebEvents.length < 2 || !finishedAt) {
- return Promise.reject();
- }
- return new Promise((resolve, reject) => {
- 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);
- // 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 => {
- resolve(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(finishedAt.getTime());
- } catch (error) {
- Sentry.captureException(error);
- reject(error);
- }
- });
- }
- export default function extractDomNodes(args: Args): Promise<Extraction[]> {
- return new Promise((resolve, reject) => {
- requestIdleCallback(
- () => {
- _extractDomNodes(args).then(resolve).catch(reject);
- },
- {
- timeout: 2500,
- }
- );
- });
- }
- type PluginOpts = {
- crumbs: Crumb[];
- isFinished: (event: eventWithTime) => boolean;
- onFinish: (mutations: Extraction[]) => void;
- };
- class BreadcrumbReferencesPlugin {
- crumbs: Crumb[];
- isFinished: (event: eventWithTime) => boolean;
- onFinish: (mutations: Extraction[]) => void;
- nextExtract: null | Extraction['html'] = null;
- 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.FullSnapshot) {
- this.extractNextCrumb({replayer});
- } else if (event.type === EventType.IncrementalSnapshot) {
- this.extractCurrentCrumb(event, {replayer});
- this.extractNextCrumb({replayer});
- }
- if (this.isFinished(event)) {
- this.onFinish(this.activities);
- }
- }
- extractCurrentCrumb(event: eventWithTime, {replayer}: {replayer: Replayer}) {
- const crumb = first(this.crumbs);
- const crumbTimestamp = +new Date(crumb?.timestamp || '');
- if (!crumb || !crumbTimestamp || crumbTimestamp > event.timestamp) {
- return;
- }
- const truncated = extractNode(crumb, replayer) || this.nextExtract;
- if (truncated) {
- this.activities.push({
- crumb,
- html: truncated,
- timestamp: crumbTimestamp,
- });
- }
- this.nextExtract = null;
- this.crumbs.shift();
- }
- extractNextCrumb({replayer}: {replayer: Replayer}) {
- const crumb = first(this.crumbs);
- const crumbTimestamp = +new Date(crumb?.timestamp || '');
- if (!crumb || !crumbTimestamp) {
- return;
- }
- this.nextExtract = extractNode(crumb, replayer);
- }
- }
- function extractNode(crumb: Crumb, replayer: Replayer) {
- const mirror = replayer.getMirror();
- // @ts-expect-error
- const nodeId = crumb.data?.nodeId || '';
- const node = mirror.getNode(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);
- }
- return truncated;
- }
- 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 = '<!-- SVG -->';
- }
- if (max <= current) {
- if (child.childElementCount > 0) {
- child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
- }
- } 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;
- }
- }
|