feat(replay): mask canvas session replay recordings on Flutter web - #499
Merged
Conversation
Contributor
posthog-flutter Compliance ReportDate: 2026-07-30 15:14:39 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.
marandaneto
reviewed
Jul 29, 2026
marandaneto
reviewed
Jul 29, 2026
…r token, foreign-view fail-closed, parser refresh, restart retry
The exception path reused the incoming delay, retrying a persistently failing apply at a fixed 250ms for the page's life; it now ramps to the same 4s ceiling as the posthog-not-ready path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nsFn) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marandaneto
reviewed
Jul 30, 2026
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 2 should fix, 7 consider. Published 9 findings (view the review). |
…-facing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marandaneto
reviewed
Jul 30, 2026
marandaneto
approved these changes
Jul 30, 2026
marandaneto
left a comment
Member
There was a problem hiding this comment.
left a last comment, approving to unblock
marandaneto
reviewed
Jul 30, 2026
5 tasks
…lement In full-page mode the embedder host is <body>; with a second engine on the page, containment matched a foreign view's canvas and paired it with our rects. Skip frames for every canvas when the host holds more than one flutter-view, since ownership cannot be proven. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ioannisj
approved these changes
Jul 30, 2026
…#501) * feat(replay): PostHogMaskWidget enables web canvas masking on its own Canvas masking only registered with posthog-js when the app declared session_recording.captureCanvas.canvasMaskRegionsFn in its posthog.init call, so a developer who wrapped sensitive UI in PostHogMaskWidget and never touched web/index.html got no masking at all, silently — while the same widget needs no setup on iOS and Android. The first PostHogMaskWidget to mount now opts the app in: the mount is routed through a conditional import (no-op off web) to WebCanvasMaskProvider, which registers the mask-region provider if it has not already. Registration stays idempotent, so any number of mask widgets produce at most one set_config and one recording restart. The gate is kept for everyone else: apps with neither a PostHogMaskWidget nor the init declaration still see no set_config, no blockSelector and no restart. Registering mid-session restarts an in-flight recording (posthog-js reads canvas capture options only when recording starts) and does not cover frames captured before the first mount — declaring canvasMaskRegionsFn in posthog.init remains the only way to cover the pre-boot window. Also drops a stale caveat from the example app: test 15's title claimed PostHogMaskWidget with multiple children needs maskAllTexts or maskAllImages. It does not on either platform — getMaskElements calls extractMaskWidgetRects() unconditionally on web, and screenshot_capturer calls getPostHogWidgetWrapperElements() outside the flags branch on mobile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPiKky15SKqNg9PT2wHeaw * fix(replay): address review — survive a failed mount-triggered apply, document both opt-in paths A throw while applying the mount opt-in now schedules a retry chain instead of permanently consuming the opt-in. The not-opted-in warnings mention mounting a PostHogMaskWidget as the easier fix, both changesets describe the two opt-in paths (and that one mask widget enables the whole masking config, maskAllTexts/maskAllImages included), and tests cover cross-path idempotency plus the failed-mount retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): note pre-mount full snapshots can embed unmasked canvas stills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): mark applied only after the recording restart succeeds set_config landing but the restart throwing left _applied latched, so no later pump retried the restart and blockSelector never took effect for the in-flight recording. * fix(replay): only opt in from a PostHogMaskWidget inside the tracked PostHogWidget tree * fix(replay): enforce a single retry chain; cover tracked-tree mount gating * docs(replay): scope the outside-tree opt-in claim to mount time; pin the ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): rewrite the mask-widget entry per changelog style Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): fold the mask-widget entry into the feature changeset One feature, one entry — the stacked PR merges into its base before release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): revalidate mounted mask widgets on every frame, fail closed outside the tracked tree The mount-time check latches the opt-in once; a PostHogWidget mounting later without containing the mask widget would ship rects that never cover it. Every maskRegionsFn call now verifies all mounted mask widgets are inside the tracked tree and skips the frame otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(replay): pin the provider's own view in regions tests The CI harness's engine flutter-view plus each test's fake one make the full-page host ambiguous, so the multi-view fail-closed path returned null before the behavior under test could. Pinning debugOwnViewHostOverride makes every regions assertion hold for its own reason. * fix(replay): resolve the tracked-tree root the way the masking walk does; guard the mount callback The walk roots at the root navigator when PostHogWidget sits under an active route, so the mount gate and per-frame revalidation now share that resolution — a mask widget in a root-navigator dialog is tracked, not rejected. The post-frame callback body is try/catch-wrapped so a throw cannot surface through FlutterError.onError into the host's error tracking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Bugfix
Issues: 3 issues
Files (5)
posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dartposthog_flutter/lib/src/replay/element_parsers/element_object_parser.dartposthog_flutter/lib/src/replay/mask/posthog_mask_widget.dartposthog_flutter/lib/src/posthog_flutter_web_handler.dartposthog_flutter/lib/posthog_flutter_web.dart
What were the main changes
- Fixes a shared masking bug:
element_object_parser.dartunconditionally relatedTextwidgets, causingmaskAllTexts: false+maskAllImages: trueto still mask all text on iOS/Android/web - Adds
PostHogMaskController.refreshParsers()so a latersetup()with different masking flags rebuilds the parser map instead of using the one captured at first singleton access - Adds
PostHogMaskController.getMaskElements()— a single widget-tree walk producing both explicit-mask and full text/image rect sets, reused by the new web canvas provider instead of duplicating mobile's walk logic - Exposes
set_config/configJS interop members on thePostHogbinding and wiresWebCanvasMaskProvider(config).register()intoposthog_flutter_web.dart's setup path - Updates
PostHogMaskWidgetdartdoc to note it requires the webmaskRegionsFnopt-in to have any effect on Flutter web
Feature
Issues: 6 issues
Files (2)
posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dartposthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart
What were the main changes
- New
WebCanvasMaskProviderregisterssession_recording.canvasCapture.maskRegionsFnwith posthog-js only when the app has declared the key inposthog.init, leaving recording untouched otherwise - Retry/backoff registration (250ms\u21924s cap) handles the posthog-js snippet stub being replaced, posthog-js loading after Flutter, and consent-gated late
initvia an__loadedcheck; cancels a predecessor's pending retry on re-register - Fails closed: a failed widget-tree walk (or a canvas belonging to a foreign flutter-view on a multi-view page) returns null so posthog-js skips the frame rather than shipping it unmasked; warns once after persistent failures
- Adds
flt-semantics-hosttoblockSelector(token-exact merge with any user selector) to stop Flutter's accessibility DOM leaking text, and restarts an in-flight recording exactly once (surviving a throwing start/stop) sinceblockSelectoris read only at rrweb start - Version-gates a console warning for posthog-js older than 1.408.0 (assume-new on absent/unparseable versions) without ever blocking registration
- New
web_canvas_mask_geometry.dartconverts parsed elements to axis-aligned, 1px-outset mask rects; the provider maps container-local rects through the container's full transform (not just an origin shift) before converting to canvas-relative CSS px
Fail closed on non-finite regions from singular ancestor transforms and on the shadow-DOM-blind no-host fallback; check canvas ownership before walk accounting so foreign canvases don't advance the failure counter; drop zero-size rects before the 1px outset; document the recordCanvas requirement in the mask widget's web snippet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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:Since #501 was merged into this branch, mounting a
PostHogMaskWidgetis a second, self-contained opt-in — the most explicit possible statement of "never record this" should not silently no-op just becauseweb/index.htmlwasn't edited. The first mount registers the provider itself (idempotent: oneset_config, one restart, however many mask widgets mount, whichever side loads first). Prefer theposthog.initdeclaration when you can: it covers the frames captured before Flutter boots (skipped instead of recorded unmasked) and avoids the one-time mid-session recording restart the mount path needs. One mount enables the whole masking configuration (maskAllTexts/maskAllImagesdefault true), matching iOS/Android semantics.With neither the key nor a mask widget, 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
Rect computation reuses the mobile masking logic — one widget-tree walk (
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).posthog-js paints the regions black inside its capture pipeline before frames are encoded — masked pixels never leave the browser. The live canvas is untouched; masking happens on a copy in the encode worker.
Fail-closed: a failed widget-tree walk returns
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 they are recorded normally; a canvas belonging to a different Flutter view on a multi-view page is skipped (null) instead — our rects must never ship with someone else's view.Accessibility side-channel closed: with a11y active, Flutter mirrors widget text into
flt-semanticsDOM nodes as plain text, which rrweb records; the provider forwardsblockSelector: 'flt-semantics-host'(merged with any user selector). Reproduced and verified gone.Lifecycle: posthog-js reads
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.)Mount opt-in machinery (from feat(replay): PostHogMaskWidget enables web canvas masking on its own #501): the apply path is a small state machine (
applied/notOptedIn/posthogNotReady) so "posthog-js is up but the app didn't opt in" parks instead of terminating — a later mask-widget mount re-enters it. A mask widget outside the trackedPostHogWidgettree cannot fail open: everymaskRegionsFncall revalidates all mounted mask widgets against the tracked tree and skips the frame if any sits outside it. The tracked-tree boundary resolves the same route-dependent way the masking walk does (root navigator whenPostHogWidgetsits under an active route), so a mask widget in a root-navigator dialog is covered, not rejected. Registration deliberately doesn't wait onrecordCanvas— remote config can enable it after init, and theblockSelectoraccessibility exclusion is worth applying even with canvas capture off.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 or aPostHogMaskWidgetmounts.💚 How did you test it?
flutter analyzeclean; full suite 247 passing (VM run also proves the conditional-import io stubs compile on mobile); the two web test files run 50 chrome-platform tests in CI. VM tests 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). The feat(replay): PostHogMaskWidget enables web canvas masking on its own #501 additions pin the mount path: mask-widget mounts opting in with nothing declared (before or after posthog-js appears), exactly oneset_config/restart across both opt-in paths and repeated mounts, outside-tree mask widgets forcing frames tonullper-frame (and recovering on removal), the root-navigator-dialog shape producing regions, the multi-view ambiguous-host fail-closed, and a failed mount-triggered apply retrying instead of consuming the opt-in.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 here and in the dartdoc; the changeset is a one-line entry per the changelog style, and a posthog.com docs PR will carry the full setup guidance):
CustomPainter-drawn text isn't in the widget tree → not masked (same as mobile;PostHogMaskWidgetis the escape hatch).PostHogMaskWidgetalone, without theposthog.initdeclaration): frames captured before the first mount are recorded unmasked, and the recording restarts once when masking switches on (the replay shows a split there) —blockSelectoris a start-time option in posthog-js. Documented on the widget's dartdoc.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. Will be called out in the docs PR.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— the feature;.changeset/mask-text-flag-fix.md— the cross-platformmaskAllTexts: falsefix), both one-line entries per the changelog style.🤖 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