Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class PrivacyRecorder implements ElementRecorder {
capturePrivacy.textAndInputPrivacyLevel,
imagePrivacyLevel:
widget.imagePrivacyLevel ?? capturePrivacy.imagePrivacyLevel,
touchPrivacyLevel:
widget.touchPrivacyLevel ?? capturePrivacy.touchPrivacyLevel,
),
nodes: nodes,
);
Expand Down
85 changes: 70 additions & 15 deletions packages/datadog_session_replay/lib/src/capture/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,44 @@ 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
bool operator ==(Object other) {
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<T>).
List<Type> get handlesTypes;

/// Override for generic types whose [Widget.runtimeType] varies with the
/// type parameter (e.g. `Radio<String>` vs `Radio<int>`). 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,
Expand All @@ -70,6 +85,11 @@ class KeyGenerator {
final Expando<int> _nodeIdExpando = Expando('sr-key');
final Expando<int> _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<int, Expando<int>> _subKeyExpandos = {};

int keyForElement(Element e) {
var value = _nodeIdExpando[e];
if (value != null) return value;
Expand All @@ -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) {
Expand All @@ -96,6 +135,7 @@ class KeyGenerator {
_resourceIdExpando[e] = value;
return value;
}

}

@immutable
Expand All @@ -110,13 +150,15 @@ class SessionReplayRecorder {
final DatadogTimeProvider _timeProvider;
final Map<Type, ElementRecorder> _elementRecordersByType = {};

/// Fallback recorders for generic widget types (e.g. [Radio]<T>) whose
/// [runtimeType] includes a type parameter and won't match the exact type
/// registered in [_elementRecordersByType].
final List<ElementRecorder> _fallbackRecorders = [];

final Map<Key, Element> _elements = {};
RUMContext? _currentContext;
bool _captureInProgress = false;
TreeCapturePrivacy _defaultTreeCapturePrivacy;
// TODO(RUM-11681): Support touch privacy
// ignore: unused_field
TouchPrivacyLevel _touchPrivacyLevel;

@visibleForTesting
set defaultTreeCapturePrivacy(TreeCapturePrivacy value) =>
Expand All @@ -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),
Expand All @@ -157,10 +196,8 @@ class SessionReplayRecorder {
List<ElementRecorder> elementRecorders, {
DatadogTimeProvider timeProvider = const DefaultTimeProvider(),
required TreeCapturePrivacy defaultCapturePrivacy,
required TouchPrivacyLevel touchPrivacyLevel,
}) : _timeProvider = timeProvider,
_defaultTreeCapturePrivacy = defaultCapturePrivacy,
_touchPrivacyLevel = touchPrivacyLevel {
_defaultTreeCapturePrivacy = defaultCapturePrivacy {
_populateElementRecorderMap(elementRecorders);
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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<T>) 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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ void main() {
defaultCapturePrivacy: TreeCapturePrivacy(
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ void main() {
defaultCapturePrivacy: TreeCapturePrivacy(
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ void main() {
defaultCapturePrivacy: TreeCapturePrivacy(
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ void main() {
defaultCapturePrivacy: TreeCapturePrivacy(
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNone,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,8 @@ void main() {
textAndInputPrivacyLevel:
TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ void main() {
textAndInputPrivacyLevel:
TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
touchPrivacyLevel: TouchPrivacyLevel.show,
)
);

registerFallbackValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ void main() {
textAndInputPrivacyLevel:
TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
touchPrivacyLevel: TouchPrivacyLevel.show,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
);
});

Expand Down Expand Up @@ -92,8 +92,8 @@ void main() {
textAndInputPrivacyLevel:
TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly,
touchPrivacyLevel: TouchPrivacyLevel.show,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
);

registerFallbackValue(
Expand Down Expand Up @@ -441,8 +441,8 @@ void main() {
textAndInputPrivacyLevel:
TextAndInputPrivacyLevel.maskSensitiveInputs,
imagePrivacyLevel: ImagePrivacyLevel.maskNone,
touchPrivacyLevel: TouchPrivacyLevel.show,
),
touchPrivacyLevel: TouchPrivacyLevel.show,
);
});

Expand Down
19 changes: 19 additions & 0 deletions packages/datadog_session_replay/test/test_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,22 @@ class TestImageProvider extends ImageProvider<TestImageProvider> {
@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<ui.Image> 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;
}