From e9058295e8b060a9c847bdb4f0872fa144e041be Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 5 Sep 2026 18:22:50 +0200 Subject: [PATCH 1/9] perf(replay): reduce DOM accessor overhead and add benchmarks --- .changeset/quiet-dom-accessor-cache.md | 5 + packages/browser/package.json | 1 + packages/browser/scripts/benchmark-replay.md | 108 +++++ packages/browser/scripts/benchmark-replay.mjs | 418 ++++++++++++++++++ .../rrweb/test/untainted-prototype.test.ts | 89 ++++ packages/rrweb/utils/src/index.ts | 23 +- 6 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 .changeset/quiet-dom-accessor-cache.md create mode 100644 packages/browser/scripts/benchmark-replay.md create mode 100644 packages/browser/scripts/benchmark-replay.mjs diff --git a/.changeset/quiet-dom-accessor-cache.md b/.changeset/quiet-dom-accessor-cache.md new file mode 100644 index 0000000000..e801cd7f40 --- /dev/null +++ b/.changeset/quiet-dom-accessor-cache.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Reduce session replay DOM traversal overhead by reusing native-accessor cache keys instead of constructing a new string for every node access. diff --git a/packages/browser/package.json b/packages/browser/package.json index 37ff470b8c..b78c2cbe6b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -18,6 +18,7 @@ "build:runtime": "BUNDLER=rolldown rolldown -c rollup.config.mjs && BUILD_ROLLUP_RUNTIME=1 rollup -c", "build:types": "BUILD_TYPES_ONLY=1 rollup -c", "bundle-size:array": "node scripts/compare-array-bundle-size.mjs", + "benchmark:replay": "node scripts/benchmark-replay.mjs", "postbuild": "node scripts/strip-lib-package-json.js && node scripts/check-mangled-property-consistency.js && node scripts/check-sourcemap-ignore-list.js", "prepack": "node scripts/strip-sourcemap-sources-content.js", "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz", diff --git a/packages/browser/scripts/benchmark-replay.md b/packages/browser/scripts/benchmark-replay.md new file mode 100644 index 0000000000..b96ef2e08e --- /dev/null +++ b/packages/browser/scripts/benchmark-replay.md @@ -0,0 +1,108 @@ +# Replay main-thread benchmark (#4217) + +An **opt-in** benchmark of the built browser SDK, from page work through actual +mocked replay requests. Uses the installed Playwright Chromium (no external sites +or new dependencies). Timing numbers are evidence, not machine-independent CI +thresholds; correctness assertions fail the command. + +## Run + +From the repository root, using the Node/pnpm versions in the current manifests: + +```sh +pnpm install --frozen-lockfile +pnpm turbo --filter=posthog-js build +cd packages/browser +pnpm exec playwright install chromium # if not already installed + +# Quick correctness smoke test +REPLAY_BENCH_NODES=1000 REPLAY_BENCH_RUNS=1 pnpm benchmark:replay + +# Three runs per size/shape/compression arm (allow ~20 minutes on a desktop) +pnpm benchmark:replay + +# Focused comparison / CPU-throttled investigation +REPLAY_BENCH_NODES=50000 REPLAY_BENCH_SHAPES=table REPLAY_BENCH_CPU=4 \ + REPLAY_BENCH_OUTPUT=/tmp/replay-after pnpm benchmark:replay + +# Separate profiled run: profiling perturbs timings +REPLAY_BENCH_NODES=50000 REPLAY_BENCH_SHAPES=table REPLAY_BENCH_RUNS=1 \ + REPLAY_BENCH_PROFILE=1 REPLAY_BENCH_OUTPUT=/tmp/replay-profile pnpm benchmark:replay +``` + +For baseline comparison, save `dist/array.js` and `dist/posthog-recorder.js` from +the baseline build and pass `REPLAY_BENCH_DIST=/absolute/path/to/saved/dist`. +Run the **same benchmark source** against both sets of artifacts. Alternate build +order across repeated comparisons and run without other benchmarks/builds in +parallel. Keep maps alongside artifacts when inspecting `.cpuprofile` files. + +Each arm has a fresh browser context/page, but uses the same browser process; +compression-arm order alternates across repetitions. This controls page state, +not all process/JIT/OS caches. Both exact local artifacts are preloaded before +measurement: download and bundle parsing are deliberately **not** recorder CPU. + +Results default to ignored `packages/browser/test-results/replay-benchmark/`. +Each completed arm is saved immediately. `results.json` also records the current +checkout SHA/dirty state, **artifact hashes**, browser/Node/platform/CPU, throttle +rate and whether profiling was enabled. The checkout SHA is not proof of a saved +baseline artifact's provenance; keep its source revision with that build. + +## Workloads and correctness + +- Node-dense rows, like the existing rrweb `test/benchmark/dom-mutation.test.ts` + workloads: approximately 10k/50k/100k nodes, including **text nodes**. Exact + serialized full-snapshot counts are reported, not confused with element counts. +- Optional `css` shape: the same rows plus 10k CSSOM-only rules and a constructed + adopted stylesheet. This deliberately covers the **non-deferrable** CSS bucket, + not network-loaded/deferred stylesheet performance. +- Recording-off rebuild control, recorder startup, explicit full snapshot, + subtree rebuild, moving the subtree, and bulk removal. Every rebuild changes + cell values to expose stale-but-plausible replay output. +- Both `session_recording.compress_events` settings. Outer request compression is + disabled in **both** arms to isolate rrweb field compression; other SDK buffer, + encoding and transport work still runs. Request batching is disabled; replay's + normal flush cadence is retained. +- A custom end marker crosses the actual SDK compression queue and transport. + Request envelopes and compressed rrweb fields are decoded **in Node**, not on + the recorded page. The benchmark rejects missing markers, decoding errors, + duplicate IDs within full snapshots, missing expected snapshots and leaked + password/text/blocking sentinels. +- After recording stops, the real Replayer rebuilds every recorded checkpoint. + Assertions cover exact row order, generation-specific text/cell attributes, + subtree parentage/removal, CSS rule count/boundary selectors and adopted-sheet + computed padding. Validation happens outside measurement windows. +- Full-snapshot counts, unexpected full snapshots (possible resyncs), add/remove + counts and per-phase **oversized-mutation and attribute-drop deltas** accompany + timing results. Raw debug counters remain cumulative. Never accept a faster + result caused by dropping data or compare runs with different resync behavior. + +## Interpreting metrics + +- `actionMs`: the synchronous page action only. For rebuilds, MutationObserver + serialization runs **after** that call; this is not the whole recorder cost. +- `wireMs`: page action start to the request containing its end marker. Includes + replay's flush delay and encoding, not server ingestion or replay readiness. +- `maxTaskMs` / `longTaskCount`: Long Tasks API entries at/after action start, + collected through marker delivery plus a 100ms observation drain. **Zero means + no observed task >=50ms, not zero blocking.** Work is scheduled in a page timer, + not performed directly inside CDP evaluate (which can hide long tasks). +- `maxFrameGapMs`: largest rAF interval overlapping the action/observation window; + excludes completed warm-up intervals. This is a responsiveness proxy, **not INP** + or a measurement of real input dispatch latency. +- `taskCpuMs` / `heapDeltaBytes`: CDP deltas over the wider collection window, + including measurement setup (50ms), marker waiting and trailing collection. + They include host DOM/layout/GC and instrumentation work. Compare the recording + off control; do not label them pure serializer CPU or peak retained memory. +- `wireBytes`: complete replay request body bytes in the phase (encoding and + envelope included). Counts can span several requests. +- `debug`: existing recorder snapshot/mutation/CSS cost and drop diagnostics. + The mutation-duration gauge does **not** include `processMutation/genAdds` + preprocessing; whole-task measurements intentionally include it. + +This is the first baseline slice, not the entire #4217 acceptance matrix. It does +not prove exact interaction timing, all mirror-reference dependencies, worker/CSP +fallback, deep DOM/iframes/shadow roots/canvas, periodic rotation, sustained churn, +peak memory, unload delivery or non-Chromium behavior. Keep those covered by their +existing correctness suites and expand this harness when optimizing those paths. +Do not infer general or statistically significant improvements from three desktop +samples, and do not treat a passing final DOM check as complete replay equivalence. diff --git a/packages/browser/scripts/benchmark-replay.mjs b/packages/browser/scripts/benchmark-replay.mjs new file mode 100644 index 0000000000..6fc825879e --- /dev/null +++ b/packages/browser/scripts/benchmark-replay.mjs @@ -0,0 +1,418 @@ +// Opt-in end-to-end benchmark for #4217. See benchmark-replay.md. +// Node CLI + installed Playwright Chromium, not an SDK runtime bundle. +// oxlint-disable compat/compat +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { gunzipSync } from 'node:zlib' +import { chromium } from '@playwright/test' +import { isArray } from '@posthog/core' + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)) +const output = path.resolve(process.env.REPLAY_BENCH_OUTPUT || path.join(packageRoot, 'test-results/replay-benchmark')) +const distRoot = path.resolve(process.env.REPLAY_BENCH_DIST || path.join(packageRoot, 'dist')) +const sizes = (process.env.REPLAY_BENCH_NODES || '10000,50000,100000').split(',').map(Number) +const repetitions = Number(process.env.REPLAY_BENCH_RUNS || 3) +const cpuRate = Number(process.env.REPLAY_BENCH_CPU || 1) +const shapes = (process.env.REPLAY_BENCH_SHAPES || 'table,css').split(',') +const profiling = process.env.REPLAY_BENCH_PROFILE === '1' +assert(sizes.every((n) => Number.isInteger(n) && n > 0)) +assert(Number.isInteger(repetitions) && repetitions > 0) +assert(Number.isFinite(cpuRate) && cpuRate >= 1) +assert(shapes.every((shape) => ['table', 'css'].includes(shape))) + +const assets = new Map() +for (const name of ['array.js', 'posthog-recorder.js']) { + assets.set(name, await readFile(path.join(distRoot, name))) +} +const replayer = await readFile(path.join(packageRoot, '../rrweb/rrweb/dist/rrweb.umd.cjs'), 'utf8') +const origin = 'https://replay-benchmark.test' +const markerTag = 'replay-benchmark-end' +const privateValues = ['BENCH_PRIVATE_INPUT', 'BENCH_PRIVATE_TEXT', 'BENCH_BLOCKED_TEXT'] + +function decodeRequest(request) { + const bytes = request.postDataBuffer() + if (!bytes) return [] + const url = new URL(request.url()) + const text = url.searchParams.get('compression') === 'gzip-js' ? gunzipSync(bytes).toString() : bytes.toString() + const parsed = + text.startsWith('{') || text.startsWith('[') + ? JSON.parse(text) + : JSON.parse(Buffer.from(new URLSearchParams(text).get('data'), 'base64').toString()) + return isArray(parsed) ? parsed : [parsed] +} + +function decodeSnapshot(event) { + if (event.cv !== '2024-10') return event + const unzip = (s) => JSON.parse(gunzipSync(Buffer.from(s, 'latin1')).toString()) + if (event.type === 2) return { ...event, data: unzip(event.data) } + const data = { ...event.data } + for (const key of ['adds', 'removes', 'texts', 'attributes']) { + if (typeof data[key] === 'string') data[key] = unzip(data[key]) + } + return { ...event, data } +} + +function countSnapshotNodes(root) { + let count = 0 + const ids = new Set() + const stack = [root] + while (stack.length) { + const node = stack.pop() + assert(!ids.has(node.id), `duplicate full-snapshot id: ${node.id}`) + ids.add(node.id) + count++ + for (const child of node.childNodes || []) stack.push(child) + } + return count +} + +await mkdir(output, { recursive: true }) +const browser = await chromium.launch() +const results = [] +try { + for (const shape of shapes) + for (const targetNodes of sizes) + for (let run = 0; run < repetitions; run++) { + // Alternate arm order to avoid always giving the same arm a warm browser process. + for (const compress of run % 2 ? [true, false] : [false, true]) { + const context = await browser.newContext() + const page = await context.newPage() + const client = await context.newCDPSession(page) + await client.send('Emulation.setCPUThrottlingRate', { rate: cpuRate }) + await client.send('Performance.enable') + const label = `${shape}-${targetNodes}-${run}-${compress ? 'gzip' : 'plain'}` + const wireEvents = [] + const receivedMarkers = new Map() + let requestBytes = 0 + let decodeError + await context.route('**/*', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const asset = assets.get(path.basename(url.pathname)) + if (url.origin !== origin) return route.abort() + if (asset) return route.fulfill({ contentType: 'application/javascript', body: asset }) + if (url.pathname === '/') + return route.fulfill({ + contentType: 'text/html', + body: '
', + }) + if (url.pathname.includes('/config') || url.pathname.startsWith('/flags/')) { + return route.fulfill({ + json: { + featureFlags: {}, + flags: {}, + sessionRecording: { endpoint: '/ses/' }, + supportedCompression: [], + }, + }) + } + if (url.pathname.startsWith('/ses/')) { + try { + requestBytes += request.postDataBuffer()?.length || 0 + for (const envelope of decodeRequest(request)) { + for (const raw of envelope.properties?.$snapshot_data || []) { + const event = decodeSnapshot(raw) + wireEvents.push(event) + if (event.type === 5 && event.data.tag === markerTag) { + receivedMarkers.set(event.data.payload, request.timing().startTime) + } + } + } + } catch (error) { + decodeError = error + } + } + return route.fulfill({ json: { status: 1 } }) + }) + try { + await page.goto(origin) + // Preload exact local artifacts: network/module loading is not serializer time. + await page.addScriptTag({ url: `${origin}/static/array.js` }) + await page.addScriptTag({ url: `${origin}/static/posthog-recorder.js` }) + await page.evaluate( + ({ targetNodes, shape, compress, origin }) => { + const fixture = document.getElementById('fixture') + const rowCount = Math.ceil(targetNodes / 21) + const cell = '12label' + const markup = Array.from( + { length: rowCount }, + (_, i) => `
${cell.repeat(4)}
` + ).join('') + let generation = 0 + window.buildFixture = () => { + generation++ + fixture.innerHTML = + markup.replaceAll('>12<', `>${generation}<`) + + 'BENCH_PRIVATE_TEXT
BENCH_BLOCKED_TEXT
' + } + window.buildFixture() + if (shape === 'css') { + const style = document.createElement('style') + style.id = 'benchmark-css' + document.head.append(style) + // Deliberately CSSOM-only: covers the non-deferrable stylesheet bucket. + for (let i = 0; i < 10000; i++) + style.sheet.insertRule(`.rule${i} { color: rgb(${i % 255}, 0, 0); }`) + const adopted = new CSSStyleSheet() + adopted.replaceSync('.cell { padding: var(--space, 1px); }') + document.adoptedStyleSheets = [adopted] + } + window.posthog.init('replay-benchmark', { + api_host: origin, + disable_session_recording: true, + opt_out_useragent_filter: true, + capture_pageview: false, + capture_pageleave: false, + autocapture: false, + disable_surveys: true, + request_batching: false, + disable_compression: true, + session_recording: { + compress_events: compress, + recordConsole: false, + recordNetwork: false, + }, + }) + }, + { targetNodes, shape, compress, origin } + ) + await page.waitForFunction( + () => + window.posthog.sessionRecording?.status === 'disabled' && + window.posthog.featureFlags.hasLoadedFlags + ) + const metrics = [] + const checkpoints = [] + for (const phase of ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove']) { + const startIndex = wireEvents.length + const bytesBefore = requestBytes + const before = await client.send('Performance.getMetrics') + if (profiling) { + await client.send('Profiler.enable') + await client.send('Profiler.start') + } + const timing = await page.evaluate( + ({ phase, markerTag }) => + new Promise((resolve) => { + const record = window.__PosthogExtensions__.rrweb.record + const longTasks = [] + const observer = new PerformanceObserver((list) => { + for (const e of list.getEntries()) + longTasks.push({ start: e.startTime, duration: e.duration }) + }) + observer.observe({ type: 'longtask' }) + let raf, + lastFrame = 0, + maxFrameGapMs = 0, + actionStart = Infinity + const tick = (now) => { + if (lastFrame > 0 && now >= actionStart) + maxFrameGapMs = Math.max(maxFrameGapMs, now - lastFrame) + lastFrame = now + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + // CDP evaluate work can be invisible to Long Tasks API. Use a page task. + setTimeout(() => { + const start = performance.now() + actionStart = start + const epochStart = performance.timeOrigin + start + switch (phase) { + case 'off-rebuild': + case 'rebuild': + window.buildFixture() + break + case 'start': + window.posthog.startSessionRecording() + document.getElementById('activity').click() + break + case 'snapshot': + record.takeFullSnapshot() + break + case 'move': + document + .getElementById('destination') + .append(document.getElementById('fixture')) + break + case 'remove': + document.getElementById('fixture').replaceChildren() + break + } + const actionMs = performance.now() - start + // Observer delivery happens before this timer. The marker passes through + // the real SDK compression queue, buffer, request encoder and transport. + setTimeout(() => { + if (phase !== 'off-rebuild') record.addCustomEvent(markerTag, phase) + window.finishMeasurement = () => { + observer.disconnect() + cancelAnimationFrame(raf) + return { + maxFrameGapMs, + longTasks: longTasks.filter((t) => t.start >= start - 1), + debug: window.posthog.sessionRecording.sdkDebugProperties, + } + } + resolve({ epochStart, actionMs }) + }, 0) + }, 50) + }), + { phase, markerTag } + ) + if (phase !== 'off-rebuild') { + const deadline = Date.now() + 30000 + while (!receivedMarkers.has(phase) && !decodeError && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 25)) + if (decodeError) throw decodeError + assert( + receivedMarkers.has(phase), + `${label}/${phase}: end marker did not reach transport` + ) + } + // Allow trailing PerformanceObserver entries to arrive, outside the action. + await page.waitForTimeout(100) + const observation = await page.evaluate(() => window.finishMeasurement()) + const after = await client.send('Performance.getMetrics') + if (profiling) { + const { profile } = await client.send('Profiler.stop') + await writeFile( + path.join(output, `${label}-${phase}.cpuprofile`), + JSON.stringify(profile) + ) + } + const metric = (data, name) => data.metrics.find((m) => m.name === name)?.value || 0 + const events = wireEvents.slice(startIndex) + const fullSnapshots = events.filter((e) => e.type === 2) + const mutations = events.filter((e) => e.type === 3 && e.data.source === 0) + if (phase === 'start' || phase === 'snapshot') assert.equal(fullSnapshots.length, 1) + const previousDebug = metrics.at(-1)?.debug || {} + const counterDelta = (key) => (observation.debug[key] || 0) - (previousDebug[key] || 0) + const serialized = JSON.stringify(events) + for (const secret of privateValues) + assert(!serialized.includes(secret), `${phase}: privacy sentinel reached wire`) + metrics.push({ + phase, + ...timing, + wireMs: phase === 'off-rebuild' ? null : receivedMarkers.get(phase) - timing.epochStart, + maxTaskMs: Math.max(0, ...observation.longTasks.map((t) => t.duration)), + longTaskCount: observation.longTasks.length, + maxFrameGapMs: observation.maxFrameGapMs, + taskCpuMs: 1000 * (metric(after, 'TaskDuration') - metric(before, 'TaskDuration')), + heapDeltaBytes: metric(after, 'JSHeapUsedSize') - metric(before, 'JSHeapUsedSize'), + wireBytes: requestBytes - bytesBefore, + fullSnapshots: fullSnapshots.map((e) => countSnapshotNodes(e.data.node)), + unexpectedFullSnapshots: Math.max( + 0, + fullSnapshots.length - (['start', 'snapshot'].includes(phase) ? 1 : 0) + ), + oversizedMutationsDropped: counterDelta( + '$sdk_debug_replay_oversized_mutations_dropped' + ), + throttledAttributesDropped: counterDelta( + '$sdk_debug_replay_throttled_mutations_dropped' + ), + adds: mutations.reduce((sum, e) => sum + e.data.adds.length, 0), + removes: mutations.reduce((sum, e) => sum + e.data.removes.length, 0), + debug: observation.debug, + }) + if (phase !== 'off-rebuild') checkpoints.push({ phase, end: wireEvents.length }) + } + // Correctness validation is deliberately outside all measurement windows. + await page.evaluate(() => window.posthog.stopSessionRecording()) + await page.addScriptTag({ content: replayer }) + for (const { phase, end } of checkpoints) { + // Check intermediate states too: an empty final tree can hide lost adds. + const generation = ['start', 'snapshot'].includes(phase) ? 2 : 3 + const replayed = await page.evaluate( + ({ events, generation, shape }) => { + const player = new window.rrweb.Replayer(events, { UNSAFE_replayCanvas: false }) + player.pause(events.at(-1).timestamp - events[0].timestamp + 1) + const doc = player.iframe.contentDocument + const fixture = doc.querySelector('#fixture') + const rows = [...doc.querySelectorAll('#fixture [data-row]')] + const cssRules = doc.getElementById('benchmark-css')?.sheet.cssRules + const result = { + fixtureCount: doc.querySelectorAll('#fixture').length, + parent: fixture?.parentElement.id, + rows: rows.length, + orderedContent: rows.every( + (row, i) => + row.getAttribute('data-row') === String(i) && + row.textContent === `${generation}label`.repeat(4) && + row.querySelectorAll('.cell[data-label="metric"]').length === 4 + ), + stylesheet: + shape !== 'css' || + (cssRules?.length === 10000 && + cssRules[0].selectorText === '.rule9999' && + cssRules[9999].selectorText === '.rule0'), + adoptedStyle: + shape !== 'css' || + rows.length === 0 || + doc.defaultView.getComputedStyle(rows[0].querySelector('.cell')) + .paddingLeft === '1px', + } + player.destroy() + return result + }, + { events: wireEvents.slice(0, end), generation, shape } + ) + assert.deepEqual( + replayed, + { + fixtureCount: 1, + parent: ['move', 'remove'].includes(phase) ? 'destination' : '', + rows: phase === 'remove' ? 0 : Math.ceil(targetNodes / 21), + orderedContent: true, + stylesheet: true, + adoptedStyle: true, + }, + `${label}/${phase}: replay did not reconstruct the fixture` + ) + } + const result = { label, shape, targetNodes, run, compress, metrics } + results.push(result) + await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)) + // oxlint-disable-next-line no-console + console.log( + label, + metrics + .map( + (m) => + `${m.phase}: task=${m.maxTaskMs.toFixed(0)}ms cpu=${m.taskCpuMs.toFixed(0)}ms adds=${m.adds} oversizedDropped=${m.oversizedMutationsDropped} attributesDropped=${m.throttledAttributesDropped} extraSnapshots=${m.unexpectedFullSnapshots}` + ) + .join(' | ') + ) + } finally { + await context.close() + } + } + } +} finally { + await writeFile( + path.join(output, 'results.json'), + JSON.stringify( + { + revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: packageRoot, encoding: 'utf8' }).trim(), + dirty: !!execFileSync('git', ['status', '--porcelain'], { cwd: packageRoot, encoding: 'utf8' }).trim(), + artifacts: Object.fromEntries( + [...assets].map(([name, data]) => [name, createHash('sha256').update(data).digest('hex')]) + ), + browser: browser.version(), + node: process.version, + platform: `${os.platform()} ${os.arch()}`, + cpu: os.cpus()[0]?.model, + cpuRate, + profiling, + results, + }, + null, + 2 + ) + ) + await browser.close() +} diff --git a/packages/rrweb/rrweb/test/untainted-prototype.test.ts b/packages/rrweb/rrweb/test/untainted-prototype.test.ts index f36511e1c2..aa1f56423d 100644 --- a/packages/rrweb/rrweb/test/untainted-prototype.test.ts +++ b/packages/rrweb/rrweb/test/untainted-prototype.test.ts @@ -40,6 +40,95 @@ async function freshGetUntaintedPrototype() { return module.getUntaintedPrototype; } +describe('untainted accessor cache', () => { + let utils: typeof import('@posthog/rrweb-utils'); + + beforeEach(async () => { + setUserAgent(CHROME_UA); + vi.resetModules(); + utils = await import('@posthog/rrweb-utils'); + }); + + afterEach(() => { + document.querySelectorAll('iframe').forEach((iframe) => iframe.remove()); + vi.restoreAllMocks(); + }); + + it('does not stringify cache keys on every DOM access', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + parent.append(child); + // Warm the native getter cache before measuring per-node work. + utils.childNodes(parent); + utils.parentNode(child); + const stringify = vi.spyOn(globalThis, 'String'); + for (let i = 0; i < 100; i++) { + expect(utils.childNodes(parent)[0]).toBe(child); + expect(utils.parentNode(child)).toBe(parent); + } + expect( + stringify.mock.calls.filter( + ([key]) => key === 'childNodes' || key === 'parentNode', + ), + ).toHaveLength(0); + }); + + it('caches getters, not DOM values or their receiver', () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + const child = document.createTextNode('first'); + a.append(child); + expect(utils.parentNode(child)).toBe(a); + expect(utils.textContent(a)).toBe('first'); + expect(utils.childNodes(a).length).toBe(1); + b.append(child); + child.textContent = 'second'; + expect(utils.parentNode(child)).toBe(b); + expect(utils.textContent(a)).toBe(''); + expect(utils.textContent(b)).toBe('second'); + expect(utils.childNodes(a).length).toBe(0); + expect(utils.childNodes(b)[0]).toBe(child); + }); + + it('still bypasses a patched getter after the native accessor is cached', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + parent.append(child); + expect(utils.childNodes(parent)[0]).toBe(child); + const patched = vi.spyOn(parent, 'childNodes', 'get').mockImplementation(() => { + throw new Error('patched childNodes must not run'); + }); + expect(utils.childNodes(parent)[0]).toBe(child); + expect(patched).not.toHaveBeenCalled(); + }); + + it('keeps Node, Element and ShadowRoot accessor caches separate', () => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const child = document.createElement('span'); + shadow.append(child); + for (let i = 0; i < 2; i++) { + expect(utils.shadowRoot(host)).toBe(shadow); + expect(utils.host(shadow)).toBe(host); + expect(utils.parentNode(child)).toBe(shadow); + expect(utils.parentElement(child)).toBeNull(); + expect(utils.childNodes(host).length).toBe(0); + expect(utils.childNodes(shadow)[0]).toBe(child); + } + }); + + it('retains the instance fallback for properties without a getter', () => { + const element = document.createElement('div'); + // Object.prototype properties must not look like cached DOM accessors. + expect(utils.getUntaintedAccessor('Node', element, 'toString')).toBe( + element.toString, + ); + expect(utils.getUntaintedAccessor('Node', element, 'constructor')).toBe( + element.constructor, + ); + }); +}); + describe('getUntaintedPrototype iframe fallback', () => { afterEach(() => { document diff --git a/packages/rrweb/utils/src/index.ts b/packages/rrweb/utils/src/index.ts index fe412a28f3..c10084c2d0 100644 --- a/packages/rrweb/utils/src/index.ts +++ b/packages/rrweb/utils/src/index.ts @@ -149,10 +149,19 @@ export function getUntaintedPrototype( } } -const untaintedAccessorCache: Record< +// Group by prototype so every node access can reuse the property key instead +// of allocating `${key}.${String(accessor)}` on the serialization hot path. +// Null prototypes keep names like `constructor` from appearing to be cached. +type AccessorCache = Record< string, (this: PrototypeOwner, ...args: unknown[]) => unknown -> = {}; +>; +const untaintedAccessorCache: Record = { + Node: Object.create(null), + ShadowRoot: Object.create(null), + MutationObserver: Object.create(null), + Element: Object.create(null), +}; export function getUntaintedAccessor< K extends keyof BasePrototypeCache, @@ -162,11 +171,9 @@ export function getUntaintedAccessor< instance: BasePrototypeCache[K], accessor: T, ): BasePrototypeCache[K][T] { - const cacheKey = `${key}.${String(accessor)}`; - if (untaintedAccessorCache[cacheKey]) - return untaintedAccessorCache[cacheKey].call( - instance, - ) as BasePrototypeCache[K][T]; + const cache: AccessorCache = untaintedAccessorCache[key]; + const cached = cache[accessor as string]; + if (cached) return cached.call(instance) as BasePrototypeCache[K][T]; const untaintedPrototype = getUntaintedPrototype(key); const untaintedAccessor = Object.getOwnPropertyDescriptor( @@ -176,7 +183,7 @@ export function getUntaintedAccessor< if (!untaintedAccessor) return instance[accessor]; - untaintedAccessorCache[cacheKey] = untaintedAccessor; + cache[accessor as string] = untaintedAccessor; return untaintedAccessor.call(instance) as BasePrototypeCache[K][T]; } From c9b2c2216743cd177b2958aee6af7549538ae055 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 5 Sep 2026 23:52:09 +0200 Subject: [PATCH 2/9] perf(replay): reuse mutation serialization options per emission --- .changeset/tidy-mutation-serialization.md | 5 + .../scripts/benchmark-replay-mutations.md | 103 ++++ packages/browser/scripts/benchmark-replay.md | 8 +- packages/browser/scripts/benchmark-replay.mjs | 450 ++++++++++++++---- .../scripts/summarize-replay-profile.mjs | 125 +++++ packages/rrweb/rrweb/src/record/mutation.ts | 11 +- .../mutation-serialization-options.test.ts | 100 ++++ 7 files changed, 701 insertions(+), 101 deletions(-) create mode 100644 .changeset/tidy-mutation-serialization.md create mode 100644 packages/browser/scripts/benchmark-replay-mutations.md create mode 100644 packages/browser/scripts/summarize-replay-profile.mjs create mode 100644 packages/rrweb/rrweb/test/record/mutation-serialization-options.test.ts diff --git a/.changeset/tidy-mutation-serialization.md b/.changeset/tidy-mutation-serialization.md new file mode 100644 index 0000000000..aeae8f3062 --- /dev/null +++ b/.changeset/tidy-mutation-serialization.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Reduce per-node allocations when serializing session replay mutations by reusing serialization options within each emission. diff --git a/packages/browser/scripts/benchmark-replay-mutations.md b/packages/browser/scripts/benchmark-replay-mutations.md new file mode 100644 index 0000000000..95b8048d6a --- /dev/null +++ b/packages/browser/scripts/benchmark-replay-mutations.md @@ -0,0 +1,103 @@ +# Mutation preprocessing investigation (#4217) + +Follow-up to PR #4801, based on `e9058295e8b060a9c847bdb4f0872fa144e041be`. This extends the [end-to-end replay benchmark](benchmark-replay.md). It does not implement asynchronous recording or an encoding worker. + +## Run + +After building the browser SDK and dependencies, run from the repository root: + +```sh +# Small correctness matrix, both compression settings +REPLAY_BENCH_MUTATIONS=1 REPLAY_BENCH_NODES=1000 \ + REPLAY_BENCH_SHAPES=table,css,shadow REPLAY_BENCH_RUNS=1 \ + pnpm --filter posthog-js benchmark:replay + +# Timing comparison: use the same script against both artifact sets +REPLAY_BENCH_MUTATIONS=1 REPLAY_BENCH_NODES=50000 \ + REPLAY_BENCH_SHAPES=table REPLAY_BENCH_RUNS=3 \ + REPLAY_BENCH_COMPRESSION=on pnpm --filter posthog-js benchmark:replay + +# Separate diagnostic run, not a timing comparison +REPLAY_BENCH_MUTATIONS=1 REPLAY_BENCH_NODES=50000 \ + REPLAY_BENCH_SHAPES=table REPLAY_BENCH_RUNS=1 \ + REPLAY_BENCH_COMPRESSION=on REPLAY_BENCH_PROFILE=1 \ + pnpm --filter posthog-js benchmark:replay +node packages/browser/scripts/summarize-replay-profile.mjs \ + packages/browser/test-results/replay-benchmark > /tmp/replay-attribution.json +``` + +Additional controls: + +- `REPLAY_BENCH_MUTATIONS=1`: add nested insertion and sustained churn, matching recording-off controls, and one trusted input probe per phase. +- `REPLAY_BENCH_CHURN_STEPS`: rebuild bursts per churn phase, default 5, range 1–20. Each burst yields through observer delivery, records a checkpoint, and waits 16 ms before the next burst. It does **not** wait for transport between bursts. +- `REPLAY_BENCH_COMPRESSION`: `on`, `off`, or `both` (default). +- `REPLAY_BENCH_SHAPES=shadow`: split the same target row count between light DOM and an open shadow root. Both contain privacy sentinels. This adds constant host/sentinel overhead, not another full copy of the workload. The nested workload connects light-DOM rows before inserting their children; the shadow subtree is populated through `innerHTML`. +- Existing `REPLAY_BENCH_DIST`, `REPLAY_BENCH_OUTPUT`, `REPLAY_BENCH_CPU` and profiling controls still apply. + +Do not run benchmark timing comparisons concurrently with builds, tests or other benchmarks. + +## Measurement and correctness + +Recording-off controls cover rebuild, nested insertion, churn, move and removal. Fixtures are restored outside the control measurement windows and before recording starts. Expected generations are captured explicitly instead of relying on hard-coded phase numbers. + +The benchmark schedules work in normal page timer tasks. During each mutation-mode phase, Node submits a trusted CDP click to the activity button. `inputDelayMs` measures from the supplied input timestamp to the button handler, including browser dispatch and main-thread waiting. This is **one synthetic lab input sample per phase**, not INP, a percentile, or a complete interaction latency distribution. The input's actual observed generation gets its own replay checkpoint. + +`actionMs` sums synchronous fixture operations; `workloadMs` includes yields, observer processing and the input wait. `totalBlockingMs` sums `max(0, task.duration - 50)` over the observation window. It is a scoped blocking metric, not Lighthouse's navigation TBT. The wider window still includes compression, transport preparation and flush waiting, as described in the baseline documentation. + +Every churn generation, trusted input and phase end is checked using the real Replayer. Assertions cover generation-specific content, row order across light/shadow DOM, parentage/removal, and CSS/adopted styles. Privacy checks inspect decoded wire events; full snapshots reject duplicate IDs. Mutation-mode runs fail on observed oversized-mutation drops, throttled-attribute drops or unexpected full snapshots. Faster data loss is not a performance improvement. + +Replay validation runs outside measurement, in fresh contexts, with network requests blocked. Event prefixes are uploaded as bounded 1 MiB JSON string chunks: tagged object uploads of large churn histories exceeded Chromium's 100 MB DevTools message limit during harness development. Those failed runs were not accepted as completed comparisons. Per-arm JSON reports remain `validation: "pending"` until all checks pass; aggregate results contain only passed arms. + +Profiling runs additionally sample page JS heap usage every 100 ms. `sampledMaxJSHeapUsedBytes` is **not true peak memory**: it can miss transient allocations and excludes native DOM, browser-process and worker memory. These samples perturb execution and are disabled in timing runs. True peak/process memory remains a follow-up. + +## Attribution + +`summarize-replay-profile.mjs [profile-directory] [browser-dist-directory]` follows the browser bundle source map into intermediate rrweb maps and original sources. Use the **matching build and intermediate maps**, not merely a saved outer `.js.map` with newer rrweb artifacts. It emits sampled self-time and exclusive coarse categories; `mutationInclusiveMs` overlaps those categories and must not be added to them. Sampling is not an exact duration instrument. + +A validated baseline 50k-node rebuild profile attributed approximately: + +- 39 ms to mutation preprocessing (`processMutation`, `genAdds` and descendants). +- 78 ms to other mutation-emission work, including mirror removal, ordering and allocations. +- 85 ms to serialization, including layout-dependent reads. + +These account for roughly 202 ms attributed to mutation code. Encoding and unattributed GC are separate. Preprocessing matters, but it is not the whole stall. Existing added/moved-set guards already skip redundant `genAdds` traversal; removing them or caching live masking decisions is not justified. + +## Small synchronous optimization + +`MutationBuffer` previously allocated a serialization options object and four callbacks for every serialized node. It now initializes those options lazily once per emission. No cross-emission cache or live DOM values are retained. `serializeNodeWithId` reads, but does not mutate, the options. `needsMask` remains unset, so each node still computes its own masking context. + +The deterministic regression test observed 500 distinct options objects for 500 fixture nodes before the change and one afterward. A later batch gets a fresh object. Tests also exercise adjacent masked/unmasked nodes, password masking and changed content across batches. + +### Local timing evidence + +Apple M4 Pro, Chromium 136.0.7103.25, approximately 50k nodes, compression enabled, no CPU throttling. Three runs per build, ordered baseline/candidate/baseline/candidate/baseline/candidate, with no concurrent benchmark/build work. These are descriptive local medians, not significance estimates or customer guarantees. + +| Workload | Longest task before / after | Input delay before / after | +| ----------------- | --------------------------- | -------------------------- | +| Start recording | 107 / 114 ms | 107.7 / 115.0 ms | +| Full snapshot | 108 / 108 ms | 109.0 / 108.7 ms | +| Rebuild | 242 / 247 ms | 247.8 / 253.7 ms | +| Nested insertion | 260 / 252 ms | 265.5 / 259.2 ms | +| Five churn bursts | 304 / 268 ms | 303.4 / 274.0 ms | +| Move subtree | 260 / 187 ms | 266.9 / 194.3 ms | + +Total blocking across five churn bursts decreased from 979 to 837 ms. Single rebuild performance was essentially flat/slightly worse; startup and full-snapshot paths are not optimized by this change. Removal produced no observed >=50 ms task in these samples, which is not zero blocking. The recorder artifact increased by 17 raw bytes / 15 gzip bytes. + +All six comparison arms passed intermediate replay/input, privacy and drop/recovery checks. Profiling remains separate: individual GC-heavy profiles vary substantially, so do not treat them as proof of a particular GC saving. + +Comparison artifacts: `/tmp/4217-mutations-{baseline,candidate}-{1,2,3}/results.json`. Both arms used the same benchmark source. Shadow support, error-handling/reporting refinements and the benchmark-hash field were finalized afterward; subsequent smoke/matrix runs validate those additions. + +## Validation and remaining work + +- SDK/dependency build, rrweb typecheck/build, targeted lint/format and syntax checks passed. +- 318 recording/accessor tests passed, 2 skipped; browser masking tests passed in Chromium, Firefox and WebKit (9 tests). +- Table, CSSOM/adopted stylesheet and shadow-root small fixtures passed in both compression settings. +- A 10k-node 4x page-throttled run passed in both compression settings. +- 50k-node shadow and 100k-node table runs passed with compression enabled. +- A negative probe discarded the first churn generation from decoded transport. An intermediate churn replay checkpoint failed as intended. The temporary probe was removed. + +This does **not** resolve #4217. The single 100k-node candidate run still had 451–618 ms tasks across the large mutation workloads. Deep DOM, iframe/canvas-heavy workloads, realistic input distributions, true peak memory, full performance comparisons across browsers/low-end devices, and prolonged churn/lifecycle behavior remain unproven. + +The next investigation should target the remaining serialization/layout and mirror/emission costs. If unavoidable traversal dominates after small optimizations, bounded processing will require an explicit snapshot/mutation consistency design—not silent dropping, skipping masking, or changing event order. + +Incident review: this local per-emission change does not alter lazy-load signatures, persisted config, node/mirror IDs, masking policy, queue ordering, sampling, session rotation or unload behavior. No recording-volume change is expected. The ref-only incident matcher sees no uncommitted diff; manual source review and the real-browser checks above are the relevant evidence, not that empty matcher result. diff --git a/packages/browser/scripts/benchmark-replay.md b/packages/browser/scripts/benchmark-replay.md index b96ef2e08e..20985cb873 100644 --- a/packages/browser/scripts/benchmark-replay.md +++ b/packages/browser/scripts/benchmark-replay.md @@ -5,6 +5,9 @@ mocked replay requests. Uses the installed Playwright Chromium (no external site or new dependencies). Timing numbers are evidence, not machine-independent CI thresholds; correctness assertions fail the command. +For nested mutations, sustained churn, trusted input probes, shadow DOM and source-map +attribution, see [the mutation investigation](benchmark-replay-mutations.md). + ## Run From the repository root, using the Node/pnpm versions in the current manifests: @@ -101,8 +104,9 @@ baseline artifact's provenance; keep its source revision with that build. This is the first baseline slice, not the entire #4217 acceptance matrix. It does not prove exact interaction timing, all mirror-reference dependencies, worker/CSP -fallback, deep DOM/iframes/shadow roots/canvas, periodic rotation, sustained churn, -peak memory, unload delivery or non-Chromium behavior. Keep those covered by their +fallback, deep DOM/iframes/canvas, all shadow-root lifecycles, periodic rotation, +peak memory, unload delivery or non-Chromium behavior. The optional mutation mode +adds bounded churn and basic shadow-root/input checkpoints, not complete coverage. Keep those covered by their existing correctness suites and expand this harness when optimizing those paths. Do not infer general or statistically significant improvements from three desktop samples, and do not treat a passing final DOM check as complete replay equivalence. diff --git a/packages/browser/scripts/benchmark-replay.mjs b/packages/browser/scripts/benchmark-replay.mjs index 6fc825879e..feb6927ed9 100644 --- a/packages/browser/scripts/benchmark-replay.mjs +++ b/packages/browser/scripts/benchmark-replay.mjs @@ -20,10 +20,15 @@ const repetitions = Number(process.env.REPLAY_BENCH_RUNS || 3) const cpuRate = Number(process.env.REPLAY_BENCH_CPU || 1) const shapes = (process.env.REPLAY_BENCH_SHAPES || 'table,css').split(',') const profiling = process.env.REPLAY_BENCH_PROFILE === '1' +const mutationWorkloads = process.env.REPLAY_BENCH_MUTATIONS === '1' +const churnSteps = Number(process.env.REPLAY_BENCH_CHURN_STEPS || 5) +const compression = process.env.REPLAY_BENCH_COMPRESSION || 'both' +assert(Number.isInteger(churnSteps) && churnSteps > 0 && churnSteps <= 20) +assert(['on', 'off', 'both'].includes(compression)) assert(sizes.every((n) => Number.isInteger(n) && n > 0)) assert(Number.isInteger(repetitions) && repetitions > 0) assert(Number.isFinite(cpuRate) && cpuRate >= 1) -assert(shapes.every((shape) => ['table', 'css'].includes(shape))) +assert(shapes.every((shape) => ['table', 'css', 'shadow'].includes(shape))) const assets = new Map() for (const name of ['array.js', 'posthog-recorder.js']) { @@ -79,8 +84,11 @@ try { for (const targetNodes of sizes) for (let run = 0; run < repetitions; run++) { // Alternate arm order to avoid always giving the same arm a warm browser process. - for (const compress of run % 2 ? [true, false] : [false, true]) { + for (const compress of (run % 2 ? [true, false] : [false, true]).filter( + (value) => compression === 'both' || value === (compression === 'on') + )) { const context = await browser.newContext() + const heapTimers = [] const page = await context.newPage() const client = await context.newCDPSession(page) await client.send('Emulation.setCPUThrottlingRate', { rate: cpuRate }) @@ -138,17 +146,44 @@ try { ({ targetNodes, shape, compress, origin }) => { const fixture = document.getElementById('fixture') const rowCount = Math.ceil(targetNodes / 21) + const lightRows = shape === 'shadow' ? Math.floor(rowCount / 2) : rowCount const cell = '12label' const markup = Array.from( - { length: rowCount }, + { length: lightRows }, (_, i) => `
${cell.repeat(4)}
` ).join('') let generation = 0 - window.buildFixture = () => { + window.buildFixture = (nested = false) => { generation++ - fixture.innerHTML = - markup.replaceAll('>12<', `>${generation}<`) + + window.fixtureGeneration = generation + if (nested) { + fixture.replaceChildren() + for (let i = 0; i < lightRows; i++) { + const row = document.createElement('div') + row.dataset.row = String(i) + fixture.append(row) + // Both parent insertion and its connected child insertion are observed. + row.innerHTML = cell.repeat(4).replaceAll('>12<', `>${generation}<`) + } + } else { + fixture.innerHTML = markup.replaceAll('>12<', `>${generation}<`) + } + const sentinels = 'BENCH_PRIVATE_TEXT
BENCH_BLOCKED_TEXT
' + fixture.insertAdjacentHTML('beforeend', sentinels) + if (shape === 'shadow') { + const host = document.createElement('div') + host.id = 'benchmark-shadow' + fixture.append(host) + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = + Array.from( + { length: rowCount - lightRows }, + (_, i) => `
${cell.repeat(4)}
` + ) + .join('') + .replaceAll('>12<', `>${generation}<`) + sentinels + } } window.buildFixture() if (shape === 'css') { @@ -186,20 +221,110 @@ try { window.posthog.sessionRecording?.status === 'disabled' && window.posthog.featureFlags.hasLoadedFlags ) + await page.exposeBinding('requestBenchmarkInput', async () => { + const timestamp = Date.now() / 1000 + await Promise.all([ + client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 20, + y: 18, + button: 'left', + buttons: 1, + clickCount: 1, + timestamp, + }), + client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 20, + y: 18, + button: 'left', + buttons: 0, + clickCount: 1, + timestamp, + }), + ]) + }) const metrics = [] const checkpoints = [] - for (const phase of ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove']) { + const phases = mutationWorkloads + ? [ + 'off-rebuild', + 'off-nested', + 'off-churn', + 'off-move', + 'off-remove', + 'start', + 'snapshot', + 'rebuild', + 'nested', + 'churn', + 'move', + 'remove', + ] + : ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove'] + for (const phase of phases) { + const off = phase.startsWith('off-') + if (mutationWorkloads && (off || phase === 'start')) { + await page.evaluate(() => { + document.body.insertBefore( + document.getElementById('fixture'), + document.getElementById('destination') + ) + window.buildFixture() + }) + await page.waitForTimeout(100) + } const startIndex = wireEvents.length const bytesBefore = requestBytes const before = await client.send('Performance.getMetrics') + const heapSamples = [] + let heapTimer, heapPending, heapError if (profiling) { await client.send('Profiler.enable') await client.send('Profiler.start') + // Diagnostic runs only: these samples perturb timing and can miss transient peaks. + heapTimer = setInterval(() => { + if (heapPending) return + heapPending = client + .send('Runtime.getHeapUsage') + .then((sample) => heapSamples.push(sample.usedSize)) + .catch((error) => { + heapError = error + }) + .finally(() => { + heapPending = undefined + }) + }, 100) + heapTimers.push(heapTimer) } const timing = await page.evaluate( - ({ phase, markerTag }) => - new Promise((resolve) => { + ({ phase, markerTag, mutationWorkloads, churnSteps, off }) => + new Promise((resolve, reject) => { + const deadline = setTimeout( + () => reject(new Error(`${phase}: workload timed out`)), + 120000 + ) const record = window.__PosthogExtensions__.rrweb.record + const definitions = [] + const checkpoint = (marker) => { + definitions.push({ + phase: marker, + generation: window.fixtureGeneration, + parent: document.getElementById('fixture').parentElement.id, + empty: !document.getElementById('fixture').childNodes.length, + }) + if (!off) record.addCustomEvent(markerTag, marker) + } + const inputDelays = [] + const onInput = (event) => { + if (!event.isTrusted) return + inputDelays.push(performance.now() - event.timeStamp) + checkpoint(`${phase}-input`) + } + if (mutationWorkloads) { + // oxlint-disable-next-line posthog-js/no-add-event-listener -- isolated benchmark page, not SDK runtime + document.getElementById('activity').addEventListener('click', onInput) + } const longTasks = [] const observer = new PerformanceObserver((list) => { for (const e of list.getEntries()) @@ -218,52 +343,85 @@ try { } raf = requestAnimationFrame(tick) // CDP evaluate work can be invisible to Long Tasks API. Use a page task. - setTimeout(() => { - const start = performance.now() - actionStart = start - const epochStart = performance.timeOrigin + start - switch (phase) { - case 'off-rebuild': - case 'rebuild': - window.buildFixture() - break - case 'start': - window.posthog.startSessionRecording() - document.getElementById('activity').click() - break - case 'snapshot': - record.takeFullSnapshot() - break - case 'move': - document - .getElementById('destination') - .append(document.getElementById('fixture')) - break - case 'remove': - document.getElementById('fixture').replaceChildren() - break - } - const actionMs = performance.now() - start - // Observer delivery happens before this timer. The marker passes through - // the real SDK compression queue, buffer, request encoder and transport. - setTimeout(() => { - if (phase !== 'off-rebuild') record.addCustomEvent(markerTag, phase) + setTimeout(async () => { + try { + const start = performance.now() + actionStart = start + const epochStart = performance.timeOrigin + start + const input = mutationWorkloads + ? window.requestBenchmarkInput() + : Promise.resolve() + const operation = phase.replace(/^off-/, '') + let actionMs = 0 + for ( + let step = 0; + step < (operation === 'churn' ? churnSteps : 1); + step++ + ) { + const actionStart = performance.now() + switch (operation) { + case 'rebuild': + case 'churn': + window.buildFixture() + break + case 'nested': + window.buildFixture(true) + break + case 'start': + window.posthog.startSessionRecording() + document.getElementById('activity').click() + break + case 'snapshot': + record.takeFullSnapshot() + break + case 'move': + document + .getElementById('destination') + .append(document.getElementById('fixture')) + break + case 'remove': + document.getElementById('fixture').replaceChildren() + break + } + actionMs += performance.now() - actionStart + // Mutation observers run before each checkpoint and before the next burst. + await new Promise((r) => setTimeout(r, 0)) + if (operation === 'churn') { + checkpoint(`${phase}-${step}`) + await new Promise((r) => setTimeout(r, 16)) + } + } + await input + const workloadMs = performance.now() - start + // Observer delivery happens before this timer. The marker passes through + // the real SDK compression queue, buffer, request encoder and transport. + await new Promise((r) => setTimeout(r, 0)) + checkpoint(phase) window.finishMeasurement = () => { observer.disconnect() cancelAnimationFrame(raf) + document + .getElementById('activity') + .removeEventListener('click', onInput) return { + definitions, + inputDelays, maxFrameGapMs, longTasks: longTasks.filter((t) => t.start >= start - 1), debug: window.posthog.sessionRecording.sdkDebugProperties, } } - resolve({ epochStart, actionMs }) - }, 0) + clearTimeout(deadline) + resolve({ epochStart, actionMs, workloadMs }) + } catch (error) { + clearTimeout(deadline) + reject(error) + } }, 50) }), - { phase, markerTag } + { phase, markerTag, mutationWorkloads, churnSteps, off } ) - if (phase !== 'off-rebuild') { + if (!off) { const deadline = Date.now() + 30000 while (!receivedMarkers.has(phase) && !decodeError && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)) @@ -276,7 +434,16 @@ try { // Allow trailing PerformanceObserver entries to arrive, outside the action. await page.waitForTimeout(100) const observation = await page.evaluate(() => window.finishMeasurement()) + if (mutationWorkloads) + assert.equal( + observation.inputDelays.length, + 1, + `${phase}: trusted input was not handled` + ) const after = await client.send('Performance.getMetrics') + clearInterval(heapTimer) + await heapPending + if (heapError) throw heapError if (profiling) { const { profile } = await client.send('Profiler.stop') await writeFile( @@ -297,12 +464,19 @@ try { metrics.push({ phase, ...timing, - wireMs: phase === 'off-rebuild' ? null : receivedMarkers.get(phase) - timing.epochStart, + wireMs: off ? null : receivedMarkers.get(phase) - timing.epochStart, + totalBlockingMs: observation.longTasks.reduce( + (sum, task) => sum + Math.max(0, task.duration - 50), + 0 + ), + inputDelayMs: observation.inputDelays[0] ?? null, maxTaskMs: Math.max(0, ...observation.longTasks.map((t) => t.duration)), longTaskCount: observation.longTasks.length, maxFrameGapMs: observation.maxFrameGapMs, taskCpuMs: 1000 * (metric(after, 'TaskDuration') - metric(before, 'TaskDuration')), heapDeltaBytes: metric(after, 'JSHeapUsedSize') - metric(before, 'JSHeapUsedSize'), + sampledMaxJSHeapUsedBytes: heapSamples.length ? Math.max(...heapSamples) : null, + heapSampleCount: heapSamples.length, wireBytes: requestBytes - bytesBefore, fullSnapshots: fullSnapshots.map((e) => countSnapshotNodes(e.data.node)), unexpectedFullSnapshots: Math.max( @@ -319,62 +493,137 @@ try { removes: mutations.reduce((sum, e) => sum + e.data.removes.length, 0), debug: observation.debug, }) - if (phase !== 'off-rebuild') checkpoints.push({ phase, end: wireEvents.length }) + await writeFile( + path.join(output, `${label}.json`), + JSON.stringify( + { + label, + shape, + targetNodes, + run, + compress, + metrics, + validation: 'pending', + }, + null, + 2 + ) + ) + if (mutationWorkloads) { + assert.equal( + metrics.at(-1).oversizedMutationsDropped, + 0, + `${label}/${phase}: oversized mutation drops invalidate timing` + ) + assert.equal( + metrics.at(-1).throttledAttributesDropped, + 0, + `${label}/${phase}: attribute drops invalidate timing` + ) + assert.equal( + metrics.at(-1).unexpectedFullSnapshots, + 0, + `${label}/${phase}: recovery snapshots invalidate timing` + ) + } + if (!off) + for (const definition of observation.definitions) { + const end = + wireEvents.findIndex( + (event) => + event.type === 5 && + event.data.tag === markerTag && + event.data.payload === definition.phase + ) + 1 + assert(end > 0, `${label}/${definition.phase}: missing checkpoint marker`) + checkpoints.push({ ...definition, end }) + } } + // Preserve diagnostics even if a correctness checkpoint fails. + const result = { label, shape, targetNodes, run, compress, metrics, validation: 'pending' } + await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)) // Correctness validation is deliberately outside all measurement windows. await page.evaluate(() => window.posthog.stopSessionRecording()) - await page.addScriptTag({ content: replayer }) - for (const { phase, end } of checkpoints) { - // Check intermediate states too: an empty final tree can hide lost adds. - const generation = ['start', 'snapshot'].includes(phase) ? 2 : 3 - const replayed = await page.evaluate( - ({ events, generation, shape }) => { - const player = new window.rrweb.Replayer(events, { UNSAFE_replayCanvas: false }) - player.pause(events.at(-1).timestamp - events[0].timestamp + 1) - const doc = player.iframe.contentDocument - const fixture = doc.querySelector('#fixture') - const rows = [...doc.querySelectorAll('#fixture [data-row]')] - const cssRules = doc.getElementById('benchmark-css')?.sheet.cssRules - const result = { - fixtureCount: doc.querySelectorAll('#fixture').length, - parent: fixture?.parentElement.id, - rows: rows.length, - orderedContent: rows.every( - (row, i) => - row.getAttribute('data-row') === String(i) && - row.textContent === `${generation}label`.repeat(4) && - row.querySelectorAll('.cell[data-label="metric"]').length === 4 - ), - stylesheet: - shape !== 'css' || - (cssRules?.length === 10000 && - cssRules[0].selectorText === '.rule9999' && - cssRules[9999].selectorText === '.rule0'), - adoptedStyle: - shape !== 'css' || - rows.length === 0 || - doc.defaultView.getComputedStyle(rows[0].querySelector('.cell')) - .paddingLeft === '1px', - } - player.destroy() - return result - }, - { events: wireEvents.slice(0, end), generation, shape } - ) - assert.deepEqual( - replayed, - { - fixtureCount: 1, - parent: ['move', 'remove'].includes(phase) ? 'destination' : '', - rows: phase === 'remove' ? 0 : Math.ceil(targetNodes / 21), - orderedContent: true, - stylesheet: true, - adoptedStyle: true, - }, - `${label}/${phase}: replay did not reconstruct the fixture` - ) + for (const { phase, end, generation, parent, empty } of checkpoints) { + // Every churn generation and trusted input gets a replay checkpoint, not just the final DOM. + // A fresh context prevents destroyed replay DOMs accumulating across large prefixes. + const validationContext = await browser.newContext() + try { + await validationContext.route('**/*', (route) => route.abort()) + const validationPage = await validationContext.newPage() + await validationPage.addScriptTag({ content: replayer }) + // Playwright's tagged object serialization can exceed CDP's 100 MB message cap. + // Upload bounded JSON strings instead; decoding remains outside timing windows. + const eventJson = JSON.stringify(wireEvents.slice(0, end)) + await validationPage.evaluate(() => { + window.replayInput = '' + }) + for (let offset = 0; offset < eventJson.length; offset += 1024 * 1024) { + await validationPage.evaluate( + (chunk) => { + window.replayInput += chunk + }, + eventJson.slice(offset, offset + 1024 * 1024) + ) + } + const replayed = await validationPage.evaluate( + ({ generation, shape }) => { + const events = JSON.parse(window.replayInput) + delete window.replayInput + const player = new window.rrweb.Replayer(events, { UNSAFE_replayCanvas: false }) + player.pause(events.at(-1).timestamp - events[0].timestamp + 1) + const doc = player.iframe.contentDocument + const fixture = doc.querySelector('#fixture') + const rows = [ + ...doc.querySelectorAll('#fixture [data-row]'), + ...(fixture + ?.querySelector('#benchmark-shadow') + ?.shadowRoot?.querySelectorAll('[data-row]') || []), + ] + const cssRules = doc.getElementById('benchmark-css')?.sheet.cssRules + const result = { + fixtureCount: doc.querySelectorAll('#fixture').length, + parent: fixture?.parentElement.id, + rows: rows.length, + orderedContent: rows.every( + (row, i) => + row.getAttribute('data-row') === String(i) && + row.textContent === `${generation}label`.repeat(4) && + row.querySelectorAll('.cell[data-label="metric"]').length === 4 + ), + stylesheet: + shape !== 'css' || + (cssRules?.length === 10000 && + cssRules[0].selectorText === '.rule9999' && + cssRules[9999].selectorText === '.rule0'), + adoptedStyle: + shape !== 'css' || + rows.length === 0 || + doc.defaultView.getComputedStyle(rows[0].querySelector('.cell')) + .paddingLeft === '1px', + } + player.destroy() + return result + }, + { generation, shape } + ) + assert.deepEqual( + replayed, + { + fixtureCount: 1, + parent, + rows: empty ? 0 : Math.ceil(targetNodes / 21), + orderedContent: true, + stylesheet: true, + adoptedStyle: true, + }, + `${label}/${phase}: replay did not reconstruct the fixture` + ) + } finally { + if (browser.isConnected()) await validationContext.close() + } } - const result = { label, shape, targetNodes, run, compress, metrics } + result.validation = 'passed' results.push(result) await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)) // oxlint-disable-next-line no-console @@ -388,7 +637,8 @@ try { .join(' | ') ) } finally { - await context.close() + heapTimers.forEach(clearInterval) + if (browser.isConnected()) await context.close() } } } @@ -408,6 +658,12 @@ try { cpu: os.cpus()[0]?.model, cpuRate, profiling, + benchmarkSha256: createHash('sha256') + .update(await readFile(fileURLToPath(import.meta.url))) + .digest('hex'), + mutationWorkloads, + churnSteps, + compression, results, }, null, diff --git a/packages/browser/scripts/summarize-replay-profile.mjs b/packages/browser/scripts/summarize-replay-profile.mjs new file mode 100644 index 0000000000..391fa4918d --- /dev/null +++ b/packages/browser/scripts/summarize-replay-profile.mjs @@ -0,0 +1,125 @@ +// oxlint-disable compat/compat -- Node CLI, not SDK runtime +// Resolve production CPU profiles through the browser and rrweb source-map chain. +// Run against the exact build that produced the profiles; see benchmark-replay.md. +import assert from 'node:assert/strict' +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { decode } from '@jridgewell/sourcemap-codec' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const directory = path.resolve(process.argv[2] || path.join(root, 'test-results/replay-benchmark')) +const dist = path.resolve(process.argv[3] || path.join(root, 'dist')) +const maps = new Map() +async function sourceMap(file) { + if (!maps.has(file)) { + let parsed + try { + parsed = JSON.parse(await readFile(`${file}.map`, 'utf8')) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + maps.set(file, parsed ? { ...parsed, decoded: decode(parsed.mappings) } : null) + } + return maps.get(file) +} +async function locate(frame) { + let file = path.join(dist, path.basename(frame.url || '')) + let line = frame.lineNumber + let column = frame.columnNumber + let sourceLine = '' + let mapped = false + for (let depth = 0; depth < 8; depth++) { + const map = await sourceMap(file) + if (!map) break + let hit + for (const segment of map.decoded[line] || []) { + if (segment[0] > column) break + if (segment.length >= 4) hit = segment + } + if (!hit) break + mapped = true + file = path.resolve(path.dirname(file), map.sourceRoot || '', map.sources[hit[1]]) + line = hit[2] + column = hit[3] + sourceLine = map.sourcesContent?.[hit[1]]?.split('\n')[line] || '' + } + return { + file, + line: line + 1, + sourceLine, + name: frame.functionName, + label: mapped + ? `${path.relative(root, file)}:${line + 1} ${frame.functionName}` + : frame.functionName || frame.url || '(native)', + } +} +const results = [] +for (const filename of (await readdir(directory)).filter((name) => name.endsWith('.cpuprofile')).sort()) { + const profile = JSON.parse(await readFile(path.join(directory, filename), 'utf8')) + assert.equal(profile.samples.length, profile.timeDeltas.length) + const nodes = new Map() + const parents = new Map() + for (const node of profile.nodes) { + nodes.set(node.id, await locate(node.callFrame)) + for (const child of node.children || []) parents.set(child, node.id) + } + const self = new Map() + const categories = {} + let mutationInclusiveMs = 0 + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i] + const ms = profile.timeDeltas[i] / 1000 + const location = nodes.get(id) + self.set(location.label, (self.get(location.label) || 0) + ms) + const stack = [] + for (let current = id; current; current = parents.get(current)) stack.push(nodes.get(current)) + const mutation = stack.some((frame) => frame.file.endsWith('/record/mutation.ts')) + if (mutation) mutationInclusiveMs += ms + // Exclusive categories. GC samples without an attributed stack remain separate. + const category = + location.name === '(idle)' + ? 'idle' + : location.name === '(garbage collector)' + ? 'gc' + : stack.some( + (frame) => + frame.file.endsWith('/record/mutation.ts') && + /private (processMutation|genAdds) =/.test(frame.sourceLine) + ) + ? 'mutationPreprocessing' + : stack.some((frame) => frame.file.endsWith('/rrweb-snapshot/src/snapshot.ts')) + ? 'serialization' + : stack.some( + (frame) => + /\/gzip\.(ts|mjs)$/.test(frame.file) || + (frame.file.endsWith('/lazy-loaded-session-recorder.ts') && + /function (gzip|serializeForCompression|compressEvent)/.test(frame.sourceLine)) + ) + ? 'encoding' + : mutation + ? 'mutationEmitOther' + : 'other' + categories[category] = (categories[category] || 0) + ms + } + results.push({ + filename, + sampledMs: categories, + mutationInclusiveMs, + topSelfMs: [...self] + .filter(([name]) => name !== '(idle)') + .sort((a, b) => b[1] - a[1]) + .slice(0, 25), + }) +} +// oxlint-disable-next-line no-console -- CLI diagnostic output +console.log( + JSON.stringify( + { + note: 'Sampled attribution, not exact wall time. Use matching intermediate source maps. Categories are exclusive; mutationInclusiveMs overlaps them.', + results, + }, + null, + 2 + ) +) diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 128fd9338e..9df37e8e26 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -359,6 +359,12 @@ export default class MutationBuffer { } return nextId; }; + // Reuse configuration and callbacks within this emission, not DOM values. + // serializeNodeWithId does not mutate the options; needsMask stays unset so + // each node still checks its own masking context. + let serializationOptions: + | Parameters[1] + | undefined; const pushAdd = (n: Node) => { const parent = dom.parentNode(n); if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') { @@ -371,7 +377,7 @@ export default class MutationBuffer { if (parentId === -1 || nextId === -1) { return addList.addNode(n); } - const sn = serializeNodeWithId(n, { + serializationOptions ??= { doc: this.doc, mirror: this.mirror, blockClass: this.blockClass, @@ -428,7 +434,8 @@ export default class MutationBuffer { onStylesheetLoad: (link, childSn) => { this.stylesheetManager.attachLinkElement(link, childSn); }, - }); + }; + const sn = serializeNodeWithId(n, serializationOptions); if (sn) { adds.push({ parentId, diff --git a/packages/rrweb/rrweb/test/record/mutation-serialization-options.test.ts b/packages/rrweb/rrweb/test/record/mutation-serialization-options.test.ts new file mode 100644 index 0000000000..3efb73607f --- /dev/null +++ b/packages/rrweb/rrweb/test/record/mutation-serialization-options.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import * as snapshot from '@posthog/rrweb-snapshot'; +import record from '../../src/record'; +import type { eventWithTime } from '@posthog/rrweb-types'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('mutation serialization options', () => { + let stop: (() => void) | undefined; + let events: eventWithTime[]; + + beforeEach(() => { + document.body.innerHTML = '
'; + events = []; + }); + + afterEach(() => { + stop?.(); + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('allocates one options object per emission instead of per added node', async () => { + const serialize = vi.spyOn(snapshot, 'serializeNodeWithId'); + stop = record({ emit: (event) => events.push(event) }); + await settle(); + serialize.mockClear(); + + document.getElementById('fixture')!.innerHTML = + '
firstsecond
'.repeat(100); + await settle(); + + const options = serialize.mock.calls + .filter( + ([node, options]) => + options.newlyAddedElement && + document.getElementById('fixture')!.contains(node), + ) + .map(([, options]) => options); + expect(options.length).toBeGreaterThan(100); + expect(new Set(options).size).toBe(1); + expect(new Set(options.map((options) => options.onSerialize)).size).toBe(1); + + serialize.mockClear(); + document.getElementById('fixture')!.innerHTML = '

next batch

'; + await settle(); + const nextOptions = serialize.mock.calls + .filter( + ([node, options]) => + options.newlyAddedElement && + document.getElementById('fixture')!.contains(node), + ) + .map(([, options]) => options); + expect(nextOptions.length).toBeGreaterThan(0); + expect(new Set(nextOptions).size).toBe(1); + expect(nextOptions[0]).not.toBe(options[0]); + }); + + it('does not share node-specific masking state between siblings or batches', async () => { + const serialize = vi.spyOn(snapshot, 'serializeNodeWithId'); + const maskTextFn = vi.fn(() => '[MASKED]'); + stop = record({ + emit: (event) => events.push(event), + maskTextClass: 'mask-me', + maskTextFn, + maskAllInputs: true, + }); + await settle(); + events.length = 0; + serialize.mockClear(); + + document.getElementById('fixture')!.innerHTML = + 'FIRST_PRIVATEfirst public' + + ''; + await settle(); + let json = JSON.stringify(events); + expect(json).toContain('[MASKED]'); + expect(json).toContain('first public'); + expect(json).not.toContain('FIRST_PRIVATE'); + expect(json).not.toContain('PRIVATE_PASSWORD'); + + events.length = 0; + document.getElementById('fixture')!.innerHTML = + 'second publicSECOND_PRIVATE'; + await settle(); + json = JSON.stringify(events); + expect(json).toContain('second public'); + expect(json).toContain('[MASKED]'); + expect(json).not.toContain('SECOND_PRIVATE'); + expect(maskTextFn).toHaveBeenCalledWith('FIRST_PRIVATE', expect.anything()); + expect(maskTextFn).toHaveBeenCalledWith( + 'SECOND_PRIVATE', + expect.anything(), + ); + for (const [, options] of serialize.mock.calls) { + if (options.newlyAddedElement) expect(options.needsMask).toBeUndefined(); + } + }); +}); From 881c58a8ddd502fde75670890573ae40e01a757c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sun, 6 Sep 2026 09:11:40 +0200 Subject: [PATCH 3/9] perf(replay): deduplicate pending mirror removal roots --- .changeset/quiet-mirror-removals.md | 5 + .../scripts/benchmark-replay-ordering.md | 103 ++++++++++ packages/browser/scripts/benchmark-replay.md | 2 + packages/browser/scripts/benchmark-replay.mjs | 178 ++++++++++++++---- packages/rrweb/rrweb/src/record/mutation.ts | 17 +- .../record/mutation-mirror-removal.test.ts | 102 ++++++++++ 6 files changed, 368 insertions(+), 39 deletions(-) create mode 100644 .changeset/quiet-mirror-removals.md create mode 100644 packages/browser/scripts/benchmark-replay-ordering.md create mode 100644 packages/rrweb/rrweb/test/record/mutation-mirror-removal.test.ts diff --git a/.changeset/quiet-mirror-removals.md b/.changeset/quiet-mirror-removals.md new file mode 100644 index 0000000000..7cd0b75a91 --- /dev/null +++ b/.changeset/quiet-mirror-removals.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Avoid repeatedly traversing the same subtree during session replay mirror cleanup when it moves multiple times in one mutation batch. diff --git a/packages/browser/scripts/benchmark-replay-ordering.md b/packages/browser/scripts/benchmark-replay-ordering.md new file mode 100644 index 0000000000..161a518e37 --- /dev/null +++ b/packages/browser/scripts/benchmark-replay-ordering.md @@ -0,0 +1,103 @@ +# Mirror removal and ordering investigation (#4217) + +This slice is based on `c9b2c2216743cd177b2958aee6af7549538ae055` (PR #4807). Both benchmark arms already include the native-getter cache and per-emission serialization-options improvements. No workers, yielding, early dropping or asynchronous recording are introduced. + +## Workloads + +Run after building the SDK and dependencies: + +```sh +# Small correctness matrix, both compression settings +REPLAY_BENCH_ORDERING=1 REPLAY_BENCH_NODES=1000 \ + REPLAY_BENCH_SHAPES=table,flat,shadow,css REPLAY_BENCH_RUNS=1 \ + pnpm --filter posthog-js benchmark:replay + +# Timing run; compare the same script against the parent's artifacts using REPLAY_BENCH_DIST +REPLAY_BENCH_ORDERING=1 REPLAY_BENCH_NODES=50000 \ + REPLAY_BENCH_SHAPES=table,flat REPLAY_BENCH_RUNS=3 \ + REPLAY_BENCH_COMPRESSION=on pnpm --filter posthog-js benchmark:replay + +# Separate diagnostic run with mirror counters, CPU profiles and sampled JS heap +REPLAY_BENCH_ORDERING=1 REPLAY_BENCH_PROFILE=1 \ + REPLAY_BENCH_NODES=10000 REPLAY_BENCH_SHAPES=table,flat \ + REPLAY_BENCH_RUNS=1 REPLAY_BENCH_COMPRESSION=on \ + pnpm --filter posthog-js benchmark:replay +``` + +Ordering mode enables the mutation benchmark's input probes and drop/recovery checks, but selects these operations: + +- Reverse the row siblings, preserving their values and IDs. +- Move the fixture between two parents repeatedly within one observer batch. `REPLAY_BENCH_MOVE_ROUNDS` defaults to 5 (range 1–20): five round trips plus a final move, **11 moves in total**. This is a deliberate stress case, not a claim about the frequency of this pattern on customer pages. +- Detach the entire fixture as one subtree, then restore it without rebuilding its contents. +- Remove its children individually through one `replaceChildren()` operation. + +Matching recording-off controls cover the same operations. Fixtures are restored outside control measurement windows. The `flat` shape uses approximately half as many rows as target nodes: each row is one element plus a text node. At roughly 50k nodes it has 25k direct row siblings, versus about 2.4k in the table shape. Element/attribute mixes differ, so compare baseline/candidate within each shape, not the absolute timings across shapes as a pure structural experiment. + +Replay checkpoints now verify forward/reversed row order, fixture absence and restoration, as well as the inherited generation, privacy, CSS and shadow-DOM assertions. A trusted input checkpoint remains part of every phase. A negative probe dropping the reorder mutation failed an intermediate reorder checkpoint as intended, even though later events could repair the final state. + +See [benchmark-replay-mutations.md](benchmark-replay-mutations.md) for input-delay, blocking-window, chunked validation and heap-sampling limitations. This remains a Chromium performance harness, not an INP or complete lifecycle test suite. + +## Diagnostic counters + +Only when **both ordering mode and profiling are enabled**, the benchmark wraps the built recorder's mirror methods in the synthetic page. It counts: + +- `removeVisits`: recursive `removeNodeFromMap` calls, including repeated visits. +- `distinctRemovedNodes`: distinct physical DOM nodes visited during the phase, not a count of serialized nodes. +- `removeRoots`: top-level cleanup calls, excluding their recursion. +- Calls to `getId`, `getMeta`, `getNode`, `has`, `hasNode` and `add`. + +Counters reset per phase. These wrappers are benchmark-only, are not shipped in SDK artifacts and perturb execution. **Do not use instrumented timings for before/after performance claims.** Method counts can overlap: `getId` calls `getMeta`, so summing them would double-count work. CPU profiles provide further attribution; there is no direct deferred-add queue-scan counter yet. + +## Finding and change + +The old `mapRemoves` array queued the same root for every removal record. Cleanup then recursively traversed that root's final DOM subtree once per queued entry, before serializing any additions. + +For the 10k-node table fixture and 11 moves: + +| Diagnostic | Parent baseline | Candidate | +| ------------------------------- | --------------: | --------: | +| Cleanup roots processed | 11 | 1 | +| Recursive node visits | 110,253 | 10,023 | +| Distinct physical nodes visited | 10,023 | 10,023 | + +The candidate uses an insertion-ordered `Set` for pending cleanup roots. It deletes each root from the queue **before** traversing it, preserving the old consume-before-traversal behavior even if traversal throws. It preserves first-seen root order and still drains cleanup before additions. A later emission can queue the same root again. + +This deduplicates **identical queued roots only**. It does not skip arbitrary overlapping parent/child roots, cache DOM contents, change mirror metadata semantics, or alter the emitted removal records. Distinct child roots must still be cleaned separately when they have moved outside an ancestor's final subtree. `Mirror.removeNodeFromMap` itself is unchanged, including shadow-root and iframe-document traversal. + +The focused regression failed on the parent with 11 visits to the fixture root instead of one. Tests also preserve mirror IDs across moves and later batches, verify detached descendants are cleaned, and exercise queue consumption and remaining work after a traversal error. + +## Local timing results + +Apple M4 Pro, Chromium 136.0.7103.25, approximately 50k nodes, compression enabled, no CPU throttling. Three runs per build and shape, with sequential baseline/candidate alternation and the same benchmark source. Every timing run had profiling/counters disabled. + +| Shape and workload | Median longest task before / after | Median input delay before / after | +| ---------------------------- | ---------------------------------- | --------------------------------- | +| Table: 11 moves in one batch | 591 / 471 ms | 597.0 / 477.3 ms | +| Flat: 11 moves in one batch | 584 / 476 ms | 591.3 / 483.3 ms | +| Flat: remove children | 77 / 67 ms | 77.5 / 67.9 ms | +| Table: reverse siblings | 174 / 170 ms | 221.5 / 216.7 ms | +| Flat: reverse siblings | 245 / 230 ms | 302.6 / 301.0 ms | + +The recorder artifact increased by 13 raw bytes, with no gzip size increase in this build. + +All 12 comparison arms passed intermediate replay/input, privacy and drop/recovery checks. These are descriptive local samples, not significance estimates or customer guarantees. The benefit is strongest for repeated moves, where duplicate traversal was demonstrated. Single-subtree removal was effectively flat. Table restoration was somewhat worse in this sample (154 / 164 ms longest task); flat restoration was flat (191 / 190 ms). Startup is not optimized. + +Artifacts: + +- `/tmp/4217-mirror-{baseline,candidate}-{1,2,3}/results.json` +- `/tmp/4217-mirror-{before,after}-profile/results.json` +- `/tmp/4217-mirror-negative.log` + +## Validation and remaining scope + +- 321 recording/accessor tests passed, 2 skipped. This includes the existing iframe, shadow-DOM and recording lifecycle tests. +- Nine masking tests passed across Chromium, Firefox and WebKit. +- SDK/dependency and rrweb builds/typechecks passed, with targeted lint/format and ES5/ES6 checks. +- Small table/flat/shadow/CSS ordering fixtures passed with both compression settings. +- A 10k-node 4x page-throttled table/flat run passed with compression enabled. +- The previous churn workload passed in both compression settings. +- Temporary fault-injection code was removed. + +This does not bound main-thread work. The repeated-move candidate still blocks for roughly 470 ms at 50k nodes. Preprocessing still handles each mutation record, and serialization, ordering and layout costs remain. The next investigation can inspect `genAdds`/`deepDelete` work and deferred-add queue scans, but must preserve their parent/order bookkeeping rather than blindly applying the cleanup deduplication rule there. + +Incident assessment: mirror cleanup order and snapshot correctness are the relevant risks. Unique roots still drain in order before additions, and existing iframe/shadow lifecycle tests plus real replay checkpoints pass. There are no new lazy-load contracts, persistence fields, sampling, rotation, flush, masking or privacy policies. No recording-volume change is expected. Real low-end-device measurements, broader performance-browser coverage and true peak memory remain open. diff --git a/packages/browser/scripts/benchmark-replay.md b/packages/browser/scripts/benchmark-replay.md index 20985cb873..ef8eac71a0 100644 --- a/packages/browser/scripts/benchmark-replay.md +++ b/packages/browser/scripts/benchmark-replay.md @@ -7,6 +7,8 @@ thresholds; correctness assertions fail the command. For nested mutations, sustained churn, trusted input probes, shadow DOM and source-map attribution, see [the mutation investigation](benchmark-replay-mutations.md). +For repeated moves, flat sibling lists and mirror cleanup counters, see +[the ordering investigation](benchmark-replay-ordering.md). ## Run diff --git a/packages/browser/scripts/benchmark-replay.mjs b/packages/browser/scripts/benchmark-replay.mjs index feb6927ed9..faf1f538d2 100644 --- a/packages/browser/scripts/benchmark-replay.mjs +++ b/packages/browser/scripts/benchmark-replay.mjs @@ -20,7 +20,10 @@ const repetitions = Number(process.env.REPLAY_BENCH_RUNS || 3) const cpuRate = Number(process.env.REPLAY_BENCH_CPU || 1) const shapes = (process.env.REPLAY_BENCH_SHAPES || 'table,css').split(',') const profiling = process.env.REPLAY_BENCH_PROFILE === '1' -const mutationWorkloads = process.env.REPLAY_BENCH_MUTATIONS === '1' +const orderingWorkloads = process.env.REPLAY_BENCH_ORDERING === '1' +const mutationWorkloads = orderingWorkloads || process.env.REPLAY_BENCH_MUTATIONS === '1' +const moveRounds = Number(process.env.REPLAY_BENCH_MOVE_ROUNDS || 5) +assert(Number.isInteger(moveRounds) && moveRounds > 0 && moveRounds <= 20) const churnSteps = Number(process.env.REPLAY_BENCH_CHURN_STEPS || 5) const compression = process.env.REPLAY_BENCH_COMPRESSION || 'both' assert(Number.isInteger(churnSteps) && churnSteps > 0 && churnSteps <= 20) @@ -28,7 +31,7 @@ assert(['on', 'off', 'both'].includes(compression)) assert(sizes.every((n) => Number.isInteger(n) && n > 0)) assert(Number.isInteger(repetitions) && repetitions > 0) assert(Number.isFinite(cpuRate) && cpuRate >= 1) -assert(shapes.every((shape) => ['table', 'css', 'shadow'].includes(shape))) +assert(shapes.every((shape) => ['table', 'css', 'shadow', 'flat'].includes(shape))) const assets = new Map() for (const name of ['array.js', 'posthog-recorder.js']) { @@ -145,17 +148,19 @@ try { await page.evaluate( ({ targetNodes, shape, compress, origin }) => { const fixture = document.getElementById('fixture') - const rowCount = Math.ceil(targetNodes / 21) + window.benchmarkFixture = fixture + const rowCount = Math.ceil(targetNodes / (shape === 'flat' ? 2 : 21)) const lightRows = shape === 'shadow' ? Math.floor(rowCount / 2) : rowCount const cell = '12label' const markup = Array.from( { length: lightRows }, - (_, i) => `
${cell.repeat(4)}
` + (_, i) => `
${shape === 'flat' ? '12' : cell.repeat(4)}
` ).join('') let generation = 0 window.buildFixture = (nested = false) => { generation++ window.fixtureGeneration = generation + window.fixtureReversed = false if (nested) { fixture.replaceChildren() for (let i = 0; i < lightRows; i++) { @@ -163,7 +168,10 @@ try { row.dataset.row = String(i) fixture.append(row) // Both parent insertion and its connected child insertion are observed. - row.innerHTML = cell.repeat(4).replaceAll('>12<', `>${generation}<`) + row.innerHTML = + shape === 'flat' + ? String(generation) + : cell.repeat(4).replaceAll('>12<', `>${generation}<`) } } else { fixture.innerHTML = markup.replaceAll('>12<', `>${generation}<`) @@ -244,36 +252,84 @@ try { }), ]) }) + if (orderingWorkloads && profiling) + await page.evaluate(() => { + const mirror = window.__PosthogExtensions__.rrweb.record.mirror + let seen, + depth = 0 + window.resetMirrorStats = () => { + seen = new WeakSet() + window.mirrorStats = { removeVisits: 0, distinctRemovedNodes: 0, removeRoots: 0 } + } + window.resetMirrorStats() + for (const method of ['getId', 'getMeta', 'getNode', 'has', 'hasNode', 'add']) { + const original = mirror[method] + mirror[method] = function (...args) { + window.mirrorStats[method] = (window.mirrorStats[method] || 0) + 1 + return original.apply(this, args) + } + } + const remove = mirror.removeNodeFromMap + mirror.removeNodeFromMap = function (node) { + const stats = window.mirrorStats + stats.removeVisits++ + if (!depth) stats.removeRoots++ + if (!seen.has(node)) { + seen.add(node) + stats.distinctRemovedNodes++ + } + depth++ + try { + return remove.call(this, node) + } finally { + depth-- + } + } + }) const metrics = [] const checkpoints = [] - const phases = mutationWorkloads + const phases = orderingWorkloads ? [ - 'off-rebuild', - 'off-nested', - 'off-churn', - 'off-move', 'off-remove', + 'off-subtree-remove', + 'off-repeat-move', + 'off-reorder', 'start', - 'snapshot', - 'rebuild', - 'nested', - 'churn', - 'move', + 'reorder', + 'repeat-move', + 'subtree-remove', + 'restore', 'remove', ] - : ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove'] + : mutationWorkloads + ? [ + 'off-rebuild', + 'off-nested', + 'off-churn', + 'off-move', + 'off-remove', + 'start', + 'snapshot', + 'rebuild', + 'nested', + 'churn', + 'move', + 'remove', + ] + : ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove'] for (const phase of phases) { const off = phase.startsWith('off-') if (mutationWorkloads && (off || phase === 'start')) { await page.evaluate(() => { document.body.insertBefore( - document.getElementById('fixture'), + document.getElementById('fixture') || window.benchmarkFixture, document.getElementById('destination') ) window.buildFixture() }) await page.waitForTimeout(100) } + if (orderingWorkloads && profiling) await page.evaluate(() => window.resetMirrorStats()) const startIndex = wireEvents.length const bytesBefore = requestBytes const before = await client.send('Performance.getMetrics') @@ -298,7 +354,7 @@ try { heapTimers.push(heapTimer) } const timing = await page.evaluate( - ({ phase, markerTag, mutationWorkloads, churnSteps, off }) => + ({ phase, markerTag, mutationWorkloads, churnSteps, moveRounds, off }) => new Promise((resolve, reject) => { const deadline = setTimeout( () => reject(new Error(`${phase}: workload timed out`)), @@ -307,11 +363,14 @@ try { const record = window.__PosthogExtensions__.rrweb.record const definitions = [] const checkpoint = (marker) => { + const fixture = document.getElementById('fixture') definitions.push({ phase: marker, generation: window.fixtureGeneration, - parent: document.getElementById('fixture').parentElement.id, - empty: !document.getElementById('fixture').childNodes.length, + reversed: window.fixtureReversed, + present: !!fixture, + parent: fixture?.parentElement.id ?? null, + empty: !fixture?.childNodes.length, }) if (!off) record.addCustomEvent(markerTag, marker) } @@ -374,6 +433,43 @@ try { case 'snapshot': record.takeFullSnapshot() break + case 'reorder': { + const fixture = window.benchmarkFixture + for (const parent of [ + fixture, + fixture.querySelector('#benchmark-shadow')?.shadowRoot, + ]) { + if (parent) + [...parent.querySelectorAll('[data-row]')] + .reverse() + .forEach((row) => parent.append(row)) + } + window.fixtureReversed = !window.fixtureReversed + break + } + case 'repeat-move': + for (let round = 0; round < moveRounds; round++) { + document + .getElementById('destination') + .append(window.benchmarkFixture) + document.body.insertBefore( + window.benchmarkFixture, + document.getElementById('destination') + ) + } + document + .getElementById('destination') + .append(window.benchmarkFixture) + break + case 'subtree-remove': + window.benchmarkFixture.remove() + break + case 'restore': + document.body.insertBefore( + window.benchmarkFixture, + document.getElementById('destination') + ) + break case 'move': document .getElementById('destination') @@ -406,6 +502,7 @@ try { return { definitions, inputDelays, + mirrorStats: window.mirrorStats || null, maxFrameGapMs, longTasks: longTasks.filter((t) => t.start >= start - 1), debug: window.posthog.sessionRecording.sdkDebugProperties, @@ -419,7 +516,7 @@ try { } }, 50) }), - { phase, markerTag, mutationWorkloads, churnSteps, off } + { phase, markerTag, mutationWorkloads, churnSteps, moveRounds, off } ) if (!off) { const deadline = Date.now() + 30000 @@ -470,6 +567,7 @@ try { 0 ), inputDelayMs: observation.inputDelays[0] ?? null, + mirrorStats: observation.mirrorStats, maxTaskMs: Math.max(0, ...observation.longTasks.map((t) => t.duration)), longTaskCount: observation.longTasks.length, maxFrameGapMs: observation.maxFrameGapMs, @@ -544,7 +642,7 @@ try { await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)) // Correctness validation is deliberately outside all measurement windows. await page.evaluate(() => window.posthog.stopSessionRecording()) - for (const { phase, end, generation, parent, empty } of checkpoints) { + for (const { phase, end, generation, parent, empty, present, reversed } of checkpoints) { // Every churn generation and trusted input gets a replay checkpoint, not just the final DOM. // A fresh context prevents destroyed replay DOMs accumulating across large prefixes. const validationContext = await browser.newContext() @@ -567,7 +665,7 @@ try { ) } const replayed = await validationPage.evaluate( - ({ generation, shape }) => { + ({ generation, shape, reversed }) => { const events = JSON.parse(window.replayInput) delete window.replayInput const player = new window.rrweb.Replayer(events, { UNSAFE_replayCanvas: false }) @@ -583,14 +681,26 @@ try { const cssRules = doc.getElementById('benchmark-css')?.sheet.cssRules const result = { fixtureCount: doc.querySelectorAll('#fixture').length, - parent: fixture?.parentElement.id, + parent: fixture?.parentElement.id ?? null, rows: rows.length, - orderedContent: rows.every( - (row, i) => - row.getAttribute('data-row') === String(i) && - row.textContent === `${generation}label`.repeat(4) && - row.querySelectorAll('.cell[data-label="metric"]').length === 4 - ), + orderedContent: rows.every((row, i) => { + const lightCount = + shape === 'shadow' ? Math.floor(rows.length / 2) : rows.length + const expectedIndex = !reversed + ? i + : i < lightCount + ? lightCount - 1 - i + : rows.length - 1 - (i - lightCount) + return ( + row.getAttribute('data-row') === String(expectedIndex) && + row.textContent === + (shape === 'flat' + ? String(generation) + : `${generation}label`.repeat(4)) && + row.querySelectorAll('.cell[data-label="metric"]').length === + (shape === 'flat' ? 0 : 4) + ) + }), stylesheet: shape !== 'css' || (cssRules?.length === 10000 && @@ -605,14 +715,14 @@ try { player.destroy() return result }, - { generation, shape } + { generation, shape, reversed } ) assert.deepEqual( replayed, { - fixtureCount: 1, + fixtureCount: present ? 1 : 0, parent, - rows: empty ? 0 : Math.ceil(targetNodes / 21), + rows: empty ? 0 : Math.ceil(targetNodes / (shape === 'flat' ? 2 : 21)), orderedContent: true, stylesheet: true, adoptedStyle: true, @@ -662,6 +772,8 @@ try { .update(await readFile(fileURLToPath(import.meta.url))) .digest('hex'), mutationWorkloads, + orderingWorkloads, + moveRounds, churnSteps, compression, results, diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 9df37e8e26..34910e4681 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -172,7 +172,9 @@ export default class MutationBuffer { private attributeMap = new WeakMap(); private generatedAttributes = new WeakMap>(); private removes: removedNodeMutation[] = []; - private mapRemoves: Node[] = []; + // Repeated moves can queue the same root before any mirror cleanup runs. + // Keep first-seen order without traversing an identical root again at emit. + private mapRemoves = new Set(); private movedMap: Record = {}; @@ -311,8 +313,10 @@ export default class MutationBuffer { } public destroy() { - while (this.mapRemoves.length) { - this.mirror.removeNodeFromMap(this.mapRemoves.shift()!); + for (const node of this.mapRemoves) { + // Consume before traversal, as shift() did, including when it throws. + this.mapRemoves.delete(node); + this.mirror.removeNodeFromMap(node); } } @@ -452,8 +456,9 @@ export default class MutationBuffer { // `mirror.getNode` and matches it against the iframe behind the removed // id. Reorder this and iframe moves will look like remove+add to that // path, tearing down observers on a still-live iframe. - while (this.mapRemoves.length) { - this.mirror.removeNodeFromMap(this.mapRemoves.shift()!); + for (const node of this.mapRemoves) { + this.mapRemoves.delete(node); + this.mirror.removeNodeFromMap(node); } for (const n of this.movedSet) { @@ -892,7 +897,7 @@ export default class MutationBuffer { }); processRemoves(n, this.removesSubTreeCache); } - this.mapRemoves.push(n); + this.mapRemoves.add(n); }); break; } diff --git a/packages/rrweb/rrweb/test/record/mutation-mirror-removal.test.ts b/packages/rrweb/rrweb/test/record/mutation-mirror-removal.test.ts new file mode 100644 index 0000000000..7fe8c4bfb6 --- /dev/null +++ b/packages/rrweb/rrweb/test/record/mutation-mirror-removal.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import record from '../../src/record'; +import { mutationBuffers } from '../../src/record/observer'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('mutation mirror removal queue', () => { + let stop: (() => void) | undefined; + + beforeEach(() => { + document.body.innerHTML = + '
' + + 'value'.repeat(100) + + '
'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + stop?.(); + document.body.innerHTML = ''; + }); + + it('traverses a repeatedly moved subtree once per emission, not once per move', async () => { + stop = record({ emit: () => {} }); + await settle(); + const root = document.getElementById('fixture')!; + const destination = document.getElementById('destination')!; + const nodes = [root, ...root.querySelectorAll('span')]; + const ids = nodes.map((node) => record.mirror.getId(node)); + expect(ids.every((id) => id > 0)).toBe(true); + const remove = vi.spyOn(record.mirror, 'removeNodeFromMap'); + + for (let round = 0; round < 5; round++) { + destination.append(root); + document.body.insertBefore(root, destination); + } + destination.append(root); + await settle(); + + expect(remove.mock.calls.filter(([node]) => node === root)).toHaveLength(1); + nodes.forEach((node, index) => { + expect(record.mirror.getId(node)).toBe(ids[index]); + expect(record.mirror.getNode(ids[index])).toBe(node); + }); + + // Deduplication is only for pending work, not for the lifetime of a node. + remove.mockClear(); + document.body.insertBefore(root, destination); + await settle(); + expect(remove.mock.calls.filter(([node]) => node === root)).toHaveLength(1); + nodes.forEach((node, index) => + expect(record.mirror.getNode(ids[index])).toBe(node), + ); + }); + + it('cleans up distinct removed roots and descendants even after repeated moves', async () => { + stop = record({ emit: () => {} }); + await settle(); + const root = document.getElementById('fixture')!; + const child = root.firstElementChild!; + const destination = document.getElementById('destination')!; + const ids = [root, child, child.firstChild!].map((node) => + record.mirror.getId(node), + ); + destination.append(root); + document.body.append(root); + // The child is no longer in root's final subtree. It needs its own cleanup. + destination.append(child); + child.remove(); + root.remove(); + await settle(); + ids.forEach((id) => expect(record.mirror.has(id)).toBe(false)); + }); + + it('consumes the current root before traversal and leaves later roots queued on error', async () => { + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find( + (buffer) => buffer.bufferDoc() === document, + )!; + buffer.lock(); + const a = document.querySelector('#fixture span')!; + const b = a.nextElementSibling!; + a.remove(); + b.remove(); + await settle(); + const remove = vi.spyOn(record.mirror, 'removeNodeFromMap'); + remove.mockImplementationOnce(() => { + throw new Error('cleanup failed'); + }); + expect(() => buffer.destroy()).toThrow('cleanup failed'); + expect(remove.mock.calls[0][0]).toBe(a); + remove.mockClear(); + buffer.destroy(); + expect(remove.mock.calls[0][0]).toBe(b); + expect(remove.mock.calls.some(([node]) => node === a)).toBe(false); + remove.mockClear(); + buffer.destroy(); + expect(remove).not.toHaveBeenCalled(); + }); +}); From 9e3a6f22553b55bda60422e0631bc672d2be9d99 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sun, 6 Sep 2026 11:21:52 +0200 Subject: [PATCH 4/9] perf(replay): avoid per-node mutation traversal callbacks --- .changeset/quieter-mutation-traversal.md | 5 + .../scripts/benchmark-replay-ordering.md | 2 +- .../scripts/benchmark-replay-preprocessing.md | 97 +++++++++++ packages/browser/scripts/benchmark-replay.md | 2 + packages/browser/scripts/benchmark-replay.mjs | 161 ++++++++++++++---- .../build-replay-preprocessing-probe.mjs | 155 +++++++++++++++++ packages/rrweb/rrweb/src/record/mutation.ts | 21 ++- .../record/mutation-child-traversal.test.ts | 137 +++++++++++++++ 8 files changed, 538 insertions(+), 42 deletions(-) create mode 100644 .changeset/quieter-mutation-traversal.md create mode 100644 packages/browser/scripts/benchmark-replay-preprocessing.md create mode 100644 packages/browser/scripts/build-replay-preprocessing-probe.mjs create mode 100644 packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts diff --git a/.changeset/quieter-mutation-traversal.md b/.changeset/quieter-mutation-traversal.md new file mode 100644 index 0000000000..fb013c1480 --- /dev/null +++ b/.changeset/quieter-mutation-traversal.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Reduce session recording overhead when DOM subtrees are moved repeatedly by avoiding per-node traversal callbacks. diff --git a/packages/browser/scripts/benchmark-replay-ordering.md b/packages/browser/scripts/benchmark-replay-ordering.md index 161a518e37..b4ab7ba326 100644 --- a/packages/browser/scripts/benchmark-replay-ordering.md +++ b/packages/browser/scripts/benchmark-replay-ordering.md @@ -27,7 +27,7 @@ REPLAY_BENCH_ORDERING=1 REPLAY_BENCH_PROFILE=1 \ Ordering mode enables the mutation benchmark's input probes and drop/recovery checks, but selects these operations: - Reverse the row siblings, preserving their values and IDs. -- Move the fixture between two parents repeatedly within one observer batch. `REPLAY_BENCH_MOVE_ROUNDS` defaults to 5 (range 1–20): five round trips plus a final move, **11 moves in total**. This is a deliberate stress case, not a claim about the frequency of this pattern on customer pages. +- Move the fixture between two parents repeatedly within one observer batch. `REPLAY_BENCH_MOVE_ROUNDS` defaults to 5 (range 0–20; zero selects a single move): five round trips plus a final move, **11 moves in total**. This is a deliberate stress case, not a claim about the frequency of this pattern on customer pages. - Detach the entire fixture as one subtree, then restore it without rebuilding its contents. - Remove its children individually through one `replaceChildren()` operation. diff --git a/packages/browser/scripts/benchmark-replay-preprocessing.md b/packages/browser/scripts/benchmark-replay-preprocessing.md new file mode 100644 index 0000000000..d1476bd865 --- /dev/null +++ b/packages/browser/scripts/benchmark-replay-preprocessing.md @@ -0,0 +1,97 @@ +# Repeated mutation preprocessing investigation (#4217) + +Baseline: `881c58a8ddd502fde75670890573ae40e01a757c` (#4808), including getter-cache, per-emission serialization-options reuse and pending mirror-root deduplication. This investigation stays synchronous and does not skip mutation records or subtree visits. + +## Workloads + +```sh +pnpm turbo --filter=posthog-js build +REPLAY_BENCH_PREPROCESSING=1 REPLAY_BENCH_NODES=50000 \ + REPLAY_BENCH_SHAPES=table,flat,deep,shadow REPLAY_BENCH_RUNS=1 \ + REPLAY_BENCH_COMPRESSION=on pnpm --filter posthog-js benchmark:replay +``` + +Preprocessing mode selects recording-off controls, startup, repeated moves, mixed moves and child removal. Mixed moves add/remove transient children, update an attribute and toggle a row's masking class during the same observer batch. The final row must be masked and carry the final attribute value. Transient private text must not reach the transport. Replay is checked at the trusted-input and phase-completion checkpoints, not just after the final operation. + +`REPLAY_BENCH_MOVE_ROUNDS=0,1,5,10` gives **1, 3, 11, 21 append/reinsert operations** respectively. Supply one value per invocation. These are deliberate stress cases, not assumed customer frequencies. + +The `deep` shape uses the table rows with `REPLAY_BENCH_DEPTH` nested wrappers (default 32, accepted range 1–128). Row count matches the table shape; the wrappers add that many physical nodes. The flat shape has wide, shallow element/text pairs. Deep-row ownership and wrapper count are checked on replay, including after sibling reversal. + +**Existing depth limit:** rrweb's default snapshot depth limit is 50. The fixture adds document/row/cell depth on top of the requested wrappers. Depths 32 and 40 passed. At 128, both baseline and candidate failed startup replay validation with zero rows instead of 48 in a 1k-node fixture. This is existing truncation, not an optimization result. The harness deliberately rejects it; no limit was increased and no correctness check was weakened. + +## Separate diagnostics from timings + +For exact visit and set-operation counts, build an explicitly instrumented pair of assets: + +```sh +node packages/browser/scripts/build-replay-preprocessing-probe.mjs /tmp/preprocessing-probe +REPLAY_BENCH_DIST=/tmp/preprocessing-probe REPLAY_BENCH_PREPROCESSING=1 \ + REPLAY_BENCH_PROFILE=1 REPLAY_BENCH_MOVE_ROUNDS=5 \ + REPLAY_BENCH_NODES=10000 REPLAY_BENCH_SHAPES=table,flat,deep,shadow \ + REPLAY_BENCH_COMPRESSION=on REPLAY_BENCH_RUNS=1 \ + pnpm --filter posthog-js benchmark:replay +``` + +The helper uses esbuild and an in-memory source transform, without editing production sources or build artifacts. It wraps each buffer's `genAdds`, `processMutation` and working sets, and instruments `deepDelete`. Weak sets count distinct physical nodes. Counters reset per phase and appear as `preprocessingStats`; `preprocessingMs` is a perturbed diagnostic, not a timing comparison. + +Both diagnostic core and recorder use unmangled source builds: mixing the source recorder with a production core would break private-property contracts. The helper also handles the existing inline canvas-worker import, but these probes exercise DOM workloads, not canvas recording. Diagnostic artifacts are not release artifacts or substitutes for production compatibility testing. + +A manifest identifies these assets, the harness checks it against the loaded probe, and non-profiled use fails immediately. These checks were exercised. **Never compare diagnostic timings to production timings.** For sampled production-bundle attribution, omit `REPLAY_BENCH_DIST`, enable profiling and use `summarize-replay-profile.mjs` with matching intermediate maps. Ordering-mode mirror wrappers are also active in these diagnostic profiles. + +### Measured work before the change + +10k-node table, compression enabled: + +| Moves | `genAdds` visits | Distinct `genAdds` nodes | `deepDelete` visits | Distinct deleted nodes | +| ----- | ---------------- | ------------------------ | ------------------- | ---------------------- | +| 1 | 10,022 | 10,022 | 0 | 0 | +| 3 | 30,066 | 10,022 | 20,046 | 10,023 | +| 11 | 110,242 | 10,022 | 100,230 | 10,023 | +| 21 | 210,462 | 10,022 | 200,460 | 10,023 | + +`deepDelete` walks light-DOM children even if they are blocked. `genAdds` respects blocking and also handles shadow children, so their distinct counts need not match. Shadow descendants are not recursively removed from the moved set by `deepDelete`; they already avoid some repeated classification. Applying mirror-root deduplication to this bookkeeping would therefore be an unsafe generalization. + +## Candidate + +Replace per-node `NodeList.forEach` calls in `genAdds` and `deepDelete` with indexed loops. Preserve the initial list length, read each child from the live list, skip slots removed during recursion, and keep light/shadow traversal and right-to-left depth-first deletion order. No caching of DOM values or masking decisions, no traversal deduplication, no changes to `processRemoves`. + +The deterministic regression on a 201-node subtree failed before the change: 4,422 `forEach` calls instead of 201. Afterward, only the unchanged `processRemoves` traversal makes those calls. The moved set remains in the same order. Other focused tests cover live-list appends/removals in light/shadow DOM and deletion order. + +**Every diagnostic visit and set-operation count matched before/after at 1, 3, 11 and 21 moves across table, flat, deep and shadow fixtures**, including mixed moves. Only the way children are enumerated changed. + +## Unprofiled comparison + +Apple M4 Pro, Chromium 136.0.7103.25, approximately 50k nodes, compression on, no CPU throttling. Three alternating baseline/candidate runs per shape; all 24 scenario arms passed. Identical harness source (`116b177c1c28010e9b379ce644dccd4cc698b27dea9ee89d89fbe597b704ff00`) and no concurrent builds, tests or diagnostics. + +| Workload | Median longest task before / after | Median input delay before / after | +| ---------------------- | ---------------------------------- | --------------------------------- | +| Table, 11 moves | 475 / 375 ms | 481.2 / 380.4 ms | +| Flat, 11 moves | 485 / 378 ms | 493.4 / 385.7 ms | +| Deep (32), 11 moves | 483 / 395 ms | 489.2 / 401.1 ms | +| Shadow, 11 moves | 374 / 314 ms | 382.0 / 319.5 ms | +| Table, mixed moves | 460 / 376 ms | 467.4 / 382.5 ms | +| Flat, mixed moves | 475 / 389 ms | 484.5 / 397.1 ms | +| Deep (32), mixed moves | 488 / 398 ms | 496.2 / 404.0 ms | +| Shadow, mixed moves | 349 / 298 ms | 357.7 / 304.8 ms | + +Startup was effectively flat: table 119 / 119 ms; deep 122 / 125 ms. Flat child removal was 67 / 67 ms. Other removal fixtures had no task crossing 50 ms, which does not mean no blocking. These local medians are descriptive, not statistical significance or customer guarantees. + +Separate production-bundle diagnostic profiles sampled table preprocessing at 305 / 212 ms and deep preprocessing at 327 / 217 ms. These include profiler/mirror-wrapper perturbation and are not exact wall time. Serialization, other emission work and encoding remain substantial. Sampled heap measurements are not true peak or process memory evidence. + +Recorder size: **+115 raw bytes, +80 gzip bytes**. SHA256 baseline `9e8222e1012e3f51d1afb677496d8dd66296c6af380fad8ccef2b405e6b5bc1b`, candidate `cc2e258054c110319d605af0dbbb18106d204c0e18c3780cfed6f2417a982c8f`. + +## Validation + +- 327 recording/accessor tests passed, 2 skipped; six focused traversal tests included. +- Nine masking tests passed across Chromium, Firefox and WebKit. +- SDK/dependency and rrweb builds/typechecks; targeted lint/format, syntax and ES5/ES6 checks. +- Small table/flat/deep/shadow/CSS preprocessing and ordering fixtures passed with compression on/off. Previous churn fixtures (table/deep/shadow) and legacy mode passed with both settings. +- 10k-node table/flat/deep fixtures passed with 4x page-only CPU throttling. Depth-40 one-move fixtures passed with both compression settings. +- An intentionally emptied movement mutation failed the first repeated-move input checkpoint: the fixture remained in the body instead of its expected destination. The temporary probe was removed. +- All accepted benchmark arms reject privacy leaks, duplicate full-snapshot IDs, unexpected recovery snapshots and mutation/attribute drops. The depth-128 failures above remain explicitly excluded, not labeled as passes. + +The relevant incident pattern is silent serialization corruption. Style serialization, replay reconstruction code, lazy-load contracts and recording policies are unchanged; real-browser checks cover intermediate ordering, masking, CSS and shadow state. No recording-volume policy change is intended. + +This remains a partial #4217 improvement: the 50k-node candidate still blocks for roughly 300–400 ms. Repeated bookkeeping visits, serialization/layout and emission ordering remain; bounding them requires a separate correctness design. + +Local evidence: `/tmp/4217-preprocess-{baseline,candidate}-{1,2,3}/`, `before-rounds-*`, `after-rounds-*`, `after-counters`, `native-{before,after}` and their attribution files. Test and negative-probe logs use the same `/tmp/4217-preprocess-` prefix. diff --git a/packages/browser/scripts/benchmark-replay.md b/packages/browser/scripts/benchmark-replay.md index ef8eac71a0..62d248ec54 100644 --- a/packages/browser/scripts/benchmark-replay.md +++ b/packages/browser/scripts/benchmark-replay.md @@ -9,6 +9,8 @@ For nested mutations, sustained churn, trusted input probes, shadow DOM and sour attribution, see [the mutation investigation](benchmark-replay-mutations.md). For repeated moves, flat sibling lists and mirror cleanup counters, see [the ordering investigation](benchmark-replay-ordering.md). +For repeated preprocessing, deep trees, mixed moves and diagnostic visit counters, +see [the preprocessing investigation](benchmark-replay-preprocessing.md). ## Run diff --git a/packages/browser/scripts/benchmark-replay.mjs b/packages/browser/scripts/benchmark-replay.mjs index faf1f538d2..8a14527a29 100644 --- a/packages/browser/scripts/benchmark-replay.mjs +++ b/packages/browser/scripts/benchmark-replay.mjs @@ -20,10 +20,13 @@ const repetitions = Number(process.env.REPLAY_BENCH_RUNS || 3) const cpuRate = Number(process.env.REPLAY_BENCH_CPU || 1) const shapes = (process.env.REPLAY_BENCH_SHAPES || 'table,css').split(',') const profiling = process.env.REPLAY_BENCH_PROFILE === '1' -const orderingWorkloads = process.env.REPLAY_BENCH_ORDERING === '1' +const preprocessingWorkloads = process.env.REPLAY_BENCH_PREPROCESSING === '1' +const orderingWorkloads = preprocessingWorkloads || process.env.REPLAY_BENCH_ORDERING === '1' +const depth = Number(process.env.REPLAY_BENCH_DEPTH || 32) +assert(Number.isInteger(depth) && depth >= 1 && depth <= 128) const mutationWorkloads = orderingWorkloads || process.env.REPLAY_BENCH_MUTATIONS === '1' const moveRounds = Number(process.env.REPLAY_BENCH_MOVE_ROUNDS || 5) -assert(Number.isInteger(moveRounds) && moveRounds > 0 && moveRounds <= 20) +assert(Number.isInteger(moveRounds) && moveRounds >= 0 && moveRounds <= 20) const churnSteps = Number(process.env.REPLAY_BENCH_CHURN_STEPS || 5) const compression = process.env.REPLAY_BENCH_COMPRESSION || 'both' assert(Number.isInteger(churnSteps) && churnSteps > 0 && churnSteps <= 20) @@ -31,16 +34,23 @@ assert(['on', 'off', 'both'].includes(compression)) assert(sizes.every((n) => Number.isInteger(n) && n > 0)) assert(Number.isInteger(repetitions) && repetitions > 0) assert(Number.isFinite(cpuRate) && cpuRate >= 1) -assert(shapes.every((shape) => ['table', 'css', 'shadow', 'flat'].includes(shape))) +assert(shapes.every((shape) => ['table', 'css', 'shadow', 'flat', 'deep'].includes(shape))) const assets = new Map() for (const name of ['array.js', 'posthog-recorder.js']) { assets.set(name, await readFile(path.join(distRoot, name))) } +const preprocessingProbe = await readFile(path.join(distRoot, 'mutation-probe.json'), 'utf8') + .then(JSON.parse) + .catch((error) => { + if (error.code === 'ENOENT') return null + throw error + }) +assert(!preprocessingProbe || profiling, 'Instrumented probe artifacts require REPLAY_BENCH_PROFILE=1') const replayer = await readFile(path.join(packageRoot, '../rrweb/rrweb/dist/rrweb.umd.cjs'), 'utf8') const origin = 'https://replay-benchmark.test' const markerTag = 'replay-benchmark-end' -const privateValues = ['BENCH_PRIVATE_INPUT', 'BENCH_PRIVATE_TEXT', 'BENCH_BLOCKED_TEXT'] +const privateValues = ['BENCH_PRIVATE_INPUT', 'BENCH_PRIVATE_TEXT', 'BENCH_BLOCKED_TEXT', 'BENCH_TRANSIENT_PRIVATE'] function decodeRequest(request) { const bytes = request.postDataBuffer() @@ -145,8 +155,13 @@ try { // Preload exact local artifacts: network/module loading is not serializer time. await page.addScriptTag({ url: `${origin}/static/array.js` }) await page.addScriptTag({ url: `${origin}/static/posthog-recorder.js` }) + assert.equal( + await page.evaluate(() => !!window.__rrwebMutationProbe), + !!preprocessingProbe, + 'Probe manifest does not match loaded recorder' + ) await page.evaluate( - ({ targetNodes, shape, compress, origin }) => { + ({ targetNodes, shape, compress, origin, depth }) => { const fixture = document.getElementById('fixture') window.benchmarkFixture = fixture const rowCount = Math.ceil(targetNodes / (shape === 'flat' ? 2 : 21)) @@ -161,6 +176,8 @@ try { generation++ window.fixtureGeneration = generation window.fixtureReversed = false + window.maskedRowId = null + window.mixedAttribute = null if (nested) { fixture.replaceChildren() for (let i = 0; i < lightRows; i++) { @@ -179,6 +196,18 @@ try { const sentinels = 'BENCH_PRIVATE_TEXT
BENCH_BLOCKED_TEXT
' fixture.insertAdjacentHTML('beforeend', sentinels) + if (shape === 'deep') { + const rows = [...fixture.querySelectorAll('[data-row]')] + let parent = fixture + for (let i = 0; i < depth; i++) { + const wrapper = document.createElement('div') + wrapper.dataset.benchmarkDepth = String(i) + parent.append(wrapper) + parent = wrapper + } + parent.id = 'deep-rows' + rows.forEach((row) => parent.append(row)) + } if (shape === 'shadow') { const host = document.createElement('div') host.id = 'benchmark-shadow' @@ -222,7 +251,7 @@ try { }, }) }, - { targetNodes, shape, compress, origin } + { targetNodes, shape, compress, origin, depth } ) await page.waitForFunction( () => @@ -288,35 +317,37 @@ try { }) const metrics = [] const checkpoints = [] - const phases = orderingWorkloads - ? [ - 'off-remove', - 'off-subtree-remove', - 'off-repeat-move', - 'off-reorder', - 'start', - 'reorder', - 'repeat-move', - 'subtree-remove', - 'restore', - 'remove', - ] - : mutationWorkloads + const phases = preprocessingWorkloads + ? ['off-repeat-move', 'off-mixed-move', 'start', 'repeat-move', 'mixed-move', 'remove'] + : orderingWorkloads ? [ - 'off-rebuild', - 'off-nested', - 'off-churn', - 'off-move', 'off-remove', + 'off-subtree-remove', + 'off-repeat-move', + 'off-reorder', 'start', - 'snapshot', - 'rebuild', - 'nested', - 'churn', - 'move', + 'reorder', + 'repeat-move', + 'subtree-remove', + 'restore', 'remove', ] - : ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove'] + : mutationWorkloads + ? [ + 'off-rebuild', + 'off-nested', + 'off-churn', + 'off-move', + 'off-remove', + 'start', + 'snapshot', + 'rebuild', + 'nested', + 'churn', + 'move', + 'remove', + ] + : ['off-rebuild', 'start', 'snapshot', 'rebuild', 'move', 'remove'] for (const phase of phases) { const off = phase.startsWith('off-') if (mutationWorkloads && (off || phase === 'start')) { @@ -330,6 +361,7 @@ try { await page.waitForTimeout(100) } if (orderingWorkloads && profiling) await page.evaluate(() => window.resetMirrorStats()) + if (preprocessingProbe) await page.evaluate(() => window.__rrwebMutationProbe.reset()) const startIndex = wireEvents.length const bytesBefore = requestBytes const before = await client.send('Performance.getMetrics') @@ -368,6 +400,8 @@ try { phase: marker, generation: window.fixtureGeneration, reversed: window.fixtureReversed, + maskedRowId: window.maskedRowId, + mixedAttribute: window.mixedAttribute, present: !!fixture, parent: fixture?.parentElement.id ?? null, empty: !fixture?.childNodes.length, @@ -442,11 +476,33 @@ try { if (parent) [...parent.querySelectorAll('[data-row]')] .reverse() - .forEach((row) => parent.append(row)) + .forEach((row) => row.parentNode.append(row)) } window.fixtureReversed = !window.fixtureReversed break } + case 'mixed-move': { + const root = window.benchmarkFixture + const row = root.querySelector('[data-row]') + window.maskedRowId = row.getAttribute('data-row') + for (let round = 0; round <= moveRounds; round++) { + document.getElementById('destination').append(root) + row.classList.toggle('ph-mask', round % 2 === 0) + row.setAttribute('data-mixed', `round-${round}`) + const transient = document.createElement('span') + transient.textContent = 'BENCH_TRANSIENT_PRIVATE' + row.append(transient) + transient.remove() + if (round < moveRounds) + document.body.insertBefore( + root, + document.getElementById('destination') + ) + } + row.classList.add('ph-mask') + window.mixedAttribute = `round-${moveRounds}` + break + } case 'repeat-move': for (let round = 0; round < moveRounds; round++) { document @@ -503,6 +559,8 @@ try { definitions, inputDelays, mirrorStats: window.mirrorStats || null, + preprocessingStats: + window.__rrwebMutationProbe?.snapshot() || null, maxFrameGapMs, longTasks: longTasks.filter((t) => t.start >= start - 1), debug: window.posthog.sessionRecording.sdkDebugProperties, @@ -568,6 +626,7 @@ try { ), inputDelayMs: observation.inputDelays[0] ?? null, mirrorStats: observation.mirrorStats, + preprocessingStats: observation.preprocessingStats, maxTaskMs: Math.max(0, ...observation.longTasks.map((t) => t.duration)), longTaskCount: observation.longTasks.length, maxFrameGapMs: observation.maxFrameGapMs, @@ -642,7 +701,17 @@ try { await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)) // Correctness validation is deliberately outside all measurement windows. await page.evaluate(() => window.posthog.stopSessionRecording()) - for (const { phase, end, generation, parent, empty, present, reversed } of checkpoints) { + for (const { + phase, + end, + generation, + parent, + empty, + present, + reversed, + maskedRowId, + mixedAttribute, + } of checkpoints) { // Every churn generation and trusted input gets a replay checkpoint, not just the final DOM. // A fresh context prevents destroyed replay DOMs accumulating across large prefixes. const validationContext = await browser.newContext() @@ -665,7 +734,7 @@ try { ) } const replayed = await validationPage.evaluate( - ({ generation, shape, reversed }) => { + ({ generation, shape, reversed, maskedRowId, mixedAttribute, depth }) => { const events = JSON.parse(window.replayInput) delete window.replayInput const player = new window.rrweb.Replayer(events, { UNSAFE_replayCanvas: false }) @@ -694,13 +763,27 @@ try { return ( row.getAttribute('data-row') === String(expectedIndex) && row.textContent === - (shape === 'flat' - ? String(generation) - : `${generation}label`.repeat(4)) && + (() => { + const text = + shape === 'flat' + ? String(generation) + : `${generation}label`.repeat(4) + return row.getAttribute('data-row') === maskedRowId + ? text.replace(/\S/g, '*') + : text + })() && + (row.getAttribute('data-row') !== maskedRowId || + (row.classList.contains('ph-mask') && + row.getAttribute('data-mixed') === mixedAttribute)) && row.querySelectorAll('.cell[data-label="metric"]').length === (shape === 'flat' ? 0 : 4) ) }), + depthPreserved: + shape !== 'deep' || + rows.length === 0 || + (fixture.querySelectorAll('[data-benchmark-depth]').length === depth && + rows.every((row) => row.parentElement.id === 'deep-rows')), stylesheet: shape !== 'css' || (cssRules?.length === 10000 && @@ -715,7 +798,7 @@ try { player.destroy() return result }, - { generation, shape, reversed } + { generation, shape, reversed, maskedRowId, mixedAttribute, depth } ) assert.deepEqual( replayed, @@ -724,6 +807,7 @@ try { parent, rows: empty ? 0 : Math.ceil(targetNodes / (shape === 'flat' ? 2 : 21)), orderedContent: true, + depthPreserved: true, stylesheet: true, adoptedStyle: true, }, @@ -773,6 +857,9 @@ try { .digest('hex'), mutationWorkloads, orderingWorkloads, + preprocessingWorkloads, + preprocessingProbe, + depth, moveRounds, churnSteps, compression, diff --git a/packages/browser/scripts/build-replay-preprocessing-probe.mjs b/packages/browser/scripts/build-replay-preprocessing-probe.mjs new file mode 100644 index 0000000000..77b4a5b4b9 --- /dev/null +++ b/packages/browser/scripts/build-replay-preprocessing-probe.mjs @@ -0,0 +1,155 @@ +// Diagnostic-only recorder. Never use its timings as production before/after evidence. +import assert from 'node:assert/strict' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +// oxlint-disable-next-line compat/compat -- Node CLI +const root = fileURLToPath(new URL('../', import.meta.url)) +const output = path.resolve(process.argv[2] || path.join(root, 'test-results/preprocessing-probe')) +assert(output !== path.join(root, 'dist'), 'Do not overwrite production artifacts') +const mutation = path.join(root, '../rrweb/rrweb/src/record/mutation.ts') +const record = path.join(root, '../rrweb/rrweb/src/record/index.ts') +const snapshotExports = [ + 'wasMaxDepthReached', + 'resetMaxDepthState', + 'getLastSnapshotCost', + 'getMutationCost', + 'getDeferredStylesheetStats', + 'getDiscardedDurationSamples', + 'resetSnapshotCostState', +] +const source = ` +import MutationBuffer from ${JSON.stringify(mutation)} +import './src/entrypoints/posthog-recorder' +const probe = globalThis.__rrwebMutationProbe = { + reset() { + this.stats = { genAddsCalls: 0, genAddsDistinct: 0, deepDeleteCalls: 0, deepDeleteVisits: 0, deepDeleteDistinct: 0, preprocessingMs: 0 } + this.genSeen = new WeakSet() + this.deepSeen = new WeakSet() + }, + snapshot() { return { ...this.stats } } +} +probe.reset() +const wrappedSets = new WeakSet() +function instrumentSet(set, name) { + if (wrappedSets.has(set)) return + wrappedSets.add(set) + for (const method of ['add', 'delete', 'has']) { + const original = set[method] + set[method] = function (...args) { + const key = name + '.' + method + probe.stats[key] = (probe.stats[key] || 0) + 1 + return original.apply(this, args) + } + } +} +const init = MutationBuffer.prototype.init +MutationBuffer.prototype.init = function (options) { + init.call(this, options) + const process = this.processMutations + this.processMutations = function (...args) { + instrumentSet(this.addedSet, 'addedSet') + instrumentSet(this.movedSet, 'movedSet') + return process.apply(this, args) + } + const gen = this.genAdds + this.genAdds = function (node, target) { + probe.stats.genAddsCalls++ + if (!probe.genSeen.has(node)) { probe.genSeen.add(node); probe.stats.genAddsDistinct++ } + return gen.call(this, node, target) + } + const processMutation = this.processMutation + this.processMutation = function (...args) { + const start = performance.now() + try { return processMutation.apply(this, args) } + finally { probe.stats.preprocessingMs += performance.now() - start } + } +} +` +const result = await build({ + stdin: { contents: source, resolveDir: root, sourcefile: 'preprocessing-probe.js' }, + bundle: true, + platform: 'browser', + format: 'iife', + target: 'es2020', + sourcemap: true, + outfile: path.join(output, 'posthog-recorder.js'), + write: false, + plugins: [ + { + name: 'preprocessing-probe', + setup(builder) { + builder.onResolve({ filter: /\?worker&inline$/ }, (args) => ({ + path: path.resolve(path.dirname(args.importer), args.path.split('?')[0]), + namespace: 'inline-worker', + })) + builder.onLoad({ filter: /.*/, namespace: 'inline-worker' }, async (args) => { + const worker = await build({ + entryPoints: [args.path], + bundle: true, + write: false, + platform: 'browser', + format: 'iife', + }) + return { + contents: `export default function InlineWorker(options) { + const url = URL.createObjectURL(new Blob([${JSON.stringify(worker.outputFiles[0].text)}], { type: 'text/javascript' })); + try { return new Worker(url, options) } finally { URL.revokeObjectURL(url) } + }`, + } + }) + builder.onResolve({ filter: /^@posthog\/rrweb-record$/ }, () => ({ + path: 'recorder', + namespace: 'probe', + })) + builder.onLoad({ filter: /.*/, namespace: 'probe' }, () => ({ + contents: `export { default as record } from ${JSON.stringify(record)}; export { ${snapshotExports.join(', ')} } from '@posthog/rrweb-snapshot'`, + resolveDir: path.dirname(record), + })) + builder.onLoad({ filter: /[\\/]record[\\/]mutation\.ts$/ }, async (args) => { + assert.equal(args.path, mutation) + let contents = await readFile(mutation, 'utf8') + const declaration = 'function deepDelete(addsSet: Set, n: Node) {' + const visit = ' const next = stack.pop()!;' + assert.equal(contents.split(declaration).length, 2) + assert.equal(contents.split(visit).length, 2) + contents = contents.replace( + declaration, + declaration + + '\n const probe = (globalThis as any).__rrwebMutationProbe; probe.stats.deepDeleteCalls++;' + ) + contents = contents.replace( + visit, + visit + + '\n probe.stats.deepDeleteVisits++; if (!probe.deepSeen.has(next)) { probe.deepSeen.add(next); probe.stats.deepDeleteDistinct++; }' + ) + return { contents, loader: 'ts', resolveDir: path.dirname(mutation) } + }) + }, + }, + ], +}) +await mkdir(output, { recursive: true }) +for (const file of result.outputFiles) await writeFile(file.path, file.contents) +// Both sides must use the same unmangled property names. Never pair this recorder +// with the production core, whose private properties have been mangled by Terser. +const core = await build({ + entryPoints: [path.join(root, 'src/entrypoints/array.ts')], + bundle: true, + platform: 'browser', + format: 'iife', + target: 'es2020', + write: false, +}) +await writeFile(path.join(output, 'array.js'), core.outputFiles[0].contents) +await writeFile( + path.join(output, 'mutation-probe.json'), + JSON.stringify({ + diagnosticOnly: true, + note: 'esbuild source recorder with invasive counters, not a production timing artifact', + }) + '\n' +) +// oxlint-disable-next-line no-console -- CLI output +console.log(output) diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index 34910e4681..b12c8d5207 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -948,12 +948,21 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too if (!isBlocked(n, this.blockClass, this.blockSelector, false)) { - dom.childNodes(n).forEach((childN) => this.genAdds(childN)); + // Avoid a callback per node on repeated subtree walks. Like forEach, + // capture the initial length but read each child from the live list. + const children = dom.childNodes(n); + for (let i = 0, length = children.length; i < length; i++) { + const childN = children[i]; + if (childN) this.genAdds(childN); + } if (hasShadowRoot(n)) { - dom.childNodes(dom.shadowRoot(n)!).forEach((childN) => { + const shadowChildren = dom.childNodes(dom.shadowRoot(n)!); + for (let i = 0, length = shadowChildren.length; i < length; i++) { + const childN = shadowChildren[i]; + if (!childN) continue; this.processedNodeManager.add(childN, this); this.genAdds(childN, n); - }); + } } } }; @@ -971,7 +980,11 @@ function deepDelete(addsSet: Set, n: Node) { while (stack.length) { const next = stack.pop()!; addsSet.delete(next); - dom.childNodes(next).forEach((childN) => stack.push(childN)); + const children = dom.childNodes(next); + for (let i = 0, length = children.length; i < length; i++) { + const childN = children[i]; + if (childN) stack.push(childN); + } } } diff --git a/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts b/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts new file mode 100644 index 0000000000..268e32160b --- /dev/null +++ b/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import record from '../../src/record'; +import { mutationBuffers } from '../../src/record/observer'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('mutation child traversal', () => { + let stop: (() => void) | undefined; + + beforeEach(() => { + document.body.innerHTML = + '
' + + 'value'.repeat(100) + + '
'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + stop?.(); + document.body.innerHTML = ''; + }); + + it('avoids per-node forEach callbacks in repeated add/delete bookkeeping', async () => { + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); // isolate preprocessing from serialization + const root = document.getElementById('fixture')!; + const destination = document.getElementById('destination')!; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL); + const nodes: Node[] = []; + do { + nodes.push(walker.currentNode); + } while (walker.nextNode()); + const lists = new Set(nodes.map((node) => node.childNodes)); + const forEach = NodeList.prototype.forEach; + let enumerations = 0; + vi.spyOn(NodeList.prototype, 'forEach').mockImplementation(function ( + this: NodeList, + ...args + ) { + if (lists.has(this)) enumerations++; + return forEach.apply(this, args); + }); + + for (let round = 0; round < 5; round++) { + destination.append(root); + document.body.insertBefore(root, destination); + } + destination.append(root); + await settle(); + + expect([...buffer['movedSet']]).toEqual(nodes); + // processRemoves still enumerates these lists once. genAdds/deepDelete + // previously enumerated all of them another 21 times using callbacks. + expect(enumerations).toBe(nodes.length); + buffer.unlock(); + await settle(); + }); + + it('keeps right-to-left depth-first deletion order', async () => { + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); + const root = document.getElementById('fixture')!; + const destination = document.getElementById('destination')!; + destination.append(root); + await settle(); + const remove = vi.spyOn(buffer['movedSet'], 'delete'); + document.body.insertBefore(root, destination); + await settle(); + expect(remove.mock.calls.map(([node]) => node)).toEqual([ + root, + ...Array.from(root.children) + .reverse() + .flatMap((span) => [span, span.firstChild]), + ]); + }); + + it.each(['light', 'shadow'] as const)( + 'keeps the initial child-list length when a %s traversal appends a sibling', + async (kind) => { + const root = document.getElementById('fixture')!; + root.innerHTML = ''; + const parent = + kind === 'shadow' ? root.attachShadow({ mode: 'open' }) : root; + parent.innerHTML = 'firstsecond'; + const first = parent.firstChild!; + const appended = document.createElement('span'); + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); + const check = vi + .spyOn(buffer['processedNodeManager'], 'inOtherBuffer') + .mockImplementation((node) => { + if (node === first) parent.append(appended); + return false; + }); + + buffer['genAdds'](root); + + expect(check.mock.calls.map(([node]) => node)).toContain(first); + expect(check.mock.calls.map(([node]) => node)).not.toContain(appended); + expect(buffer['addedSet'].has(appended)).toBe(false); + }, + ); + + it.each(['light', 'shadow'] as const)( + 'skips a sibling removed during a %s traversal', + async (kind) => { + const root = document.getElementById('fixture')!; + root.innerHTML = ''; + const parent = + kind === 'shadow' ? root.attachShadow({ mode: 'open' }) : root; + parent.innerHTML = 'firstsecond'; + const first = parent.firstChild!; + const removed = parent.lastChild!; + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); + const check = vi + .spyOn(buffer['processedNodeManager'], 'inOtherBuffer') + .mockImplementation((node) => { + if (node === first) parent.removeChild(removed); + return false; + }); + + expect(() => buffer['genAdds'](root)).not.toThrow(); + expect(check.mock.calls.map(([node]) => node)).not.toContain(removed); + expect(buffer['movedSet'].has(removed)).toBe(false); + }, + ); +}); From f45416053617f752ca05de0dec7439e369e51228 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sun, 6 Sep 2026 12:40:20 +0200 Subject: [PATCH 5/9] perf(replay): skip empty text child-list reads --- .changeset/quiet-text-leaves.md | 5 ++ .../scripts/benchmark-replay-hotspots.md | 77 +++++++++++++++++++ packages/browser/scripts/benchmark-replay.md | 2 + packages/browser/scripts/benchmark-replay.mjs | 40 +++++++++- packages/rrweb/rrweb/src/record/mutation.ts | 4 + .../test/record/mutation-text-leaf.test.ts | 58 ++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-text-leaves.md create mode 100644 packages/browser/scripts/benchmark-replay-hotspots.md create mode 100644 packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts diff --git a/.changeset/quiet-text-leaves.md b/.changeset/quiet-text-leaves.md new file mode 100644 index 0000000000..997eddc008 --- /dev/null +++ b/.changeset/quiet-text-leaves.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Reduce session recording overhead by avoiding empty child-list reads for text nodes during mutation processing. diff --git a/packages/browser/scripts/benchmark-replay-hotspots.md b/packages/browser/scripts/benchmark-replay-hotspots.md new file mode 100644 index 0000000000..d65a41ada0 --- /dev/null +++ b/packages/browser/scripts/benchmark-replay-hotspots.md @@ -0,0 +1,77 @@ +# Remaining replay hotspots after #4811 + +Baseline: `9e3a6f22553b55bda60422e0631bc672d2be9d99`, stacked on all four prior synchronous improvements. No asynchronous recording, geometry cache or mutation deduplication is introduced here. + +## Profiling without mirror wrappers + +The benchmark now accepts `REPLAY_BENCH_MIRROR_COUNTERS=0` to disable the existing mirror wrappers while retaining CPU profiling. It also reports CDP `LayoutDuration` and `RecalcStyleDuration` deltas as `layoutCpuMs` and `styleCpuMs`. + +```sh +REPLAY_BENCH_PREPROCESSING=1 REPLAY_BENCH_PROFILE=1 \ + REPLAY_BENCH_MIRROR_COUNTERS=0 REPLAY_BENCH_NODES=50000 \ + REPLAY_BENCH_SHAPES=table,flat,deep,shadow REPLAY_BENCH_RUNS=1 \ + REPLAY_BENCH_COMPRESSION=on pnpm --filter posthog-js benchmark:replay +``` + +Separate `REPLAY_BENCH_LAYOUT_COUNTERS=1` runs wrap `Element.getBoundingClientRect` and report its calls, distinct nodes and elapsed time. These counters require profiling and are never enabled in timing comparisons. The default mirror-counter behavior remains unchanged. + +Sampled CPU attribution for 11 moves at approximately 50k nodes, compression on: + +| Shape | Preprocessing | Other emission | Serialization | Encoding | +| ------------------ | ------------- | -------------- | ------------- | -------- | +| Table | 224 ms | 37 ms | 95 ms | 46 ms | +| Flat | 220 ms | 48 ms | 100 ms | 45 ms | +| Deep (32 wrappers) | 200 ms | 51 ms | 101 ms | 47 ms | +| Shadow | 122 ms | 64 ms | 85 ms | 48 ms | + +These are sampled diagnostics, not exact phase wall times. `genAdds`, `deepDelete` and child-list access remain visible hot paths. + +## Layout was not a repeated-read opportunity + +A separate diagnostic found one rectangle read per repeated-move phase for table/flat/deep and two for shadow, matching the blocked placeholders. The reads took approximately 32, 51, 34 and 38 ms respectively. Recording-off controls made no JS rectangle calls, but still incurred nearly the same browser layout/style work: + +| Shape | Layout + style, recording off | Layout + style, recording on | +| ------ | ----------------------------- | ---------------------------- | +| Table | 19.2 + 13.6 ms | 19.3 + 12.5 ms | +| Flat | 39.7 + 10.9 ms | 39.6 + 11.0 ms | +| Deep | 19.2 + 15.3 ms | 19.5 + 14.9 ms | +| Shadow | 19.1 + 13.4 ms | 23.5 + 14.6 ms | + +The recorder forces layout earlier, but most of this is also ordinary page layout after the move. It is not evidence of an extra 30–50 ms that can simply be eliminated. No geometry caching was attempted: stale blocked-element dimensions or positions would corrupt replay, and the same element was not repeatedly measured here. + +## Small candidate: text-node child-list fast path + +After a text node is classified and added to the appropriate set, `genAdds` still fetches its empty child list and checks for a shadow root. `deepDelete` likewise reads an empty list after deleting the text from the set. Valid DOM text nodes cannot have children or shadow roots. + +The candidate skips those reads, but deliberately keeps the `isBlocked` call before the `genAdds` fast path. `classMatchesRegex` can use a stateful regular expression, so skipping blocking checks could change later decisions. A regression verifies that a global regexp is still evaluated and its `lastIndex` is updated. + +The work-count regression failed before the change with 2,200 text child-list accesses instead of 100. Afterward, only the unchanged `processRemoves` walk accounts for those 100 accesses. Text nodes remain in the moved set and still pass through normal serialization/masking. Diagnostic node visits and set-operation counts match the baseline at 10k nodes / 11 moves across all four shapes, including mixed moves. + +## Unprofiled comparison + +Apple M4 Pro, Chromium 136.0.7103.25, approximately 50k nodes, compression on, no throttling, three alternating baseline/candidate runs per shape. No concurrent builds/tests/diagnostics. Both builds used harness SHA256 `0ef2f2fcdffa8dbb502ddb1c785de6a51b6d17814277a755024e1a2b35b7caab`. All 24 scenario arms passed replay/input/privacy/drop checks. + +| Shape | Repeated-move longest task | Repeated-move input delay | Mixed-move longest task | +| ------ | -------------------------- | ------------------------- | ----------------------- | +| Table | 374 / 365 ms | 379.5 / 370.5 ms | 370 / 349 ms | +| Flat | 380 / 368 ms | 387.5 / 375.2 ms | 391 / 366 ms | +| Deep | 396 / 388 ms | 402.5 / 394.0 ms | 395 / 380 ms | +| Shadow | 308 / 300 ms | 315.0 / 305.6 ms | 302 / 290 ms | + +Values are before / after medians. This is a **smaller, provisional result**: roughly 2–3% on repeated moves and 4–6% on mixed moves. Individual runs overlap, for example table baseline `[374, 365, 385]` versus candidate `[360, 366, 365]`. Three desktop samples do not establish statistical significance or typical customer benefit. Startup and removal were effectively flat. A zero longest-task value in raw results means no task reached 50 ms, not zero blocking. + +The recorder increased by 63 raw bytes / 14 gzip bytes. Baseline SHA256: `cc2e258054c110319d605af0dbbb18106d204c0e18c3780cfed6f2417a982c8f`. Candidate: `ccd60e2fb1d540c5564c5b07abd74a94c866b3396132e8122a11125c0b779557`. + +## Validation and recommendation + +- 329 recording/accessor tests passed, 2 skipped, including two new text-leaf tests and existing light/shadow traversal, masking, mirror and iframe/lifecycle coverage. +- Nine browser masking tests passed across Chromium, Firefox and WebKit. +- Small table/flat/deep/shadow/CSS preprocessing fixtures and table/deep/shadow churn fixtures passed with compression on/off. +- 10k table/flat/deep fixtures passed with 4x page-only throttling and compression on. +- SDK/dependency builds/typechecks, targeted lint/format, syntax and ES5/ES6 checks passed. + +The relevant incident risk remains silent serializer corruption. This does not change style serialization, masking rules, node IDs, lazy-load contracts, session rotation or recording-volume policy. Blocking checks, set updates and node visits remain intact. This section records the investigation results; PR closeout review is reported separately. + +Keep this as a small follow-up candidate, not another large performance claim. The dominant remaining cost is repeated O(moves × descendants) bookkeeping. Safely reducing those visits needs separate parent/order/cancellation analysis. Geometry caching and async offload are not justified by this investigation. Issue #4217 remains unresolved. + +Evidence: `/tmp/4217-hotspots-profile-layout-{0,1}/`, `/tmp/4217-hotspots-attribution.json`, `/tmp/4217-hotspots-{baseline,candidate}-{1,2,3}/`, `/tmp/4217-hotspots-counters/`, and `/tmp/4217-hotspots-*.log`. diff --git a/packages/browser/scripts/benchmark-replay.md b/packages/browser/scripts/benchmark-replay.md index 62d248ec54..32f0e0c9f3 100644 --- a/packages/browser/scripts/benchmark-replay.md +++ b/packages/browser/scripts/benchmark-replay.md @@ -11,6 +11,8 @@ For repeated moves, flat sibling lists and mirror cleanup counters, see [the ordering investigation](benchmark-replay-ordering.md). For repeated preprocessing, deep trees, mixed moves and diagnostic visit counters, see [the preprocessing investigation](benchmark-replay-preprocessing.md). +For wrapper-free profiles, layout attribution and the text-leaf candidate, see +[the remaining-hotspots investigation](benchmark-replay-hotspots.md). ## Run diff --git a/packages/browser/scripts/benchmark-replay.mjs b/packages/browser/scripts/benchmark-replay.mjs index 8a14527a29..f01992110e 100644 --- a/packages/browser/scripts/benchmark-replay.mjs +++ b/packages/browser/scripts/benchmark-replay.mjs @@ -22,6 +22,9 @@ const shapes = (process.env.REPLAY_BENCH_SHAPES || 'table,css').split(',') const profiling = process.env.REPLAY_BENCH_PROFILE === '1' const preprocessingWorkloads = process.env.REPLAY_BENCH_PREPROCESSING === '1' const orderingWorkloads = preprocessingWorkloads || process.env.REPLAY_BENCH_ORDERING === '1' +const mirrorCounters = orderingWorkloads && profiling && process.env.REPLAY_BENCH_MIRROR_COUNTERS !== '0' +const layoutCounters = process.env.REPLAY_BENCH_LAYOUT_COUNTERS === '1' +assert(!layoutCounters || profiling, 'Layout counters require profiling') const depth = Number(process.env.REPLAY_BENCH_DEPTH || 32) assert(Number.isInteger(depth) && depth >= 1 && depth <= 128) const mutationWorkloads = orderingWorkloads || process.env.REPLAY_BENCH_MUTATIONS === '1' @@ -281,7 +284,7 @@ try { }), ]) }) - if (orderingWorkloads && profiling) + if (mirrorCounters) await page.evaluate(() => { const mirror = window.__PosthogExtensions__.rrweb.record.mirror let seen, @@ -317,6 +320,29 @@ try { }) const metrics = [] const checkpoints = [] + if (layoutCounters) + await page.evaluate(() => { + const original = Element.prototype.getBoundingClientRect + window.resetLayoutStats = () => { + window.layoutStats = { calls: 0, distinctNodes: 0, elapsedMs: 0 } + window.layoutSeen = new WeakSet() + } + window.resetLayoutStats() + Element.prototype.getBoundingClientRect = function (...args) { + const stats = window.layoutStats + stats.calls++ + if (!window.layoutSeen.has(this)) { + window.layoutSeen.add(this) + stats.distinctNodes++ + } + const start = performance.now() + try { + return original.apply(this, args) + } finally { + stats.elapsedMs += performance.now() - start + } + } + }) const phases = preprocessingWorkloads ? ['off-repeat-move', 'off-mixed-move', 'start', 'repeat-move', 'mixed-move', 'remove'] : orderingWorkloads @@ -360,7 +386,8 @@ try { }) await page.waitForTimeout(100) } - if (orderingWorkloads && profiling) await page.evaluate(() => window.resetMirrorStats()) + if (mirrorCounters) await page.evaluate(() => window.resetMirrorStats()) + if (layoutCounters) await page.evaluate(() => window.resetLayoutStats()) if (preprocessingProbe) await page.evaluate(() => window.__rrwebMutationProbe.reset()) const startIndex = wireEvents.length const bytesBefore = requestBytes @@ -559,6 +586,7 @@ try { definitions, inputDelays, mirrorStats: window.mirrorStats || null, + layoutStats: window.layoutStats || null, preprocessingStats: window.__rrwebMutationProbe?.snapshot() || null, maxFrameGapMs, @@ -627,6 +655,12 @@ try { inputDelayMs: observation.inputDelays[0] ?? null, mirrorStats: observation.mirrorStats, preprocessingStats: observation.preprocessingStats, + layoutStats: observation.layoutStats, + layoutCpuMs: + 1000 * (metric(after, 'LayoutDuration') - metric(before, 'LayoutDuration')), + styleCpuMs: + 1000 * + (metric(after, 'RecalcStyleDuration') - metric(before, 'RecalcStyleDuration')), maxTaskMs: Math.max(0, ...observation.longTasks.map((t) => t.duration)), longTaskCount: observation.longTasks.length, maxFrameGapMs: observation.maxFrameGapMs, @@ -852,6 +886,8 @@ try { cpu: os.cpus()[0]?.model, cpuRate, profiling, + mirrorCounters, + layoutCounters, benchmarkSha256: createHash('sha256') .update(await readFile(fileURLToPath(import.meta.url))) .digest('hex'), diff --git a/packages/rrweb/rrweb/src/record/mutation.ts b/packages/rrweb/rrweb/src/record/mutation.ts index b12c8d5207..2724c7b8da 100644 --- a/packages/rrweb/rrweb/src/record/mutation.ts +++ b/packages/rrweb/rrweb/src/record/mutation.ts @@ -948,6 +948,9 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too if (!isBlocked(n, this.blockClass, this.blockSelector, false)) { + // Text nodes cannot have children or a shadow root. Keep the blocking + // check above: skipping it can change stateful RegExp behavior. + if (n.nodeType === n.TEXT_NODE) return; // Avoid a callback per node on repeated subtree walks. Like forEach, // capture the initial length but read each child from the live list. const children = dom.childNodes(n); @@ -980,6 +983,7 @@ function deepDelete(addsSet: Set, n: Node) { while (stack.length) { const next = stack.pop()!; addsSet.delete(next); + if (next.nodeType === next.TEXT_NODE) continue; const children = dom.childNodes(next); for (let i = 0, length = children.length; i < length; i++) { const childN = children[i]; diff --git a/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts b/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts new file mode 100644 index 0000000000..976148c811 --- /dev/null +++ b/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; +import dom from '@posthog/rrweb-utils'; +import record from '../../src/record'; +import { mutationBuffers } from '../../src/record/observer'; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); +let stop: (() => void) | undefined; +afterEach(() => { + vi.restoreAllMocks(); + stop?.(); + document.body.innerHTML = ''; +}); + +it('still evaluates the blocking regexp for a text leaf', async () => { + document.body.innerHTML = 'value'; + const text = document.querySelector('span')!.firstChild!; + const blockClass = /blocked/g; + stop = record({ emit: () => {}, blockClass }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); + const test = vi.spyOn(blockClass, 'test'); + blockClass.lastIndex = 2; + buffer['genAdds'](text); + expect(test).toHaveBeenCalledWith('visible'); + expect(blockClass.lastIndex).toBe(0); + expect(buffer['movedSet'].has(text)).toBe(true); +}); + +it('does not fetch empty text child lists during repeated add/delete walks', async () => { + document.body.innerHTML = + '
' + 'value'.repeat(100) + '
'; + const root = document.querySelector('main')!; + const destination = document.querySelector('aside')!; + const texts = new Set( + Array.from(root.children, (node) => node.firstChild!), + ); + stop = record({ emit: () => {} }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + buffer.lock(); + const children = vi.spyOn(dom, 'childNodes'); + for (let round = 0; round < 5; round++) { + destination.append(root); + document.body.insertBefore(root, destination); + } + destination.append(root); + await settle(); + expect(texts.size).toBe(100); + for (const text of texts) expect(buffer['movedSet'].has(text)).toBe(true); + // The unchanged processRemoves walk still reads each text's list once. + expect(children.mock.calls.filter(([node]) => texts.has(node)).length).toBe( + 100, + ); + buffer.unlock(); + await settle(); +}); From 5bdca092a20a4a45b8486fd79d0ab86661ae0c1d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 7 Sep 2026 12:50:35 +0200 Subject: [PATCH 6/9] fix(replay): isolate prototype cache buckets from inherited keys --- .../rrweb/test/untainted-prototype.test.ts | 32 +++++++++++++++++++ packages/rrweb/utils/src/index.ts | 15 +++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/rrweb/rrweb/test/untainted-prototype.test.ts b/packages/rrweb/rrweb/test/untainted-prototype.test.ts index aa1f56423d..c16caf59b6 100644 --- a/packages/rrweb/rrweb/test/untainted-prototype.test.ts +++ b/packages/rrweb/rrweb/test/untainted-prototype.test.ts @@ -117,6 +117,30 @@ describe('untainted accessor cache', () => { } }); + it.each(['__proto__', 'constructor', 'toString'])( + 'does not use inherited %s as an accessor-cache bucket', + (key) => { + const element = document.createElement('div'); + const setPrototype = vi.spyOn(Object.prototype, '__proto__', 'set'); + try { + // Untyped library callers can supply keys outside BasePrototypeCache. + // Reject them before treating an inherited object as a writable cache. + expect(() => + Reflect.apply(utils.getUntaintedAccessor, undefined, [ + key, + element, + '__proto__', + ]), + ).toThrow(TypeError); + expect(setPrototype).not.toHaveBeenCalled(); + } finally { + setPrototype.mockRestore(); + } + expect(Object.getPrototypeOf(Object.prototype)).toBeNull(); + expect(utils.childNodes(element).length).toBe(0); + }, + ); + it('retains the instance fallback for properties without a getter', () => { const element = document.createElement('div'); // Object.prototype properties must not look like cached DOM accessors. @@ -126,6 +150,14 @@ describe('untainted accessor cache', () => { expect(utils.getUntaintedAccessor('Node', element, 'constructor')).toBe( element.constructor, ); + // A special accessor name is safe inside a valid prototype bucket. + expect( + Reflect.apply(utils.getUntaintedAccessor, undefined, [ + 'Node', + element, + '__proto__', + ]), + ).toBe(Object.getPrototypeOf(element)); }); }); diff --git a/packages/rrweb/utils/src/index.ts b/packages/rrweb/utils/src/index.ts index c10084c2d0..d9134f5991 100644 --- a/packages/rrweb/utils/src/index.ts +++ b/packages/rrweb/utils/src/index.ts @@ -151,17 +151,18 @@ export function getUntaintedPrototype( // Group by prototype so every node access can reuse the property key instead // of allocating `${key}.${String(accessor)}` on the serialization hot path. -// Null prototypes keep names like `constructor` from appearing to be cached. +// Both levels have null prototypes: neither prototype names nor accessor names +// like `constructor` may resolve to inherited objects or functions. type AccessorCache = Record< string, (this: PrototypeOwner, ...args: unknown[]) => unknown >; -const untaintedAccessorCache: Record = { - Node: Object.create(null), - ShadowRoot: Object.create(null), - MutationObserver: Object.create(null), - Element: Object.create(null), -}; +const untaintedAccessorCache: Record = + Object.create(null); +untaintedAccessorCache.Node = Object.create(null); +untaintedAccessorCache.ShadowRoot = Object.create(null); +untaintedAccessorCache.MutationObserver = Object.create(null); +untaintedAccessorCache.Element = Object.create(null); export function getUntaintedAccessor< K extends keyof BasePrototypeCache, From 61be4cc40d80c8b5b7dbbfeb3e8dc90252a995b8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 7 Sep 2026 18:04:59 +0200 Subject: [PATCH 7/9] test(replay): cover matching stateful regexps for text leaves --- .../test/record/mutation-text-leaf.test.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts b/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts index 976148c811..12c171494a 100644 --- a/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts +++ b/packages/rrweb/rrweb/test/record/mutation-text-leaf.test.ts @@ -12,21 +12,31 @@ afterEach(() => { document.body.innerHTML = ''; }); -it('still evaluates the blocking regexp for a text leaf', async () => { - document.body.innerHTML = 'value'; - const text = document.querySelector('span')!.firstChild!; - const blockClass = /blocked/g; - stop = record({ emit: () => {}, blockClass }); - await settle(); - const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; - buffer.lock(); - const test = vi.spyOn(blockClass, 'test'); - blockClass.lastIndex = 2; - buffer['genAdds'](text); - expect(test).toHaveBeenCalledWith('visible'); - expect(blockClass.lastIndex).toBe(0); - expect(buffer['movedSet'].has(text)).toBe(true); -}); +it.each([ + { blockClass: /blocked/g, initialIndex: 2, finalIndex: 0 }, + { blockClass: /isibl/g, initialIndex: 1, finalIndex: 6 }, +])( + 'still evaluates the blocking regexp $blockClass for a text leaf', + async ({ blockClass, initialIndex, finalIndex }) => { + // Record the text before adding a class that the matching case would block. + document.body.innerHTML = 'value'; + const span = document.querySelector('span')!; + const text = span.firstChild!; + stop = record({ emit: () => {}, blockClass }); + await settle(); + const buffer = mutationBuffers.find((b) => b.bufferDoc() === document)!; + expect(buffer['mirror'].hasNode(text)).toBe(true); + buffer.lock(); + span.className = 'visible'; + const test = vi.spyOn(blockClass, 'test'); + blockClass.lastIndex = initialIndex; + buffer['genAdds'](text); + expect(blockClass.lastIndex).toBe(finalIndex); + expect(test).toHaveBeenCalledTimes(1); + expect(test).toHaveBeenCalledWith('visible'); + expect(buffer['movedSet'].has(text)).toBe(true); + }, +); it('does not fetch empty text child lists during repeated add/delete walks', async () => { document.body.innerHTML = From c5d59fc55e584b3f57493f778788462914092bc9 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 08:12:22 +0200 Subject: [PATCH 8/9] test(replay): assert node identity in traversal order --- .../test/record/mutation-child-traversal.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts b/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts index 268e32160b..dd00c66db0 100644 --- a/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts +++ b/packages/rrweb/rrweb/test/record/mutation-child-traversal.test.ts @@ -11,7 +11,9 @@ describe('mutation child traversal', () => { beforeEach(() => { document.body.innerHTML = '
' + - 'value'.repeat(100) + + Array.from({ length: 100 }, (_, i) => `value ${i}`).join( + '', + ) + '
'; }); @@ -51,7 +53,9 @@ describe('mutation child traversal', () => { destination.append(root); await settle(); - expect([...buffer['movedSet']]).toEqual(nodes); + const moved = [...buffer['movedSet']]; + expect(moved).toHaveLength(nodes.length); + moved.forEach((node, i) => expect(node).toBe(nodes[i])); // processRemoves still enumerates these lists once. genAdds/deepDelete // previously enumerated all of them another 21 times using callbacks. expect(enumerations).toBe(nodes.length); @@ -71,12 +75,15 @@ describe('mutation child traversal', () => { const remove = vi.spyOn(buffer['movedSet'], 'delete'); document.body.insertBefore(root, destination); await settle(); - expect(remove.mock.calls.map(([node]) => node)).toEqual([ + const expected = [ root, ...Array.from(root.children) .reverse() .flatMap((span) => [span, span.firstChild]), - ]); + ]; + const removed = remove.mock.calls.map(([node]) => node); + expect(removed).toHaveLength(expected.length); + removed.forEach((node, i) => expect(node).toBe(expected[i])); }); it.each(['light', 'shadow'] as const)( From 590f67a67470f35e57fa7ab685f8bffc71a39083 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 09:16:22 +0200 Subject: [PATCH 9/9] test(replay): await playback and canvas event completion --- packages/rrweb/rrweb/test/record/webgl.test.ts | 4 +++- packages/rrweb/rrweb/test/replayer.test.ts | 15 +++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/rrweb/rrweb/test/record/webgl.test.ts b/packages/rrweb/rrweb/test/record/webgl.test.ts index 15b59278f2..62e06e09cc 100644 --- a/packages/rrweb/rrweb/test/record/webgl.test.ts +++ b/packages/rrweb/rrweb/test/record/webgl.test.ts @@ -14,6 +14,7 @@ import { assertSnapshot, launchPuppeteer, stripBase64, + waitForCondition, waitForRAF, } from '../utils'; import type { ICanvas } from '@posthog/rrweb-snapshot'; @@ -262,7 +263,8 @@ describe('record webgl', function (this: ISuite) { }); }); - await ctx.page.waitForTimeout(50); + // Wait for the final batch to reach the exposed emit callback as well. + await waitForCondition(() => ctx.events.length >= 5); await assertSnapshot(ctx.events); expect(ctx.events.length).toEqual(5); diff --git a/packages/rrweb/rrweb/test/replayer.test.ts b/packages/rrweb/rrweb/test/replayer.test.ts index e45af62c65..568599e24f 100644 --- a/packages/rrweb/rrweb/test/replayer.test.ts +++ b/packages/rrweb/rrweb/test/replayer.test.ts @@ -1002,12 +1002,15 @@ describe('replayer', function () { it('replays same timestamp events in correct order', async () => { await page.evaluate(`events = ${JSON.stringify(orderingEvents)}`); - await page.evaluate(` - const { Replayer } = rrweb; - const replayer = new Replayer(events); - replayer.play(); - `); - await page.waitForTimeout(50); + await page.evaluate((finishEvent) => { + const win = window as IWindow; + const replayer = new win.rrweb.Replayer(win.events); + // A loaded runner may not deliver the first frame within 50 ms. + return new Promise((resolve) => { + replayer.on(finishEvent, () => resolve()); + replayer.play(); + }); + }, ReplayerEvents.Finish); await assertDomSnapshot(page); });