diff --git a/.changeset/attribute-filter-option.md b/.changeset/attribute-filter-option.md new file mode 100644 index 0000000000..f8a2dc558a --- /dev/null +++ b/.changeset/attribute-filter-option.md @@ -0,0 +1,7 @@ +--- +'@posthog/rrweb': minor +'@posthog/types': minor +'posthog-js': minor +--- + +feat: add `session_recording.attributeFilter` option that passes an attribute allowlist through to the native MutationObserver, so mutations to unlisted attributes (e.g. animation-driven inline `style` churn) never cost recording CPU (port of upstream rrweb #1873) diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index 037bdfe77c..da7070007e 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -1920,6 +1920,24 @@ describe('Lazy SessionRecording', () => { }) }) + it('passes a configured attributeFilter through to rrweb.record', () => { + posthog.config.session_recording.attributeFilter = ['class', 'value'] + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + }, + }) + ) + + expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( + expect.objectContaining({ + attributeFilter: ['class', 'value'], + }) + ) + }) + it('still starts when the bundled core has no SessionIdManager.on (version skew with CDN recorder)', () => { // The recorder chunk is loaded from the CDN and can run against an older bundled core. // SessionIdManager.on was only added in posthog-js 1.268.6, so simulate an older core that diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index f51b34b9c3..8eb0dec090 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -1948,6 +1948,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt collectFonts: false, inlineStylesheet: true, recordCrossOriginIframes: false, + attributeFilter: undefined, } // only allows user to set our allowlisted options diff --git a/packages/browser/src/extensions/replay/types/rrweb.ts b/packages/browser/src/extensions/replay/types/rrweb.ts index 779e78e9b3..9f19811753 100644 --- a/packages/browser/src/extensions/replay/types/rrweb.ts +++ b/packages/browser/src/extensions/replay/types/rrweb.ts @@ -83,6 +83,7 @@ export type recordOptions = { maskTextFn?: MaskTextFn slimDOMOptions?: SlimDOMOptions | 'all' | true ignoreCSSAttributes?: Set + attributeFilter?: string[] inlineStylesheet?: boolean hooks?: hooksParam packFn?: PackFn diff --git a/packages/rrweb/rrweb/src/record/index.ts b/packages/rrweb/rrweb/src/record/index.ts index da0ba4f99d..ade5fefa0a 100644 --- a/packages/rrweb/rrweb/src/record/index.ts +++ b/packages/rrweb/rrweb/src/record/index.ts @@ -106,6 +106,7 @@ function record( plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), + attributeFilter, errorHandler, } = options; @@ -393,6 +394,7 @@ function record( canvasManager, keepIframeSrcFn, processedNodeManager, + attributeFilter, }, mirror, }); @@ -636,6 +638,7 @@ function record( processedNodeManager, canvasManager, ignoreCSSAttributes, + attributeFilter, plugins: plugins ?.filter((p) => p.observer) diff --git a/packages/rrweb/rrweb/src/record/observer.ts b/packages/rrweb/rrweb/src/record/observer.ts index 6b42f07a82..544dbe6185 100644 --- a/packages/rrweb/rrweb/src/record/observer.ts +++ b/packages/rrweb/rrweb/src/record/observer.ts @@ -91,14 +91,22 @@ export function initMutationObserver( ) => MutationObserver)( callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)), ); - observer.observe(rootEl, { + const mutationObserverInit: MutationObserverInit = { attributes: true, attributeOldValue: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true, - }); + }; + // Delegate attribute filtering to the native MutationObserver: unlisted + // attributes never fire the callback, so they cost no recording CPU. + // An empty array would mean "observe no attributes at all", which is never + // what a caller wants and could come from bad config, so treat it as unset. + if (options.attributeFilter && options.attributeFilter.length > 0) { + mutationObserverInit.attributeFilter = options.attributeFilter; + } + observer.observe(rootEl, mutationObserverInit); return { observer, buffer: mutationBuffer }; } diff --git a/packages/rrweb/rrweb/src/types.ts b/packages/rrweb/rrweb/src/types.ts index bd0689f49f..d0f468d446 100644 --- a/packages/rrweb/rrweb/src/types.ts +++ b/packages/rrweb/rrweb/src/types.ts @@ -57,6 +57,20 @@ export type recordOptions = { maskTextFn?: MaskTextFn; slimDOMOptions?: SlimDOMOptions | 'all' | true; ignoreCSSAttributes?: Set; + /** + * Limit which DOM attributes the MutationObserver watches, by passing the + * list through to the native `MutationObserver.observe` `attributeFilter`. + * When set, mutations to unlisted attributes never fire the observer + * callback at all, so they cost no recording CPU - useful to exclude + * high-frequency inline `style` mutations from JS-driven animations. + * + * Filtered attributes are invisible to replay, so only set this when that + * loss of fidelity is acceptable. When omitted (or set to an empty array) + * all attributes are observed, the default behaviour. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#attributefilter + */ + attributeFilter?: string[]; inlineStylesheet?: boolean; hooks?: hooksParam; packFn?: PackFn; @@ -120,6 +134,7 @@ export type observerParam = { canvasManager: CanvasManager; processedNodeManager: ProcessedNodeManager; ignoreCSSAttributes: Set; + attributeFilter?: string[]; plugins: Array<{ observer: ( cb: (...arg: Array) => void, @@ -154,6 +169,7 @@ export type MutationBufferParam = Pick< | 'shadowDomManager' | 'canvasManager' | 'processedNodeManager' + | 'attributeFilter' >; export type ReplayPlugin = { diff --git a/packages/rrweb/rrweb/test/record/attribute-filter.test.ts b/packages/rrweb/rrweb/test/record/attribute-filter.test.ts new file mode 100644 index 0000000000..f78195a3d2 --- /dev/null +++ b/packages/rrweb/rrweb/test/record/attribute-filter.test.ts @@ -0,0 +1,202 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import { vi } from 'vitest'; +import type { recordOptions } from '../../src/types'; +import { + listenerHandler, + eventWithTime, + EventType, + IncrementalSource, + mutationData, +} from '@posthog/rrweb-types'; +import { getServerURL, launchPuppeteer, startServer, waitForRAF } from '../utils'; +import type { Server } from 'http'; + +interface ISuite { + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; + events: eventWithTime[]; + server: Server; + serverURL: string; +} + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + }; + emit: (e: eventWithTime) => undefined; +} + +const CONTENT = ` + + + +
hello
+
+ + +`; + +function attributeMutations(events: eventWithTime[]) { + return events + .filter( + (e) => + e.type === EventType.IncrementalSnapshot && + (e.data as { source: number }).source === IncrementalSource.Mutation, + ) + .flatMap((e) => (e.data as mutationData).attributes) + .flatMap((a) => Object.keys(a.attributes)); +} + +describe('record: attributeFilter', function (this: ISuite) { + vi.setConfig({ testTimeout: 100_000 }); + + const ctx = {} as ISuite; + + beforeAll(async () => { + ctx.server = await startServer(); + ctx.serverURL = getServerURL(ctx.server); + ctx.browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + afterAll(async () => { + await ctx.browser?.close(); + ctx.server?.close(); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto('about:blank'); + await ctx.page.setContent(CONTENT); + await ctx.page.evaluate(ctx.code); + + ctx.events = []; + await ctx.page.exposeFunction('emit', (e: eventWithTime) => { + if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { + return; + } + ctx.events.push(e); + }); + + ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await ctx.page.close(); + }); + + it('records all attribute mutations when attributeFilter is not set', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + }); + const target = document.getElementById('target')!; + target.setAttribute('style', 'color: blue;'); + target.setAttribute('class', 'changed'); + target.setAttribute('data-foo', 'bar'); + }); + await waitForRAF(ctx.page); + + const mutated = attributeMutations(ctx.events); + expect(mutated).toContain('style'); + expect(mutated).toContain('class'); + expect(mutated).toContain('data-foo'); + }); + + it('only records mutations for listed attributes when attributeFilter is set', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + attributeFilter: ['class'], + }); + const target = document.getElementById('target')!; + target.setAttribute('style', 'color: blue;'); + target.setAttribute('class', 'changed'); + target.setAttribute('data-foo', 'bar'); + }); + await waitForRAF(ctx.page); + + const mutated = attributeMutations(ctx.events); + expect(mutated).toContain('class'); + expect(mutated).not.toContain('style'); + expect(mutated).not.toContain('data-foo'); + }); + + it('applies the filter to mutations inside shadow roots', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + attributeFilter: ['class'], + }); + const host = document.getElementById('shadow-host')!; + const shadow = host.attachShadow({ mode: 'open' }); + const inner = document.createElement('div'); + inner.id = 'inner'; + shadow.appendChild(inner); + }); + await waitForRAF(ctx.page); + await ctx.page.evaluate(() => { + const host = document.getElementById('shadow-host')!; + const inner = host.shadowRoot!.getElementById('inner')!; + inner.setAttribute('style', 'color: blue;'); + inner.setAttribute('class', 'shadow-changed'); + }); + await waitForRAF(ctx.page); + + const mutated = attributeMutations(ctx.events); + expect(mutated).toContain('class'); + expect(mutated).not.toContain('style'); + }); + + it('treats an empty attributeFilter as unset rather than observing nothing', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + attributeFilter: [], + }); + const target = document.getElementById('target')!; + target.setAttribute('style', 'color: blue;'); + }); + await waitForRAF(ctx.page); + + const mutated = attributeMutations(ctx.events); + expect(mutated).toContain('style'); + }); + + it('still records childList and characterData mutations for filtered-out attributes', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + attributeFilter: ['class'], + }); + const target = document.getElementById('target')!; + const child = document.createElement('span'); + child.textContent = 'added'; + target.appendChild(child); + target.firstChild!.textContent = 'changed text'; + }); + await waitForRAF(ctx.page); + + const mutationEvents = ctx.events.filter( + (e) => + e.type === EventType.IncrementalSnapshot && + (e.data as { source: number }).source === IncrementalSource.Mutation, + ); + const adds = mutationEvents.flatMap((e) => (e.data as mutationData).adds); + const texts = mutationEvents.flatMap((e) => (e.data as mutationData).texts); + expect(adds.length).toBeGreaterThan(0); + expect(texts.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap b/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap index de775eea9f..11d36c0067 100644 --- a/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap +++ b/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap @@ -478,6 +478,10 @@ exports[`config snapshot for PostHogConfig 1`] = ` "false", "true" ], + "attributeFilter": [ + "undefined", + "string[]" + ], "recordHeaders": [ "undefined", "false", diff --git a/packages/types/src/posthog-config.ts b/packages/types/src/posthog-config.ts index fb7e018afa..ee7cc8e5ad 100644 --- a/packages/types/src/posthog-config.ts +++ b/packages/types/src/posthog-config.ts @@ -545,6 +545,21 @@ export interface SessionRecordingOptions { */ recordCrossOriginIframes?: boolean + /** + * ADVANCED: limit which DOM attributes are observed for mutations, by passing + * the list to the native `MutationObserver` `attributeFilter`. Mutations to + * unlisted attributes never reach the recorder at all, so they cost no + * recording CPU - useful to exclude high-frequency inline `style` mutations + * from JS-driven animations on animation-heavy pages. + * + * Attributes left off the list are invisible to replay, so only set this when + * that loss of fidelity is acceptable. When unset (the default) or set to an + * empty array, all attributes are observed. + * + * Normally only altered alongside posthog support guidance. + */ + attributeFilter?: string[] + /** * Derived from `rrweb.record` options * @see https://github.com/rrweb-io/rrweb/blob/master/guide.md