diff --git a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js new file mode 100644 index 000000000000..269b531b57eb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + useCompression: false, + _experiments: { + ignoreMutations: ['.moving'], + }, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/subject.js b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/subject.js new file mode 100644 index 000000000000..b886ea05a458 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/subject.js @@ -0,0 +1,26 @@ +function moveElement(el, remaining) { + if (!remaining) { + el.classList.remove('moving'); + + setTimeout(() => { + el.style.transform = `translate(${remaining}0px, 0)`; + el.classList.add('moved'); + }); + return; + } + + el.style.transform = `translate(${remaining}0px, 0)`; + + setTimeout(() => { + moveElement(el, remaining - 1); + }, 10); +} + +const el = document.querySelector('#mutation-target'); +const btn = document.querySelector('#button-move'); + +btn.addEventListener('click', event => { + el.classList.add('moving'); + event.preventDefault(); + moveElement(el, 20); +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/template.html b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/template.html new file mode 100644 index 000000000000..58cb29d50590 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/template.html @@ -0,0 +1,11 @@ + + + + + + +
This is moved around!
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/test.ts b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/test.ts new file mode 100644 index 000000000000..3d76a2b07b1a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { mutationData } from '@sentry-internal/rrweb-types'; +import { sentryTest } from '../../../utils/fixtures'; +import type { RecordingSnapshot } from '../../../utils/replayHelpers'; +import { collectReplayRequests, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest('allows to ignore mutations via `ignoreMutations` option', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.goto(url); + await reqPromise0; + + const requestsPromise = collectReplayRequests(page, recordingEvents => { + const events = recordingEvents as (RecordingSnapshot & { data: mutationData })[]; + return events.some(event => event.data.attributes?.some(attr => attr.attributes['class'] === 'moved')); + }); + + page.locator('#button-move').click(); + + const requests = await requestsPromise; + + // All transform mutatinos are ignored and not captured + const transformMutations = requests.replayRecordingSnapshots.filter( + item => + (item.data as mutationData)?.attributes?.some( + attr => attr.attributes['style'] && attr.attributes['class'] !== 'moved', + ), + ); + + // Should capture the final class mutation + const classMutations = requests.replayRecordingSnapshots.filter( + item => (item.data as mutationData)?.attributes?.some(attr => attr.attributes['class']), + ); + + expect(transformMutations).toEqual([]); + expect(classMutations).toEqual([ + { + data: { + adds: [], + attributes: [ + { + attributes: { + class: 'moved', + style: { + transform: 'translate(0px, 0px)', + }, + }, + id: expect.any(Number), + }, + ], + removes: [], + source: expect.any(Number), + texts: [], + }, + timestamp: 0, + type: 3, + }, + ]); +}); diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 87fa1182aeeb..6fc607e1c2d8 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -53,6 +53,7 @@ import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; import { logger } from './util/logger'; import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext'; +import { closestElementOfNode } from './util/rrweb'; import { sendReplay } from './util/sendReplay'; import { RateLimitError } from './util/sendReplayRequest'; import type { SKIPPED } from './util/throttle'; @@ -1304,7 +1305,20 @@ export class ReplayContainer implements ReplayContainerInterface { } /** Handler for rrweb.record.onMutation */ - private _onMutationHandler(mutations: unknown[]): boolean { + private _onMutationHandler(mutations: MutationRecord[]): boolean { + const { ignoreMutations } = this._options._experiments; + if (ignoreMutations?.length) { + if ( + mutations.some(mutation => { + const el = closestElementOfNode(mutation.target); + const selector = ignoreMutations.join(','); + return el?.matches(selector); + }) + ) { + return false; + } + } + const count = mutations.length; const mutationLimit = this._options.mutationLimit; @@ -1336,3 +1350,12 @@ export class ReplayContainer implements ReplayContainerInterface { return true; } } + +interface MutationRecord { + type: string; + target: Node; + oldValue: string | null; + addedNodes: NodeList; + removedNodes: NodeList; + attributeName: string | null; +} diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index c5336dbe5d25..47960934ad3e 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -234,6 +234,13 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ recordCrossOriginIframes: boolean; autoFlushOnFeedback: boolean; + /** + * Completetly ignore mutations matching the given selectors. + * This can be used if a specific type of mutation is causing (e.g. performance) problems. + * NOTE: This can be dangerous to use, as mutations are applied as incremental patches. + * Make sure to verify that the captured replays still work when using this option. + */ + ignoreMutations: string[]; }>; } diff --git a/packages/replay-internal/src/util/rrweb.ts b/packages/replay-internal/src/util/rrweb.ts new file mode 100644 index 000000000000..838e75000378 --- /dev/null +++ b/packages/replay-internal/src/util/rrweb.ts @@ -0,0 +1,18 @@ +/** + * Vendored in from @sentry-internal/rrweb. + * + * This is a copy of the function from rrweb, it is not nicely exported there. + */ +export function closestElementOfNode(node: Node | null): HTMLElement | null { + if (!node) { + return null; + } + + // Catch access to node properties to avoid Firefox "permission denied" errors + try { + const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; + return el; + } catch (error) { + return null; + } +}