Skip to content

perf: Privacy at Capture — unmask-selector presence probe (experimental) - #10

Open
roggernaut wants to merge 2 commits into
privacy-at-capture-corefrom
privacy-at-capture-unmask-probe
Open

perf: Privacy at Capture — unmask-selector presence probe (experimental)#10
roggernaut wants to merge 2 commits into
privacy-at-capture-corefrom
privacy-at-capture-unmask-probe

Conversation

@roggernaut

@roggernaut roggernaut commented Aug 31, 2026

Copy link
Copy Markdown
Owner

A performance optimization for the non-legacy presets.

Every non-minimal preset compiles a non-null unmaskTextSelectorbalanced and strict always include [data-privacy="unmask"] and the cross-vendor unmask classes, whether or not any rule configured one. That permanently forces serializeNodeWithId's checkAncestors branch on, which re-walks ancestors with Element.prototype.matches for every single node instead of trusting the inherited masking decision — O(nodes × depth) on a page that has no unmask target anywhere. Most pages never put anything under an unmask selector, so that walk buys nothing.

resolveUnmaskTextSelector probes once per full snapshot and once per mutation flush — not per node — for whether the selector currently matches anything in the document. querySelector/querySelectorAll never pierce shadow-DOM boundaries, so the probe walks into every open shadow root explicitly; otherwise it would wrongly conclude that an unmask target living inside an open shadow tree doesn't exist.

When nothing matches, null is passed downward for that pass and the cheap short-circuit is restored. When a match exists, the selector is returned unchanged and per-node checking happens exactly as before. A selector that throws (detached or hostile document) is assumed present, so behavior fails closed to masking.

Experimental

This is a novel optimization with no vendor precedent. Probe cost, paid per snapshot and per mutation flush even on pages that do have an unmask target, where it buys nothing:

  • one querySelector per root
  • one querySelectorAll('*') walk per root, to enumerate open shadow roots

Measured benefit: on a 40-deep × 40-wide DOM under strict with no unmask target, total matches() calls drop from thousands to under 200 (asserted by test/privacy-perf.test.ts).

Layers directly on #4; independent of #8 and #9.

Tests

  • packages/rrweb-snapshot — 225 passed, 1 skipped (includes the two new perf guards)
  • packages/rrweb test/record/privacy.test.ts + test/record.test.ts — passing headless (current counts in CI)
  • npx tsc -b clean

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Bundle Size Changes

Size change: +49.28 kB (+0.45%) | Total size: 11079.33 kB

all - 2101.05 kB -> 2111.45 kB (+10.40 kB (+0.49%))
File Base PR Diff
all.cjs 603.81 kB 606.84 kB +3.04 kB (+0.50%)
all.js 603.48 kB 606.51 kB +3.04 kB (+0.50%)
all.umd.cjs 607.35 kB 610.39 kB +3.04 kB (+0.50%)
all.umd.min.cjs 286.41 kB 287.70 kB +1.29 kB (+0.45%)
browser-client - 794.48 kB -> 804.87 kB (+10.39 kB (+1.31%))
File Base PR Diff
browser-client.cjs 227.78 kB 230.81 kB +3.03 kB (+1.33%)
browser-client.js 227.55 kB 230.58 kB +3.03 kB (+1.33%)
browser-client.umd.cjs 230.94 kB 233.97 kB +3.03 kB (+1.31%)
browser-client.umd.min.cjs 108.22 kB 109.51 kB +1.30 kB (+1.20%)
record - 676.87 kB -> 687.73 kB (+10.86 kB (+1.60%))
File Base PR Diff
record.cjs 194.29 kB 197.56 kB +3.27 kB (+1.68%)
record.js 194.20 kB 197.47 kB +3.27 kB (+1.68%)
record.umd.cjs 195.31 kB 198.34 kB +3.03 kB (+1.55%)
record.umd.min.cjs 93.07 kB 94.37 kB +1.30 kB (+1.39%)
rrweb - 2039.66 kB -> 2050.56 kB (+10.89 kB (+0.53%))
File Base PR Diff
rrweb.cjs 587.18 kB 590.46 kB +3.28 kB (+0.56%)
rrweb.js 586.88 kB 590.16 kB +3.28 kB (+0.56%)
rrweb.umd.cjs 588.52 kB 591.56 kB +3.04 kB (+0.52%)
rrweb.umd.min.cjs 277.09 kB 278.38 kB +1.29 kB (+0.47%)
rrweb-snapshot - 702.08 kB -> 708.83 kB (+6.75 kB (+0.96%))
File Base PR Diff
rrweb-snapshot.cjs 201.81 kB 203.86 kB +2.05 kB (+1.02%)
rrweb-snapshot.js 200.40 kB 202.42 kB +2.02 kB (+1.01%)
rrweb-snapshot.umd.cjs 204.43 kB 206.48 kB +2.05 kB (+1.00%)
rrweb-snapshot.umd.min.cjs 95.45 kB 96.08 kB +642 B (+0.66%)

