Skip to content

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

Merged
turnipdabeets merged 18 commits into
mainfrom
feat/web-canvas-masking
Jul 30, 2026
Merged

feat(replay): mask canvas session replay recordings on Flutter web#499
turnipdabeets merged 18 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 — shipped in posthog-js 1.408.0, and _minPosthogJsVersion is pinned to 1.408.0. 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).

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,
    },
  },
})

Since #501 was merged into this branch, mounting a PostHogMaskWidget is a second, self-contained opt-in — the most explicit possible statement of "never record this" should not silently no-op just because web/index.html wasn't edited. The first mount registers the provider itself (idempotent: one set_config, one restart, however many mask widgets mount, whichever side loads first). Prefer the posthog.init declaration 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/maskAllImages default true), matching iOS/Android semantics.

With neither the key nor a mask widget, 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 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-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.)

  • 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 tracked PostHogWidget tree cannot fail open: every maskRegionsFn call 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 when PostHogWidget sits under an active route), so a mask widget in a root-navigator dialog is covered, not rejected. Registration deliberately doesn't wait on recordCanvas — remote config can enable it after init, and the blockSelector accessibility exclusion is worth applying even with canvas capture off.

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 or a PostHogMaskWidget mounts.

💚 How did you test it?

  • flutter analyze clean; 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 (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). 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 one set_config/restart across both opt-in paths and repeated mounts, outside-tree mask widgets forcing frames to null per-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.
  • 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 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; PostHogMaskWidget is the escape hatch).
  • Mount-path caveats (when relying on PostHogMaskWidget alone, without the posthog.init declaration): frames captured before the first mount are recorded unmasked, and the recording restarts once when masking switches on (the replay shows a split there) — blockSelector is a start-time option in posthog-js. Documented on the widget's dartdoc.
  • 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. Will be called out in the docs PR.
  • 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

  • Changesets added (.changeset/canvas-masking-web.md — the feature; .changeset/mask-text-flag-fix.md — the cross-platform maskAllTexts: false fix), 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 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-30 15:14:39 UTC
Duration: 96868ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 140ms
Format Validation.Event Has Uuid 118ms
Format Validation.Event Has Lib Properties 117ms
Format Validation.Distinct Id Is String 114ms
Format Validation.Token Is Present 116ms
Format Validation.Custom Properties Preserved 117ms
Format Validation.Event Has Timestamp 117ms
Retry Behavior.Retries On 503 5330ms
Retry Behavior.Does Not Retry On 400 2118ms
Retry Behavior.Does Not Retry On 401 2119ms
Retry Behavior.Respects Retry After Header 8125ms
Retry Behavior.Implements Backoff 15453ms
Retry Behavior.Retries On 500 5228ms
Retry Behavior.Retries On 502 5227ms
Retry Behavior.Retries On 504 5226ms
Retry Behavior.Max Retries Respected 15439ms
Deduplication.Generates Unique Uuids 126ms
Deduplication.Preserves Uuid On Retry 5225ms
Deduplication.Preserves Uuid And Timestamp On Retry 10336ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5230ms
Deduplication.No Duplicate Events In Batch 124ms
Deduplication.Different Events Have Different Uuids 119ms
Compression.Sends Gzip When Enabled 116ms
Batch Format.Uses Proper Batch Structure 115ms
Batch Format.Flush With No Events Sends Nothing 108ms
Batch Format.Multiple Events Batched Together 127ms
Error Handling.Does Not Retry On 403 2118ms
Error Handling.Does Not Retry On 413 2118ms
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 13ms
Request Payload.Flags Request Uses V2 Query Param 10ms
Request Payload.Flags Request Hits Flags Path Not Decide 10ms
Request Payload.Flags Request Omits Authorization Header 10ms
Request Payload.Token In Flags Body Matches Init 10ms
Request Payload.Groups Round Trip 11ms
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 10ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 11ms
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 18ms
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 Outdated
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.
Comment thread .changeset/canvas-masking-web.md Outdated
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart Outdated
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart Outdated
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
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
@marandaneto
marandaneto requested a review from a team July 29, 2026 08:45
turnipdabeets and others added 5 commits July 29, 2026 18:19
…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>
Comment thread .changeset/canvas-masking-web.md Outdated
@posthog

posthog Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 2 should fix, 7 consider.

Published 9 findings (view the review).

…-facing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a last comment, approving to unblock

Comment thread .changeset/canvas-masking-web.md
…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>
…#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>
@posthog

posthog Bot commented Jul 30, 2026

Copy link
Copy Markdown

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Bugfix

Issues: 3 issues

Files (5)
  • posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart
  • posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart
  • posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart
  • posthog_flutter/lib/src/posthog_flutter_web_handler.dart
  • posthog_flutter/lib/posthog_flutter_web.dart
What were the main changes
  • Fixes a shared masking bug: element_object_parser.dart unconditionally related Text widgets, causing maskAllTexts: false + maskAllImages: true to still mask all text on iOS/Android/web
  • Adds PostHogMaskController.refreshParsers() so a later setup() 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/config JS interop members on the PostHog binding and wires WebCanvasMaskProvider(config).register() into posthog_flutter_web.dart's setup path
  • Updates PostHogMaskWidget dartdoc to note it requires the web maskRegionsFn opt-in to have any effect on Flutter web

Feature

Issues: 6 issues

Files (2)
  • posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
  • posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart
What were the main changes
  • New WebCanvasMaskProvider registers session_recording.canvasCapture.maskRegionsFn with posthog-js only when the app has declared the key in posthog.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 init via an __loaded check; 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-host to blockSelector (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) since blockSelector is 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.dart converts 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

Comment thread posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart Outdated
Comment thread posthog_flutter/lib/src/replay/mask/posthog_mask_controller.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
Comment thread posthog_flutter/lib/src/replay/mask/posthog_mask_controller.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
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
turnipdabeets and others added 2 commits July 30, 2026 18:08
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>
@turnipdabeets
turnipdabeets enabled auto-merge (squash) July 30, 2026 15:13
@turnipdabeets
turnipdabeets merged commit 0d8b279 into main Jul 30, 2026
27 checks passed
@turnipdabeets
turnipdabeets deleted the feat/web-canvas-masking branch July 30, 2026 15:16
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

4 participants