Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/attribute-filter-option.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/extensions/replay/types/rrweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type recordOptions = {
maskTextFn?: MaskTextFn
slimDOMOptions?: SlimDOMOptions | 'all' | true
ignoreCSSAttributes?: Set<string>
attributeFilter?: string[]
inlineStylesheet?: boolean
hooks?: hooksParam
packFn?: PackFn
Expand Down
3 changes: 3 additions & 0 deletions packages/rrweb/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function record<T = eventWithTime>(
plugins,
keepIframeSrcFn = () => false,
ignoreCSSAttributes = new Set([]),
attributeFilter,
errorHandler,
} = options;

Expand Down Expand Up @@ -393,6 +394,7 @@ function record<T = eventWithTime>(
canvasManager,
keepIframeSrcFn,
processedNodeManager,
attributeFilter,
},
mirror,
});
Expand Down Expand Up @@ -636,6 +638,7 @@ function record<T = eventWithTime>(
processedNodeManager,
canvasManager,
ignoreCSSAttributes,
attributeFilter,
plugins:
plugins
?.filter((p) => p.observer)
Expand Down
12 changes: 10 additions & 2 deletions packages/rrweb/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
16 changes: 16 additions & 0 deletions packages/rrweb/rrweb/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ export type recordOptions<T> = {
maskTextFn?: MaskTextFn;
slimDOMOptions?: SlimDOMOptions | 'all' | true;
ignoreCSSAttributes?: Set<string>;
/**
* 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;
Expand Down Expand Up @@ -120,6 +134,7 @@ export type observerParam = {
canvasManager: CanvasManager;
processedNodeManager: ProcessedNodeManager;
ignoreCSSAttributes: Set<string>;
attributeFilter?: string[];
plugins: Array<{
observer: (
cb: (...arg: Array<unknown>) => void,
Expand Down Expand Up @@ -154,6 +169,7 @@ export type MutationBufferParam = Pick<
| 'shadowDomManager'
| 'canvasManager'
| 'processedNodeManager'
| 'attributeFilter'
>;

export type ReplayPlugin = {
Expand Down
202 changes: 202 additions & 0 deletions packages/rrweb/rrweb/test/record/attribute-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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<eventWithTime>,
) => listenerHandler | undefined;
};
emit: (e: eventWithTime) => undefined;
}

const CONTENT = `
<!DOCTYPE html>
<html>
<body>
<div id="target" class="initial" style="color: red;">hello</div>
<div id="shadow-host"></div>
</body>
</html>
`;

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,10 @@ exports[`config snapshot for PostHogConfig 1`] = `
"false",
"true"
],
"attributeFilter": [
"undefined",
"string[]"
],
"recordHeaders": [
"undefined",
"false",
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/posthog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading