feat(record): add attributeFilter option for native MutationObserver filtering - #1873
kunalfermat wants to merge 2 commits into
Conversation
…r filtering
CSS animations on carousels, sliders, and animated badges can generate
1,000+ style attribute mutations per second with zero user interaction.
Today there is no way to prevent these from hitting the MutationObserver
callback — `ignoreCSSAttributes` only filters CSSOM `setProperty` calls,
not inline `style` attribute changes observed by MutationObserver.
This adds an `attributeFilter` option to `record()` that passes through
to `MutationObserver.observe()`. When set, the browser's native
filtering prevents unlisted attributes from ever firing the JS callback,
eliminating CPU overhead entirely — not just filtering after the fact.
Usage:
```js
record({
emit(event) { ... },
attributeFilter: ['class', 'value', 'checked', 'selected', 'disabled'],
// 'style' mutations never fire — zero overhead
});
```
When omitted, all attributes are observed (existing default behavior).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 33080e5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
This looks like an interesting idea and cuts off the problem at the source, preventing unnecessary processing in rrweb.
Edit: I see you mentioned #1694 already — did you test out any throttling values for your problem website? |
|
There is also (undocumented I now realize) |
Thanks for the feedback @eoghanmurray ! On the ergonomics concern — totally fair. An allowlist puts the burden on users to enumerate every attribute they care about, which is error-prone. A simpler alternative would be an inverse option like ignoreAttributes: ['style'] — users only list what to exclude, which is a much shorter list (usually just style). That's easier to reason about and harder to get wrong. Happy to rework the API in that direction if you think it's a better fit. On #1694 (mutation emission throttling) — we actually implemented something very similar on our side. Our pixel has an emit-callback throttle that drops style-only attribute mutations firing faster than 100ms apart. It works well for bandwidth/storage (~85% reduction in mutation events on our noisiest brand site), but doesn't help with CPU because rrweb's MutationObserver callback still fires 1,400+/s and processes every mutation before our throttle sees it. That's what motivated the native attributeFilter approach — the browser never fires the JS callback for filtered-out attributes, so the expensive processMutation path (getAttribute, style diffing, etc.) never runs. That said, #1694 and this PR are complementary — throttling controls how often mutations are emitted, while attributeFilter/ignoreAttributes controls which mutations are observed. Both have value for different use cases. |
|
|
Ok I'm leaning towards wontfix as I believe any sort of attributeFilter means we can't discover new attributes being added to an element, so we'd get a hidden gap. The problem is that it's really hard to explain this in the config docs. As you have to understand how https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe works anyway to use this safely, I think it's fair to recommend wiring this up directly in a fork of the repo (where My other objection is that you are setting a filter to target e.g. all style attributes, whereas a subset might be the problem. A no-config solution to that would be to detect the high frequency/noisy element+attribute combinations and take them 'offline', but we've currently just one root observer set up and I dunno how a single element could be omitted. Happy to listen to your thoughts or more detail on your noisy example. |
I just realized that #1933 might fix the worst of this. Basically the style mutation is special in that it's processing is more complicated (it drops in a dummy element so as to construct a before/after comparison of styles). Before #1933 this happens every time a mutation comes in, but afterwards it only happens upon emit. If you also add the 100ms (or higher) mutation throttling config now available in that PR, it should pick out the noisy elements only and do that style construction only once per 100ms and I think it should have a major relief on cpu. New PRs now give you a pre-built package to try it out: |
Summary
Add an
attributeFilteroption torecord()that delegates attribute filtering to the browser's nativeMutationObserver.observe({ attributeFilter }). When set, unlisted attributes never fire the JS callback, eliminating CPU overhead from high-frequency style animations entirely — not just filtering after the fact.Problem
CSS animations on carousels, sliders, and animated badges generate 1,000+
styleattribute mutations per second with zero user interaction. On sites like littlesleepies.com, 99% of all rrweb events are attribute mutations from a product carousel (7,200 mutations/5s) and animated VIP badges (1,200 mutations/5s).Today there is no way to prevent these from reaching the MutationObserver callback:
ignoreCSSAttributesonly filters CSSOMCSSStyleDeclaration.setProperty()calls, not inlinestyleattribute changes observed by MutationObserverhooks.mutationfires after collection — the expensive processing has already happenedblockSelectorhides the element entirely from replayRelated issues: #197, #221, #668, #1447, #1820
Complementary to: #1694 (mutation emission throttling)
Solution
Pass
attributeFilterthrough to the nativeMutationObserver.observe()call. This is the most performant approach — the browser never fires the JS callback for filtered-out attributes, so there is zero CPU overhead per-mutation for unlisted attributes.When
attributeFilteris omitted or undefined, all attributes are observed (existing default behavior — fully backwards compatible).Changes
packages/rrweb/src/types.ts— AddedattributeFilter?: string[]torecordOptions,observerParam, andMutationBufferParampackages/rrweb/src/record/observer.ts— PassattributeFiltertoobserver.observe()when providedpackages/rrweb/src/record/index.ts— WireattributeFilterfrom record options through to observer params.changeset/add-attribute-filter.md— Minor changesetTrade-offs
attributeFilteris explicitly setstyle, ALL style mutations are invisible (not just animation ones). Users who need some style mutations recorded should not filterstylestyleanimations (visually indistinguishable at 0/s vs 1,400/s) but users should be awareReal-world impact
Tested on littlesleepies.com (product carousel + VIP badges):
attributeFilter: ['class']: MutationObserver callback never fires for style changes → ~99% reduction in mutation processing