@roggernaut
roggernaut force-pushed the privacy-at-capture-unmask-probe branch from 4474320 to ffb28f3 Compare September 2, 2026 14:33
@roggernaut
roggernaut force-pushed the privacy-at-capture-core branch from ee793c7 to da75a0f Compare September 2, 2026 19:51
@roggernaut
roggernaut force-pushed the privacy-at-capture-unmask-probe branch from ffb28f3 to b5322f2 Compare September 2, 2026 21:06
Rogier Trimpe and others added 2 commits September 3, 2026 12:13
…xperimental)

Every non-legacy preset compiles a non-null `unmaskTextSelector`, which
permanently forces `serializeNodeWithId`'s `checkAncestors` branch on and
re-walks ancestors with `matches()` for every node — O(nodes x depth) on a
page with no unmask target anywhere.

`resolveUnmaskTextSelector` probes once per full snapshot and once per
mutation flush (not per node) for whether the selector matches anything in the
document, walking into open shadow roots explicitly since `querySelector` does
not pierce them. When nothing matches, `null` is passed downward and the cheap
short-circuit is restored; otherwise the selector is returned unchanged. A
throwing selector is assumed present, failing closed to masking.

EXPERIMENTAL: a novel optimization with no vendor precedent. Probe cost, paid
per snapshot and per flush even where it buys nothing: one `querySelector`
plus one `querySelectorAll('*')` per root.

Paired with it, and at the same per-flush granularity, the mutation buffer
memoises `needMaskingText` results in a `Map` keyed by the element the walk
actually starts from: with `checkAncestors` on and no inherited decision the
result is a pure function of that element, so N `characterData` mutations
under one parent share a single walk. Nothing survives the flush -- between
flushes the DOM, and every ancestor chain a decision came from, is free to
change, and holding the keys would pin removed nodes in memory.

Unlike the probe, this half has direct vendor precedent: Datadog allocates one
privacy-level cache per synchronous mutation batch, sound "because we process
incremental mutations synchronously, [so] we know that privacy levels cannot
change during the process" (serializeMutations.ts:95-102). The two compose --
when the probe has nulled the selector for a flush the walk already
short-circuits and the cache costs one `Map` lookup on top of nearly nothing.
Measured on ten text mutations under one parent: 30 unmask-selector
`matches()` probes before, 3 after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se the probe's cost

- The comment at the `snapshot()` entry claimed the unresolved policy was
  threaded into nested iframe documents, but the code threaded the value
  already resolved against the parent document. An unmask target existing
  only inside a same-origin iframe was therefore ignored — the parent's
  "nothing matches" answer masked it. The unresolved selector now rides
  alongside the resolved one, and the deferred iframe and stylesheet
  re-serializations each re-probe against their own document.
- Disclose the probe's cost in the guide and changeset: it is a full-document
  element sweep, costliest precisely when no unmask target exists, scaling
  with DOM size on every flush.

refactor(privacy): rename the `manual` preset to `minimal` in this layer's
own addition -- the perf-smoke doc comment's "non-manual preset" reference.
Clean rename, nothing released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roggernaut
roggernaut force-pushed the privacy-at-capture-unmask-probe branch from b5322f2 to a64c9ae Compare September 3, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant