Skip to content

Commit f52ab47

Browse files
authored
feat(replay): Add _experiments.ignoreMutations option (#16816)
This option allows to configure a selector list of elements to not capture mutation for. This is under `_experiments` for now: ```js Sentry.replayIntegration({ _experiments: { ignoreMutations: ['.dragging'] } }); ``` Fixes #16797
1 parent 5118f59 commit f52ab47

File tree

7 files changed

+172
-1
lines changed

7 files changed

+172
-1
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = Sentry.replayIntegration({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
useCompression: false,
9+
_experiments: {
10+
ignoreMutations: ['.moving'],
11+
},
12+
});
13+
14+
Sentry.init({
15+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
16+
sampleRate: 0,
17+
replaysSessionSampleRate: 1.0,
18+
replaysOnErrorSampleRate: 0.0,
19+
20+
integrations: [window.Replay],
21+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
function moveElement(el, remaining) {
2+
if (!remaining) {
3+
el.classList.remove('moving');
4+
5+
setTimeout(() => {
6+
el.style.transform = `translate(${remaining}0px, 0)`;
7+
el.classList.add('moved');
8+
});
9+
return;
10+
}
11+
12+
el.style.transform = `translate(${remaining}0px, 0)`;
13+
14+
setTimeout(() => {
15+
moveElement(el, remaining - 1);
16+
}, 10);
17+
}
18+
19+
const el = document.querySelector('#mutation-target');
20+
const btn = document.querySelector('#button-move');
21+
22+
btn.addEventListener('click', event => {
23+
el.classList.add('moving');
24+
event.preventDefault();
25+
moveElement(el, 20);
26+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<div id="mutation-target" style="position: relative">This is moved around!</div>
8+
9+
<button id="button-move" type="button">Move</button>
10+
</body>
11+
</html>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
import type { mutationData } from '@sentry-internal/rrweb-types';
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import type { RecordingSnapshot } from '../../../utils/replayHelpers';
5+
import { collectReplayRequests, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
6+
7+
sentryTest('allows to ignore mutations via `ignoreMutations` option', async ({ getLocalTestUrl, page }) => {
8+
if (shouldSkipReplayTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestUrl({ testDir: __dirname });
13+
14+
const reqPromise0 = waitForReplayRequest(page, 0);
15+
16+
await page.goto(url);
17+
await reqPromise0;
18+
19+
const requestsPromise = collectReplayRequests(page, recordingEvents => {
20+
const events = recordingEvents as (RecordingSnapshot & { data: mutationData })[];
21+
return events.some(event => event.data.attributes?.some(attr => attr.attributes['class'] === 'moved'));
22+
});
23+
24+
page.locator('#button-move').click();
25+
26+
const requests = await requestsPromise;
27+
28+
// All transform mutatinos are ignored and not captured
29+
const transformMutations = requests.replayRecordingSnapshots.filter(
30+
item =>
31+
(item.data as mutationData)?.attributes?.some(
32+
attr => attr.attributes['style'] && attr.attributes['class'] !== 'moved',
33+
),
34+
);
35+
36+
// Should capture the final class mutation
37+
const classMutations = requests.replayRecordingSnapshots.filter(
38+
item => (item.data as mutationData)?.attributes?.some(attr => attr.attributes['class']),
39+
);
40+
41+
expect(transformMutations).toEqual([]);
42+
expect(classMutations).toEqual([
43+
{
44+
data: {
45+
adds: [],
46+
attributes: [
47+
{
48+
attributes: {
49+
class: 'moved',
50+
style: {
51+
transform: 'translate(0px, 0px)',
52+
},
53+
},
54+
id: expect.any(Number),
55+
},
56+
],
57+
removes: [],
58+
source: expect.any(Number),
59+
texts: [],
60+
},
61+
timestamp: 0,
62+
type: 3,
63+
},
64+
]);
65+
});

packages/replay-internal/src/replay.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { isExpired } from './util/isExpired';
5353
import { isSessionExpired } from './util/isSessionExpired';
5454
import { logger } from './util/logger';
5555
import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext';
56+
import { closestElementOfNode } from './util/rrweb';
5657
import { sendReplay } from './util/sendReplay';
5758
import { RateLimitError } from './util/sendReplayRequest';
5859
import type { SKIPPED } from './util/throttle';
@@ -1304,7 +1305,20 @@ export class ReplayContainer implements ReplayContainerInterface {
13041305
}
13051306

13061307
/** Handler for rrweb.record.onMutation */
1307-
private _onMutationHandler(mutations: unknown[]): boolean {
1308+
private _onMutationHandler(mutations: MutationRecord[]): boolean {
1309+
const { ignoreMutations } = this._options._experiments;
1310+
if (ignoreMutations?.length) {
1311+
if (
1312+
mutations.some(mutation => {
1313+
const el = closestElementOfNode(mutation.target);
1314+
const selector = ignoreMutations.join(',');
1315+
return el?.matches(selector);
1316+
})
1317+
) {
1318+
return false;
1319+
}
1320+
}
1321+
13081322
const count = mutations.length;
13091323

13101324
const mutationLimit = this._options.mutationLimit;
@@ -1336,3 +1350,12 @@ export class ReplayContainer implements ReplayContainerInterface {
13361350
return true;
13371351
}
13381352
}
1353+
1354+
interface MutationRecord {
1355+
type: string;
1356+
target: Node;
1357+
oldValue: string | null;
1358+
addedNodes: NodeList;
1359+
removedNodes: NodeList;
1360+
attributeName: string | null;
1361+
}

packages/replay-internal/src/types/replay.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,13 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
234234
*/
235235
recordCrossOriginIframes: boolean;
236236
autoFlushOnFeedback: boolean;
237+
/**
238+
* Completetly ignore mutations matching the given selectors.
239+
* This can be used if a specific type of mutation is causing (e.g. performance) problems.
240+
* NOTE: This can be dangerous to use, as mutations are applied as incremental patches.
241+
* Make sure to verify that the captured replays still work when using this option.
242+
*/
243+
ignoreMutations: string[];
237244
}>;
238245
}
239246

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Vendored in from @sentry-internal/rrweb.
3+
*
4+
* This is a copy of the function from rrweb, it is not nicely exported there.
5+
*/
6+
export function closestElementOfNode(node: Node | null): HTMLElement | null {
7+
if (!node) {
8+
return null;
9+
}
10+
11+
// Catch access to node properties to avoid Firefox "permission denied" errors
12+
try {
13+
const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
14+
return el;
15+
} catch (error) {
16+
return null;
17+
}
18+
}

0 commit comments

Comments
 (0)