Skip to content

feat(replay): PostHogMaskWidget enables web canvas masking on its own - #501

Merged
turnipdabeets merged 24 commits into
feat/web-canvas-maskingfrom
feat/web-canvas-mask-widget-autoregister
Jul 30, 2026
Merged

feat(replay): PostHogMaskWidget enables web canvas masking on its own#501
turnipdabeets merged 24 commits into
feat/web-canvas-maskingfrom
feat/web-canvas-mask-widget-autoregister

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #499 (feat/web-canvas-masking) — this PR targets that branch, not main. Review it after #499, or squash the two together before merging; they are one feature.

💡 Motivation and Context

#499 gives Flutter web canvas masking, but it only registers the mask-region provider with posthog-js when the app already declared session_recording.canvasCapture.maskRegionsFn in its posthog.init call.

That leaves a footgun: a developer wraps sensitive UI in PostHogMaskWidget, does not also edit web/index.html, and gets zero masking on web, silently. PostHogMaskWidget is the most explicit possible statement of "never record this", and it needs no setup at all on iOS and Android — so web quietly diverging is the worst possible failure mode.

The gate itself is not wrong. Registering does two things beyond attaching a function: it adds blockSelector: 'flt-semantics-host' (so Flutter's accessibility DOM is not recorded in the clear), and it restarts an in-flight recording, because posthog-js only reads those options when rrweb's record() starts. Imposing that on every Flutter web app — including ones that never asked for masking — is not acceptable.

So this PR adds a second way to opt in rather than removing the gate: a mounted PostHogMaskWidget is itself the opt-in.

🔨 What changed

  • PostHogMaskWidgetState.initState calls notifyMaskWidgetMounted(), resolved through a conditional import (canvas_mask_registration_io.dart / ..._web.dart). Off web it is an empty function body, so no web code reaches mobile builds and nothing changes on iOS/Android/macOS.
  • On web the hook defers to the end of the frame and calls WebCanvasMaskProvider.notifyMaskWidgetMounted(). Deferring matters: registration restarts the recording, and rrweb can take a canvas snapshot from inside that call — which would walk a widget tree that is still being built.
  • WebCanvasMaskProvider keeps the same "did the app declare it?" gate, but now also applies when a mask widget has mounted. The retry/apply path became a small state machine (applied / notOptedIn / posthogNotReady) so that "posthog-js is up but the app did not opt in" parks instead of terminating — a PostHogMaskWidget mounting later re-enters it. Registration is idempotent: at most one set_config and at most one recording restart, however many mask widgets mount, and whether posthog-js loads before or after Flutter.

Apps with no PostHogMaskWidget and no declaration are still completely untouched: no set_config, no blockSelector, no restart.

Also drops a stale caveat from the example app: test 15's section 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 (post-#500). A browser check against the built app on feat/web-canvas-masking with maskAllTexts: false, maskAllImages: false confirmed it: the provider returned a full-width rect covering the whole Row, amber box included.

⚠️ Cost and residual caveats

These are documented in the CHANGELOG and in the PostHogMaskWidget dartdoc:

  • One mount enables the whole masking configuration, not just the wrapped subtree: maskAllTexts/maskAllImages default to true, so a single PostHogMaskWidget turns on full text and image canvas masking — the same semantics as mounting one on iOS/Android.
  • The recording restarts once, mid-session, the first time a mask widget mounts. The replay will show a split at that point. Unavoidable without a posthog-js change: blockSelector is a start-time option.
  • Frames captured before the first mount are recorded unmasked. Declaring maskRegionsFn: () => null in posthog.init is still the only thing that covers the window between page load and Flutter booting (those frames are skipped instead), and it also avoids the restart. The HTML setup is now an optimization, not a requirement.
  • PostHogWidget is still required. An app that mounts a PostHogMaskWidget but is not wrapped in PostHogWidget now fails closed — canvas frames are skipped rather than recorded unmasked, with a console warning explaining the fix. That is the correct behavior for something that explicitly asked for masking, but it is a behavior change for that (misconfigured) shape of app. The outside-tree check runs once, at the mask widget's first mount: a mask widget that mounts before any PostHogWidget exists is treated as this no-PostHogWidget shape and does opt in (documented in the changeset, pinned by a test).

By design: registration does not wait on recordCanvas

Registration opts in without first checking that canvas capture is actually enabled. That is deliberate, for two reasons:

  • Gating on it would fail open. recordCanvas can arrive from remote config after posthog.init, so a ph.config check at registration time can read "canvas off" for a session that goes on to record the canvas — masking would silently never attach.
  • Registration earns its keep even with canvas capture off. Applying the config also installs blockSelector: 'flt-semantics-host', and that exclusion is independent of the canvas: with accessibility active, Flutter mirrors widget text into flt-semantics DOM nodes, which rrweb records as plaintext during ordinary DOM recording. So a canvas-off app that mounts a PostHogMaskWidget still gains a real thing — the a11y text side-channel is closed.

The restart is gated on ph.sessionRecordingStarted() regardless, so an app that is not recording pays nothing at all.

💚 How did you test it?

Three new tests in test/web_canvas_mask_provider_test.dart (already listed in the "Test (web)" CI job, so no workflow change needed):

  • a mounted PostHogMaskWidget opts the app in when posthog.init declared nothing — provider attached, blockSelector set, recording restarted once;
  • exactly one set_config and one restart across two pumps with three and then five mask widgets;
  • a mask widget that mounts while posthog-js is still absent opts in as soon as posthog-js appears.

A follow-up commit (review fixes, merged with the updated #499 base) adds two more: cross-path idempotency (app opted in via the init declaration, then a mask widget mounts — still exactly one set_config and one restart), and a set_config throw during the mount-triggered apply being retried and applied on a later tick instead of permanently consuming the opt-in.

The pre-existing leaves posthog-js untouched when the app declares no mask provider test is unchanged and still passing, which is the assertion that apps opting into nothing stay untouched.

Ran locally, all green:

  • flutter test — 247 passing
  • flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/web_canvas_mask_provider_test.dart — 46 passing
  • flutter analyze — no issues
  • dart format --output=none --set-exit-if-changed ./ — exit 0

The registration change itself was not manually verified in a browser against a real posthog-js build; the posthog-js side of the contract is exercised by the stub, as in #499. The test 15 measurement quoted above was taken by hand on the base branch, not by the agent.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Release note shipped as a one-line changeset (.changeset/mask-widget-web-canvas.md) per the changelog style; the behavioral detail (whole-config opt-in, one-time restart, pre-mount window, PostHogWidget requirement, mount-time outside-tree check) lives in this PR body and the PostHogMaskWidget dartdoc.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written with Claude Code (Opus 5). The design question was how to let shared widget code trigger web-only registration without leaking dart:js_interop into mobile builds; the repo's existing _io.dart / _web.dart conditional-import convention (see origin.dart, chunk_ids.dart) handles it, so the mount hook is a one-line no-op function on every non-web platform.

Two alternatives were rejected: removing the gate outright (would restart recording and drop the semantics tree for every Flutter web app, including ones that never asked for masking), and gating registration on recordCanvas being visibly enabled in ph.config (fails open when canvas capture arrives via remote config, and would forfeit the semantics exclusion for canvas-off apps — see "By design" above).

Deferring the hook to a post-frame callback was a deliberate change after the first working version called it synchronously from initState — registering synchronously means posthog-js may re-enter the mask-region provider mid-build, which walks the render tree.

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

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-07-30 14:42:47 UTC
Duration: 96756ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 136ms
Format Validation.Event Has Uuid 118ms
Format Validation.Event Has Lib Properties 115ms
Format Validation.Distinct Id Is String 114ms
Format Validation.Token Is Present 113ms
Format Validation.Custom Properties Preserved 116ms
Format Validation.Event Has Timestamp 115ms
Retry Behavior.Retries On 503 5329ms
Retry Behavior.Does Not Retry On 400 2117ms
Retry Behavior.Does Not Retry On 401 2117ms
Retry Behavior.Respects Retry After Header 8124ms
Retry Behavior.Implements Backoff 15446ms
Retry Behavior.Retries On 500 5225ms
Retry Behavior.Retries On 502 5226ms
Retry Behavior.Retries On 504 5220ms
Retry Behavior.Max Retries Respected 15444ms
Deduplication.Generates Unique Uuids 123ms
Deduplication.Preserves Uuid On Retry 5224ms
Deduplication.Preserves Uuid And Timestamp On Retry 10334ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5226ms
Deduplication.No Duplicate Events In Batch 121ms
Deduplication.Different Events Have Different Uuids 115ms
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 122ms
Error Handling.Does Not Retry On 403 2115ms
Error Handling.Does Not Retry On 413 2117ms
Error Handling.Retries On 408 5223ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 12ms
Request Payload.Flags Request Uses V2 Query Param 8ms
Request Payload.Flags Request Hits Flags Path Not Decide 9ms
Request Payload.Flags Request Omits Authorization Header 9ms
Request Payload.Token In Flags Body Matches Init 9ms
Request Payload.Groups Round Trip 9ms
Request Payload.Groups Default To Empty Object 9ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 9ms
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 9ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 111ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller 9ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 114ms

@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-mask-widget-autoregister branch from 5c00fc8 to 93e3d0d Compare July 27, 2026 19:02
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-masking branch from 688652a to 035e3f1 Compare July 27, 2026 20:44
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-mask-widget-autoregister branch from 93e3d0d to 8ba79e4 Compare July 27, 2026 20:51
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-masking branch from 035e3f1 to 2010638 Compare July 27, 2026 21:01
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-mask-widget-autoregister branch from 8ba79e4 to 393cdd5 Compare July 27, 2026 21:02
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-masking branch from 2010638 to 01e3715 Compare July 28, 2026 13:43
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
@turnipdabeets
turnipdabeets force-pushed the feat/web-canvas-mask-widget-autoregister branch from 393cdd5 to fc3a116 Compare July 28, 2026 13:44
turnipdabeets and others added 7 commits July 28, 2026 17:13
…register-fixes

# Conflicts:
#	posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
#	posthog_flutter/test/web_canvas_mask_provider_test.dart
… 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>
…g.canvasCapture.maskRegionsFn

Also renames the old path in this branch's own additions (mount opt-in
warnings, dartdoc, changeset, tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vas stills

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the provider conflict by keeping the mount state machine alongside
the version gate, and adds a test that the warning also fires on the
mount-triggered apply path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets marked this pull request as ready for review July 28, 2026 18:51
@turnipdabeets
turnipdabeets requested a review from a team as a code owner July 28, 2026 18:51
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart Outdated
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Security Review

A failed recording stop can leave the provider marked as applied even though the semantics exclusion never took effect, allowing accessibility DOM text to remain visible to session recording.

Prompt To Fix All With AI
### Issue 1
posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:246
**Applied state precedes restart**

When `stopSessionRecording()` throws after `set_config` succeeds, `_applied` remains true and later attempts skip the restart required for `blockSelector`, causing Flutter accessibility text to continue being recorded in plaintext. **How this was verified:** `_applied` is assigned before the stop/start calls, while the adjacent code documents that the selector only takes effect when recording starts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/fea..." | Re-trigger Greptile

@posthog

posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 1 consider.

Published 2 findings (view the review).

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.
@posthog

posthog Bot commented Jul 28, 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

Changes

Issues: 1 issue

Files (7)
  • .changeset/canvas-masking-web.md
  • .changeset/mask-widget-web-canvas.md
  • example/lib/masking_tests_screen.dart
  • posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart
  • posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart
  • posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart
  • posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart

Other findings (outside the changed lines)

Valid issues on this PR's files that sit on lines GitHub won't let us comment on inline.

Retry loop never backs off when _apply() keeps throwing, unlike the posthog-not-ready path

Priority: consider | File: posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:161-178 | Category: performance

Why we think it's a valid issue
  • Checked: Traced _scheduleRetry (L164-181) and every _apply() throw source (set_config L248, stopSessionRecording/startSessionRecording L255-256), plus the paths that enter the chain with an exception in flight (_onMaskWidgetMounted catch L140-141, register catch L120-121).
  • Found: The code claim is accurate: next is seeded to delay (L166) and only ramped inside the try on a posthogNotReady result (L172-175); a thrown _apply() skips to catch (L176-178) leaving next == delay, so _scheduleRetry(next) (L179) reschedules at the same interval. The posthogNotReady path deliberately backs off to a 4s ceiling (comment L161-163: 'polls forever once backed off to 4s'); the exception path does not.
  • Found (reachability): A true infinite fixed-250ms loop requires set_config or stopSessionRecording to throw on every tick — if only startSessionRecording throws, the next tick sees sessionRecordingStarted()==false (L254), skips the restart, sets _applied=true (L260), and the chain terminates in ~2 ticks.
  • Impact: Confirmed but bounded: under a persistent set_config/stopSessionRecording throw the chain re-runs the apply at 4Hz for the page's life, producing printIfDebug spam (L177) and wasted interop calls; recording is left running in both loop cases (no crash/hang/data-loss), and it only bites in an already-degenerate posthog-js state.
  • Priority: Real, verified inconsistency with a one-line fix, but a rare/degenerate trigger and bounded (log-spam / wasted-cycles) impact — real-but-minor, so down-ranked from should_fix to consider rather than surfaced at higher urgency.
Issue description

This chunk adds a new, expected way to enter the shared _scheduleRetry chain with a genuine exception in flight: _onMaskWidgetMounted()'s catch block explicitly schedules a retry (_polling = true; _scheduleRetry(const Duration(milliseconds: 250));) when _apply() throws mid-way (e.g. set_config or stopSessionRecording/startSessionRecording failing). But _scheduleRetry's Timer callback only advances the backoff delay on the success/not-ready branch: next is initialized to the current delay and is only reassigned to doubled/capped-at-4s inside the try block, right before the early return. When _apply() throws again on that retry tick, execution jumps straight to catch (e) { printIfDebug(...); }, next is never touched, and _scheduleRetry(next) reschedules at the exact same delay it started with. Contrast this with the posthogNotReady path, which the adjacent comment explicitly says 'polls forever once backed off to 4s' to avoid hammering anything while waiting for a slow app boot — that reasoning was never extended to the exception path this PR newly makes reachable. If an app hits a persistent failure after a PostHogMaskWidget mounts (e.g. stopSessionRecording()/startSessionRecording() throwing every time due to a broken posthog-js state, a hostile monkey-patch, or a CSP/extension interference that's not transient), the SDK will retry the full _apply() — including set_config and a stopSessionRecording/startSessionRecording pair — every 250ms for the rest of the page session, since nothing ever raises the delay past its starting value. None of the existing tests ('an exception during one retry tick does not kill the chain' and 'retries the full apply when the restart throws on first register') exercise a repeated/persistent throw — both stub the failure to occur exactly once and succeed on the second attempt, so this fixed-interval-forever behavior has no test coverage and was not caught by any prior pass.

Suggested fix

Compute the backed-off next delay unconditionally (before or independent of the try/catch), so a repeated exception still ramps the interval up toward the same 4s ceiling used for the not-ready case, e.g. move the doubled/next calculation above the try, or wrap only the _apply() call itself in the try and always fall through to the backoff computation afterward.

Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
Comment thread posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart Outdated
Comment thread posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
@marandaneto
marandaneto requested a review from a team July 29, 2026 11:42
@turnipdabeets

turnipdabeets commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

valid — the "retry loop never backs off when _apply() keeps throwing" finding was correct (confirmed the exception path reused the incoming delay while the posthog-not-ready path ramped to 4s, and the same structure existed on the base branch #499). Fixed in 017c8a1: the backoff now computes up front so both paths follow the same 250ms→4s curve, with a regression test discriminating the doubling chain from fixed-rate retries by attempt count. Nice catch on the reachability analysis too — it correctly bounded the impact to log spam rather than data loss.

Comment thread .changeset/mask-widget-web-canvas.md Outdated

@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/mask-widget-web-canvas.md Outdated
One feature, one entry — the stacked PR merges into its base before release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
turnipdabeets and others added 3 commits July 30, 2026 17:13
…sed 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>
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.

@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

Changes

Issues: 2 issues

Files (7)
  • .changeset/canvas-masking-web.md
  • .changeset/mask-widget-web-canvas.md
  • example/lib/masking_tests_screen.dart
  • posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart
  • posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart
  • posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart
  • posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart

Comment thread posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart Outdated
…oes; 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>
@turnipdabeets
turnipdabeets merged commit 6258c55 into feat/web-canvas-masking Jul 30, 2026
22 checks passed
@turnipdabeets
turnipdabeets deleted the feat/web-canvas-mask-widget-autoregister branch July 30, 2026 14:52
turnipdabeets added a commit that referenced this pull request Jul 30, 2026
)

* feat(replay): mask canvas session replay recordings on Flutter web

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>

* fix(replay): keep retrying web canvas mask registration until posthog.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>

* refactor(replay): follow posthog-js rename to session_recording.canvasCapture.maskRegionsFn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changeset): correct the rr_dataURL full-snapshot note — CanvasKit canvases are readable there

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(replay): warn once when posthog-js is too old to mask canvas frames

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>

* fix(replay): cancel the predecessor provider's retry chain on register

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.

* fix(replay): retry registration when the first apply throws mid-way

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.

* fix(replay): honor maskAllTexts=false for Text widgets in the shared masking walk

* fix(replay): address review — container transform, exact blockSelector token, foreign-view fail-closed, parser refresh, restart retry

* docs(changeset): trim the canvas-masking changeset to the essentials

* fix(replay): back off retries when the apply keeps throwing

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.

* docs(changeset): note multi-view foreign Flutter canvases are skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(replay): pin the posthog-js minimum to 1.408.0 (ships maskRegionsFn)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changeset): rewrite entries per changelog style — one-line, user-facing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(replay): fail closed when multiple Flutter views share one host element

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>

* feat(replay): PostHogMaskWidget enables web canvas masking on its own (#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>

* fix(replay): harden canvas masking edge paths from review

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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants