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.