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.