From 01e37150cf2a7fea49e2fd467f1f8a29896a43a1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 21 Jul 2026 10:49:26 -0400 Subject: [PATCH 01/17] feat(replay): mask canvas session replay recordings on Flutter web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/canvas-masking-web.md | 58 +++ .github/workflows/ci.yml | 2 +- posthog_flutter/lib/posthog_flutter_web.dart | 3 + .../lib/src/posthog_flutter_web_handler.dart | 3 + .../replay/mask/posthog_mask_controller.dart | 33 ++ .../src/replay/mask/posthog_mask_widget.dart | 5 + .../replay/web/web_canvas_mask_geometry.dart | 23 ++ .../replay/web/web_canvas_mask_provider.dart | 305 ++++++++++++++++ .../test/posthog_mask_controller_test.dart | 67 ++++ .../test/web_canvas_mask_geometry_test.dart | 59 ++++ .../test/web_canvas_mask_provider_test.dart | 330 ++++++++++++++++++ 11 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 .changeset/canvas-masking-web.md create mode 100644 posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart create mode 100644 posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart create mode 100644 posthog_flutter/test/posthog_mask_controller_test.dart create mode 100644 posthog_flutter/test/web_canvas_mask_geometry_test.dart create mode 100644 posthog_flutter/test/web_canvas_mask_provider_test.dart diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md new file mode 100644 index 00000000..e1486e86 --- /dev/null +++ b/.changeset/canvas-masking-web.md @@ -0,0 +1,58 @@ +--- +"posthog_flutter": minor +--- + +**Session replay masking now works on Flutter web** ([#496](https://github.com/PostHog/posthog-flutter/issues/496)). + +On Flutter web your app is painted into a single ``, so PostHog's DOM-based +masking could not see any of your text — session recordings captured it in the clear +even if you had masking configured. Masking now applies inside the canvas. + +To enable it, add `canvasMaskRegionsFn` to the `posthog.init` call in your +`web/index.html`: + +```js +posthog.init('', { + session_recording: { + captureCanvas: { + recordCanvas: true, + // the plugin replaces this once Flutter has started; until then + // frames are skipped rather than recorded unmasked + canvasMaskRegionsFn: () => null, + }, + }, +}) +``` + +Once enabled, `sessionReplayConfig.maskAllTexts`, `maskAllImages`, +`PostHogMaskWidget` and obscured text fields all mask canvas content, and Flutter's +accessibility tree is excluded from DOM capture so your text is not recorded through +it either. + +Notes: + +- If you leave `canvasMaskRegionsFn` out, the plugin changes nothing and recording + behaves exactly as posthog-js is configured — as it did before this release. + **This includes `PostHogMaskWidget`**: on web it has no effect unless + `canvasMaskRegionsFn` is declared, because the canvas is masked by posthog-js and + the plugin only supplies rectangles to it once you have opted in. On iOS and + Android `PostHogMaskWidget` continues to work with no extra setup. + If canvas recording is enabled in your project settings rather than in + `posthog.init`, the plugin cannot detect it and will not warn. +- Web replay is configured entirely in `posthog.init`. The `config.sessionReplay` + Dart flag drives iOS/Android screenshot capture only and does not affect what + is recorded on web. +- When you opt in, the plugin adds `flt-semantics-host` to + `session_recording.blockSelector` so Flutter's accessibility tree is not recorded + as plaintext. A client-side `blockSelector` takes precedence over the one in your + project's Privacy and masking settings, so if you rely on a project-level selector, + list it in `posthog.init` as well — the plugin merges with what is there and cannot + see the project-level value. posthog-js may also log a notice about `blockSelector` + in `posthog.init` for this reason. +- Widgets rendered as DOM rather than canvas pixels — `HtmlElementView`-based platform + views such as maps, webviews and iframes — are recorded as DOM and masked by + posthog-js's DOM rules, not by canvas mask regions. `PostHogMaskWidget` around a + platform view does not mask it on web. +- Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are + skipped instead of recorded unmasked, and a console warning explains the fix. +- Requires a posthog-js version that supports `captureCanvas.canvasMaskRegionsFn`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4a90a86..7df259c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 publish-dry-run: needs: detect-markdown-only diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index e62f72b1..33cc43d9 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -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. @@ -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!; diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 41db48d9..e4c6c4ba 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -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); diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart index 419c3792..b78bfd73 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart @@ -61,6 +61,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? 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(), + ]; + } catch (e) { + printIfDebug( + 'Error during render tree parsing or rectangle extraction: $e', + ); + return null; + } + } + List? getPostHogWidgetWrapperElements() { final context = containerKey.currentContext; diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 0ddc3af6..95aaf5de 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -4,6 +4,11 @@ import 'package:flutter/material.dart'; /// /// 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 +/// `session_recording: { captureCanvas: { canvasMaskRegionsFn: () => null } }` +/// in your `posthog.init` call to turn it on. iOS and Android need no setup. class PostHogMaskWidget extends StatefulWidget { /// The widget subtree to mask in session replay snapshots. final Widget child; diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart new file mode 100644 index 00000000..abcd7fce --- /dev/null +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart @@ -0,0 +1,23 @@ +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 containerMaskRects(List elements) { + final rects = []; + for (final element in elements) { + final transform = element.transform; + final rect = (transform != null + ? MatrixUtils.transformRect(transform, element.rect) + : element.rect) + // outset so capture-resolution rounding can't leave a sub-pixel glyph + // edge visible at the mask border + .inflate(1.0); + if (!rect.isFinite || rect.isEmpty) { + continue; + } + rects.add(rect); + } + return rects; +} diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart new file mode 100644 index 00000000..04984d19 --- /dev/null +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:web/web.dart' as web; + +import '../../posthog_config.dart'; +import '../../posthog_flutter_web_handler.dart'; +import '../../util/logging.dart'; +import '../mask/posthog_mask_controller.dart'; +import 'web_canvas_mask_geometry.dart'; + +extension type _JSMaskRegion._(JSObject _) implements JSObject { + external factory _JSMaskRegion({ + required double x, + required double y, + required double width, + required double height, + }); +} + +@JS('Object.assign') +external JSObject _objectAssign(JSObject target, JSObject source); + +const _semanticsBlockSelector = 'flt-semantics-host'; + +/// Supplies widget-tree mask rectangles to posthog-js canvas recording via +/// `session_recording.captureCanvas.canvasMaskRegionsFn`, so text painted +/// into the CanvasKit canvas can be masked even though DOM masking cannot +/// see it. +/// +/// An app opts in by declaring `canvasMaskRegionsFn` in its `posthog.init` +/// call; declaring it as `() => null` also covers the frames captured before +/// this provider takes over. Without that key nothing is registered, so +/// recording is left exactly as posthog-js configured it. +/// +/// Fails closed: a failed widget-tree walk returns null, which makes +/// posthog-js skip the frame instead of shipping it unmasked. +class WebCanvasMaskProvider { + WebCanvasMaskProvider(this._config); + + final PostHogConfig _config; + + List? _cachedContainerRects; + int _cachedAtFrame = -1; + int _consecutiveWalkFailures = 0; + bool _warnedWalkFailure = false; + + // shared so a second provider's cache is not compared against a counter that + // never advances; the callback cannot be removed once added + static int _frameCount = 0; + static bool _frameCallbackRegistered = false; + + void register() { + try { + _registerUnsafe(); + } catch (e) { + printIfDebug('PostHog: failed to register web canvas masking: $e'); + } + } + + void _registerUnsafe() { + final ph = posthog; + if (ph != null && _tryApplyConfig(ph)) { + return; + } + // the posthog-js snippet installs a config-less stub and array.js later + // REPLACES window.posthog with the real instance, so each retry must + // re-read the getter; set_config against the stub would merge onto an + // empty base and wipe the user's session_recording config + printIfDebug( + 'PostHog: posthog-js not fully loaded yet, ' + 'retrying canvas mask registration.', + ); + _scheduleRetry(const Duration(milliseconds: 250), Duration.zero); + } + + void _scheduleRetry(Duration delay, Duration elapsed) { + if (elapsed >= const Duration(minutes: 2)) { + printIfDebug( + 'PostHog: posthog-js did not become available, ' + 'web canvas masking disabled.', + ); + return; + } + Timer(delay, () { + try { + final current = posthog; + if (current != null && _tryApplyConfig(current)) { + return; + } + final doubled = delay * 2; + final next = doubled > const Duration(seconds: 4) + ? const Duration(seconds: 4) + : doubled; + _scheduleRetry(next, elapsed + delay); + } catch (e) { + printIfDebug('PostHog: web canvas masking retry failed: $e'); + } + }); + } + + void _ensureFrameCounter() { + if (_frameCallbackRegistered) { + return; + } + _frameCallbackRegistered = true; + SchedulerBinding.instance.addPersistentFrameCallback((_) { + _frameCount++; + }); + } + + bool _tryApplyConfig(PostHog ph) { + if (ph.config == null) { + return false; + } + + // shallow-merge on top of any user-provided session_recording config — + // posthog-js set_config replaces the whole session_recording object + final sessionRecording = JSObject(); + final existing = ph.config?.getProperty('session_recording'.toJS); + if (existing.isA()) { + _objectAssign(sessionRecording, existing as JSObject); + } + + final captureCanvas = JSObject(); + final existingCaptureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + if (existingCaptureCanvas.isA()) { + _objectAssign(captureCanvas, existingCaptureCanvas as JSObject); + } + // the app opts into canvas masking by declaring canvasMaskRegionsFn in + // posthog.init — registering regardless would restart an in-flight + // recording and drop the semantics tree for apps that never asked + if (!captureCanvas.has('canvasMaskRegionsFn')) { + _warnNotOptedIn(captureCanvas); + return true; + } + _ensureFrameCounter(); + captureCanvas.setProperty( + 'canvasMaskRegionsFn'.toJS, + _computeMaskRegions.toJS, + ); + sessionRecording.setProperty('captureCanvas'.toJS, captureCanvas); + + _blockSemanticsHost(sessionRecording); + + final config = JSObject(); + config.setProperty('session_recording'.toJS, sessionRecording); + ph.set_config(config); + + // blockSelector is only read when rrweb's record() starts, so an in-flight + // recording must be restarted + if (ph.sessionRecordingStarted()) { + ph.stopSessionRecording(); + ph.startSessionRecording(); + } + return true; + } + + // canvas recording can also be switched on from project settings, which the + // plugin cannot read — so only the posthog.init half of the leak is warnable + void _warnNotOptedIn(JSObject captureCanvas) { + printIfDebug( + 'PostHog: canvasMaskRegionsFn is not declared in posthog.init, ' + 'so Flutter web canvas masking is off.', + ); + final replayConfig = _config.sessionReplayConfig; + if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { + return; + } + final recordCanvas = captureCanvas.getProperty('recordCanvas'.toJS); + if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { + web.console.warn( + 'PostHog: canvas session recording is enabled but canvasMaskRegionsFn ' + 'is missing from posthog.init, so text painted by Flutter is recorded ' + 'unmasked. See the posthog_flutter CHANGELOG for the snippet.' + .toJS, + ); + } + } + + // with accessibility enabled, Flutter mirrors widget text into the + // flt-semantics DOM tree, which rrweb would otherwise record in plaintext + void _blockSemanticsHost(JSObject sessionRecording) { + final existing = sessionRecording.getProperty('blockSelector'.toJS); + var selector = _semanticsBlockSelector; + if (existing.isA()) { + final current = (existing as JSString).toDart; + if (current.contains(_semanticsBlockSelector)) { + return; + } + selector = '$current, $_semanticsBlockSelector'; + } + sessionRecording.setProperty('blockSelector'.toJS, selector.toJS); + } + + // null tells posthog-js to skip the frame rather than ship it unmasked + JSArray? _computeMaskRegions(web.HTMLCanvasElement canvas) { + try { + return _unsafeComputeMaskRegions(canvas); + } catch (e) { + printIfDebug('PostHog: error computing canvas mask regions: $e'); + return null; + } + } + + JSArray? _unsafeComputeMaskRegions(web.HTMLCanvasElement canvas) { + final host = _flutterViewHost(canvas); + if (host == null) { + return JSArray(); + } + + final containerRects = _currentContainerRects(); + if (containerRects == null) { + _noteWalkFailure(); + return null; + } + _consecutiveWalkFailures = 0; + if (containerRects.isEmpty) { + return JSArray(); + } + + // Flutter logical pixels == CSS pixels on web, but localToGlobal is + // relative to the flutter-view host, which may itself be embedded away + // from the viewport origin — hence the extra hostRect term + var containerOrigin = Offset.zero; + final containerObject = PostHogMaskController + .instance.containerKey.currentContext + ?.findRenderObject(); + if (containerObject is RenderBox && containerObject.hasSize) { + containerOrigin = containerObject.localToGlobal(Offset.zero); + } + final canvasRect = canvas.getBoundingClientRect(); + final hostRect = host.getBoundingClientRect(); + final offset = containerOrigin + + Offset(hostRect.left, hostRect.top) - + Offset(canvasRect.left, canvasRect.top); + + final regions = []; + for (final rect in containerRects) { + final shifted = rect.shift(offset); + regions.add(_JSMaskRegion( + x: shifted.left, + y: shifted.top, + width: shifted.width, + height: shifted.height, + )); + } + return regions.toJS; + } + + // rects only change when Flutter paints a frame, so cache per frame instead + // of recomputing on every posthog-js canvas tick + List? _currentContainerRects() { + if (_cachedContainerRects != null && _cachedAtFrame == _frameCount) { + return _cachedContainerRects; + } + + final replayConfig = _config.sessionReplayConfig; + final elements = PostHogMaskController.instance.getMaskElements( + includeAllWidgets: + replayConfig.maskAllTexts || replayConfig.maskAllImages, + ); + if (elements == null) { + _cachedContainerRects = null; + return null; + } + + final rects = containerMaskRects(elements); + _cachedContainerRects = rects; + _cachedAtFrame = _frameCount; + return rects; + } + + // PostHogWidget can mount a moment after recording starts, so a few + // fail-closed frames during boot are normal; only a persistent failure + // means the canvas is never recorded + void _noteWalkFailure() { + if (_warnedWalkFailure) { + return; + } + _consecutiveWalkFailures++; + if (_consecutiveWalkFailures >= 10) { + _warnedWalkFailure = true; + web.console.warn( + 'PostHog: session replay masking cannot find the PostHogWidget ' + 'widget tree, so canvas frames are not being recorded. ' + 'Wrap your app in PostHogWidget, or remove canvasMaskRegionsFn ' + 'from your posthog.init to disable canvas masking (the canvas ' + 'is then recorded unmasked).' + .toJS, + ); + } + } + + web.Element? _flutterViewHost(web.HTMLCanvasElement canvas) { + final root = canvas.getRootNode(); + final web.Element start = + root.isA() ? (root as web.ShadowRoot).host : canvas; + return start.closest('flutter-view'); + } +} diff --git a/posthog_flutter/test/posthog_mask_controller_test.dart b/posthog_flutter/test/posthog_mask_controller_test.dart new file mode 100644 index 00000000..f72a0faa --- /dev/null +++ b/posthog_flutter/test/posthog_mask_controller_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +void main() { + Widget appWithContainerKey() { + return MaterialApp( + home: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: Scaffold( + body: Column( + children: [ + const Text('visible text'), + PostHogMaskWidget(child: const Text('wrapped')), + const TextField(obscureText: true), + ], + ), + ), + ), + ); + } + + Set describe(List elements) { + return elements.map((e) => '${e.type}:${e.rect}').toSet(); + } + + testWidgets('getMaskElements with all widgets matches the two-walk union', + (tester) async { + await tester.pumpWidget(appWithContainerKey()); + + final combined = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + final wrapperOnly = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + final allWidgets = + PostHogMaskController.instance.getCurrentWidgetsElements(); + + expect(combined, isNotNull); + expect( + describe(combined!), + describe([...wrapperOnly!, ...allWidgets!]), + ); + expect(combined, isNotEmpty); + }); + + testWidgets('getMaskElements without all widgets matches the wrapper walk', + (tester) async { + await tester.pumpWidget(appWithContainerKey()); + + final combined = PostHogMaskController.instance + .getMaskElements(includeAllWidgets: false); + final wrapperOnly = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + + expect(combined, isNotNull); + expect(describe(combined!), describe(wrapperOnly!)); + }); + + test('getMaskElements returns null without a mounted container', () { + expect( + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true), + isNull, + ); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_geometry_test.dart b/posthog_flutter/test/web_canvas_mask_geometry_test.dart new file mode 100644 index 00000000..924ce0d1 --- /dev/null +++ b/posthog_flutter/test/web_canvas_mask_geometry_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/web/web_canvas_mask_geometry.dart'; + +void main() { + ElementData element(Rect rect, {Matrix4? transform}) { + return ElementData(rect: rect, type: 'Text', transform: transform); + } + + test('outsets plain rects by one pixel', () { + final rects = containerMaskRects([ + element(const Rect.fromLTWH(10, 20, 30, 40)), + ]); + + expect(rects, [const Rect.fromLTWH(9, 19, 32, 42)]); + }); + + test('applies the element transform before outsetting', () { + final rects = containerMaskRects([ + element( + const Rect.fromLTWH(0, 0, 10, 10), + transform: Matrix4.translationValues(100, 200, 0) + ..scaleByDouble(2.0, 2.0, 2.0, 1.0), + ), + ]); + + expect(rects, [const Rect.fromLTWH(99, 199, 22, 22)]); + }); + + test('bounds rotated elements with an axis-aligned rect', () { + final rects = containerMaskRects([ + element( + const Rect.fromLTWH(0, 0, 10, 10), + transform: Matrix4.rotationZ(0.5), + ), + ]); + + expect(rects, hasLength(1)); + final rect = rects.single; + expect( + rect.contains(MatrixUtils.transformPoint( + Matrix4.rotationZ(0.5), + const Offset(10, 10), + )), + isTrue); + expect(rect.contains(const Offset(0, 0)), isTrue); + }); + + test('drops empty and non-finite rects', () { + final rects = containerMaskRects([ + element(Rect.zero.deflate(2)), + element(const Rect.fromLTWH(0, 0, double.infinity, 10)), + element(const Rect.fromLTWH(0, 0, 5, 5)), + ]); + + expect(rects, [const Rect.fromLTWH(-1, -1, 7, 7)]); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart new file mode 100644 index 00000000..aba31bd6 --- /dev/null +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -0,0 +1,330 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; +import 'package:web/web.dart' as web; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + JSObject? capturedConfig; + var stopRecordingCalls = 0; + var startRecordingCalls = 0; + + JSObject installPosthogStub({ + JSObject? sessionRecording, + bool withConfig = true, + bool recordingStarted = false, + bool declaresMaskProvider = true, + }) { + capturedConfig = null; + stopRecordingCalls = 0; + startRecordingCalls = 0; + final stub = JSObject(); + if (withConfig) { + final config = JSObject(); + if (declaresMaskProvider) { + sessionRecording ??= JSObject(); + final existing = + sessionRecording.getProperty('captureCanvas'.toJS); + final captureCanvas = + existing.isA() ? existing as JSObject : JSObject(); + captureCanvas.setProperty('canvasMaskRegionsFn'.toJS, null); + sessionRecording.setProperty('captureCanvas'.toJS, captureCanvas); + } + if (sessionRecording != null) { + config.setProperty('session_recording'.toJS, sessionRecording); + } + stub.setProperty('config'.toJS, config); + } + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + capturedConfig = cfg; + }).toJS, + ); + stub.setProperty( + 'sessionRecordingStarted'.toJS, + (() => recordingStarted.toJS).toJS, + ); + stub.setProperty( + 'stopSessionRecording'.toJS, + (() { + stopRecordingCalls++; + }).toJS, + ); + stub.setProperty( + 'startSessionRecording'.toJS, + (() { + startRecordingCalls++; + }).toJS, + ); + web.window.setProperty('posthog'.toJS, stub); + return stub; + } + + tearDown(() { + web.window.setProperty('posthog'.toJS, null); + }); + + JSObject capturedSessionRecording() { + expect(capturedConfig, isNotNull); + final sessionRecording = + capturedConfig!.getProperty('session_recording'.toJS); + expect(sessionRecording.isA(), isTrue); + return sessionRecording as JSObject; + } + + test('leaves posthog-js untouched when the app declares no mask provider', + () { + installPosthogStub(declaresMaskProvider: true, recordingStarted: true); + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(capturedConfig, isNull); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + test('registers the mask provider via set_config', () { + installPosthogStub(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final sessionRecording = capturedSessionRecording(); + final captureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + expect( + captureCanvas + .getProperty('canvasMaskRegionsFn'.toJS) + .isA(), + isTrue, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + }); + + test('preserves existing session_recording config when merging', () { + final existingCaptureCanvas = JSObject() + ..setProperty('canvasFps'.toJS, 2.toJS); + final existingSessionRecording = JSObject() + ..setProperty('blockSelector'.toJS, '.secret'.toJS) + ..setProperty('maskAllInputs'.toJS, false.toJS) + ..setProperty('captureCanvas'.toJS, existingCaptureCanvas); + installPosthogStub(sessionRecording: existingSessionRecording); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording.getProperty('maskAllInputs'.toJS).dartify(), + false, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + '.secret, flt-semantics-host', + ); + final captureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + expect( + captureCanvas.getProperty('canvasFps'.toJS).dartify(), + 2, + ); + expect( + captureCanvas + .getProperty('canvasMaskRegionsFn'.toJS) + .isA(), + isTrue, + ); + }); + + test('blocks the semantics host even when maskAllTexts is false', () { + installPosthogStub(); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false; + + WebCanvasMaskProvider(config).register(); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + }); + + test('returns no regions for a canvas outside the flutter view', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('captureCanvas'.toJS) + .getProperty('canvasMaskRegionsFn'.toJS); + final canvas = web.document.createElement('canvas'); + final regions = regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, isEmpty); + }); + + test( + 'fails closed for a flutter-view canvas when no PostHogWidget is ' + 'mounted', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('captureCanvas'.toJS) + .getProperty('canvasMaskRegionsFn'.toJS); + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + try { + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + test('warns once when the widget-tree walk keeps failing', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final consoleObject = web.window.getProperty('console'.toJS); + final originalWarn = consoleObject.getProperty('warn'.toJS); + var warnCalls = 0; + consoleObject.setProperty( + 'warn'.toJS, + ((JSAny? message) { + warnCalls++; + }).toJS, + ); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + try { + final regionsFn = capturedSessionRecording() + .getProperty('captureCanvas'.toJS) + .getProperty('canvasMaskRegionsFn'.toJS); + for (var i = 0; i < 12; i++) { + expect(regionsFn.callAsFunction(null, canvas), isNull); + } + expect(warnCalls, 1); + } finally { + flutterView.remove(); + consoleObject.setProperty('warn'.toJS, originalWarn); + } + }); + + testWidgets('maps mask rects into canvas-relative coordinates', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Padding( + padding: const EdgeInsets.only(left: 100, top: 200), + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 50px; top: 60px'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 20px; top: 30px'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('captureCanvas'.toJS) + .getProperty('canvasMaskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect(region.getProperty('x'.toJS).toDartDouble, 79); + expect(region.getProperty('y'.toJS).toDartDouble, 169); + expect(region.getProperty('width'.toJS).toDartDouble, 32); + expect(region.getProperty('height'.toJS).toDartDouble, 42); + } finally { + flutterView.remove(); + } + }); + + test('defers set_config until posthog-js exposes its config', () { + installPosthogStub(withConfig: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(capturedConfig, isNull); + }); + + test('applies config after posthog-js appears with no snippet stub at all', + () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + installPosthogStub(); + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(capturedConfig, isNotNull); + }); + + test( + 'applies config after array.js replaces the snippet stub with the real ' + 'instance', () async { + installPosthogStub(withConfig: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + installPosthogStub(); + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(capturedConfig, isNotNull); + }); + + test('restarts an in-flight recording so the new config applies', () { + installPosthogStub(recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + test('does not restart recording when none is in flight', () { + installPosthogStub(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); +} From 6f44d83350f7e48a84e8a32f808fc324355663d7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 17:10:58 +0300 Subject: [PATCH 02/17] fix(replay): keep retrying web canvas mask registration until posthog.init runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/canvas-masking-web.md | 4 ++ .../replay/web/web_canvas_mask_provider.dart | 48 +++++++++++----- .../test/web_canvas_mask_provider_test.dart | 56 +++++++++++++++++++ 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index e1486e86..b41d9a09 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -49,6 +49,10 @@ Notes: list it in `posthog.init` as well — the plugin merges with what is there and cannot see the project-level value. posthog-js may also log a notice about `blockSelector` in `posthog.init` for this reason. +- rrweb's DOM full snapshot serializes 2D-context canvases inline (`rr_dataURL`) + without applying mask regions. Flutter's CanvasKit renderer draws to WebGL, which + that path does not serialize, so your Flutter canvas is not exposed through it — + but keep it in mind if your page contains additional 2D canvases of its own. - Widgets rendered as DOM rather than canvas pixels — `HtmlElementView`-based platform views such as maps, webviews and iframes — are recorded as DOM and masked by posthog-js's DOM rules, not by canvas mask regions. `PostHogMaskWidget` around a diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 04984d19..6ce4c94d 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:web/web.dart' as web; @@ -41,8 +42,17 @@ const _semanticsBlockSelector = 'flt-semantics-host'; class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); + static WebCanvasMaskProvider? _active; + + @visibleForTesting + static void resetForTesting() { + _active?._retryTimer?.cancel(); + _active = null; + } + final PostHogConfig _config; + Timer? _retryTimer; List? _cachedContainerRects; int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; @@ -55,6 +65,7 @@ class WebCanvasMaskProvider { void register() { try { + _active = this; _registerUnsafe(); } catch (e) { printIfDebug('PostHog: failed to register web canvas masking: $e'); @@ -74,31 +85,28 @@ class WebCanvasMaskProvider { 'PostHog: posthog-js not fully loaded yet, ' 'retrying canvas mask registration.', ); - _scheduleRetry(const Duration(milliseconds: 250), Duration.zero); + _scheduleRetry(const Duration(milliseconds: 250)); } - void _scheduleRetry(Duration delay, Duration elapsed) { - if (elapsed >= const Duration(minutes: 2)) { - printIfDebug( - 'PostHog: posthog-js did not become available, ' - 'web canvas masking disabled.', - ); - return; - } - Timer(delay, () { + // polls forever once backed off to 4s: a consent-gated app can call + // posthog.init minutes after Flutter boots, and giving up would silently + // leave its canvas frames skipped (canvasMaskRegionsFn stuck at () => null) + void _scheduleRetry(Duration delay) { + _retryTimer = Timer(delay, () { + var next = delay; try { final current = posthog; if (current != null && _tryApplyConfig(current)) { return; } final doubled = delay * 2; - final next = doubled > const Duration(seconds: 4) + next = doubled > const Duration(seconds: 4) ? const Duration(seconds: 4) : doubled; - _scheduleRetry(next, elapsed + delay); } catch (e) { printIfDebug('PostHog: web canvas masking retry failed: $e'); } + _scheduleRetry(next); }); } @@ -106,16 +114,28 @@ class WebCanvasMaskProvider { if (_frameCallbackRegistered) { return; } - _frameCallbackRegistered = true; SchedulerBinding.instance.addPersistentFrameCallback((_) { _frameCount++; }); + _frameCallbackRegistered = true; } - bool _tryApplyConfig(PostHog ph) { + // posthog-js constructs its instance with a default config before init() + // runs, so a present config does not mean the app has called init — a + // consent-gated app may init long after Flutter boots. Only __loaded + // (set when init completes) distinguishes the two. + bool _isInitialized(PostHog ph) { if (ph.config == null) { return false; } + final loaded = (ph as JSObject).getProperty('__loaded'.toJS); + return loaded.isA() && (loaded as JSBoolean).toDart; + } + + bool _tryApplyConfig(PostHog ph) { + if (!_isInitialized(ph)) { + return false; + } // shallow-merge on top of any user-provided session_recording config — // posthog-js set_config replaces the whole session_recording object diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index aba31bd6..638519b4 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -20,6 +20,7 @@ void main() { JSObject installPosthogStub({ JSObject? sessionRecording, bool withConfig = true, + bool loaded = true, bool recordingStarted = false, bool declaresMaskProvider = true, }) { @@ -42,6 +43,9 @@ void main() { config.setProperty('session_recording'.toJS, sessionRecording); } stub.setProperty('config'.toJS, config); + // the real instance carries __loaded; the snippet stub (withConfig: + // false) has neither config nor __loaded + stub.setProperty('__loaded'.toJS, loaded.toJS); } stub.setProperty( 'set_config'.toJS, @@ -69,7 +73,12 @@ void main() { return stub; } + // cancel the previous test's retry chain so a stale chain cannot apply + // config against this test's stub + setUp(WebCanvasMaskProvider.resetForTesting); + tearDown(() { + WebCanvasMaskProvider.resetForTesting(); web.window.setProperty('posthog'.toJS, null); }); @@ -310,6 +319,53 @@ void main() { expect(capturedConfig, isNotNull); }); + test( + 'keeps retrying while posthog is present but uninitialized, then ' + 'applies once init declares the mask provider', () async { + final stub = installPosthogStub(loaded: false, declaresMaskProvider: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await Future.delayed(const Duration(milliseconds: 600)); + // present-but-uninitialized must not be classified as not-opted-in + expect(capturedConfig, isNull); + + final captureCanvas = JSObject() + ..setProperty('canvasMaskRegionsFn'.toJS, null); + final sessionRecording = JSObject() + ..setProperty('captureCanvas'.toJS, captureCanvas); + stub + .getProperty('config'.toJS) + .setProperty('session_recording'.toJS, sessionRecording); + stub.setProperty('__loaded'.toJS, true.toJS); + + await Future.delayed(const Duration(milliseconds: 2500)); + expect(capturedConfig, isNotNull); + }); + + test('an exception during one retry tick does not kill the chain', () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final stub = installPosthogStub(); + var setConfigCalls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + setConfigCalls++; + if (setConfigCalls == 1) { + throw StateError('stub failure'); + } + capturedConfig = cfg; + }).toJS, + ); + + await Future.delayed(const Duration(milliseconds: 2500)); + expect(setConfigCalls, 2); + expect(capturedConfig, isNotNull); + }); + test('restarts an in-flight recording so the new config applies', () { installPosthogStub(recordingStarted: true); From 62e77ebc999c5ec7f81c0ec32cb50ca735721bfb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 17:38:23 +0300 Subject: [PATCH 03/17] refactor(replay): follow posthog-js rename to session_recording.canvasCapture.maskRegionsFn Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 12 ++-- .../src/replay/mask/posthog_mask_widget.dart | 2 +- .../replay/web/web_canvas_mask_provider.dart | 44 +++++++------ .../test/web_canvas_mask_provider_test.dart | 61 +++++++++++-------- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index b41d9a09..666ff80a 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -8,7 +8,7 @@ On Flutter web your app is painted into a single ``, so PostHog's DOM-ba masking could not see any of your text — session recordings captured it in the clear even if you had masking configured. Masking now applies inside the canvas. -To enable it, add `canvasMaskRegionsFn` to the `posthog.init` call in your +To enable it, add `maskRegionsFn` to the `posthog.init` call in your `web/index.html`: ```js @@ -16,9 +16,11 @@ posthog.init('', { session_recording: { captureCanvas: { recordCanvas: true, + }, + canvasCapture: { // the plugin replaces this once Flutter has started; until then // frames are skipped rather than recorded unmasked - canvasMaskRegionsFn: () => null, + maskRegionsFn: () => null, }, }, }) @@ -31,10 +33,10 @@ it either. Notes: -- If you leave `canvasMaskRegionsFn` out, the plugin changes nothing and recording +- If you leave `maskRegionsFn` out, the plugin changes nothing and recording behaves exactly as posthog-js is configured — as it did before this release. **This includes `PostHogMaskWidget`**: on web it has no effect unless - `canvasMaskRegionsFn` is declared, because the canvas is masked by posthog-js and + `maskRegionsFn` is declared, because the canvas is masked by posthog-js and the plugin only supplies rectangles to it once you have opted in. On iOS and Android `PostHogMaskWidget` continues to work with no extra setup. If canvas recording is enabled in your project settings rather than in @@ -59,4 +61,4 @@ Notes: platform view does not mask it on web. - Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are skipped instead of recorded unmasked, and a console warning explains the fix. -- Requires a posthog-js version that supports `captureCanvas.canvasMaskRegionsFn`. +- Requires a posthog-js version that supports `canvasCapture.maskRegionsFn`. diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 95aaf5de..74fd339e 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; /// /// **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 -/// `session_recording: { captureCanvas: { canvasMaskRegionsFn: () => null } }` +/// `session_recording: { canvasCapture: { maskRegionsFn: () => null } }` /// in your `posthog.init` call to turn it on. iOS and Android need no setup. class PostHogMaskWidget extends StatefulWidget { /// The widget subtree to mask in session replay snapshots. diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 6ce4c94d..174113ef 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -28,11 +28,11 @@ external JSObject _objectAssign(JSObject target, JSObject source); const _semanticsBlockSelector = 'flt-semantics-host'; /// Supplies widget-tree mask rectangles to posthog-js canvas recording via -/// `session_recording.captureCanvas.canvasMaskRegionsFn`, so text painted +/// `session_recording.canvasCapture.maskRegionsFn`, so text painted /// into the CanvasKit canvas can be masked even though DOM masking cannot /// see it. /// -/// An app opts in by declaring `canvasMaskRegionsFn` in its `posthog.init` +/// An app opts in by declaring `maskRegionsFn` in its `posthog.init` /// call; declaring it as `() => null` also covers the frames captured before /// this provider takes over. Without that key nothing is registered, so /// recording is left exactly as posthog-js configured it. @@ -90,7 +90,7 @@ class WebCanvasMaskProvider { // polls forever once backed off to 4s: a consent-gated app can call // posthog.init minutes after Flutter boots, and giving up would silently - // leave its canvas frames skipped (canvasMaskRegionsFn stuck at () => null) + // leave its canvas frames skipped (maskRegionsFn stuck at () => null) void _scheduleRetry(Duration delay) { _retryTimer = Timer(delay, () { var next = delay; @@ -145,25 +145,25 @@ class WebCanvasMaskProvider { _objectAssign(sessionRecording, existing as JSObject); } - final captureCanvas = JSObject(); - final existingCaptureCanvas = - sessionRecording.getProperty('captureCanvas'.toJS); - if (existingCaptureCanvas.isA()) { - _objectAssign(captureCanvas, existingCaptureCanvas as JSObject); + final canvasCapture = JSObject(); + final existingCanvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); + if (existingCanvasCapture.isA()) { + _objectAssign(canvasCapture, existingCanvasCapture as JSObject); } - // the app opts into canvas masking by declaring canvasMaskRegionsFn in + // the app opts into canvas masking by declaring maskRegionsFn in // posthog.init — registering regardless would restart an in-flight // recording and drop the semantics tree for apps that never asked - if (!captureCanvas.has('canvasMaskRegionsFn')) { - _warnNotOptedIn(captureCanvas); + if (!canvasCapture.has('maskRegionsFn')) { + _warnNotOptedIn(sessionRecording); return true; } _ensureFrameCounter(); - captureCanvas.setProperty( - 'canvasMaskRegionsFn'.toJS, + canvasCapture.setProperty( + 'maskRegionsFn'.toJS, _computeMaskRegions.toJS, ); - sessionRecording.setProperty('captureCanvas'.toJS, captureCanvas); + sessionRecording.setProperty('canvasCapture'.toJS, canvasCapture); _blockSemanticsHost(sessionRecording); @@ -182,19 +182,25 @@ class WebCanvasMaskProvider { // canvas recording can also be switched on from project settings, which the // plugin cannot read — so only the posthog.init half of the leak is warnable - void _warnNotOptedIn(JSObject captureCanvas) { + void _warnNotOptedIn(JSObject sessionRecording) { printIfDebug( - 'PostHog: canvasMaskRegionsFn is not declared in posthog.init, ' + 'PostHog: maskRegionsFn is not declared in posthog.init, ' 'so Flutter web canvas masking is off.', ); final replayConfig = _config.sessionReplayConfig; if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { return; } - final recordCanvas = captureCanvas.getProperty('recordCanvas'.toJS); + final captureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + if (!captureCanvas.isA()) { + return; + } + final recordCanvas = + (captureCanvas as JSObject).getProperty('recordCanvas'.toJS); if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { web.console.warn( - 'PostHog: canvas session recording is enabled but canvasMaskRegionsFn ' + 'PostHog: canvas session recording is enabled but maskRegionsFn ' 'is missing from posthog.init, so text painted by Flutter is recorded ' 'unmasked. See the posthog_flutter CHANGELOG for the snippet.' .toJS, @@ -308,7 +314,7 @@ class WebCanvasMaskProvider { web.console.warn( 'PostHog: session replay masking cannot find the PostHogWidget ' 'widget tree, so canvas frames are not being recorded. ' - 'Wrap your app in PostHogWidget, or remove canvasMaskRegionsFn ' + 'Wrap your app in PostHogWidget, or remove maskRegionsFn ' 'from your posthog.init to disable canvas masking (the canvas ' 'is then recorded unmasked).' .toJS, diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 638519b4..8ef31045 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -33,11 +33,11 @@ void main() { if (declaresMaskProvider) { sessionRecording ??= JSObject(); final existing = - sessionRecording.getProperty('captureCanvas'.toJS); - final captureCanvas = + sessionRecording.getProperty('canvasCapture'.toJS); + final canvasCapture = existing.isA() ? existing as JSObject : JSObject(); - captureCanvas.setProperty('canvasMaskRegionsFn'.toJS, null); - sessionRecording.setProperty('captureCanvas'.toJS, captureCanvas); + canvasCapture.setProperty('maskRegionsFn'.toJS, null); + sessionRecording.setProperty('canvasCapture'.toJS, canvasCapture); } if (sessionRecording != null) { config.setProperty('session_recording'.toJS, sessionRecording); @@ -108,12 +108,10 @@ void main() { WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); final sessionRecording = capturedSessionRecording(); - final captureCanvas = - sessionRecording.getProperty('captureCanvas'.toJS); + final canvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); expect( - captureCanvas - .getProperty('canvasMaskRegionsFn'.toJS) - .isA(), + canvasCapture.getProperty('maskRegionsFn'.toJS).isA(), isTrue, ); expect( @@ -123,11 +121,15 @@ void main() { }); test('preserves existing session_recording config when merging', () { + final existingCanvasCapture = JSObject() + ..setProperty('resolutionScale'.toJS, 0.5.toJS); final existingCaptureCanvas = JSObject() + ..setProperty('recordCanvas'.toJS, true.toJS) ..setProperty('canvasFps'.toJS, 2.toJS); final existingSessionRecording = JSObject() ..setProperty('blockSelector'.toJS, '.secret'.toJS) ..setProperty('maskAllInputs'.toJS, false.toJS) + ..setProperty('canvasCapture'.toJS, existingCanvasCapture) ..setProperty('captureCanvas'.toJS, existingCaptureCanvas); installPosthogStub(sessionRecording: existingSessionRecording); @@ -142,17 +144,29 @@ void main() { sessionRecording.getProperty('blockSelector'.toJS).dartify(), '.secret, flt-semantics-host', ); + final canvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); + expect( + canvasCapture.getProperty('resolutionScale'.toJS).dartify(), + 0.5, + ); + expect( + canvasCapture.getProperty('maskRegionsFn'.toJS).isA(), + isTrue, + ); final captureCanvas = sessionRecording.getProperty('captureCanvas'.toJS); + expect( + captureCanvas.getProperty('recordCanvas'.toJS).dartify(), + true, + ); expect( captureCanvas.getProperty('canvasFps'.toJS).dartify(), 2, ); expect( - captureCanvas - .getProperty('canvasMaskRegionsFn'.toJS) - .isA(), - isTrue, + captureCanvas.getProperty('maskRegionsFn'.toJS), + isNull, ); }); @@ -175,8 +189,8 @@ void main() { WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); final regionsFn = capturedSessionRecording() - .getProperty('captureCanvas'.toJS) - .getProperty('canvasMaskRegionsFn'.toJS); + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); final canvas = web.document.createElement('canvas'); final regions = regionsFn.callAsFunction(null, canvas) as JSArray; @@ -190,8 +204,8 @@ void main() { WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); final regionsFn = capturedSessionRecording() - .getProperty('captureCanvas'.toJS) - .getProperty('canvasMaskRegionsFn'.toJS); + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); final flutterView = web.document.createElement('flutter-view'); final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); @@ -223,8 +237,8 @@ void main() { web.document.body!.appendChild(flutterView); try { final regionsFn = capturedSessionRecording() - .getProperty('captureCanvas'.toJS) - .getProperty('canvasMaskRegionsFn'.toJS); + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); for (var i = 0; i < 12; i++) { expect(regionsFn.callAsFunction(null, canvas), isNull); } @@ -267,8 +281,8 @@ void main() { WebCanvasMaskProvider(config).register(); final regionsFn = capturedSessionRecording() - .getProperty('captureCanvas'.toJS) - .getProperty('canvasMaskRegionsFn'.toJS); + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); final regions = regionsFn.callAsFunction(null, canvas) as JSArray; @@ -329,10 +343,9 @@ void main() { // present-but-uninitialized must not be classified as not-opted-in expect(capturedConfig, isNull); - final captureCanvas = JSObject() - ..setProperty('canvasMaskRegionsFn'.toJS, null); + final canvasCapture = JSObject()..setProperty('maskRegionsFn'.toJS, null); final sessionRecording = JSObject() - ..setProperty('captureCanvas'.toJS, captureCanvas); + ..setProperty('canvasCapture'.toJS, canvasCapture); stub .getProperty('config'.toJS) .setProperty('session_recording'.toJS, sessionRecording); From c2a3904484dcd24ca8ab52181720ab54d0ffcce8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 17:45:55 +0300 Subject: [PATCH 04/17] =?UTF-8?q?docs(changeset):=20correct=20the=20rr=5Fd?= =?UTF-8?q?ataURL=20full-snapshot=20note=20=E2=80=94=20CanvasKit=20canvase?= =?UTF-8?q?s=20are=20readable=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index 666ff80a..ad2be36c 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -51,10 +51,13 @@ Notes: list it in `posthog.init` as well — the plugin merges with what is there and cannot see the project-level value. posthog-js may also log a notice about `blockSelector` in `posthog.init` for this reason. -- rrweb's DOM full snapshot serializes 2D-context canvases inline (`rr_dataURL`) - without applying mask regions. Flutter's CanvasKit renderer draws to WebGL, which - that path does not serialize, so your Flutter canvas is not exposed through it — - but keep it in mind if your page contains additional 2D canvases of its own. +- rrweb's DOM full snapshot serializes canvas pixels on a separate path + (`rr_dataURL`) that mask regions do not touch, and on current Flutter the + CanvasKit canvas is readable through it — a full snapshot can embed an + unmasked screenshot of your whole app. posthog-js therefore skips canvas + pixel serialization in full snapshots whenever `maskRegionsFn` is configured. + Declaring it in `posthog.init` covers this from the moment recording starts, + and the plugin's registration restart carries it through every later snapshot. - Widgets rendered as DOM rather than canvas pixels — `HtmlElementView`-based platform views such as maps, webviews and iframes — are recorded as DOM and masked by posthog-js's DOM rules, not by canvas mask regions. `PostHogMaskWidget` around a From db7004de6b99d7a8e968211d246a40ef7264b19d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 19:46:17 +0300 Subject: [PATCH 05/17] fix(replay): warn once when posthog-js is too old to mask canvas frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/canvas-masking-web.md | 5 +- .../replay/web/web_canvas_mask_provider.dart | 65 +++++++++++++++++ .../test/web_canvas_mask_provider_test.dart | 73 +++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index ad2be36c..6de8164a 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -64,4 +64,7 @@ Notes: platform view does not mask it on web. - Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are skipped instead of recorded unmasked, and a console warning explains the fix. -- Requires a posthog-js version that supports `canvasCapture.maskRegionsFn`. +- Requires a posthog-js version that supports `canvasCapture.maskRegionsFn` + (minimum version pinned at release). On an older posthog-js the plugin still + registers — the accessibility-tree exclusion works there — but canvas frames + are NOT masked, and a console warning tells you to upgrade. diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 174113ef..3fef7a43 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -27,6 +27,11 @@ external JSObject _objectAssign(JSObject target, JSObject source); const _semanticsBlockSelector = 'flt-semantics-host'; +// Placeholder that keeps the gate inert: must be pinned to the first +// posthog-js release containing canvasCapture.maskRegionsFn support +// (github.com/PostHog/posthog-js#4270) before this package releases. +const _minPosthogJsVersion = '0.0.0'; + /// Supplies widget-tree mask rectangles to posthog-js canvas recording via /// `session_recording.canvasCapture.maskRegionsFn`, so text painted /// into the CanvasKit canvas can be masked even though DOM masking cannot @@ -43,11 +48,17 @@ class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); static WebCanvasMaskProvider? _active; + static bool _warnedOldPosthogJs = false; + + @visibleForTesting + static String? debugMinPosthogJsVersionOverride; @visibleForTesting static void resetForTesting() { _active?._retryTimer?.cancel(); _active = null; + _warnedOldPosthogJs = false; + debugMinPosthogJsVersionOverride = null; } final PostHogConfig _config; @@ -158,6 +169,7 @@ class WebCanvasMaskProvider { _warnNotOptedIn(sessionRecording); return true; } + _warnIfPosthogJsTooOld(ph); _ensureFrameCounter(); canvasCapture.setProperty( 'maskRegionsFn'.toJS, @@ -208,6 +220,59 @@ class WebCanvasMaskProvider { } } + // warns only on a version confirmed older than the minimum: an absent or + // unparseable version (custom bundle, future scheme) is assumed new so the + // gate can never misfire, and registration always proceeds — blockSelector + // still protects the accessibility DOM on old posthog-js + void _warnIfPosthogJsTooOld(PostHog ph) { + if (_warnedOldPosthogJs) { + return; + } + try { + final min = debugMinPosthogJsVersionOverride ?? _minPosthogJsVersion; + final raw = (ph as JSObject).getProperty('version'.toJS); + if (!raw.isA()) { + return; + } + final observed = (raw as JSString).toDart; + if (!_isConfirmedOlder(observed, min)) { + return; + } + _warnedOldPosthogJs = true; + web.console.warn( + 'PostHog: this posthog-js version ($observed) does not support ' + 'canvasCapture.maskRegionsFn, so canvas frames are NOT masked ' + '— upgrade to at least $min for canvas masking. The ' + 'accessibility-DOM exclusion is still applied.' + .toJS, + ); + } catch (e) { + printIfDebug('PostHog: could not check the posthog-js version: $e'); + } + } + + static bool _isConfirmedOlder(String observed, String min) { + final observedParts = _parseSemVerPrefix(observed); + final minParts = _parseSemVerPrefix(min); + if (observedParts == null || minParts == null) { + return false; + } + for (var i = 0; i < 3; i++) { + if (observedParts[i] != minParts[i]) { + return observedParts[i] < minParts[i]; + } + } + return false; + } + + static List? _parseSemVerPrefix(String version) { + final match = RegExp(r'^(\d+)\.(\d+)\.(\d+)').firstMatch(version.trim()); + if (match == null) { + return null; + } + return [for (var i = 1; i <= 3; i++) int.parse(match.group(i)!)]; + } + // with accessibility enabled, Flutter mirrors widget text into the // flt-semantics DOM tree, which rrweb would otherwise record in plaintext void _blockSemanticsHost(JSObject sessionRecording) { diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 8ef31045..f2c1166c 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -23,6 +23,7 @@ void main() { bool loaded = true, bool recordingStarted = false, bool declaresMaskProvider = true, + String? version, }) { capturedConfig = null; stopRecordingCalls = 0; @@ -69,10 +70,27 @@ void main() { startRecordingCalls++; }).toJS, ); + if (version != null) { + stub.setProperty('version'.toJS, version.toJS); + } web.window.setProperty('posthog'.toJS, stub); return stub; } + int Function() interceptWarns() { + final consoleObject = web.window.getProperty('console'.toJS); + final originalWarn = consoleObject.getProperty('warn'.toJS); + var warnCalls = 0; + consoleObject.setProperty( + 'warn'.toJS, + ((JSAny? message) { + warnCalls++; + }).toJS, + ); + addTearDown(() => consoleObject.setProperty('warn'.toJS, originalWarn)); + return () => warnCalls; + } + // cancel the previous test's retry chain so a stale chain cannot apply // config against this test's stub setUp(WebCanvasMaskProvider.resetForTesting); @@ -396,4 +414,59 @@ void main() { expect(stopRecordingCalls, 0); expect(startRecordingCalls, 0); }); + + test('warns once but still registers when posthog-js is too old', () { + installPosthogStub(version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 1); + expect(capturedConfig, isNotNull); + }); + + test('does not warn when posthog-js meets the minimum', () { + installPosthogStub(version: '1.407.0'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('does not warn when posthog-js exposes no version', () { + installPosthogStub(); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('does not warn on an unparseable version', () { + installPosthogStub(version: 'not-a-version'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('warns at most once across repeated applies', () { + installPosthogStub(version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 1); + }); } From 207f4c10fca8eaa1ff3a1508c603828c6e6b95fe Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 20:20:52 +0300 Subject: [PATCH 06/17] 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. --- .../replay/web/web_canvas_mask_provider.dart | 3 +++ .../test/web_canvas_mask_provider_test.dart | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 3fef7a43..c0f53a9e 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -76,6 +76,9 @@ class WebCanvasMaskProvider { void register() { try { + // a second setup() must not leave the predecessor's retry chain + // polling — it would apply config captured from the old Posthog config + _active?._retryTimer?.cancel(); _active = this; _registerUnsafe(); } catch (e) { diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index f2c1166c..12290871 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -397,6 +397,30 @@ void main() { expect(capturedConfig, isNotNull); }); + test("a second register cancels the predecessor's retry chain", () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final stub = installPosthogStub(recordingStarted: true); + var setConfigCalls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + setConfigCalls++; + capturedConfig = cfg; + }).toJS, + ); + + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + test('restarts an in-flight recording so the new config applies', () { installPosthogStub(recordingStarted: true); From bdcbbe6e15f8b65ea2f2a15645aa454babdcb77d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 22:17:11 +0300 Subject: [PATCH 07/17] 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. --- .../replay/web/web_canvas_mask_provider.dart | 3 +++ .../test/web_canvas_mask_provider_test.dart | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index c0f53a9e..58c3933c 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -82,7 +82,10 @@ class WebCanvasMaskProvider { _active = this; _registerUnsafe(); } catch (e) { + // a partial first apply (set_config landed, restart threw) must not end + // the chain — the retry re-runs the whole apply, which is idempotent printIfDebug('PostHog: failed to register web canvas masking: $e'); + _scheduleRetry(const Duration(milliseconds: 250)); } } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 12290871..c434e227 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -421,6 +421,30 @@ void main() { expect(capturedConfig, isNotNull); }); + test('retries the full apply when the restart throws on first register', + () async { + final stub = installPosthogStub(recordingStarted: true); + var stopAttempts = 0; + stub.setProperty( + 'stopSessionRecording'.toJS, + (() { + stopAttempts++; + if (stopAttempts == 1) { + throw StateError('stub stop failure'); + } + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(startRecordingCalls, 0); + + await Future.delayed(const Duration(milliseconds: 600)); + + expect(stopAttempts, 2); + expect(startRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + test('restarts an in-flight recording so the new config applies', () { installPosthogStub(recordingStarted: true); From e7d982c383737e9dc644a0368765f89bf7836a6a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 18:19:27 +0300 Subject: [PATCH 08/17] fix(replay): honor maskAllTexts=false for Text widgets in the shared masking walk --- .../element_object_parser.dart | 13 ++- .../test/element_object_parser_test.dart | 82 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 posthog_flutter/test/element_object_parser_test.dart diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart index 98f80e92..25889ff7 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart @@ -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; + } } } diff --git a/posthog_flutter/test/element_object_parser_test.dart b/posthog_flutter/test/element_object_parser_test.dart new file mode 100644 index 00000000..953ac229 --- /dev/null +++ b/posthog_flutter/test/element_object_parser_test.dart @@ -0,0 +1,82 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + Future setupPosthog({ + required bool maskAllTexts, + required bool maskAllImages, + }) async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + final config = PostHogConfig('test_project_token'); + config.sessionReplayConfig.maskAllTexts = maskAllTexts; + config.sessionReplayConfig.maskAllImages = maskAllImages; + await Posthog().setup(config); + // the controller singleton may have been created before this setup + PostHogMaskController.instance.refreshParsers(config.sessionReplayConfig); + } + + tearDown(() async { + PostHogMaskController.instance.refreshParsers(null); + await Posthog().close(); + }); + + Future pumpTree(WidgetTester tester) async { + // real async work: it never completes inside the test's FakeAsync zone + final ui.Image image = (await tester.runAsync( + () => createTestImage(width: 20, height: 20), + ))!; + addTearDown(image.dispose); + await tester.pumpWidget( + MaterialApp( + home: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: Scaffold( + body: Column( + children: [ + const Text('some text'), + RawImage(image: image, width: 20, height: 20), + ], + ), + ), + ), + ), + ); + } + + Set types(List elements) => + elements.map((e) => e.type).toSet(); + + testWidgets( + 'maskAllTexts=false keeps Text out of the mask set even when ' + 'maskAllImages is on', (tester) async { + await setupPosthog(maskAllTexts: false, maskAllImages: true); + await pumpTree(tester); + + final elements = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + + expect(elements, isNotNull); + expect(types(elements!), isNot(contains('Text'))); + expect(types(elements), isNot(contains('RichText'))); + expect(types(elements), contains('RawImage')); + }); + + testWidgets('maskAllTexts=true still masks Text', (tester) async { + await setupPosthog(maskAllTexts: true, maskAllImages: true); + await pumpTree(tester); + + final elements = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + + expect(elements, isNotNull); + expect(types(elements!), contains('Text')); + }); +} From 7b8480d9719f537d72e98661210ee1f71571886b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 18:19:27 +0300 Subject: [PATCH 09/17] =?UTF-8?q?fix(replay):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20container=20transform,=20exact=20blockSelector=20to?= =?UTF-8?q?ken,=20foreign-view=20fail-closed,=20parser=20refresh,=20restar?= =?UTF-8?q?t=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../replay/mask/posthog_mask_controller.dart | 20 ++- .../replay/web/web_canvas_mask_provider.dart | 92 +++++++++-- .../test/posthog_mask_controller_test.dart | 15 ++ .../test/web_canvas_mask_provider_test.dart | 150 +++++++++++++++++- 4 files changed, 261 insertions(+), 16 deletions(-) diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart index b78bfd73..15dd6a73 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart @@ -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 parsers; + Map 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 _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); + } + static final PostHogMaskController instance = PostHogMaskController._privateConstructor( Posthog().config?.sessionReplayConfig, diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 58c3933c..d274bd52 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart' show View; import 'package:web/web.dart' as web; import '../../posthog_config.dart'; @@ -53,12 +55,18 @@ class WebCanvasMaskProvider { @visibleForTesting static String? debugMinPosthogJsVersionOverride; + // the test harness runs full-page, where the real host is and can + // never be distinguished from a foreign view's host + @visibleForTesting + static web.Element? debugOwnViewHostOverride; + @visibleForTesting static void resetForTesting() { _active?._retryTimer?.cancel(); _active = null; _warnedOldPosthogJs = false; debugMinPosthogJsVersionOverride = null; + debugOwnViewHostOverride = null; } final PostHogConfig _config; @@ -68,6 +76,7 @@ class WebCanvasMaskProvider { int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; bool _warnedWalkFailure = false; + bool _pendingRestart = false; // shared so a second provider's cache is not compared against a counter that // never advances; the callback cannot be removed once added @@ -80,6 +89,10 @@ class WebCanvasMaskProvider { // polling — it would apply config captured from the old Posthog config _active?._retryTimer?.cancel(); _active = this; + // the controller singleton may predate this setup() and still hold a + // parser map built from an older config's masking flags + PostHogMaskController.instance + .refreshParsers(_config.sessionReplayConfig); _registerUnsafe(); } catch (e) { // a partial first apply (set_config landed, restart threw) must not end @@ -190,10 +203,16 @@ class WebCanvasMaskProvider { ph.set_config(config); // blockSelector is only read when rrweb's record() starts, so an in-flight - // recording must be restarted + // recording must be restarted. _pendingRestart survives a stop that + // succeeded while the matching start threw: the retry sees the recording + // as already stopped and must still finish the restart. if (ph.sessionRecordingStarted()) { + _pendingRestart = true; ph.stopSessionRecording(); + } + if (_pendingRestart) { ph.startSessionRecording(); + _pendingRestart = false; } return true; } @@ -286,7 +305,13 @@ class WebCanvasMaskProvider { var selector = _semanticsBlockSelector; if (existing.isA()) { final current = (existing as JSString).toDart; - if (current.contains(_semanticsBlockSelector)) { + // token-exact: a user selector like `.not-flt-semantics-host` must not + // pass for the semantics-host selector itself + final alreadyBlocked = current + .split(',') + .map((token) => token.trim()) + .contains(_semanticsBlockSelector); + if (alreadyBlocked) { return; } selector = '$current, $_semanticsBlockSelector'; @@ -316,29 +341,41 @@ class WebCanvasMaskProvider { return null; } _consecutiveWalkFailures = 0; + + // our rects always describe PostHogWidget's tree — shipping them with a + // different flutter-view's canvas would record that view unmasked + if (!_isOwnViewCanvas(host)) { + return null; + } if (containerRects.isEmpty) { return JSArray(); } - // Flutter logical pixels == CSS pixels on web, but localToGlobal is - // relative to the flutter-view host, which may itself be embedded away - // from the viewport origin — hence the extra hostRect term - var containerOrigin = Offset.zero; + // rects are container-local, so map them through the container's full + // transform (an ancestor Transform.scale scales painted content, and a + // plain origin shift would leave the masks at the unscaled size); the + // hostRect term covers a flutter-view embedded away from the viewport + // origin, since Flutter logical pixels == CSS pixels on web + Matrix4? containerTransform; final containerObject = PostHogMaskController .instance.containerKey.currentContext ?.findRenderObject(); if (containerObject is RenderBox && containerObject.hasSize) { - containerOrigin = containerObject.localToGlobal(Offset.zero); + containerTransform = containerObject.getTransformTo(null); } final canvasRect = canvas.getBoundingClientRect(); final hostRect = host.getBoundingClientRect(); - final offset = containerOrigin + - Offset(hostRect.left, hostRect.top) - - Offset(canvasRect.left, canvasRect.top); + final offset = Offset( + hostRect.left - canvasRect.left, + hostRect.top - canvasRect.top, + ); final regions = []; for (final rect in containerRects) { - final shifted = rect.shift(offset); + final globalRect = containerTransform == null + ? rect + : MatrixUtils.transformRect(containerTransform, rect); + final shifted = globalRect.shift(offset); regions.add(_JSMaskRegion( x: shifted.left, y: shifted.top, @@ -349,6 +386,39 @@ class WebCanvasMaskProvider { return regions.toJS; } + // In full-page mode the embedder host is , which contains every + // flutter-view on the page, so only a view embedded in a dedicated host + // element (multi-view) can be told apart from ours. + bool _isOwnViewCanvas(web.Element canvasViewHost) { + final ownHost = debugOwnViewHostOverride ?? _resolveOwnViewHost(); + if (ownHost != null) { + return ownHost.contains(canvasViewHost); + } + // unresolvable: with a single flutter-view it can only be ours + return web.document.querySelectorAll('flutter-view').length <= 1; + } + + web.Element? _resolveOwnViewHost() { + try { + final context = + PostHogMaskController.instance.containerKey.currentContext; + if (context == null) { + return null; + } + final viewId = View.maybeOf(context)?.viewId; + if (viewId == null) { + return null; + } + final hostElement = ui_web.views.getHostElement(viewId); + if (hostElement != null && hostElement.isA()) { + return hostElement as web.Element; + } + } catch (e) { + printIfDebug('PostHog: could not resolve the Flutter view host: $e'); + } + return null; + } + // rects only change when Flutter paints a frame, so cache per frame instead // of recomputing on every posthog-js canvas tick List? _currentContainerRects() { diff --git a/posthog_flutter/test/posthog_mask_controller_test.dart b/posthog_flutter/test/posthog_mask_controller_test.dart index f72a0faa..77cc6011 100644 --- a/posthog_flutter/test/posthog_mask_controller_test.dart +++ b/posthog_flutter/test/posthog_mask_controller_test.dart @@ -64,4 +64,19 @@ void main() { isNull, ); }); + + test('refreshParsers rebuilds the parser map from the new config', () { + final controller = PostHogMaskController.instance; + addTearDown(() => controller.refreshParsers(null)); + + final textsOnly = PostHogConfig('phc_test').sessionReplayConfig + ..maskAllImages = false; + controller.refreshParsers(textsOnly); + expect(controller.parsers.keys, isNot(contains('RenderImage'))); + expect(controller.parsers.keys, contains('RenderParagraph')); + + final imagesToo = PostHogConfig('phc_test').sessionReplayConfig; + controller.refreshParsers(imagesToo); + expect(controller.parsers.keys, contains('RenderImage')); + }); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index c434e227..3eff2668 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -54,20 +54,23 @@ void main() { capturedConfig = cfg; }).toJS, ); + var recordingState = recordingStarted; stub.setProperty( 'sessionRecordingStarted'.toJS, - (() => recordingStarted.toJS).toJS, + (() => recordingState.toJS).toJS, ); stub.setProperty( 'stopSessionRecording'.toJS, (() { stopRecordingCalls++; + recordingState = false; }).toJS, ); stub.setProperty( 'startSessionRecording'.toJS, (() { startRecordingCalls++; + recordingState = true; }).toJS, ); if (version != null) { @@ -188,6 +191,23 @@ void main() { ); }); + test( + 'appends the semantics selector when an existing one only contains it ' + 'as a substring', () { + final existingSessionRecording = JSObject() + ..setProperty('blockSelector'.toJS, '.not-flt-semantics-host'.toJS); + installPosthogStub(sessionRecording: existingSessionRecording); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect( + capturedSessionRecording() + .getProperty('blockSelector'.toJS) + .dartify(), + '.not-flt-semantics-host, flt-semantics-host', + ); + }); + test('blocks the semantics host even when maskAllTexts is false', () { installPosthogStub(); final config = PostHogConfig('phc_test') @@ -315,6 +335,108 @@ void main() { } }); + testWidgets('scales mask rects by an ancestor transform around the container', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + Transform.scale( + scale: 2, + alignment: Alignment.topLeft, + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 0; top: 0'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 0; top: 0'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + // container-local (0,0,30,40) outsets to (-1,-1,32,42), then doubles + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect(region.getProperty('x'.toJS).toDartDouble, -2); + expect(region.getProperty('y'.toJS).toDartDouble, -2); + expect(region.getProperty('width'.toJS).toDartDouble, 64); + expect(region.getProperty('height'.toJS).toDartDouble, 84); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'fails closed for a canvas in a foreign flutter-view on a multi-view ' + 'page', (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ); + + web.Element embeddedView(web.Element host) { + final view = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + view.appendChild(canvas); + host.appendChild(view); + return canvas; + } + + final ownHost = web.document.createElement('div'); + final ownCanvas = embeddedView(ownHost); + final foreignHost = web.document.createElement('div'); + final foreignCanvas = embeddedView(foreignHost); + web.document.body!.appendChild(ownHost); + web.document.body!.appendChild(foreignHost); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = ownHost; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, foreignCanvas), isNull); + final regions = + regionsFn.callAsFunction(null, ownCanvas) as JSArray; + expect(regions.toDart, hasLength(1)); + } finally { + ownHost.remove(); + foreignHost.remove(); + } + }); + test('defers set_config until posthog-js exposes its config', () { installPosthogStub(withConfig: false); @@ -445,6 +567,32 @@ void main() { expect(capturedConfig, isNotNull); }); + test('finishes the restart when stop succeeds but start throws', () async { + final stub = installPosthogStub(recordingStarted: true); + var startAttempts = 0; + stub.setProperty( + 'startSessionRecording'.toJS, + (() { + startAttempts++; + if (startAttempts == 1) { + throw StateError('stub start failure'); + } + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + // the stub now reports the recording as stopped, so the retry must not + // skip the restart block + expect(stopRecordingCalls, 1); + expect(startAttempts, 1); + + await Future.delayed(const Duration(milliseconds: 600)); + + expect(startAttempts, 2); + expect(stopRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + test('restarts an in-flight recording so the new config applies', () { installPosthogStub(recordingStarted: true); From 551ff8c9c97a97f15916d233c9e254124625a3da Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 18:19:27 +0300 Subject: [PATCH 10/17] docs(changeset): trim the canvas-masking changeset to the essentials --- .changeset/canvas-masking-web.md | 70 ++++++++++++-------------------- 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index 6de8164a..624ebe99 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -4,12 +4,12 @@ **Session replay masking now works on Flutter web** ([#496](https://github.com/PostHog/posthog-flutter/issues/496)). -On Flutter web your app is painted into a single ``, so PostHog's DOM-based -masking could not see any of your text — session recordings captured it in the clear -even if you had masking configured. Masking now applies inside the canvas. +On Flutter web the app is painted into a single ``, so DOM-based masking +could not see any of your text. Masking now applies inside the canvas: +`maskAllTexts`, `maskAllImages`, `PostHogMaskWidget` and obscured text fields all +mask canvas content, and Flutter's accessibility DOM is excluded from capture. -To enable it, add `maskRegionsFn` to the `posthog.init` call in your -`web/index.html`: +To enable it, add `maskRegionsFn` to `posthog.init` in your `web/index.html`: ```js posthog.init('', { @@ -26,45 +26,25 @@ posthog.init('', { }) ``` -Once enabled, `sessionReplayConfig.maskAllTexts`, `maskAllImages`, -`PostHogMaskWidget` and obscured text fields all mask canvas content, and Flutter's -accessibility tree is excluded from DOM capture so your text is not recorded through -it either. +The contract is fail-closed: -Notes: +- Without `maskRegionsFn` the plugin changes nothing — including + `PostHogMaskWidget`, which has no effect on web unless you opt in (iOS and + Android need no setup). +- Your app must be wrapped in `PostHogWidget`; if the widget tree can't be + walked, canvas frames are skipped instead of recorded unmasked, and a console + warning explains the fix. +- Full DOM snapshots skip canvas pixel serialization while `maskRegionsFn` is + configured, so they can't embed an unmasked screenshot of the app. +- On a posthog-js without `canvasCapture.maskRegionsFn` support (minimum version + pinned at release) canvas frames are NOT masked — a console warning tells you + to upgrade. -- If you leave `maskRegionsFn` out, the plugin changes nothing and recording - behaves exactly as posthog-js is configured — as it did before this release. - **This includes `PostHogMaskWidget`**: on web it has no effect unless - `maskRegionsFn` is declared, because the canvas is masked by posthog-js and - the plugin only supplies rectangles to it once you have opted in. On iOS and - Android `PostHogMaskWidget` continues to work with no extra setup. - If canvas recording is enabled in your project settings rather than in - `posthog.init`, the plugin cannot detect it and will not warn. -- Web replay is configured entirely in `posthog.init`. The `config.sessionReplay` - Dart flag drives iOS/Android screenshot capture only and does not affect what - is recorded on web. -- When you opt in, the plugin adds `flt-semantics-host` to - `session_recording.blockSelector` so Flutter's accessibility tree is not recorded - as plaintext. A client-side `blockSelector` takes precedence over the one in your - project's Privacy and masking settings, so if you rely on a project-level selector, - list it in `posthog.init` as well — the plugin merges with what is there and cannot - see the project-level value. posthog-js may also log a notice about `blockSelector` - in `posthog.init` for this reason. -- rrweb's DOM full snapshot serializes canvas pixels on a separate path - (`rr_dataURL`) that mask regions do not touch, and on current Flutter the - CanvasKit canvas is readable through it — a full snapshot can embed an - unmasked screenshot of your whole app. posthog-js therefore skips canvas - pixel serialization in full snapshots whenever `maskRegionsFn` is configured. - Declaring it in `posthog.init` covers this from the moment recording starts, - and the plugin's registration restart carries it through every later snapshot. -- Widgets rendered as DOM rather than canvas pixels — `HtmlElementView`-based platform - views such as maps, webviews and iframes — are recorded as DOM and masked by - posthog-js's DOM rules, not by canvas mask regions. `PostHogMaskWidget` around a - platform view does not mask it on web. -- Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are - skipped instead of recorded unmasked, and a console warning explains the fix. -- Requires a posthog-js version that supports `canvasCapture.maskRegionsFn` - (minimum version pinned at release). On an older posthog-js the plugin still - registers — the accessibility-tree exclusion works there — but canvas frames - are NOT masked, and a console warning tells you to upgrade. +Caveats: web replay is configured in `posthog.init` (the Dart `sessionReplay` +flag drives iOS/Android only); list any project-level `blockSelector` in +`posthog.init` too, as the client-side selector takes precedence; DOM-rendered +platform views (`HtmlElementView`) follow posthog-js's DOM masking rules, not +canvas mask regions. + +Also fixes `maskAllTexts: false` still masking `Text` widgets when +`maskAllImages` is on — this applies to iOS/Android screenshot masking too. From 017c8a1bfd86885bcfed11c833ec860021f8aa93 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 19:25:54 +0300 Subject: [PATCH 11/17] 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. --- .../replay/web/web_canvas_mask_provider.dart | 11 +++++----- .../test/web_canvas_mask_provider_test.dart | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index d274bd52..4151ea9a 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -123,16 +123,17 @@ class WebCanvasMaskProvider { // leave its canvas frames skipped (maskRegionsFn stuck at () => null) void _scheduleRetry(Duration delay) { _retryTimer = Timer(delay, () { - var next = delay; + // ramped up front so a throwing apply backs off like the + // posthog-not-ready path instead of retrying at a fixed 250ms forever + final doubled = delay * 2; + final next = doubled > const Duration(seconds: 4) + ? const Duration(seconds: 4) + : doubled; try { final current = posthog; if (current != null && _tryApplyConfig(current)) { return; } - final doubled = delay * 2; - next = doubled > const Duration(seconds: 4) - ? const Duration(seconds: 4) - : doubled; } catch (e) { printIfDebug('PostHog: web canvas masking retry failed: $e'); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 3eff2668..28660fe4 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -567,6 +567,26 @@ void main() { expect(capturedConfig, isNotNull); }); + test('backs off when the apply keeps throwing', () async { + final stub = installPosthogStub(); + var attempts = 0; + void failingSetConfig(JSObject cfg) { + attempts++; + throw StateError('stub failure'); + } + + stub.setProperty('set_config'.toJS, failingSetConfig.toJS); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await Future.delayed(const Duration(milliseconds: 2200)); + + // the doubling chain (250, 500, 1000, 2000ms) allows ~4 attempts in this + // window; fixed-250ms retries would reach ~9 + expect(attempts, greaterThanOrEqualTo(3)); + expect(attempts, lessThanOrEqualTo(5)); + }); + test('finishes the restart when stop succeeds but start throws', () async { final stub = installPosthogStub(recordingStarted: true); var startAttempts = 0; From 75b59e48aea0e5dcda85f4f0eaa3d69428b47a19 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 19:56:27 +0300 Subject: [PATCH 12/17] docs(changeset): note multi-view foreign Flutter canvases are skipped Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index 624ebe99..bc4069ae 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -44,7 +44,9 @@ Caveats: web replay is configured in `posthog.init` (the Dart `sessionReplay` flag drives iOS/Android only); list any project-level `blockSelector` in `posthog.init` too, as the client-side selector takes precedence; DOM-rendered platform views (`HtmlElementView`) follow posthog-js's DOM masking rules, not -canvas mask regions. +canvas mask regions; on a page embedding multiple Flutter views, canvases +belonging to other Flutter views are skipped entirely (not recorded), since this +plugin's mask regions only describe its own view. Also fixes `maskAllTexts: false` still masking `Text` widgets when `maskAllImages` is on — this applies to iOS/Android screenshot masking too. From 06c43d27e55ca36e0726e921994428beecdd5e19 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 23:35:12 +0300 Subject: [PATCH 13/17] chore(replay): pin the posthog-js minimum to 1.408.0 (ships maskRegionsFn) Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 6 +++--- .../lib/src/replay/web/web_canvas_mask_provider.dart | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index bc4069ae..2c602fed 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -36,9 +36,9 @@ The contract is fail-closed: warning explains the fix. - Full DOM snapshots skip canvas pixel serialization while `maskRegionsFn` is configured, so they can't embed an unmasked screenshot of the app. -- On a posthog-js without `canvasCapture.maskRegionsFn` support (minimum version - pinned at release) canvas frames are NOT masked — a console warning tells you - to upgrade. +- On a posthog-js without `canvasCapture.maskRegionsFn` support (older than + 1.408.0) canvas frames are NOT masked — a console warning tells you to + upgrade. Caveats: web replay is configured in `posthog.init` (the Dart `sessionReplay` flag drives iOS/Android only); list any project-level `blockSelector` in diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 4151ea9a..4c5de897 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -29,10 +29,9 @@ external JSObject _objectAssign(JSObject target, JSObject source); const _semanticsBlockSelector = 'flt-semantics-host'; -// Placeholder that keeps the gate inert: must be pinned to the first -// posthog-js release containing canvasCapture.maskRegionsFn support -// (github.com/PostHog/posthog-js#4270) before this package releases. -const _minPosthogJsVersion = '0.0.0'; +// First posthog-js release with canvasCapture.maskRegionsFn support +// (github.com/PostHog/posthog-js#4270). +const _minPosthogJsVersion = '1.408.0'; /// Supplies widget-tree mask rectangles to posthog-js canvas recording via /// `session_recording.canvasCapture.maskRegionsFn`, so text painted From 6e41deb20f878d3aef60649800affecbc83e914b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 16:49:43 +0300 Subject: [PATCH 14/17] =?UTF-8?q?docs(changeset):=20rewrite=20entries=20pe?= =?UTF-8?q?r=20changelog=20style=20=E2=80=94=20one-line,=20user-facing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 49 +------------------------------- .changeset/mask-text-flag-fix.md | 5 ++++ 2 files changed, 6 insertions(+), 48 deletions(-) create mode 100644 .changeset/mask-text-flag-fix.md diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index 2c602fed..a608164b 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -2,51 +2,4 @@ "posthog_flutter": minor --- -**Session replay masking now works on Flutter web** ([#496](https://github.com/PostHog/posthog-flutter/issues/496)). - -On Flutter web the app is painted into a single ``, so DOM-based masking -could not see any of your text. Masking now applies inside the canvas: -`maskAllTexts`, `maskAllImages`, `PostHogMaskWidget` and obscured text fields all -mask canvas content, and Flutter's accessibility DOM is excluded from capture. - -To enable it, add `maskRegionsFn` to `posthog.init` in your `web/index.html`: - -```js -posthog.init('', { - session_recording: { - captureCanvas: { - recordCanvas: true, - }, - canvasCapture: { - // the plugin replaces this once Flutter has started; until then - // frames are skipped rather than recorded unmasked - maskRegionsFn: () => null, - }, - }, -}) -``` - -The contract is fail-closed: - -- Without `maskRegionsFn` the plugin changes nothing — including - `PostHogMaskWidget`, which has no effect on web unless you opt in (iOS and - Android need no setup). -- Your app must be wrapped in `PostHogWidget`; if the widget tree can't be - walked, canvas frames are skipped instead of recorded unmasked, and a console - warning explains the fix. -- Full DOM snapshots skip canvas pixel serialization while `maskRegionsFn` is - configured, so they can't embed an unmasked screenshot of the app. -- On a posthog-js without `canvasCapture.maskRegionsFn` support (older than - 1.408.0) canvas frames are NOT masked — a console warning tells you to - upgrade. - -Caveats: web replay is configured in `posthog.init` (the Dart `sessionReplay` -flag drives iOS/Android only); list any project-level `blockSelector` in -`posthog.init` too, as the client-side selector takes precedence; DOM-rendered -platform views (`HtmlElementView`) follow posthog-js's DOM masking rules, not -canvas mask regions; on a page embedding multiple Flutter views, canvases -belonging to other Flutter views are skipped entirely (not recorded), since this -plugin's mask regions only describe its own view. - -Also fixes `maskAllTexts: false` still masking `Text` widgets when -`maskAllImages` is on — this applies to iOS/Android screenshot masking too. +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`) diff --git a/.changeset/mask-text-flag-fix.md b/.changeset/mask-text-flag-fix.md new file mode 100644 index 00000000..1a87bd8b --- /dev/null +++ b/.changeset/mask-text-flag-fix.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Fix `maskAllTexts: false` still masking `Text` widgets when `maskAllImages` is enabled From 863317e8f8c8ce3034bc8fdb5b066748742e399a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 17:11:53 +0300 Subject: [PATCH 15/17] fix(replay): fail closed when multiple Flutter views share one host element In full-page mode the embedder host is ; 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 --- .../replay/web/web_canvas_mask_provider.dart | 16 +++++- .../test/web_canvas_mask_provider_test.dart | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 4c5de897..461a04ff 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -75,6 +75,7 @@ class WebCanvasMaskProvider { int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; bool _warnedWalkFailure = false; + bool _warnedAmbiguousHost = false; bool _pendingRestart = false; // shared so a second provider's cache is not compared against a counter that @@ -387,11 +388,22 @@ class WebCanvasMaskProvider { } // In full-page mode the embedder host is , which contains every - // flutter-view on the page, so only a view embedded in a dedicated host - // element (multi-view) can be told apart from ours. + // flutter-view on the page — containment only proves ownership when the + // host holds a single flutter-view. With more, our rects could be paired + // with a foreign view's canvas and record it unmasked, so fail closed. bool _isOwnViewCanvas(web.Element canvasViewHost) { final ownHost = debugOwnViewHostOverride ?? _resolveOwnViewHost(); if (ownHost != null) { + if (ownHost.querySelectorAll('flutter-view').length > 1) { + if (!_warnedAmbiguousHost) { + _warnedAmbiguousHost = true; + printIfDebug( + 'PostHog: multiple Flutter views share one host element, so ' + 'canvas frames are skipped — masks cannot be matched to a view.', + ); + } + return false; + } return ownHost.contains(canvasViewHost); } // unresolvable: with a single flutter-view it can only be ours diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 28660fe4..f665b9b3 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -316,6 +316,9 @@ void main() { installPosthogStub(); try { + // the harness's own flutter-view plus this fake one make the real + // host ambiguous, which now fails closed + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; WebCanvasMaskProvider(config).register(); final regionsFn = capturedSessionRecording() @@ -365,6 +368,7 @@ void main() { installPosthogStub(); try { + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; WebCanvasMaskProvider(config).register(); final regionsFn = capturedSessionRecording() @@ -437,6 +441,55 @@ void main() { } }); + testWidgets( + 'fails closed for every canvas when the host contains more than one ' + 'flutter-view', (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ); + + web.Element embeddedView(web.Element host) { + final view = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + view.appendChild(canvas); + host.appendChild(view); + return canvas; + } + + // full-page mode: getHostElement resolves to , which hosts both + // our view and the foreign one + final sharedHost = web.document.createElement('div'); + final ownCanvas = embeddedView(sharedHost); + final foreignCanvas = embeddedView(sharedHost); + web.document.body!.appendChild(sharedHost); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = sharedHost; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, ownCanvas), isNull); + expect(regionsFn.callAsFunction(null, foreignCanvas), isNull); + } finally { + sharedHost.remove(); + } + }); + test('defers set_config until posthog-js exposes its config', () { installPosthogStub(withConfig: false); From 6258c55a64a56c403d1cc6232b03e9c3d8adc41d Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:52:18 +0300 Subject: [PATCH 16/17] feat(replay): PostHogMaskWidget enables web canvas masking on its own (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) 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 * docs(changeset): note pre-mount full snapshots can embed unmasked canvas stills Co-Authored-By: Claude Fable 5 * 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 * docs(changeset): rewrite the mask-widget entry per changelog style Co-Authored-By: Claude Fable 5 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/canvas-masking-web.md | 2 +- example/lib/masking_tests_screen.dart | 2 +- .../mask/canvas_mask_registration_io.dart | 7 + .../mask/canvas_mask_registration_web.dart | 80 ++++ .../src/replay/mask/posthog_mask_widget.dart | 21 +- .../replay/web/web_canvas_mask_provider.dart | 209 +++++++-- .../test/web_canvas_mask_provider_test.dart | 405 +++++++++++++++++- 7 files changed, 690 insertions(+), 36 deletions(-) create mode 100644 posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart create mode 100644 posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index a608164b..8242424c 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -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+) diff --git a/example/lib/masking_tests_screen.dart b/example/lib/masking_tests_screen.dart index dd1e3ffa..12a50855 100644 --- a/example/lib/masking_tests_screen.dart +++ b/example/lib/masking_tests_screen.dart @@ -256,7 +256,7 @@ class _MaskingTestsScreenState extends State { // 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, diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart new file mode 100644 index 00000000..b9e993e1 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart @@ -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) {} diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart new file mode 100644 index 00000000..a606d271 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -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; +} diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 74fd339e..826135ad 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -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; @@ -27,10 +40,12 @@ class PostHogMaskWidgetState extends State { @override void initState() { super.initState(); + notifyMaskWidgetMounted(context); } @override void dispose() { + notifyMaskWidgetUnmounted(context); super.dispose(); } diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 461a04ff..88d21e0c 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -6,7 +6,8 @@ import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart' show View; +import 'package:flutter/widgets.dart' + show BuildContext, ModalRoute, Navigator, View; import 'package:web/web.dart' as web; import '../../posthog_config.dart'; @@ -33,15 +34,25 @@ const _semanticsBlockSelector = 'flt-semantics-host'; // (github.com/PostHog/posthog-js#4270). const _minPosthogJsVersion = '1.408.0'; +enum _ApplyResult { + applied, + + /// posthog-js is up but the app has not opted into canvas masking. + notOptedIn, + posthogNotReady, +} + /// Supplies widget-tree mask rectangles to posthog-js canvas recording via /// `session_recording.canvasCapture.maskRegionsFn`, so text painted /// into the CanvasKit canvas can be masked even though DOM masking cannot /// see it. /// -/// An app opts in by declaring `maskRegionsFn` in its `posthog.init` -/// call; declaring it as `() => null` also covers the frames captured before -/// this provider takes over. Without that key nothing is registered, so -/// recording is left exactly as posthog-js configured it. +/// An app opts in either by declaring `maskRegionsFn` in its +/// `posthog.init` call — declaring it as `() => null` also covers the frames +/// captured before this provider takes over — or by mounting a +/// `PostHogMaskWidget`, which registers the provider on first mount. Without +/// either, nothing is registered and recording is left exactly as posthog-js +/// configured it. /// /// Fails closed: a failed widget-tree walk returns null, which makes /// posthog-js skip the frame instead of shipping it unmasked. @@ -49,7 +60,9 @@ class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); static WebCanvasMaskProvider? _active; + static bool _maskWidgetSeen = false; static bool _warnedOldPosthogJs = false; + static final Set _mountedMaskWidgets = {}; @visibleForTesting static String? debugMinPosthogJsVersionOverride; @@ -59,10 +72,56 @@ class WebCanvasMaskProvider { @visibleForTesting static web.Element? debugOwnViewHostOverride; + /// Opts the app into canvas masking because a `PostHogMaskWidget` mounted. + /// + /// Called from shared widget code through a conditional import, so it must + /// stay safe to call any number of times; a mount that happens before + /// [register] is remembered and applied when [register] runs. + static void notifyMaskWidgetMounted() { + if (_maskWidgetSeen) { + return; + } + _maskWidgetSeen = true; + try { + _active?._onMaskWidgetMounted(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + } + } + + static void registerMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.add(context); + } + + /// The render object rooting the tree the masking walk actually covers, + /// mirroring `RootElementProvider`: when the tracked context sits under an + /// active route the walk roots at the root navigator (so it covers dialogs + /// and other routes), otherwise at the tracked context itself. Every + /// tracked-tree membership check must use this same root, or a mask widget + /// in a dialog the walk covers would be treated as untracked. + static RenderObject? trackedTreeRoot(BuildContext trackedContext) { + try { + if (ModalRoute.of(trackedContext)?.isActive ?? false) { + return Navigator.of(trackedContext, rootNavigator: true) + .context + .findRenderObject(); + } + } catch (e) { + printIfDebug('PostHog: could not resolve the tracked-tree root: $e'); + } + return trackedContext.findRenderObject(); + } + + static void unregisterMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.remove(context); + } + @visibleForTesting static void resetForTesting() { _active?._retryTimer?.cancel(); _active = null; + _maskWidgetSeen = false; + _mountedMaskWidgets.clear(); _warnedOldPosthogJs = false; debugMinPosthogJsVersionOverride = null; debugOwnViewHostOverride = null; @@ -75,7 +134,11 @@ class WebCanvasMaskProvider { int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; bool _warnedWalkFailure = false; + bool _warnedMaskWidgetOutsideTree = false; bool _warnedAmbiguousHost = false; + bool _applied = false; + bool _maskWidgetMounted = false; + bool _polling = false; bool _pendingRestart = false; // shared so a second provider's cache is not compared against a counter that @@ -89,11 +152,12 @@ class WebCanvasMaskProvider { // polling — it would apply config captured from the old Posthog config _active?._retryTimer?.cancel(); _active = this; + _maskWidgetMounted = _maskWidgetSeen; // the controller singleton may predate this setup() and still hold a // parser map built from an older config's masking flags PostHogMaskController.instance .refreshParsers(_config.sessionReplayConfig); - _registerUnsafe(); + _pump(); } catch (e) { // a partial first apply (set_config landed, restart threw) must not end // the chain — the retry re-runs the whole apply, which is idempotent @@ -102,9 +166,27 @@ class WebCanvasMaskProvider { } } - void _registerUnsafe() { - final ph = posthog; - if (ph != null && _tryApplyConfig(ph)) { + void _onMaskWidgetMounted() { + if (_maskWidgetMounted) { + return; + } + _maskWidgetMounted = true; + // a retry chain still in flight picks the flag up on its next tick + if (_applied || _polling) { + return; + } + try { + _pump(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + // the opt-in is latched, so keep a chain alive to apply it later — + // otherwise a failed first apply would consume the opt-in for good + _scheduleRetry(const Duration(milliseconds: 250)); + } + } + + void _pump() { + if (_apply() != _ApplyResult.posthogNotReady) { return; } // the posthog-js snippet installs a config-less stub and array.js later @@ -122,6 +204,11 @@ class WebCanvasMaskProvider { // posthog.init minutes after Flutter boots, and giving up would silently // leave its canvas frames skipped (maskRegionsFn stuck at () => null) void _scheduleRetry(Duration delay) { + // every scheduling path must set _polling, and at most one timer may be + // outstanding — a sibling chain surviving here could outlive the + // cancellation in register() and reapply a stale provider's config + _retryTimer?.cancel(); + _polling = true; _retryTimer = Timer(delay, () { // ramped up front so a throwing apply backs off like the // posthog-not-ready path instead of retrying at a fixed 250ms forever @@ -130,8 +217,8 @@ class WebCanvasMaskProvider { ? const Duration(seconds: 4) : doubled; try { - final current = posthog; - if (current != null && _tryApplyConfig(current)) { + if (_apply() != _ApplyResult.posthogNotReady) { + _polling = false; return; } } catch (e) { @@ -163,9 +250,13 @@ class WebCanvasMaskProvider { return loaded.isA() && (loaded as JSBoolean).toDart; } - bool _tryApplyConfig(PostHog ph) { - if (!_isInitialized(ph)) { - return false; + _ApplyResult _apply() { + if (_applied) { + return _ApplyResult.applied; + } + final ph = posthog; + if (ph == null || !_isInitialized(ph)) { + return _ApplyResult.posthogNotReady; } // shallow-merge on top of any user-provided session_recording config — @@ -183,11 +274,12 @@ class WebCanvasMaskProvider { _objectAssign(canvasCapture, existingCanvasCapture as JSObject); } // the app opts into canvas masking by declaring maskRegionsFn in - // posthog.init — registering regardless would restart an in-flight - // recording and drop the semantics tree for apps that never asked - if (!canvasCapture.has('maskRegionsFn')) { + // posthog.init or by mounting a PostHogMaskWidget — registering regardless + // would restart an in-flight recording and drop the semantics tree for + // apps that never asked + if (!canvasCapture.has('maskRegionsFn') && !_maskWidgetMounted) { _warnNotOptedIn(sessionRecording); - return true; + return _ApplyResult.notOptedIn; } _warnIfPosthogJsTooOld(ph); _ensureFrameCounter(); @@ -204,9 +296,12 @@ class WebCanvasMaskProvider { ph.set_config(config); // blockSelector is only read when rrweb's record() starts, so an in-flight - // recording must be restarted. _pendingRestart survives a stop that - // succeeded while the matching start threw: the retry sees the recording - // as already stopped and must still finish the restart. + // recording must be restarted — worth it even with canvas capture off, + // since the semantics exclusion applies to plain DOM recording too (and + // recordCanvas can arrive from remote config after init anyway). + // _pendingRestart survives a stop that succeeded while the matching start + // threw: the retry sees the recording as already stopped and must still + // finish the restart. if (ph.sessionRecordingStarted()) { _pendingRestart = true; ph.stopSessionRecording(); @@ -215,15 +310,18 @@ class WebCanvasMaskProvider { ph.startSessionRecording(); _pendingRestart = false; } - return true; + // only after the restart: a throw above must leave the apply retryable, + // or the selector never takes effect for this recording + _applied = true; + return _ApplyResult.applied; } // canvas recording can also be switched on from project settings, which the // plugin cannot read — so only the posthog.init half of the leak is warnable void _warnNotOptedIn(JSObject sessionRecording) { printIfDebug( - 'PostHog: maskRegionsFn is not declared in posthog.init, ' - 'so Flutter web canvas masking is off.', + 'PostHog: Flutter web canvas masking is off — mount a PostHogMaskWidget ' + 'or declare maskRegionsFn in posthog.init to enable it.', ); final replayConfig = _config.sessionReplayConfig; if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { @@ -238,9 +336,11 @@ class WebCanvasMaskProvider { (captureCanvas as JSObject).getProperty('recordCanvas'.toJS); if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { web.console.warn( - 'PostHog: canvas session recording is enabled but maskRegionsFn ' - 'is missing from posthog.init, so text painted by Flutter is recorded ' - 'unmasked. See the posthog_flutter CHANGELOG for the snippet.' + 'PostHog: canvas session recording is enabled but masking is not, so ' + 'text painted by Flutter is recorded unmasked. Mount a ' + 'PostHogMaskWidget to enable masking, or declare ' + 'maskRegionsFn in posthog.init (see the posthog_flutter ' + 'CHANGELOG for the snippet).' .toJS, ); } @@ -343,6 +443,18 @@ class WebCanvasMaskProvider { } _consecutiveWalkFailures = 0; + if (!_maskWidgetsInsideTrackedTree()) { + if (!_warnedMaskWidgetOutsideTree) { + _warnedMaskWidgetOutsideTree = true; + printIfDebug( + 'PostHog: a PostHogMaskWidget is mounted outside the PostHogWidget ' + 'tree, so masking could never cover it — canvas frames are skipped ' + 'until it is moved inside PostHogWidget or removed.', + ); + } + return null; + } + // our rects always describe PostHogWidget's tree — shipping them with a // different flutter-view's canvas would record that view unmasked if (!_isOwnViewCanvas(host)) { @@ -431,6 +543,45 @@ class WebCanvasMaskProvider { return null; } + // Re-checked on every frame, not only at mount: a mask widget that mounted + // before any PostHogWidget latches the opt-in, and a PostHogWidget mounting + // later without containing it would otherwise ship rects that never cover + // the widget. Deliberately outside the per-frame rects cache, so a cached + // value cannot outlive a tree change. The boundary is [trackedTreeRoot], + // the walk's own route-dependent root — not PostHogWidget's subtree. + bool _maskWidgetsInsideTrackedTree() { + if (_mountedMaskWidgets.isEmpty) { + return true; + } + final trackedContext = + PostHogMaskController.instance.containerKey.currentContext; + if (trackedContext == null) { + return false; + } + final tracked = trackedTreeRoot(trackedContext); + if (tracked == null) { + return false; + } + for (final context in _mountedMaskWidgets) { + if (!context.mounted) { + continue; + } + var inside = false; + RenderObject? node = context.findRenderObject(); + while (node != null) { + if (identical(node, tracked)) { + inside = true; + break; + } + node = node.parent; + } + if (!inside) { + return false; + } + } + return true; + } + // rects only change when Flutter paints a frame, so cache per frame instead // of recomputing on every posthog-js canvas tick List? _currentContainerRects() { @@ -467,9 +618,7 @@ class WebCanvasMaskProvider { web.console.warn( 'PostHog: session replay masking cannot find the PostHogWidget ' 'widget tree, so canvas frames are not being recorded. ' - 'Wrap your app in PostHogWidget, or remove maskRegionsFn ' - 'from your posthog.init to disable canvas masking (the canvas ' - 'is then recorded unmasked).' + 'Wrap your app in PostHogWidget.' .toJS, ); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index f665b9b3..cfc7d594 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -4,7 +4,7 @@ library; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; @@ -14,6 +14,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); JSObject? capturedConfig; + var setConfigCalls = 0; var stopRecordingCalls = 0; var startRecordingCalls = 0; @@ -26,6 +27,7 @@ void main() { String? version, }) { capturedConfig = null; + setConfigCalls = 0; stopRecordingCalls = 0; startRecordingCalls = 0; final stub = JSObject(); @@ -52,6 +54,7 @@ void main() { 'set_config'.toJS, ((JSObject cfg) { capturedConfig = cfg; + setConfigCalls++; }).toJS, ); var recordingState = recordingStarted; @@ -123,6 +126,389 @@ void main() { expect(startRecordingCalls, 0); }); + testWidgets('opts in when a PostHogMaskWidget mounted before register()', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 0); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('a mounted PostHogMaskWidget opts the app in on its own', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS) + .isA(), + isTrue, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('registers once however many PostHogMaskWidgets mount', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget maskWidgets(int count) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: List.generate( + count, + (_) => const PostHogMaskWidget(child: SizedBox.shrink()), + ), + ), + ); + await tester.pumpWidget(maskWidgets(3)); + await tester.pumpWidget(maskWidgets(5)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a PostHogMaskWidget mount is a no-op when posthog.init already ' + 'opted in', (tester) async { + installPosthogStub(recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(setConfigCalls, 1); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'an exception during the mount-triggered apply does not consume the ' + 'opt-in', (tester) async { + final stub = installPosthogStub(declaresMaskProvider: false); + var calls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + calls++; + if (calls == 1) { + throw StateError('stub failure'); + } + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + await tester.pump(const Duration(milliseconds: 600)); + + expect(calls, 2); + expect(capturedConfig, isNotNull); + }); + + testWidgets('opts in once posthog-js arrives after the widget mounted', + (tester) async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + installPosthogStub(declaresMaskProvider: false); + await tester.pump(const Duration(milliseconds: 300)); + + expect(setConfigCalls, 1); + }); + + testWidgets( + 'a retry chain from a throw-path apply cannot outlive a second ' + 'register()', (tester) async { + final stub = installPosthogStub(recordingStarted: true); + var successfulSetConfigs = 0; + var failing = true; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + if (failing) { + throw StateError('stub failure'); + } + successfulSetConfigs++; + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + // the first apply threw, so a retry is pending; a mask widget mounting + // now must join that chain instead of starting a second one + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(successfulSetConfigs, 0); + + failing = false; + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(successfulSetConfigs, 1); + + // an orphaned first-chain timer would fire here and apply a second time + await tester.pump(const Duration(seconds: 5)); + + expect(successfulSetConfigs, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a mask widget outside the tracked PostHogWidget tree does not opt in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ), + ); + + expect(setConfigCalls, 0); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + testWidgets( + 'a mask widget that mounts before any PostHogWidget opts in ' + '(no-PostHogWidget shape), but a later PostHogWidget that excludes it ' + 'makes frames fail closed', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget layout({required bool withPostHogWidget}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + if (withPostHogWidget) + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withPostHogWidget: false)); + expect(setConfigCalls, 1); + + await tester.pumpWidget(layout(withPostHogWidget: true)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + + // the latched opt-in must not ship the sibling tree's rects while this + // mask widget sits outside it + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'a mask widget that mounts before a PostHogWidget that later contains ' + 'it keeps producing regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + final maskKey = GlobalKey(); + Widget mask() => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + key: maskKey, + child: const SizedBox(width: 30, height: 40), + ), + ); + + await tester.pumpWidget(mask()); + expect(setConfigCalls, 1); + + await tester.pumpWidget(PostHogWidget(child: mask())); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect( + region.getProperty('width'.toJS).toDartDouble, + greaterThanOrEqualTo(30), + ); + expect( + region.getProperty('height'.toJS).toDartDouble, + greaterThanOrEqualTo(40), + ); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'frames recover once the mask widget outside the tracked tree is ' + 'removed', (tester) async { + installPosthogStub(recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + Widget layout({required bool withOutsideMask}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + if (withOutsideMask) + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withOutsideMask: true)); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + + await tester.pumpWidget(layout(withOutsideMask: false)); + + expect(regionsFn.callAsFunction(null, canvas), isNotNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets('a mask widget inside the tracked PostHogWidget tree opts in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + PostHogWidget(child: PostHogMaskWidget(child: const SizedBox.shrink())), + ); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a mask widget in a dialog above PostHogWidget opts in and produces ' + 'regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + expect(setConfigCalls, 0); + + // PostHogWidget sits UNDER the home route, so the walk roots at the root + // navigator and covers the dialog — the tracked-tree boundary must too + await tester.pumpWidget( + MaterialApp( + home: PostHogWidget( + child: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + expect(setConfigCalls, 0); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(setConfigCalls, 1); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = regionsFn.callAsFunction(null, canvas); + expect(regions, isNotNull); + expect((regions as JSArray).toDart, isNotEmpty); + } finally { + flutterView.remove(); + } + }); + test('registers the mask provider via set_config', () { installPosthogStub(); @@ -248,6 +634,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { expect(regionsFn.callAsFunction(null, canvas), isNull); } finally { @@ -273,6 +660,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) @@ -738,4 +1126,19 @@ void main() { expect(warns(), 1); }); + + testWidgets('warns about an old posthog-js on the mount-triggered apply', + (tester) async { + installPosthogStub(declaresMaskProvider: false, version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(warns(), 0); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + expect(warns(), 1); + expect(capturedConfig, isNotNull); + }); } From 46364cc080239192a19d53049a20b12cb36e6088 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 18:08:43 +0300 Subject: [PATCH 17/17] 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 --- .../src/replay/mask/posthog_mask_widget.dart | 11 +++-- .../replay/web/web_canvas_mask_geometry.dart | 13 +++--- .../replay/web/web_canvas_mask_provider.dart | 30 +++++++++----- .../test/web_canvas_mask_geometry_test.dart | 4 +- .../test/web_canvas_mask_provider_test.dart | 40 +++++++++++++++++++ 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 826135ad..6db8927e 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -12,10 +12,13 @@ import 'canvas_mask_registration_io.dart' /// 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 } }` +/// 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 diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart index abcd7fce..83ed9ff1 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart @@ -8,16 +8,15 @@ List containerMaskRects(List elements) { final rects = []; for (final element in elements) { final transform = element.transform; - final rect = (transform != null - ? MatrixUtils.transformRect(transform, element.rect) - : element.rect) - // outset so capture-resolution rounding can't leave a sub-pixel glyph - // edge visible at the mask border - .inflate(1.0); + final rect = transform != null + ? MatrixUtils.transformRect(transform, element.rect) + : element.rect; if (!rect.isFinite || rect.isEmpty) { continue; } - rects.add(rect); + // 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; } diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 88d21e0c..13665369 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -436,12 +436,13 @@ class WebCanvasMaskProvider { return JSArray(); } - final containerRects = _currentContainerRects(); - if (containerRects == null) { - _noteWalkFailure(); + // our rects always describe PostHogWidget's tree — shipping them with a + // different flutter-view's canvas would record that view unmasked; checked + // before the walk so foreign canvases neither cost a walk nor advance the + // walk-failure counter + if (!_isOwnViewCanvas(host)) { return null; } - _consecutiveWalkFailures = 0; if (!_maskWidgetsInsideTrackedTree()) { if (!_warnedMaskWidgetOutsideTree) { @@ -455,11 +456,12 @@ class WebCanvasMaskProvider { return null; } - // our rects always describe PostHogWidget's tree — shipping them with a - // different flutter-view's canvas would record that view unmasked - if (!_isOwnViewCanvas(host)) { + final containerRects = _currentContainerRects(); + if (containerRects == null) { + _noteWalkFailure(); return null; } + _consecutiveWalkFailures = 0; if (containerRects.isEmpty) { return JSArray(); } @@ -489,6 +491,12 @@ class WebCanvasMaskProvider { ? rect : MatrixUtils.transformRect(containerTransform, rect); final shifted = globalRect.shift(offset); + // a singular/perspective ancestor transform yields non-finite + // components, and posthog-js fillRect silently no-ops on those — the + // frame would ship unmasked, so skip it instead + if (!shifted.isFinite) { + return null; + } regions.add(_JSMaskRegion( x: shifted.left, y: shifted.top, @@ -518,8 +526,12 @@ class WebCanvasMaskProvider { } return ownHost.contains(canvasViewHost); } - // unresolvable: with a single flutter-view it can only be ours - return web.document.querySelectorAll('flutter-view').length <= 1; + // unresolvable: claim ownership only when the lone light-DOM flutter-view + // is the canvas's own — a count of 0 with a flutter-view canvas in hand + // means the views sit in shadow roots querySelectorAll cannot see, which + // is ambiguous + final views = web.document.querySelectorAll('flutter-view'); + return views.length == 1 && views.item(0)!.contains(canvasViewHost); } web.Element? _resolveOwnViewHost() { diff --git a/posthog_flutter/test/web_canvas_mask_geometry_test.dart b/posthog_flutter/test/web_canvas_mask_geometry_test.dart index 924ce0d1..50bf24f8 100644 --- a/posthog_flutter/test/web_canvas_mask_geometry_test.dart +++ b/posthog_flutter/test/web_canvas_mask_geometry_test.dart @@ -47,9 +47,11 @@ void main() { expect(rect.contains(const Offset(0, 0)), isTrue); }); - test('drops empty and non-finite rects', () { + test('drops empty and non-finite rects before outsetting', () { final rects = containerMaskRects([ element(Rect.zero.deflate(2)), + element(Rect.zero), + element(const Rect.fromLTWH(3, 4, 0, 10)), element(const Rect.fromLTWH(0, 0, double.infinity, 10)), element(const Rect.fromLTWH(0, 0, 5, 5)), ]); diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index cfc7d594..5950e10e 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -777,6 +777,46 @@ void main() { } }); + testWidgets('fails closed when an ancestor transform is singular', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + Transform( + transform: Matrix4.identity()..setEntry(3, 3, 0), + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + testWidgets( 'fails closed for a canvas in a foreign flutter-view on a multi-view ' 'page', (tester) async {