Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom replay masking rules #2324

Merged
merged 29 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fa68455
refactor: change redaction logic to custom filters
vaind Sep 25, 2024
ec669f4
refactor widget filter to handle errors gracefully
vaind Sep 26, 2024
ad0f33c
cleanup
vaind Sep 26, 2024
665d01d
add an option to disable masking asset images
vaind Sep 26, 2024
bd5d026
add new masking config class
vaind Sep 30, 2024
37586fa
update widget filter to use the masking config
vaind Sep 30, 2024
8135d28
cleanup
vaind Sep 30, 2024
f6fbf8a
Merge branch 'main' into feat/replay-custom-redact
vaind Sep 30, 2024
90273fc
masking tests
vaind Oct 1, 2024
1179d12
cleanup
vaind Oct 7, 2024
a72c3b1
test masking editable text
vaind Oct 7, 2024
c1495e0
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 7, 2024
8f3c849
fix tests on web
vaind Oct 7, 2024
cc5d71f
fix tests on web
vaind Oct 7, 2024
e877795
fix tests for wasm
vaind Oct 7, 2024
fa920ce
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 9, 2024
85a7fee
add SentryMask and SentryUnmask widgets
vaind Oct 9, 2024
04805ce
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 9, 2024
edaf732
linter issue
vaind Oct 9, 2024
118398e
chore: changelog
vaind Oct 9, 2024
3101e0a
rename to SentryMaskingDecision
vaind Oct 9, 2024
1fe6f3f
mark new replay APIs as experimental
vaind Oct 9, 2024
d0c56a3
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 10, 2024
35495b2
Update flutter/lib/src/replay/masking_config.dart
vaind Oct 10, 2024
534bbb4
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 10, 2024
4fa9a3d
chore: update changelog
vaind Oct 10, 2024
64ee0f4
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 10, 2024
7739f6b
Merge branch 'main' into feat/replay-custom-redact
vaind Oct 10, 2024
be99d93
test: mask parent if masking the child fails
vaind Oct 10, 2024
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@
- Emit `transaction.data` inside `contexts.trace.data` ([#2284](https://github.com/getsentry/sentry-dart/pull/2284))
- Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291))
- Windows native error & obfuscation support ([#2286](https://github.com/getsentry/sentry-dart/pull/2286))
- Replay: user-configurable masking (redaction) for widget classes and specific widget instances. ([#2324](https://github.com/getsentry/sentry-dart/pull/2324))
Some examples of the configuration:

```dart
await SentryFlutter.init(
(options) {
...
options.experimental.replay.mask<IconButton>();
options.experimental.replay.unmask<Image>();
options.experimental.replay.maskCallback<Text>(
(Element element, Text widget) =>
(widget.data?.contains('secret') ?? false)
? SentryMaskingDecision.mask
: SentryMaskingDecision.continueProcessing);
},
appRunner: () => runApp(MyApp()),
);
```
vaind marked this conversation as resolved.
Show resolved Hide resolved

### Enhancements

Expand Down
3 changes: 3 additions & 0 deletions flutter/lib/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export 'src/sentry_replay_options.dart';
export 'src/flutter_sentry_attachment.dart';
export 'src/sentry_asset_bundle.dart' show SentryAssetBundle;
export 'src/integrations/on_error_integration.dart';
export 'src/replay/masking_config.dart' show SentryMaskingDecision;
export 'src/screenshot/sentry_mask_widget.dart';
export 'src/screenshot/sentry_unmask_widget.dart';
export 'src/screenshot/sentry_screenshot_widget.dart';
export 'src/screenshot/sentry_screenshot_quality.dart';
export 'src/user_interaction/sentry_user_interaction_widget.dart';
Expand Down
86 changes: 86 additions & 0 deletions flutter/lib/src/replay/masking_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

@internal
class SentryMaskingConfig {
@visibleForTesting
final List<SentryMaskingRule> rules;

final int length;

SentryMaskingConfig(List<SentryMaskingRule> rules)
// Note: fixed-size list has performance benefits over growable list.
: rules = List.of(rules, growable: false),
length = rules.length;

SentryMaskingDecision shouldMask<T extends Widget>(
Element element, T widget) {
for (int i = 0; i < length; i++) {
if (rules[i].appliesTo(widget)) {
// We use a switch here to get lints if more values are added.
switch (rules[i].shouldMask(element, widget)) {
case SentryMaskingDecision.mask:
return SentryMaskingDecision.mask;
case SentryMaskingDecision.unmask:
return SentryMaskingDecision.unmask;
case SentryMaskingDecision.continueProcessing:
// Continue to the next matching rule.
}
}
}
return SentryMaskingDecision.continueProcessing;
}
}

@experimental
enum SentryMaskingDecision {
/// Mask the widget and its children
mask,

/// Leave the widget visible, including its children (no more rules will
/// be checked for children).
unmask,

/// Don't make a decision - continue checking other rules and children.
continueProcessing
}

@internal
abstract class SentryMaskingRule<T extends Widget> {
bool appliesTo(Widget widget) => widget is T;
vaind marked this conversation as resolved.
Show resolved Hide resolved
SentryMaskingDecision shouldMask(Element element, T widget);

const SentryMaskingRule();
}

@internal
class SentryMaskingCustomRule<T extends Widget> extends SentryMaskingRule<T> {
final SentryMaskingDecision Function(Element element, T widget) callback;

const SentryMaskingCustomRule(this.callback);

@override
SentryMaskingDecision shouldMask(Element element, T widget) =>
callback(element, widget);

@override
String toString() => '$SentryMaskingCustomRule<$T>($callback)';
}

@internal
class SentryMaskingConstantRule<T extends Widget> extends SentryMaskingRule<T> {
final SentryMaskingDecision _value;
const SentryMaskingConstantRule(this._value);

@override
SentryMaskingDecision shouldMask(Element element, T widget) {
// This rule only makes sense with true/false. Continue won't do anything.
assert(_value == SentryMaskingDecision.mask ||
_value == SentryMaskingDecision.unmask);
return _value;
}

@override
String toString() =>
'$SentryMaskingConstantRule<$T>(${_value == SentryMaskingDecision.mask ? 'mask' : 'unmask'})';
}
9 changes: 3 additions & 6 deletions flutter/lib/src/replay/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@ class ScreenshotRecorder {
bool warningLogged = false;

ScreenshotRecorder(this.config, this.options) {
final replayOptions = options.experimental.replay;
if (replayOptions.redactAllText || replayOptions.redactAllImages) {
_widgetFilter = WidgetFilter(
redactText: replayOptions.redactAllText,
redactImages: replayOptions.redactAllImages,
logger: options.logger);
final maskingConfig = options.experimental.replay.buildMaskingConfig();
if (maskingConfig.length > 0) {
_widgetFilter = WidgetFilter(maskingConfig, options.logger);
}
}

Expand Down
143 changes: 83 additions & 60 deletions flutter/lib/src/replay/widget_filter.dart
Original file line number Diff line number Diff line change
@@ -1,42 +1,35 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import '../sentry_asset_bundle.dart';
import 'masking_config.dart';

@internal
class WidgetFilter {
final items = <WidgetFilterItem>[];
final SentryLogger logger;
final bool redactText;
final bool redactImages;
final SentryMaskingConfig config;
static const _defaultColor = Color.fromARGB(255, 0, 0, 0);
late double _pixelRatio;
late Rect _bounds;
final _warnedWidgets = <int>{};
final AssetBundle _rootAssetBundle;

WidgetFilter(
{required this.redactText,
required this.redactImages,
required this.logger,
@visibleForTesting AssetBundle? rootAssetBundle})
: _rootAssetBundle = rootAssetBundle ?? rootBundle;
WidgetFilter(this.config, this.logger);

void obscure(BuildContext context, double pixelRatio, Rect bounds) {
_pixelRatio = pixelRatio;
_bounds = bounds;
items.clear();
if (context is Element) {
_obscure(context);
_process(context);
} else {
context.visitChildElements(_obscure);
context.visitChildElements(_process);

Check warning on line 28 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L28

Added line #L28 was not covered by tests
}
}

void _obscure(Element element) {
void _process(Element element) {
final widget = element.widget;

if (!_isVisible(widget)) {
Expand All @@ -47,47 +40,64 @@
return;
}

final obscured = _obscureIfNeeded(element, widget);
if (!obscured) {
element.visitChildElements(_obscure);
final decision = config.shouldMask(element, widget);
switch (decision) {
case SentryMaskingDecision.mask:
final item = _obscureElementOrParent(element, widget);
if (item != null) {
items.add(item);
}
break;
case SentryMaskingDecision.unmask:
logger(SentryLevel.debug, "WidgetFilter unmasked: $widget");
break;
case SentryMaskingDecision.continueProcessing:
// If this element should not be obscured, visit and check its children.
element.visitChildElements(_process);
break;
}
}

/// Determine the color and bounding box of the widget.
/// If the widget is offscreen, returns null.
/// If the widget cannot be obscured, obscures the parent.
@pragma('vm:prefer-inline')
bool _obscureIfNeeded(Element element, Widget widget) {
Color? color;

if (redactText && widget is Text) {
color = widget.style?.color;
} else if (redactText && widget is EditableText) {
color = widget.style.color;
} else if (redactImages && widget is Image) {
if (widget.image is AssetBundleImageProvider) {
final image = widget.image as AssetBundleImageProvider;
if (isBuiltInAssetImage(image)) {
logger(SentryLevel.debug,
"WidgetFilter skipping asset: $widget ($image).");
return false;
WidgetFilterItem? _obscureElementOrParent(Element element, Widget widget) {
while (true) {
try {
return _obscure(element, widget);
} catch (e, stackTrace) {
final parent = element.parent;
if (!_warnedWidgets.contains(widget.hashCode)) {
_warnedWidgets.add(widget.hashCode);
logger(
SentryLevel.warning,

Check warning on line 74 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L70-L74

Added lines #L70 - L74 were not covered by tests
'WidgetFilter cannot mask widget $widget: $e.'
'Obscuring the parent instead: ${parent?.widget}.',

Check warning on line 76 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L76

Added line #L76 was not covered by tests
stackTrace: stackTrace);
}
if (parent == null) {
return WidgetFilterItem(_defaultColor, _bounds);

Check warning on line 80 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L80

Added line #L80 was not covered by tests
}
element = parent;
widget = element.widget;

Check warning on line 83 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L83

Added line #L83 was not covered by tests
}
color = widget.color;
} else {
// No other type is currently obscured.
return false;
}

final renderObject = element.renderObject;
if (renderObject is! RenderBox) {
_cantObscure(widget, "its renderObject is not a RenderBox");
return false;
}
}

var rect = _boundingBox(renderObject);
/// Determine the color and bounding box of the widget.
/// If the widget is offscreen, returns null.
/// This function may throw in which case the caller is responsible for
/// calling it again on the parent element.
@pragma('vm:prefer-inline')
WidgetFilterItem? _obscure(Element element, Widget widget) {
final RenderBox renderBox = element.renderObject as RenderBox;
var rect = _boundingBox(renderBox);

// If it's a clipped render object, use parent's offset and size.
// This helps with text fields which often have oversized render objects.
if (renderObject.parent is RenderStack) {
final renderStack = (renderObject.parent as RenderStack);
if (renderBox.parent is RenderStack) {
final renderStack = (renderBox.parent as RenderStack);
final clipBehavior = renderStack.clipBehavior;
if (clipBehavior == Clip.hardEdge ||
clipBehavior == Clip.antiAlias ||
Expand All @@ -102,19 +112,28 @@
logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget");
return true;
}());
return false;
return null;
}

items.add(WidgetFilterItem(color ?? _defaultColor, rect));
assert(() {
logger(SentryLevel.debug, "WidgetFilter obscuring: $widget");
logger(SentryLevel.debug, "WidgetFilter masking: $widget");
return true;
}());

return true;
Color? color;
if (widget is Text) {
color = (widget).style?.color;
} else if (widget is EditableText) {
color = (widget).style.color;
} else if (widget is Image) {
color = (widget).color;
}

return WidgetFilterItem(color ?? _defaultColor, rect);
}

// We cut off some widgets early because they're not visible at all.
@pragma('vm:prefer-inline')
bool _isVisible(Widget widget) {
if (widget is Visibility) {
return widget.visible;
Expand All @@ -128,9 +147,10 @@
return true;
}

@visibleForTesting
@internal
@pragma('vm:prefer-inline')
bool isBuiltInAssetImage(AssetBundleImageProvider image) {
static bool isBuiltInAssetImage(
AssetBundleImageProvider image, AssetBundle rootAssetBundle) {
late final AssetBundle? bundle;
if (image is AssetImage) {
bundle = image.bundle;
Expand All @@ -140,17 +160,8 @@
return false;
}
return (bundle == null ||
bundle == _rootAssetBundle ||
(bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle));
}

@pragma('vm:prefer-inline')
void _cantObscure(Widget widget, String message) {
if (!_warnedWidgets.contains(widget.hashCode)) {
_warnedWidgets.add(widget.hashCode);
logger(SentryLevel.warning,
"WidgetFilter cannot obscure widget $widget: $message");
}
bundle == rootAssetBundle ||
(bundle is SentryAssetBundle && bundle.bundle == rootAssetBundle));
}

@pragma('vm:prefer-inline')
Expand All @@ -165,9 +176,21 @@
}
}

@internal
class WidgetFilterItem {
final Color color;
final Rect bounds;

const WidgetFilterItem(this.color, this.bounds);
}

extension on Element {
Element? get parent {

Check warning on line 188 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L188

Added line #L188 was not covered by tests
Element? result;
visitAncestorElements((el) {

Check warning on line 190 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/replay/widget_filter.dart#L190

Added line #L190 was not covered by tests
result = el;
return false;
});
return result;
}
}
13 changes: 13 additions & 0 deletions flutter/lib/src/screenshot/sentry_mask_widget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

/// Wrapping your widget in [SentryMask] will mask it when capturing replays.
@experimental
class SentryMask extends StatelessWidget {
final Widget child;

const SentryMask(this.child, {super.key});

@override
Widget build(BuildContext context) => child;
}
13 changes: 13 additions & 0 deletions flutter/lib/src/screenshot/sentry_unmask_widget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

/// Wrapping your widget in [SentryUnmask] will unmask it when capturing replays.
@experimental
class SentryUnmask extends StatelessWidget {
final Widget child;

const SentryUnmask(this.child, {super.key});

@override
Widget build(BuildContext context) => child;
}
Loading
Loading