diff --git a/packages/replay-internal/src/constants.ts b/packages/replay-internal/src/constants.ts index da253a68ec8f..63936d1d477f 100644 --- a/packages/replay-internal/src/constants.ts +++ b/packages/replay-internal/src/constants.ts @@ -53,3 +53,6 @@ export const MAX_REPLAY_DURATION = 3_600_000; // 60 minutes in ms; /** Default attributes to be ignored when `maskAllText` is enabled */ export const DEFAULT_IGNORED_ATTRIBUTES = ['title', 'placeholder']; + +// Time window in which to check for repeated DOM mutations +export const MUTATION_DEBOUNCE_TIME = 100; // ms diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 89df655050e5..8d69da307492 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -9,6 +9,7 @@ import { SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD, WINDOW, + MUTATION_DEBOUNCE_TIME, } from './constants'; import { ClickDetector } from './coreHandlers/handleClick'; import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent'; @@ -169,6 +170,17 @@ export class ReplayContainer implements ReplayContainerInterface { /** Ensure page remains active when a key is pressed. */ private _handleKeyboardEvent: (event: KeyboardEvent) => void; + /** + * Map to track the history for DOM node mutations + */ + private _lastMutationMap: WeakMap< + Node, + { + timestamp: number; + fingerprint: string; + } + >; + public constructor({ options, recordingOptions, @@ -272,6 +284,10 @@ export class ReplayContainer implements ReplayContainerInterface { this._handleKeyboardEvent = (event: KeyboardEvent) => { handleKeyboardEvent(this, event); }; + + if (options._experiments.dropRepetitiveMutations) { + this._lastMutationMap = new WeakMap(); + } } /** Get the event context. */ @@ -1303,10 +1319,60 @@ export class ReplayContainer implements ReplayContainerInterface { } } + /** + * Heuristically create an identifier for a mutation record. + * This is used for checking on repeated mutations on the same target. + */ + private _getMutationFingerprint(mutation: MutationRecord): string { + if (mutation.type === 'attributes') { + return `attr:${mutation.attributeName}`; + } + // For other mutation types, return empty string + // TODO: Should be extended to handle other mutation types + return ''; + } + /** Handler for rrweb.record.onMutation */ private _onMutationHandler(mutations: unknown[]): boolean { const count = mutations.length; + if (this._options._experiments.dropRepetitiveMutations) { + const now = Date.now(); + + // Filter out repeated mutations + const uniqueMutations = (mutations as MutationRecord[]).filter(mutation => { + const target = mutation.target; + const lastMutation = this._lastMutationMap.get(target); + + // Create a fingerprint of this mutation + const fingerprint = this._getMutationFingerprint(mutation); + + // Check if this is a repeated mutation within our debounce window + if ( + fingerprint && + lastMutation && + lastMutation.fingerprint === fingerprint && + now - lastMutation.timestamp < MUTATION_DEBOUNCE_TIME + ) { + return false; // Skip this mutation + } + + // Update mutation tracking for this target + this._lastMutationMap.set(target, { + timestamp: now, + fingerprint, + }); + + return true; + }); + + // All mutations are repetitions, do not process in rrweb + if (uniqueMutations.length === 0) { + // todo: maybe create a new breadcrumb here? + return false; + } + } + const mutationLimit = this._options.mutationLimit; const mutationBreadcrumbLimit = this._options.mutationBreadcrumbLimit; const overMutationLimit = mutationLimit && count > mutationLimit; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 280db17db57a..2124b895eee9 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -230,6 +230,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { traceInternals: boolean; continuousCheckout: number; autoFlushOnFeedback: boolean; + dropRepetitiveMutations: boolean; }>; }