Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
01e3715
feat(replay): mask canvas session replay recordings on Flutter web
turnipdabeets Jul 21, 2026
6f44d83
fix(replay): keep retrying web canvas mask registration until posthog…
turnipdabeets Jul 28, 2026
62e77eb
refactor(replay): follow posthog-js rename to session_recording.canva…
turnipdabeets Jul 28, 2026
c2a3904
docs(changeset): correct the rr_dataURL full-snapshot note β€” CanvasKi…
turnipdabeets Jul 28, 2026
db7004d
fix(replay): warn once when posthog-js is too old to mask canvas frames
turnipdabeets Jul 28, 2026
207f4c1
fix(replay): cancel the predecessor provider's retry chain on register
turnipdabeets Jul 28, 2026
bdcbbe6
fix(replay): retry registration when the first apply throws mid-way
turnipdabeets Jul 28, 2026
e7d982c
fix(replay): honor maskAllTexts=false for Text widgets in the shared …
turnipdabeets Jul 29, 2026
7b8480d
fix(replay): address review β€” container transform, exact blockSelecto…
turnipdabeets Jul 29, 2026
551ff8c
docs(changeset): trim the canvas-masking changeset to the essentials
turnipdabeets Jul 29, 2026
017c8a1
fix(replay): back off retries when the apply keeps throwing
turnipdabeets Jul 29, 2026
75b59e4
docs(changeset): note multi-view foreign Flutter canvases are skipped
turnipdabeets Jul 29, 2026
06c43d2
chore(replay): pin the posthog-js minimum to 1.408.0 (ships maskRegio…
turnipdabeets Jul 29, 2026
6e41deb
docs(changeset): rewrite entries per changelog style β€” one-line, user…
turnipdabeets Jul 30, 2026
863317e
fix(replay): fail closed when multiple Flutter views share one host e…
turnipdabeets Jul 30, 2026
6258c55
feat(replay): PostHogMaskWidget enables web canvas masking on its own…
turnipdabeets Jul 30, 2026
46364cc
fix(replay): harden canvas masking edge paths from review
turnipdabeets Jul 30, 2026
68ea063
Merge branch 'main' into feat/web-canvas-masking
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
5 changes: 5 additions & 0 deletions .changeset/canvas-masking-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": minor
Comment thread
turnipdabeets marked this conversation as resolved.
---

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+)
5 changes: 5 additions & 0 deletions .changeset/mask-text-flag-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": patch
---

Fix `maskAllTexts: false` still masking `Text` widgets when `maskAllImages` is enabled
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ jobs:
- name: Test (web)
if: needs.detect-markdown-only.outputs.markdown_only != 'true'
working-directory: ./posthog_flutter
run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart
run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart test/web_canvas_mask_provider_test.dart

# dart2js resolves the isolate-handler conditional import differently;
# only a wasm compile exercises the dart2wasm selection this test guards.
Expand Down
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
3 changes: 3 additions & 0 deletions posthog_flutter/lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'src/logs/posthog_log_severity.dart';
import 'src/posthog_config.dart';
import 'src/posthog_flutter_platform_interface.dart';
import 'src/posthog_flutter_web_handler.dart';
import 'src/replay/web/web_canvas_mask_provider.dart';
import 'src/utils/capture_utils.dart';

/// A web implementation of the PosthogFlutterPlatform of the PosthogFlutter plugin.
Expand Down Expand Up @@ -68,6 +69,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
final ph = posthog;
_config = config;

WebCanvasMaskProvider(config).register();

