Skip to content

Commit be08651

Browse files
authored
Add debounce to capturing screenshots (#2368)
1 parent 6634afa commit be08651

File tree

9 files changed

+400
-42
lines changed

9 files changed

+400
-42
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@
3333
```
3434
- Linux native error & obfuscation support ([#2431](https://github.com/getsentry/sentry-dart/pull/2431))
3535
- Improve Device context on plain Dart and Flutter desktop apps ([#2441](https://github.com/getsentry/sentry-dart/pull/2441))
36+
- Add debounce to capturing screenshots ([#2368](https://github.com/getsentry/sentry-dart/pull/2368))
37+
- Per default, screenshots are debounced for 2 seconds.
38+
- If you need more granular screenshots, you can opt out of debouncing:
39+
```dart
40+
await SentryFlutter.init((options) {
41+
options.beforeCaptureScreenshot = (event, hint, debounce) {
42+
if (debounce) {
43+
return true; // Capture screenshot even if the SDK wants to debounce it.
44+
} else {
45+
// check event and hint
46+
...
47+
}
48+
};
49+
});
50+
```
51+
- Replace deprecated `BeforeScreenshotCallback` with new `BeforeCaptureCallback`.
3652

3753
### Fixes
3854

flutter/lib/src/event_processor/screenshot_event_processor.dart

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ import '../screenshot/recorder.dart';
99
import '../screenshot/recorder_config.dart';
1010
import 'package:flutter/widgets.dart' as widget;
1111

12+
import '../utils/debouncer.dart';
13+
1214
class ScreenshotEventProcessor implements EventProcessor {
1315
final SentryFlutterOptions _options;
1416

1517
late final ScreenshotRecorder _recorder;
18+
late final Debouncer _debouncer;
1619

1720
ScreenshotEventProcessor(this._options) {
1821
final targetResolution = _options.screenshotQuality.targetResolution();
19-
2022
_recorder = ScreenshotRecorder(
2123
ScreenshotRecorderConfig(
2224
width: targetResolution,
@@ -25,6 +27,11 @@ class ScreenshotEventProcessor implements EventProcessor {
2527
_options,
2628
isReplayRecorder: false,
2729
);
30+
_debouncer = Debouncer(
31+
// ignore: invalid_use_of_internal_member
32+
_options.clock,
33+
waitTimeMs: 2000,
34+
);
2835
}
2936

3037
@override
@@ -43,29 +50,51 @@ class ScreenshotEventProcessor implements EventProcessor {
4350
return event; // No need to attach screenshot of feedback form.
4451
}
4552

53+
// skip capturing in case of debouncing (=too many frequent capture requests)
54+
// the BeforeCaptureCallback may overrules the debouncing decision
55+
final shouldDebounce = _debouncer.shouldDebounce();
56+
57+
// ignore: deprecated_member_use_from_same_package
4658
final beforeScreenshot = _options.beforeScreenshot;
47-
if (beforeScreenshot != null) {
48-
try {
49-
final result = beforeScreenshot(event, hint: hint);
50-
bool takeScreenshot;
59+
final beforeCapture = _options.beforeCaptureScreenshot;
60+
61+
try {
62+
FutureOr<bool>? result;
63+
64+
if (beforeCapture != null) {
65+
result = beforeCapture(event, hint, shouldDebounce);
66+
} else if (beforeScreenshot != null) {
67+
result = beforeScreenshot(event, hint: hint);
68+
}
69+
70+
bool takeScreenshot = true;
71+
72+
if (result != null) {
5173
if (result is Future<bool>) {
5274
takeScreenshot = await result;
5375
} else {
5476
takeScreenshot = result;
5577
}
56-
if (!takeScreenshot) {
57-
return event;
58-
}
59-
} catch (exception, stackTrace) {
78+
} else if (shouldDebounce) {
6079
_options.logger(
61-
SentryLevel.error,
62-
'The beforeScreenshot callback threw an exception',
63-
exception: exception,
64-
stackTrace: stackTrace,
80+
SentryLevel.debug,
81+
'Skipping screenshot capture due to debouncing (too many captures within ${_debouncer.waitTimeMs}ms)',
6582
);
66-
if (_options.automatedTestMode) {
67-
rethrow;
68-
}
83+
takeScreenshot = false;
84+
}
85+
86+
if (!takeScreenshot) {
87+
return event;
88+
}
89+
} catch (exception, stackTrace) {
90+
_options.logger(
91+
SentryLevel.error,
92+
'The beforeCapture/beforeScreenshot callback threw an exception',
93+
exception: exception,
94+
stackTrace: stackTrace,
95+
);
96+
if (_options.automatedTestMode) {
97+
rethrow;
6998
}
7099
}
71100

@@ -88,8 +117,7 @@ class ScreenshotEventProcessor implements EventProcessor {
88117
return event;
89118
}
90119

91-
Uint8List? screenshotData = await createScreenshot();
92-
120+
final screenshotData = await createScreenshot();
93121
if (screenshotData != null) {
94122
hint.screenshot = SentryAttachment.fromScreenshotData(screenshotData);
95123
}

flutter/lib/src/integrations/screenshot_integration.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import '../event_processor/screenshot_event_processor.dart';
33
import '../sentry_flutter_options.dart';
44

55
/// Adds [ScreenshotEventProcessor] to options event processors if
6-
/// [SentryFlutterOptions.screenshot.attach] is true
6+
/// [SentryFlutterOptions.attachScreenshot] is true
77
class ScreenshotIntegration implements Integration<SentryFlutterOptions> {
88
SentryFlutterOptions? _options;
99
ScreenshotEventProcessor? _screenshotEventProcessor;

flutter/lib/src/sentry_flutter_options.dart

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,14 @@ class SentryFlutterOptions extends SentryOptions {
196196
/// See https://docs.sentry.io/platforms/flutter/troubleshooting/#screenshot-integration-background-crash
197197
bool attachScreenshotOnlyWhenResumed = false;
198198

199+
@Deprecated(
200+
'Will be removed in a future version. Use [beforeCaptureScreenshot] instead')
201+
BeforeScreenshotCallback? beforeScreenshot;
202+
199203
/// Sets a callback which is executed before capturing screenshots. Only
200204
/// relevant if `attachScreenshot` is set to true. When false is returned
201205
/// from the function, no screenshot will be attached.
202-
BeforeScreenshotCallback? beforeScreenshot;
206+
BeforeCaptureCallback? beforeCaptureScreenshot;
203207

204208
/// Enable or disable automatic breadcrumbs for User interactions Using [Listener]
205209
///
@@ -417,9 +421,34 @@ class _SentryFlutterExperimentalOptions {
417421
_privacy ?? SentryPrivacyOptions();
418422
}
419423

420-
/// Callback being executed in [ScreenshotEventProcessor], deciding if a
421-
/// screenshot should be recorded and attached.
422-
typedef BeforeScreenshotCallback = FutureOr<bool> Function(
423-
SentryEvent event, {
424-
Hint? hint,
425-
});
424+
@Deprecated(
425+
'Will be removed in a future version. Use [BeforeCaptureCallback] instead')
426+
typedef BeforeScreenshotCallback = FutureOr<bool> Function(SentryEvent event,
427+
{Hint? hint});
428+
429+
/// A callback which can be used to suppress capturing of screenshots.
430+
/// It's called in [ScreenshotEventProcessor] if screenshots are enabled.
431+
/// This gives more fine-grained control over when capturing should be performed,
432+
/// e.g., only capture screenshots for fatal events or override any debouncing for important events.
433+
///
434+
/// Since capturing can be resource-intensive, the debounce parameter should be respected if possible.
435+
///
436+
/// Example:
437+
/// ```dart
438+
/// if (debounce) {
439+
/// return false;
440+
/// } else {
441+
/// // check event and hint
442+
/// }
443+
/// ```
444+
///
445+
/// [event] is the event to be checked.
446+
/// [hint] provides additional hints.
447+
/// [debounce] indicates if capturing is marked for being debounced.
448+
///
449+
/// Returns `true` if capturing should be performed, otherwise `false`.
450+
typedef BeforeCaptureCallback = FutureOr<bool> Function(
451+
SentryEvent event,
452+
Hint hint,
453+
bool debounce,
454+
);

flutter/lib/src/utils/debouncer.dart

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
import 'dart:async';
2-
import 'package:flutter/foundation.dart';
31
import 'package:meta/meta.dart';
2+
import 'package:sentry/sentry.dart';
43

54
@internal
65
class Debouncer {
7-
final int milliseconds;
8-
Timer? _timer;
6+
final ClockProvider clockProvider;
7+
final int waitTimeMs;
8+
DateTime? _lastExecutionTime;
99

10-
Debouncer({required this.milliseconds});
10+
Debouncer(this.clockProvider, {this.waitTimeMs = 2000});
1111

12-
void run(VoidCallback action) {
13-
_timer?.cancel();
14-
_timer = Timer(Duration(milliseconds: milliseconds), action);
15-
}
12+
bool shouldDebounce() {
13+
final currentTime = clockProvider();
14+
final lastExecutionTime = _lastExecutionTime;
15+
_lastExecutionTime = currentTime;
16+
17+
if (lastExecutionTime != null &&
18+
currentTime.difference(lastExecutionTime).inMilliseconds < waitTimeMs) {
19+
return true;
20+
}
1621

17-
void dispose() {
18-
_timer?.cancel();
22+
return false;
1923
}
2024
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'dart:async';
2+
import 'package:flutter/foundation.dart';
3+
import 'package:meta/meta.dart';
4+
5+
@internal
6+
class TimerDebouncer {
7+
final int milliseconds;
8+
Timer? _timer;
9+
10+
TimerDebouncer({required this.milliseconds});
11+
12+
void run(VoidCallback action) {
13+
_timer?.cancel();
14+
_timer = Timer(Duration(milliseconds: milliseconds), action);
15+
}
16+
17+
void dispose() {
18+
_timer?.cancel();
19+
}
20+
}

flutter/lib/src/widgets_binding_observer.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import 'dart:ui';
33

44
import 'package:flutter/foundation.dart';
55
import 'package:flutter/material.dart';
6+
import 'utils/timer_debouncer.dart';
67
import '../sentry_flutter.dart';
7-
import 'utils/debouncer.dart';
88

99
/// This is a `WidgetsBindingObserver` which can observe some events of a
1010
/// Flutter application.
@@ -25,7 +25,8 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver {
2525
required SentryFlutterOptions options,
2626
}) : _hub = hub ?? HubAdapter(),
2727
_options = options,
28-
_screenSizeStreamController = StreamController(sync: true) {
28+
_screenSizeStreamController = StreamController(sync: true),
29+
_didChangeMetricsDebouncer = TimerDebouncer(milliseconds: 100) {
2930
if (_options.enableWindowMetricBreadcrumbs) {
3031
_screenSizeStreamController.stream
3132
.map(
@@ -47,12 +48,11 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver {
4748

4849
final Hub _hub;
4950
final SentryFlutterOptions _options;
51+
final TimerDebouncer _didChangeMetricsDebouncer;
5052

5153
// ignore: deprecated_member_use
5254
final StreamController<SingletonFlutterWindow?> _screenSizeStreamController;
5355

54-
final _didChangeMetricsDebouncer = Debouncer(milliseconds: 100);
55-
5656
/// This method records lifecycle events.
5757
/// It tries to mimic the behavior of ActivityBreadcrumbsIntegration of Sentry
5858
/// Android for lifecycle events.
@@ -91,7 +91,6 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver {
9191
if (!_options.enableWindowMetricBreadcrumbs) {
9292
return;
9393
}
94-
9594
_didChangeMetricsDebouncer.run(() {
9695
// ignore: deprecated_member_use
9796
final window = _options.bindingUtils.instance?.window;

0 commit comments

Comments
 (0)