From 00469f48d5669107d0f8e67e69432e2202eb5766 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 4 Jul 2025 09:48:52 +0200 Subject: [PATCH 1/3] feat(replay): Add `ignoreMutations` option This option allows to configure a selector list of elements to not capture mutation for. --- .../suites/replay/ignoreMutations/init.js | 19 ++++++ .../suites/replay/ignoreMutations/subject.js | 26 ++++++++ .../replay/ignoreMutations/template.html | 11 ++++ .../suites/replay/ignoreMutations/test.ts | 65 +++++++++++++++++++ packages/replay-internal/src/integration.ts | 2 + packages/replay-internal/src/replay.ts | 26 +++++++- packages/replay-internal/src/types/replay.ts | 8 +++ 7 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/ignoreMutations/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/ignoreMutations/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/ignoreMutations/test.ts 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..0f800f3fd1f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + useCompression: false, + 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/integration.ts b/packages/replay-internal/src/integration.ts index 6db78dced270..8b85837b82c5 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -84,6 +84,7 @@ export class Replay implements Integration { mutationBreadcrumbLimit = 750, mutationLimit = 10_000, + ignoreMutations = [], slowClickTimeout = 7_000, slowClickIgnoreSelectors = [], @@ -167,6 +168,7 @@ export class Replay implements Integration { maskAllText, mutationBreadcrumbLimit, mutationLimit, + ignoreMutations, slowClickTimeout, slowClickIgnoreSelectors, networkDetailAllowUrls, diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 87fa1182aeeb..604929f99425 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up import type { ReplayRecordingMode, Span } from '@sentry/core'; import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; -import { EventType, record } from '@sentry-internal/rrweb'; +import { EventType, record, utils as rrwebUtils } from '@sentry-internal/rrweb'; import { BUFFER_CHECKOUT_TIME, SESSION_IDLE_EXPIRE_DURATION, @@ -1304,7 +1304,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; + if (ignoreMutations.length) { + if ( + mutations.some(mutation => { + const el = rrwebUtils.closestElementOfNode(mutation.target); + const selector = ignoreMutations.join(','); + return el?.matches(selector); + }) + ) { + return false; + } + } + const count = mutations.length; const mutationLimit = this._options.mutationLimit; @@ -1336,3 +1349,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..cb3ec1f94461 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -161,6 +161,14 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ mutationLimit: number; + /** + * 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[]; + /** * The max. time in ms to wait for a slow click to finish. * After this amount of time we stop waiting for actions after a click happened. From 8a8cb589049b7b8383bdadde51b417072336544a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 7 Jul 2025 14:28:48 +0200 Subject: [PATCH 2/3] make it experimental --- .../suites/replay/ignoreMutations/init.js | 4 +++- packages/replay-internal/src/integration.ts | 2 -- packages/replay-internal/src/replay.ts | 4 ++-- packages/replay-internal/src/types/replay.ts | 15 +++++++-------- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js index 0f800f3fd1f9..269b531b57eb 100644 --- a/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js +++ b/dev-packages/browser-integration-tests/suites/replay/ignoreMutations/init.js @@ -6,7 +6,9 @@ window.Replay = Sentry.replayIntegration({ flushMaxDelay: 200, minReplayDuration: 0, useCompression: false, - ignoreMutations: ['.moving'], + _experiments: { + ignoreMutations: ['.moving'], + }, }); Sentry.init({ diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 8b85837b82c5..6db78dced270 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -84,7 +84,6 @@ export class Replay implements Integration { mutationBreadcrumbLimit = 750, mutationLimit = 10_000, - ignoreMutations = [], slowClickTimeout = 7_000, slowClickIgnoreSelectors = [], @@ -168,7 +167,6 @@ export class Replay implements Integration { maskAllText, mutationBreadcrumbLimit, mutationLimit, - ignoreMutations, slowClickTimeout, slowClickIgnoreSelectors, networkDetailAllowUrls, diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 604929f99425..bde3dea3bcbc 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -1305,8 +1305,8 @@ export class ReplayContainer implements ReplayContainerInterface { /** Handler for rrweb.record.onMutation */ private _onMutationHandler(mutations: MutationRecord[]): boolean { - const { ignoreMutations } = this._options; - if (ignoreMutations.length) { + const { ignoreMutations } = this._options._experiments; + if (ignoreMutations?.length) { if ( mutations.some(mutation => { const el = rrwebUtils.closestElementOfNode(mutation.target); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index cb3ec1f94461..47960934ad3e 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -161,14 +161,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ mutationLimit: number; - /** - * 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[]; - /** * The max. time in ms to wait for a slow click to finish. * After this amount of time we stop waiting for actions after a click happened. @@ -242,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[]; }>; } From 10752f412eef363c89da8882da0a50e898eb308d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 7 Jul 2025 14:36:13 +0200 Subject: [PATCH 3/3] vendor util in --- packages/replay-internal/src/replay.ts | 5 +++-- packages/replay-internal/src/util/rrweb.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 packages/replay-internal/src/util/rrweb.ts diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index bde3dea3bcbc..6fc607e1c2d8 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up import type { ReplayRecordingMode, Span } from '@sentry/core'; import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; -import { EventType, record, utils as rrwebUtils } from '@sentry-internal/rrweb'; +import { EventType, record } from '@sentry-internal/rrweb'; import { BUFFER_CHECKOUT_TIME, SESSION_IDLE_EXPIRE_DURATION, @@ -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'; @@ -1309,7 +1310,7 @@ export class ReplayContainer implements ReplayContainerInterface { if (ignoreMutations?.length) { if ( mutations.some(mutation => { - const el = rrwebUtils.closestElementOfNode(mutation.target); + const el = closestElementOfNode(mutation.target); const selector = ignoreMutations.join(','); return el?.matches(selector); }) 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; + } +}