Skip to content

feat(replay): mask canvas session replay recordings on Flutter web - #499

Open
turnipdabeets wants to merge 7 commits into
mainfrom
feat/web-canvas-masking
Open

feat(replay): mask canvas session replay recordings on Flutter web#499
turnipdabeets wants to merge 7 commits into
mainfrom
feat/web-canvas-masking

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

💡 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/maskAllImages and PostHogMaskWidget were all silent no-ops on web.

Depends on PostHog/posthog-js#4270 — draft until that ships in a release. On older posthog-js the plugin still registers and never throws (verified live against released 1.407.5: the flt-semantics-host accessibility exclusion works there, set_config with a function is accepted, the restart is harmless) — but canvas frames are recorded unmasked, so the plugin emits a one-time console warning when the detected posthog.version is confirmed older than the minimum (absent/unparseable versions assume-new, so the gate can never misfire on future builds). Before releasing, pin _minPosthogJsVersion (currently an inert 0.0.0 placeholder) to #4270's release number.

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:

posthog.init('<token>', {
  session_recording: {
    captureCanvas: { recordCanvas: true },
    canvasCapture: {
      // plugin replaces this once Flutter starts; until then frames are
      // skipped rather than recorded unmasked
      maskRegionsFn: () => null,
    },
  },
})

Without that key the plugin registers nothing — no set_config, no recording restart, no blockSelector change — and recording behaves exactly as posthog-js is configured, as it does today. The mobile-only config.sessionReplay flag 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/maskAllImages gate the full text/image sets; PostHogMaskWidget and obscured text fields always mask. Rects are transform-aware (AABB for rotated/scaled widgets), outset 1px, converted to canvas-relative CSS px against the flutter-view host 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 the PostHogWidget requirement). Declaring maskRegionsFn: () => null extends 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.
  • Accessibility side-channel closed: with a11y active, Flutter mirrors widget text into flt-semantics DOM nodes as plain text, which rrweb records; the provider forwards blockSelector: 'flt-semantics-host' (merged with any user selector). Reproduced and verified gone.
  • Lifecycle: posthog-js reads blockSelector only at rrweb record() 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. A window.posthog that exists but hasn't finished init (posthog-js's __loaded flag false — e.g. consent-gated init) counts as not-ready and keeps being retried; only an initialized config without the maskRegionsFn key is treated as not opted in. Covers the snippet stub being replaced by the real instance, posthog-js loading after Flutter entirely, and posthog.init running arbitrarily late. (__loaded is undocumented but de-facto-stable posthog-js API: set when _init completes, 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 PostHogMaskWidget start working on web with mobile-identical semantics, once the provider is declared.

💚 How did you test it?

  • flutter analyze clean; 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 (no set_config, no restart), the canvas-relative coordinate conversion pinned to exact pixels, fail-closed null for 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 (__loaded false) 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).
  • End-to-end against the posthog-js branch from feat(replay): canvas mask regions for session replay canvas capture posthog-js#4270: wire-level canvas frames decoded at before_send and 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 (only PostHogMaskWidget subtrees masked). Rotated and scaled text verified to mask via their transform-aware AABBs. Recordings played back in the production PostHog player.
  • Perf: cold walk ~20µs per masked text node (200 widgets ≈ 4.4ms, 1000 ≈ 20.5ms, chrome/debug); per-frame cache makes steady-state ≈0.01ms. O(N) walk optimization (shared with mobile) is a measured follow-up.

Known limitations (documented in the changeset; docs PR to follow):

  • CustomPainter-drawn text isn't in the widget tree → not masked (same as mobile; PostHogMaskWidget is the escape hatch).
  • PostHogMaskWidget needs the maskRegionsFn declaration 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 first PostHogMaskWidget mount enable masking itself.
  • A client-side blockSelector takes precedence over the project-level one. The plugin adds flt-semantics-host via set_config, and posthog-js resolves client ?? server, so a selector configured only in project settings is superseded. The plugin cannot merge with it — the server value is not exposed on posthog.config. Called out in the changeset.
  • Platform views are DOM, not canvas pixels. HtmlElementView-based widgets (maps, webviews) are recorded by posthog-js's DOM rules; PostHogMaskWidget around one does not mask it on web.
  • Requires PostHogWidget wrapping the app — without it, masking fails closed (frames skipped) by design.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (Changeset added; posthog.com docs PR drafted, to land with the releases.)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Changeset added (.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 requireMaskProvider boolean in favour of the provider declaration itself carrying the opt-in, and this PR dropped an earlier config.sessionReplay gate for the same reason — it would have given a flag documented as iOS/Android-only a silent web meaning, and its false default 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_config against the stub would wipe user session_recording config, 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() dropped PostHogMaskWidget wrappers with more than one masked child, affecting shipped iOS/Android as well as web.

🤖 Generated with Claude Code

@turnipdabeets turnipdabeets self-assigned this Jul 24, 2026
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-07-28 19:22:05 UTC
Duration: 96879ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 142ms
Format Validation.Event Has Uuid 121ms
Format Validation.Event Has Lib Properties 119ms
Format Validation.Distinct Id Is String 116ms
Format Validation.Token Is Present 116ms
Format Validation.Custom Properties Preserved 118ms
Format Validation.Event Has Timestamp 118ms
Retry Behavior.Retries On 503 5333ms
Retry Behavior.Does Not Retry On 400 2118ms
Retry Behavior.Does Not Retry On 401 2118ms
Retry Behavior.Respects Retry After Header 8123ms
Retry Behavior.Implements Backoff 15436ms
Retry Behavior.Retries On 500 5227ms
Retry Behavior.Retries On 502 5227ms
Retry Behavior.Retries On 504 5229ms
Retry Behavior.Max Retries Respected 15449ms
Deduplication.Generates Unique Uuids 127ms
Deduplication.Preserves Uuid On Retry 5225ms
Deduplication.Preserves Uuid And Timestamp On Retry 10335ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5231ms
Deduplication.No Duplicate Events In Batch 125ms
Deduplication.Different Events Have Different Uuids 117ms
Compression.Sends Gzip When Enabled 117ms
Batch Format.Uses Proper Batch Structure 115ms
Batch Format.Flush With No Events Sends Nothing 111ms
Batch Format.Multiple Events Batched Together 124ms
Error Handling.Does Not Retry On 403 2117ms
Error Handling.Does Not Retry On 413 2116ms
Error Handling.Retries On 408 5226ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 14ms
Request Payload.Flags Request Uses V2 Query Param 9ms
Request Payload.Flags Request Hits Flags Path Not Decide 10ms
Request Payload.Flags Request Omits Authorization Header 13ms
Request Payload.Token In Flags Body Matches Init 10ms
Request Payload.Groups Round Trip 14ms
Request Payload.Groups Default To Empty Object 10ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 11ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 10ms
Request Payload.Disable Geoip Omitted Defaults To False 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 10ms
Request Lifecycle.No Flags Request On Init Alone 5ms
Request Lifecycle.No Flags Request On Normal Capture 113ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 17ms
Request Lifecycle.Mock Response Value Is Returned To Caller 10ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 117ms

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
turnipdabeets force-pushed the feat/web-canvas-masking branch from 2010638 to 01e3715 Compare July 28, 2026 13:43
turnipdabeets and others added 4 commits July 28, 2026 17:10
….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
turnipdabeets marked this pull request as ready for review July 28, 2026 17:05
@turnipdabeets
turnipdabeets requested a review from a team as a code owner July 28, 2026 17:05
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
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.
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.

Flutter web canvas recordings don't support masking of painted Text widgets

1 participant