From 0fa7408c4a5f4b8860eef6216b889aca2bc326cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?La=CC=82n=20Nguyen?= Date: Wed, 25 Feb 2026 13:19:09 +0100 Subject: [PATCH] Add touchPrivacyLevel, canHandle/fallback recorders, and subKeyForElement - Add `touchPrivacyLevel` to `TreeCapturePrivacy`; `performCapture` now discards pointer events unless the level is `show` - Add `canHandle(Widget)` default method to `ElementRecorder` to support generic widget types (e.g. Radio) that can't match by exact runtimeType - Add `_fallbackRecorders` list and `_recorderFor()` to `SessionReplayRecorder` for O(1) type-map lookup with canHandle fallback - Add `subKeyForElement(element, subIndex)` to `KeyGenerator` for compound widgets that emit multiple wireframes from a single Element - Remove standalone `touchPrivacyLevel` constructor parameter from `SessionReplayRecorder`; propagate through `TreeCapturePrivacy` instead - Fix `PrivacyRecorder` to carry `touchPrivacyLevel` into subtree privacy - Add `createTestImage` helper to test_utils.dart - Update all tests and golden tests for new `TreeCapturePrivacy` API --- .../image_masking_golden_test.dart | 4 +- .../simple_widget_golden_test.dart | 4 +- .../golden_test/text_masking_golden_test.dart | 4 +- .../element_recorders/privacy_recorder.dart | 2 + .../lib/src/capture/recorder.dart | 85 +++++++++++++++---- .../test/capture/container_recorder_test.dart | 4 +- .../capture/custom_paint_recorder_test.dart | 4 +- .../capture/editable_text_recorder_test.dart | 4 +- .../test/capture/image_recorder_test.dart | 4 +- .../test/capture/pointer_capture_test.dart | 4 +- .../test/capture/privacy_recorder_test.dart | 4 +- .../test/capture/recorder_test.dart | 6 +- .../test/test_utils.dart | 19 +++++ 13 files changed, 112 insertions(+), 36 deletions(-) diff --git a/packages/datadog_session_replay/example/golden_test/image_masking_golden_test.dart b/packages/datadog_session_replay/example/golden_test/image_masking_golden_test.dart index ded005e92..ac6c77704 100644 --- a/packages/datadog_session_replay/example/golden_test/image_masking_golden_test.dart +++ b/packages/datadog_session_replay/example/golden_test/image_masking_golden_test.dart @@ -38,8 +38,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNone, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ), ); platform = MockDatadogSessionReplayPlatform(); DatadogSessionReplayPlatform.instance = platform; diff --git a/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart b/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart index 3394e836a..abf895e60 100644 --- a/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart +++ b/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart @@ -26,8 +26,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNone, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ), ); platform = MockDatadogSessionReplayPlatform(); DatadogSessionReplayPlatform.instance = platform; diff --git a/packages/datadog_session_replay/example/golden_test/text_masking_golden_test.dart b/packages/datadog_session_replay/example/golden_test/text_masking_golden_test.dart index 61339a293..ccc8b5aa4 100644 --- a/packages/datadog_session_replay/example/golden_test/text_masking_golden_test.dart +++ b/packages/datadog_session_replay/example/golden_test/text_masking_golden_test.dart @@ -40,8 +40,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, imagePrivacyLevel: ImagePrivacyLevel.maskNone, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ), ); platform = MockDatadogSessionReplayPlatform(); DatadogSessionReplayPlatform.instance = platform; diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/privacy_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/privacy_recorder.dart index ca33f7bcc..1c7ef9aae 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/privacy_recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/privacy_recorder.dart @@ -57,6 +57,8 @@ class PrivacyRecorder implements ElementRecorder { capturePrivacy.textAndInputPrivacyLevel, imagePrivacyLevel: widget.imagePrivacyLevel ?? capturePrivacy.imagePrivacyLevel, + touchPrivacyLevel: + widget.touchPrivacyLevel ?? capturePrivacy.touchPrivacyLevel, ), nodes: nodes, ); diff --git a/packages/datadog_session_replay/lib/src/capture/recorder.dart b/packages/datadog_session_replay/lib/src/capture/recorder.dart index 1ef9c86a8..643da8b69 100644 --- a/packages/datadog_session_replay/lib/src/capture/recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/recorder.dart @@ -28,10 +28,12 @@ import 'view_tree_snapshot.dart'; class TreeCapturePrivacy { final TextAndInputPrivacyLevel textAndInputPrivacyLevel; final ImagePrivacyLevel imagePrivacyLevel; + final TouchPrivacyLevel touchPrivacyLevel; const TreeCapturePrivacy({ required this.textAndInputPrivacyLevel, required this.imagePrivacyLevel, + this.touchPrivacyLevel = TouchPrivacyLevel.hide, }); @override @@ -39,18 +41,31 @@ class TreeCapturePrivacy { if (other is! TreeCapturePrivacy) return false; return other.textAndInputPrivacyLevel == textAndInputPrivacyLevel && - other.imagePrivacyLevel == imagePrivacyLevel; + other.imagePrivacyLevel == imagePrivacyLevel && + other.touchPrivacyLevel == touchPrivacyLevel; } @override int get hashCode { - return textAndInputPrivacyLevel.hashCode; + return Object.hash( + textAndInputPrivacyLevel, + imagePrivacyLevel, + touchPrivacyLevel, + ); } } abstract interface class ElementRecorder { + /// The exact widget types this recorder handles (used for O(1) lookup). + /// Leave empty and override [canHandle] for generic widget types (e.g. Radio). List get handlesTypes; + /// Override for generic types whose [Widget.runtimeType] varies with the + /// type parameter (e.g. `Radio` vs `Radio`). The default + /// implementation returns `false`; non-generic recorders rely solely on + /// [handlesTypes] and never need to override this. + bool canHandle(Widget widget) => false; + CaptureNodeSemantics? captureSemantics( Element element, CapturedViewAttributes attributes, @@ -70,6 +85,11 @@ class KeyGenerator { final Expando _nodeIdExpando = Expando('sr-key'); final Expando _resourceIdExpando = Expando('sr-resource-key'); + /// Sub-key expandos indexed by sub-element index. + /// Used by compound widgets (e.g. Switch, Slider) that produce multiple + /// wireframes from a single [Element] and need extra stable IDs. + final Map> _subKeyExpandos = {}; + int keyForElement(Element e) { var value = _nodeIdExpando[e]; if (value != null) return value; @@ -83,6 +103,25 @@ class KeyGenerator { return value; } + /// Returns a stable sub-element key for [e] at position [subIndex]. + /// Each unique [subIndex] gets its own stable ID that persists across + /// frames, just like [keyForElement]. + int subKeyForElement(Element e, int subIndex) { + final expando = _subKeyExpandos.putIfAbsent( + subIndex, + () => Expando('sr-sub-key-$subIndex'), + ); + var value = expando[e]; + if (value != null) return value; + + value = _nextElementKey; + _nextElementKey = _nextElementKey + 1; + if (_nextElementKey >= maxKey) _nextElementKey = 0; + + expando[e] = value; + return value; + } + bool hasImageKey(ui.Image e) => _resourceIdExpando[e] != null; int keyForImage(ui.Image e) { @@ -96,6 +135,7 @@ class KeyGenerator { _resourceIdExpando[e] = value; return value; } + } @immutable @@ -110,13 +150,15 @@ class SessionReplayRecorder { final DatadogTimeProvider _timeProvider; final Map _elementRecordersByType = {}; + /// Fallback recorders for generic widget types (e.g. [Radio]) whose + /// [runtimeType] includes a type parameter and won't match the exact type + /// registered in [_elementRecordersByType]. + final List _fallbackRecorders = []; + final Map _elements = {}; RUMContext? _currentContext; bool _captureInProgress = false; TreeCapturePrivacy _defaultTreeCapturePrivacy; - // TODO(RUM-11681): Support touch privacy - // ignore: unused_field - TouchPrivacyLevel _touchPrivacyLevel; @visibleForTesting set defaultTreeCapturePrivacy(TreeCapturePrivacy value) => @@ -127,19 +169,16 @@ class SessionReplayRecorder { SessionReplayRecorder({ DatadogTimeProvider timeProvider = const DefaultTimeProvider(), required TreeCapturePrivacy defaultCapturePrivacy, - required TouchPrivacyLevel touchPrivacyLevel, }) : this._( KeyGenerator(), timeProvider, defaultCapturePrivacy, - touchPrivacyLevel, ); SessionReplayRecorder._( KeyGenerator keyGenerator, this._timeProvider, this._defaultTreeCapturePrivacy, - this._touchPrivacyLevel, ) { _populateElementRecorderMap([ ContainerRecorder(keyGenerator), @@ -157,10 +196,8 @@ class SessionReplayRecorder { List elementRecorders, { DatadogTimeProvider timeProvider = const DefaultTimeProvider(), required TreeCapturePrivacy defaultCapturePrivacy, - required TouchPrivacyLevel touchPrivacyLevel, }) : _timeProvider = timeProvider, - _defaultTreeCapturePrivacy = defaultCapturePrivacy, - _touchPrivacyLevel = touchPrivacyLevel { + _defaultTreeCapturePrivacy = defaultCapturePrivacy { _populateElementRecorderMap(elementRecorders); } @@ -257,9 +294,12 @@ class SessionReplayRecorder { nodes: nodes, ); - // We shouldn't have multiple pointer snapshots, but even if we - // do, for now just take the first one. - final pointerSnapshot = pointerSnapshots.firstOrNull; + // Respect global touch privacy: if the default level is hide, discard + // all captured pointer events before sending to the processor. + final pointerSnapshot = + _defaultTreeCapturePrivacy.touchPrivacyLevel == TouchPrivacyLevel.show + ? pointerSnapshots.firstOrNull + : null; return CaptureResult(viewTreeSnapshot, pointerSnapshot); } @@ -277,7 +317,22 @@ class SessionReplayRecorder { for (final type in recorder.handlesTypes) { _elementRecordersByType[type] = recorder; } + // All recorders are kept in the fallback list so that generic widget + // types (e.g. Radio) can be matched via canHandle() when the + // exact-type map misses. + _fallbackRecorders.add(recorder); + } + } + + /// Finds an [ElementRecorder] for [widget], first via the O(1) type map and + /// then falling back to the ordered fallback list for generic types. + ElementRecorder? _recorderFor(Widget widget) { + final exact = _elementRecordersByType[widget.runtimeType]; + if (exact != null) return exact; + for (final fb in _fallbackRecorders) { + if (fb.canHandle(widget)) return fb; } + return null; } // Certain elements will cause everything under the element to be invisible, such @@ -335,7 +390,7 @@ class SessionReplayRecorder { } final widget = e.widget; - final recorder = _elementRecordersByType[widget.runtimeType]; + final recorder = _recorderFor(widget); var subtreeStrategy = CaptureNodeSubtreeStrategy.record; if (recorder != null) { final transformMatrix = renderObject.getTransformTo( diff --git a/packages/datadog_session_replay/test/capture/container_recorder_test.dart b/packages/datadog_session_replay/test/capture/container_recorder_test.dart index d27bfb686..0b3d0a8f4 100644 --- a/packages/datadog_session_replay/test/capture/container_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/container_recorder_test.dart @@ -29,8 +29,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/custom_paint_recorder_test.dart b/packages/datadog_session_replay/test/capture/custom_paint_recorder_test.dart index 9d6cb208e..4071ee374 100644 --- a/packages/datadog_session_replay/test/capture/custom_paint_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/custom_paint_recorder_test.dart @@ -34,8 +34,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/editable_text_recorder_test.dart b/packages/datadog_session_replay/test/capture/editable_text_recorder_test.dart index d7c466eec..6452551a2 100644 --- a/packages/datadog_session_replay/test/capture/editable_text_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/editable_text_recorder_test.dart @@ -35,8 +35,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/image_recorder_test.dart b/packages/datadog_session_replay/test/capture/image_recorder_test.dart index 21a56a39b..66430f833 100644 --- a/packages/datadog_session_replay/test/capture/image_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/image_recorder_test.dart @@ -54,8 +54,8 @@ void main() { defaultCapturePrivacy: TreeCapturePrivacy( textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNone, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/pointer_capture_test.dart b/packages/datadog_session_replay/test/capture/pointer_capture_test.dart index 486009549..a9e884aad 100644 --- a/packages/datadog_session_replay/test/capture/pointer_capture_test.dart +++ b/packages/datadog_session_replay/test/capture/pointer_capture_test.dart @@ -347,8 +347,8 @@ void main() { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/privacy_recorder_test.dart b/packages/datadog_session_replay/test/capture/privacy_recorder_test.dart index 8bf4b8844..becd8c843 100644 --- a/packages/datadog_session_replay/test/capture/privacy_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/privacy_recorder_test.dart @@ -227,8 +227,8 @@ void main() { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, - ), - touchPrivacyLevel: TouchPrivacyLevel.show, + touchPrivacyLevel: TouchPrivacyLevel.show, + ) ); registerFallbackValue( diff --git a/packages/datadog_session_replay/test/capture/recorder_test.dart b/packages/datadog_session_replay/test/capture/recorder_test.dart index 3d6e48fd9..6927ed4ad 100644 --- a/packages/datadog_session_replay/test/capture/recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/recorder_test.dart @@ -52,8 +52,8 @@ void main() { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + touchPrivacyLevel: TouchPrivacyLevel.show, ), - touchPrivacyLevel: TouchPrivacyLevel.show, ); }); @@ -92,8 +92,8 @@ void main() { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + touchPrivacyLevel: TouchPrivacyLevel.show, ), - touchPrivacyLevel: TouchPrivacyLevel.show, ); registerFallbackValue( @@ -441,8 +441,8 @@ void main() { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, imagePrivacyLevel: ImagePrivacyLevel.maskNone, + touchPrivacyLevel: TouchPrivacyLevel.show, ), - touchPrivacyLevel: TouchPrivacyLevel.show, ); }); diff --git a/packages/datadog_session_replay/test/test_utils.dart b/packages/datadog_session_replay/test/test_utils.dart index 815a4f45a..e7a2b706d 100644 --- a/packages/datadog_session_replay/test/test_utils.dart +++ b/packages/datadog_session_replay/test/test_utils.dart @@ -81,3 +81,22 @@ class TestImageProvider extends ImageProvider { @override String toString() => '${describeIdentity(this)}()'; } + +/// Creates a solid-color [ui.Image] of the given [width] × [height] for use +/// in tests that need a real pixel buffer (e.g. image capture / hashing). +Future createTestImage({ + required int width, + required int height, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final paint = ui.Paint()..color = const Color(0xFF4286F4); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + paint, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +}