Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@
- 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()),
);
```

Also, you can wrap any of your widgets with `SentryMask()` or `SentryUnmask()` widgets to mask/unmask them, respectively. For example:

```dart
 SentryUnmask(Text('Not secret at all'));
```

- Support `captureFeedback` ([#2230](https://github.com/getsentry/sentry-dart/pull/2230))
- Deprecated `Sentry.captureUserFeedback`, use `captureFeedback` instead.
- Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead.
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
87 changes: 87 additions & 0 deletions flutter/lib/src/replay/masking_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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> {
@pragma('vm:prefer-inline')
bool appliesTo(Widget widget) => widget is T;
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
156 changes: 96 additions & 60 deletions flutter/lib/src/replay/widget_filter.dart
Original file line number Diff line number Diff line change
@@ -1,42 +1,39 @@
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;
/// Used to test _obscureElementOrParent
@visibleForTesting
bool throwInObscure = false;

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 32 in flutter/lib/src/replay/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

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

Added line #L32 was not covered by tests
}
}

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

if (!_isVisible(widget)) {
Expand All @@ -47,47 +44,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,
'WidgetFilter cannot mask widget $widget: $e.'
'Obscuring the parent instead: ${parent?.widget}.',
stackTrace: stackTrace);
}
if (parent == null) {
return WidgetFilterItem(_defaultColor, _bounds);

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L84 was not covered by tests
}
element = parent;
widget = element.widget;
}
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 +116,37 @@
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;
}

// test-only code
assert(() {
if (throwInObscure) {
throwInObscure = false;
return false;
}
return true;
}());

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 +160,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 +173,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 +189,21 @@
}
}

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

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

extension on Element {
Element? get parent {
Element? result;
visitAncestorElements((el) {
result = el;
return false;
});
return result;
}
}
Loading
Loading