Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fc3a116
feat(replay): PostHogMaskWidget enables web canvas masking on its own
turnipdabeets Jul 27, 2026
bbcf5ad
Merge branch 'wip/web-canvas-masking-fixes' into wip/mask-widget-auto…
turnipdabeets Jul 28, 2026
4e35667
fix(replay): address review β€” survive a failed mount-triggered apply,…
turnipdabeets Jul 28, 2026
b37d4de
Merge feat/web-canvas-masking: rename config path to session_recordin…
turnipdabeets Jul 28, 2026
84f0155
Merge feat/web-canvas-masking: correct the rr_dataURL full-snapshot note
turnipdabeets Jul 28, 2026
93402e5
docs(changeset): note pre-mount full snapshots can embed unmasked can…
turnipdabeets Jul 28, 2026
529ca9a
Merge feat/web-canvas-masking: warn once when posthog-js is too old
turnipdabeets Jul 28, 2026
591d5d0
Merge remote-tracking branch 'origin/feat/web-canvas-masking' into HEAD
turnipdabeets Jul 28, 2026
e6850ea
Merge remote-tracking branch 'origin/feat/web-canvas-masking' into HEAD
turnipdabeets Jul 28, 2026
04c4bcd
fix(replay): mark applied only after the recording restart succeeds
turnipdabeets Jul 28, 2026
e1900cd
fix(replay): only opt in from a PostHogMaskWidget inside the tracked …
turnipdabeets Jul 29, 2026
02740e4
Merge feat/web-canvas-masking: container transform, blockSelector tok…
turnipdabeets Jul 29, 2026
d1a01b4
fix(replay): enforce a single retry chain; cover tracked-tree mount g…
turnipdabeets Jul 29, 2026
134bbf0
Merge remote-tracking branch 'origin/feat/web-canvas-masking' into HEAD
turnipdabeets Jul 29, 2026
8be5469
docs(replay): scope the outside-tree opt-in claim to mount time; pin …
turnipdabeets Jul 29, 2026
6bbaa6c
Merge feat/web-canvas-masking: multi-view changeset caveat
turnipdabeets Jul 29, 2026
ee84798
Merge feat/web-canvas-masking: pin posthog-js minimum to 1.408.0
turnipdabeets Jul 29, 2026
83ae5ae
Merge feat/web-canvas-masking: changelog-style changeset rewrite
turnipdabeets Jul 30, 2026
7815171
docs(changeset): rewrite the mask-widget entry per changelog style
turnipdabeets Jul 30, 2026
9cbf50f
docs(changeset): fold the mask-widget entry into the feature changeset
turnipdabeets Jul 30, 2026
7eec532
fix(replay): revalidate mounted mask widgets on every frame, fail clo…
turnipdabeets Jul 30, 2026
d0cd4d1
Merge feat/web-canvas-masking: multi-view fail-closed + changelog-sty…
turnipdabeets Jul 30, 2026
9672c69
test(replay): pin the provider's own view in regions tests
turnipdabeets Jul 30, 2026
e89ff8e
fix(replay): resolve the tracked-tree root the way the masking walk d…
turnipdabeets Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/canvas-masking-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"posthog_flutter": minor
---

Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas (requires posthog-js 1.408.0+; enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`)
Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas β€” enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or just by mounting a `PostHogMaskWidget` (requires posthog-js 1.408.0+)
2 changes: 1 addition & 1 deletion example/lib/masking_tests_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ class _MaskingTestsScreenState extends State<MaskingTestsScreen> {
// The amber box matches no masking rule on its own, so it is
// covered only if the wrapper contributes its own rect.
_buildTestSection(
'Test 15: PostHogMaskWidget with multiple children (needs maskAllTexts or maskAllImages)',
'Test 15: PostHogMaskWidget with multiple children',
PostHogMaskWidget(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:flutter/widgets.dart';

/// Canvas masking is a Flutter web concern; on every other platform
/// `PostHogMaskWidget` is honored by the native screenshot pipeline instead.
void notifyMaskWidgetMounted(BuildContext context) {}

void notifyMaskWidgetUnmounted(BuildContext context) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

import '../../util/logging.dart';
import '../web/web_canvas_mask_provider.dart';
import 'posthog_mask_controller.dart';

/// A mounted `PostHogMaskWidget` is an explicit request for masking, so it
/// opts the app into canvas masking even when `posthog.init` never declared
/// `maskRegionsFn`.
///
/// Deferred to the end of the frame because `initState` runs during Flutter's
/// build phase: registering calls straight into posthog-js and restarts an
/// in-flight recording.
void notifyMaskWidgetMounted(BuildContext context) {
WebCanvasMaskProvider.registerMaskWidgetContext(context);
SchedulerBinding.instance.addPostFrameCallback((_) {
try {
if (!_isInTrackedTree(context)) {
printIfDebug(
'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree '
'PostHog tracks, so masking could never cover it β€” it does not '
'enable web canvas masking.',
);
return;
}
WebCanvasMaskProvider.notifyMaskWidgetMounted();
} catch (e) {
printIfDebug('PostHog: error enabling web canvas masking: $e');
}
});
}
Comment thread
turnipdabeets marked this conversation as resolved.

void notifyMaskWidgetUnmounted(BuildContext context) {
WebCanvasMaskProvider.unregisterMaskWidgetContext(context);
}

/// The masking walk only sees the tracked tree (whose route-dependent root is
/// [WebCanvasMaskProvider.trackedTreeRoot], the boundary this check walks
/// against), so a mask widget outside it would opt masking in while its own
/// rects are never produced β€” the walk would succeed and ship rects that do
/// not cover the widget. With
/// no tracked tree at all the opt-in stays allowed: every walk then fails and
/// frames are skipped (fail closed), which is the documented behavior for an
/// app missing PostHogWidget.
///
/// The check runs once, in the mount's post-frame callback: a null tracked
/// context at that moment is treated as the no-PostHogWidget shape and
/// allowed. That one-shot allowance is backstopped by
/// [WebCanvasMaskProvider], which revalidates every mounted mask widget when
/// regions are computed β€” if a PostHogWidget later mounts without containing
/// this widget, frames are skipped (fail closed) rather than recorded
/// unmasked.
bool _isInTrackedTree(BuildContext context) {
final trackedContext =
PostHogMaskController.instance.containerKey.currentContext;
if (trackedContext == null) {
Comment thread
turnipdabeets marked this conversation as resolved.
return true;
}
final tracked = WebCanvasMaskProvider.trackedTreeRoot(trackedContext);
if (tracked == null) {
// cannot prove the mask widget is outside the tracked tree
return true;
}
if (!context.mounted) {
return false;
}
final renderObject = context.findRenderObject();
if (renderObject == null) {
return false;
}
RenderObject? node = renderObject;
while (node != null) {
if (identical(node, tracked)) {
return true;
}
node = node.parent;
}
return false;
}
21 changes: 18 additions & 3 deletions posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import 'package:flutter/material.dart';

import 'canvas_mask_registration_io.dart'
if (dart.library.js_interop) 'canvas_mask_registration_web.dart';

/// Masks a widget subtree in PostHog session replay snapshots.
///
/// Wrap sensitive UI with [PostHogMaskWidget] to hide that area in captured
/// screenshots, regardless of the global session replay masking settings.
///
/// **Flutter web:** this has no effect unless canvas masking is enabled, since
/// the canvas is masked by posthog-js rather than by this plugin. Declare
/// **Flutter web:** the canvas is masked by posthog-js rather than by this
/// plugin, so the first [PostHogMaskWidget] to mount turns canvas masking on β€”
/// which restarts an in-flight recording once, because masking also excludes
/// the Flutter semantics DOM tree via `blockSelector`, and posthog-js only
/// reads that when recording starts. Frames captured before that first
/// mount are recorded unmasked; to cover the window between `posthog.init` and
/// Flutter booting, declare
/// `session_recording: { canvasCapture: { maskRegionsFn: () => null } }`
/// in your `posthog.init` call to turn it on. iOS and Android need no setup.
/// in your `posthog.init` call β€” until this plugin takes over, those frames are
/// skipped instead of recorded. Your app must be wrapped in `PostHogWidget`,
/// and every [PostHogMaskWidget] must sit inside it β€” otherwise canvas frames
/// are skipped instead of recorded unmasked, until the mask widget is moved
/// inside `PostHogWidget` or removed. iOS and Android need no setup either
/// way.
class PostHogMaskWidget extends StatefulWidget {
/// The widget subtree to mask in session replay snapshots.
final Widget child;
Expand All @@ -27,10 +40,12 @@ class PostHogMaskWidgetState extends State<PostHogMaskWidget> {
@override
void initState() {
super.initState();
notifyMaskWidgetMounted(context);
}

@override
void dispose() {
notifyMaskWidgetUnmounted(context);
super.dispose();
}

Expand Down
Loading
Loading