feat(replay): mask canvas session replay recordings on Flutter web - #499
Open
turnipdabeets wants to merge 7 commits into
Open
feat(replay): mask canvas session replay recordings on Flutter web#499turnipdabeets wants to merge 7 commits into
turnipdabeets wants to merge 7 commits into
Conversation
Contributor
posthog-flutter Compliance ReportDate: 2026-07-28 19:22:05 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
turnipdabeets
force-pushed
the
feat/web-canvas-masking
branch
4 times, most recently
from
July 27, 2026 18:36
89d7af3 to
688652a
Compare
5 tasks
turnipdabeets
force-pushed
the
feat/web-canvas-masking
branch
from
July 27, 2026 20:44
688652a to
035e3f1
Compare
7 tasks
turnipdabeets
force-pushed
the
feat/web-canvas-masking
branch
from
July 27, 2026 21:01
035e3f1 to
2010638
Compare
On Flutter web (CanvasKit) session replay is recorded by posthog-js canvas capture, and DOM-based masking cannot reach text painted into the canvas — sessionReplayConfig masking options were silent no-ops (#496). setup() now registers a mask-region provider with posthog-js (session_recording.captureCanvas.canvasMaskRegionsFn): widget-tree rects are computed with the same selection logic mobile uses (maskAllTexts / maskAllImages / PostHogMaskWidget / obscured text fields), converted to canvas-relative CSS pixels (transform-aware, 1px outset, per-Flutter-frame cache), and painted black inside the posthog-js capture pipeline before frames are encoded. Fails closed: a failed widget-tree walk yields a full-canvas mask, and with requireMaskProvider set in the posthog.init HTML config, frames captured before Flutter registers are blacked out. The flt-semantics accessibility tree (which mirrors widget text into recordable DOM) is excluded via blockSelector, and an in-flight recording is restarted once so start-time options apply. Registration retries with backoff until posthog-js is available — covering both the snippet stub being replaced by the real instance and posthog-js loading after Flutter entirely. Requires posthog-js with captureCanvas.canvasMaskRegionsFn support and config.sessionReplay = true. Fixes #496 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
turnipdabeets
force-pushed
the
feat/web-canvas-masking
branch
from
July 28, 2026 13:43
2010638 to
01e3715
Compare
….init runs posthog-js constructs its instance with a default config before init(), so a present config no longer counts as initialized — only __loaded does. The retry chain now polls indefinitely at the 4s backoff cap instead of giving up after 2 minutes, so consent-gated apps that init late still get masking, and an exception during a retry tick reschedules instead of killing the chain. Also latch the frame-callback flag only after registration succeeds, and cancel live retry chains between tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sCapture.maskRegionsFn Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t canvases are readable there Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registration still proceeds on an old posthog-js — the blockSelector accessibility-DOM exclusion works there — but canvas frames ship unmasked, so emit one console.warn when the detected version is confirmed older than the minimum. Absent or unparseable versions are assumed new so the gate cannot misfire on custom bundles or future version schemes. The minimum is a '0.0.0' placeholder until the first posthog-js release with canvasCapture.maskRegionsFn support exists to pin against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
turnipdabeets
marked this pull request as ready for review
July 28, 2026 17:05
Prompt To Fix All With AI### Issue 1
posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:354-357
**Per-type masking is ignored**
When `maskAllImages` is enabled while `maskAllTexts` is disabled, `includeAllWidgets` still includes every parsed element, including the unconditionally parsed `Text` elements, causing ordinary text to be masked despite the application explicitly disabling text masking.
### Issue 2
posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:77-80
**Stale retry providers remain active**
When `Posthog.setup` replaces a provider while its predecessor is waiting for posthog-js initialization, the predecessor's timer continues and later installs a callback backed by the old replay settings, potentially overriding the new configuration and restarting an in-flight recording.
```suggestion
void register() {
try {
_active?._retryTimer?.cancel();
_active = this;
_registerUnsafe();
```
### Issue 3
posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:29
**Compatibility warning is disabled**
With `_minPosthogJsVersion` set to `0.0.0`, every released semantic version passes the compatibility check, so applications using posthog-js versions without `maskRegionsFn` support continue recording unmasked canvas frames without receiving the promised upgrade warning.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(replay): warn once when posthog-js i..." | Re-trigger Greptile |
A second Posthog().setup() left the first provider's retry timer polling; once posthog-js appeared, both chains applied config and restarted the recording twice.
A restart that throws after set_config landed used to end the chain in register()'s catch, leaving blockSelector unapplied until a natural recording restart.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
Fixes #496 — on Flutter web, session replay is posthog-js canvas capture, and the entire app (including PII text) is pixels inside the CanvasKit canvas. DOM-based masking can't see it, so
sessionReplayConfig.maskAllTexts/maskAllImagesandPostHogMaskWidgetwere all silent no-ops on web.How you turn it on
Web replay is configured in
posthog.init, so the opt-in lives there too — declare the mask-provider slot and the plugin fills it once Flutter has booted:Without that key the plugin registers nothing — no
set_config, no recording restart, noblockSelectorchange — and recording behaves exactly as posthog-js is configured, as it does today. The mobile-onlyconfig.sessionReplayflag has no effect on web (it gates the native iOS/Android recorder; overloading it here would have made a flag documented as "Android and iOS" silently govern web behavior).What it does
PostHogMaskController.getMaskElements, new single-walk method) with the exact selection rules mobile uses:maskAllTexts/maskAllImagesgate the full text/image sets;PostHogMaskWidgetand obscured text fields always mask. Rects are transform-aware (AABB for rotated/scaled widgets), outset 1px, converted to canvas-relative CSS px against theflutter-viewhost rect (correct for embedded/offset views), and cached per Flutter frame (idle cost ≈0.01ms/call, measured).null, which makes posthog-js skip that frame rather than ship it unmasked (one-time console warning after ~10 consecutive failures explains thePostHogWidgetrequirement). DeclaringmaskRegionsFn: () => nullextends the same protection to frames captured before Flutter boots. Non-Flutter canvases get an empty region list (identity-checked via the flutter-view host chain), so other canvases on the page are unaffected.flt-semanticsDOM nodes as plain text, which rrweb records; the provider forwardsblockSelector: 'flt-semantics-host'(merged with any user selector). Reproduced and verified gone.blockSelectoronly at rrwebrecord()start, so an in-flight recording is restarted once after registration (same session id; sampling decision persists). Registration retries with backoff (250ms→4s), then keeps polling at the 4s cap indefinitely. Awindow.posthogthat exists but hasn't finishedinit(posthog-js's__loadedflag false — e.g. consent-gated init) counts as not-ready and keeps being retried; only an initialized config without themaskRegionsFnkey is treated as not opted in. Covers the snippet stub being replaced by the real instance, posthog-js loading after Flutter entirely, andposthog.initrunning arbitrarily late. (__loadedis undocumented but de-facto-stable posthog-js API: set when_initcompletes, absent/false on the stub and on a constructed-but-uninitialized instance.)No new public Dart API (package barrel diff is zero lines). Existing config options and
PostHogMaskWidgetstart working on web with mobile-identical semantics, once the provider is declared.💚 How did you test it?
flutter analyzeclean; full suite 244 passing. VM tests 7/7 for the new code (geometry incl. rotation AABB + degenerate rects; single-walk equivalence vs the two-walk union); browser tests 20/20 (--platform chrome, wired into CI): set_config merge preserving user config, no registration at all when the provider isn't declared (noset_config, no restart), the canvas-relative coordinate conversion pinned to exact pixels, fail-closednullfor a flutter-view canvas without PostHogWidget, warn-once on persistent walk failure, foreign-canvas exclusion, semantics blockSelector with maskAllTexts=false, deferred registration + stub-replacement convergence (incl. posthog-js absent entirely at setup), restart-exactly-once, posthog-js present-but-uninitialized (__loadedfalse) applying once init declares the provider, and a throwing retry tick rescheduling instead of killing the chain, plus the version-gate warning matrix (older posthog-js warns once but still registers; newer/absent/garbage versions never warn).before_sendand inspected pixel-by-pixel across four configurations — no provider declared (nothing masked),maskAllTexts+maskAllImages(everything masked), text-only (photos correctly left visible), and neither flag (onlyPostHogMaskWidgetsubtrees masked). Rotated and scaled text verified to mask via their transform-aware AABBs. Recordings played back in the production PostHog player.Known limitations (documented in the changeset; docs PR to follow):
CustomPainter-drawn text isn't in the widget tree → not masked (same as mobile;PostHogMaskWidgetis the escape hatch).PostHogMaskWidgetneeds themaskRegionsFndeclaration on web — without it the plugin never registers, so the widget has nothing to paint through. Documented on the widget's dartdoc and in the changeset. feat(replay): PostHogMaskWidget enables web canvas masking on its own #501 (stacked on this branch) removes this limitation by having the firstPostHogMaskWidgetmount enable masking itself.blockSelectortakes precedence over the project-level one. The plugin addsflt-semantics-hostviaset_config, and posthog-js resolvesclient ?? server, so a selector configured only in project settings is superseded. The plugin cannot merge with it — the server value is not exposed onposthog.config. Called out in the changeset.HtmlElementView-based widgets (maps, webviews) are recorded by posthog-js's DOM rules;PostHogMaskWidgetaround one does not mask it on web.PostHogWidgetwrapping the app — without it, masking fails closed (frames skipped) by design.📝 Checklist
If releasing new changes
.changeset/canvas-masking-web.md).🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code from the investigation of #496 (transferred from PostHog/posthog#66291). The provider deliberately reuses the mobile masking walk rather than a parallel implementation, so web and mobile masking semantics cannot drift.
Two design changes during review, both narrowing the public surface: posthog-js dropped its
requireMaskProviderboolean in favour of the provider declaration itself carrying the opt-in, and this PR dropped an earlierconfig.sessionReplaygate for the same reason — it would have given a flag documented as iOS/Android-only a silent web meaning, and itsfalsedefault would have made unmasked recording the default for anyone enabling canvas capture.Notable findings fixed during pre-PR review loops (several with executed repros): posthog-js replaces the snippet stub rather than upgrading it (retry must re-read
window.posthog),set_configagainst the stub would wipe usersession_recordingconfig, start-time-only options require the one-time recording restart, and the a11y semantics tree leaks widget text as DOM text nodes.Rebased on #500 (
fix(replay): mask every element that matched a masking rule), which fixed a shared masking bug this work surfaced —extractRects()droppedPostHogMaskWidgetwrappers with more than one masked child, affecting shipped iOS/Android as well as web.🤖 Generated with Claude Code