if (config.onFeatureFlags != null && ph != null) {
final dartCallback = config.onFeatureFlags!;

Expand Down
3 changes: 3 additions & 0 deletions posthog_flutter/lib/src/posthog_flutter_web_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ extension PostHogExtension on PostHog {
external void startSessionRecording();
external void stopSessionRecording();
external bool sessionRecordingStarted();
// ignore: non_constant_identifier_names
external void set_config(JSAny config);
external JSObject? get config;
external SessionManager? get sessionManager;
// ignore: non_constant_identifier_names
external void _overrideSDKInfo(JSAny sdkName, JSAny sdkVersion);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ class ElementObjectParser {
}

if (element.widget is Text) {
final elementData = _elementParser.relate(element);
final config = Posthog().config?.sessionReplayConfig;
final maskAllTexts = config?.maskAllTexts ?? true;

if (elementData != null) {
activeElementData.addChildren(elementData);
return elementData;
if (maskAllTexts) {
final elementData = _elementParser.relate(element);

if (elementData != null) {
activeElementData.addChildren(elementData);
return elementData;
}
}
}

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');
}
});
}

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) {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,36 @@ import 'package:posthog_flutter/src/replay/mask/widget_elements_decipher.dart';
import 'package:posthog_flutter/src/util/logging.dart';

class PostHogMaskController {
late final Map<String, ElementParser> parsers;
Map<String, ElementParser> parsers;

final GlobalKey containerKey = GlobalKey();

final WidgetElementsDecipher _widgetScraper;

PostHogMaskController._privateConstructor(PostHogSessionReplayConfig? config)
: _widgetScraper = WidgetElementsDecipher(
: parsers = _buildParsers(config),
_widgetScraper = WidgetElementsDecipher(
elementDataFactory: ElementDataFactory(),
elementObjectParser: ElementObjectParser(),
rootElementProvider: RootElementProvider(),
) {
parsers = ElementParsersConst(
);

static Map<String, ElementParser> _buildParsers(
PostHogSessionReplayConfig? config,
) {
return ElementParsersConst(
DefaultElementParserFactory(),
config,
).parsersMap;
}

/// Rebuilds the parser map for [config]. The singleton captures the config
/// present at first access, which a later `setup()` with different masking
/// flags would otherwise never update.
void refreshParsers(PostHogSessionReplayConfig? config) {
parsers = _buildParsers(config);
}

Comment thread
turnipdabeets marked this conversation as resolved.
static final PostHogMaskController instance =
PostHogMaskController._privateConstructor(
Posthog().config?.sessionReplayConfig,
Expand Down Expand Up @@ -61,6 +73,39 @@ class PostHogMaskController {
}
}

/// Single-walk variant used by web canvas masking: one [parseRenderTree]
/// producing both the explicit-mask set and (optionally) the full text/image
/// set, instead of two separate walks. Returns null when the tree can't be
/// walked (no [PostHogWidget] mounted, or parsing failed) so callers can
/// fail closed.
List<ElementData>? getMaskElements({required bool includeAllWidgets}) {
final context = containerKey.currentContext;

if (context == null) {
printIfDebug('Error: containerKey.currentContext is null.');
return null;
}

try {
final widgetElementsTree = _widgetScraper.parseRenderTree(context);

if (widgetElementsTree == null) {
printIfDebug('Error: widgetElementsTree is null after parsing.');
return null;
}

return [
...widgetElementsTree.extractMaskWidgetRects(),
if (includeAllWidgets) ...widgetElementsTree.extractRects(),
Comment thread
turnipdabeets marked this conversation as resolved.
];
} catch (e) {
printIfDebug(
'Error during render tree parsing or rectangle extraction: $e',
);
return null;
}
}

List<ElementData>? getPostHogWidgetWrapperElements() {
final context = containerKey.currentContext;

Expand Down
23 changes: 23 additions & 0 deletions posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
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:** 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. Canvas recording itself must be enabled
/// separately β€” either with `captureCanvas: { recordCanvas: true }` as shown
/// below, or with the canvas capture toggle in your project's session replay
/// settings. Frames captured before that first mount are recorded unmasked;
/// to cover the window between `posthog.init` and Flutter booting, declare
/// `session_recording: { captureCanvas: { recordCanvas: true },
/// canvasCapture: { maskRegionsFn: () => null } }`
/// 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 @@ -22,10 +43,12 @@ class PostHogMaskWidgetState extends State<PostHogMaskWidget> {
@override
void initState() {
super.initState();
notifyMaskWidgetMounted(context);
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:flutter/rendering.dart';

import '../element_parsers/element_data.dart';

/// Converts parsed widget elements to axis-aligned mask rects in the
/// PostHogWidget container's coordinate space.
List<Rect> containerMaskRects(List<ElementData> elements) {
final rects = <Rect>[];
for (final element in elements) {
final transform = element.transform;
final rect = transform != null
? MatrixUtils.transformRect(transform, element.rect)
: element.rect;
if (!rect.isFinite || rect.isEmpty) {
continue;
}
Comment thread
turnipdabeets marked this conversation as resolved.
// outset so capture-resolution rounding can't leave a sub-pixel glyph
// edge visible at the mask border
rects.add(rect.inflate(1.0));
}
return rects;
}
Loading
Loading