diff --git a/CHANGELOG.md b/CHANGELOG.md index 875b1a4d0..38027d9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- User Interaction transactions and breadcrumbs ([#1137](https://github.com/getsentry/sentry-dart/pull/1137)) + ## 6.16.1 ### Fixes diff --git a/dart/lib/src/noop_sentry_span.dart b/dart/lib/src/noop_sentry_span.dart index 55cbe1261..bdf91f7ac 100644 --- a/dart/lib/src/noop_sentry_span.dart +++ b/dart/lib/src/noop_sentry_span.dart @@ -86,4 +86,7 @@ class NoOpSentrySpan extends ISentrySpan { @override SentryTracesSamplingDecision? get samplingDecision => null; + + @override + void scheduleFinish() {} } diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index 500e04f2a..b25bc1617 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -81,6 +81,33 @@ class Breadcrumb { ); } + factory Breadcrumb.userInteraction({ + String? message, + SentryLevel? level, + DateTime? timestamp, + Map? data, + required String subCategory, + String? viewId, + String? viewClass, + }) { + final newData = data ?? {}; + if (viewId != null) { + newData['view.id'] = viewId; + } + if (viewClass != null) { + newData['view.class'] = viewClass; + } + + return Breadcrumb( + message: message, + level: level, + category: 'ui.$subCategory', + type: 'user', + timestamp: timestamp, + data: newData, + ); + } + /// Describes the breadcrumb. /// /// This field is optional and may be set to null. diff --git a/dart/lib/src/protocol/sentry_span.dart b/dart/lib/src/protocol/sentry_span.dart index 6baa2df6a..e204eabfd 100644 --- a/dart/lib/src/protocol/sentry_span.dart +++ b/dart/lib/src/protocol/sentry_span.dart @@ -197,4 +197,7 @@ class SentrySpan extends ISentrySpan { @override SentryTraceContextHeader? traceContext() => _tracer.traceContext(); + + @override + void scheduleFinish() => _tracer.scheduleFinish(); } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index e83f84120..182c3d0cc 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -299,6 +299,15 @@ class SentryOptions { /// array, and only attach tracing headers if a match was found. final List tracePropagationTargets = ['.*']; + /// The idle time to wait until the transaction will be finished. + /// The transaction will use the end timestamp of the last finished span as + /// the endtime for the transaction. + /// + /// When set to null the transaction must be finished manually. + /// + /// The default is 3 seconds. + Duration? idleTimeout = Duration(seconds: 3); + SentryOptions({this.dsn, PlatformChecker? checker}) { if (checker != null) { platformChecker = checker; diff --git a/dart/lib/src/sentry_span_interface.dart b/dart/lib/src/sentry_span_interface.dart index 74d55570d..e8690c829 100644 --- a/dart/lib/src/sentry_span_interface.dart +++ b/dart/lib/src/sentry_span_interface.dart @@ -71,4 +71,7 @@ abstract class ISentrySpan { /// Returns the trace context. @experimental SentryTraceContextHeader? traceContext(); + + @internal + void scheduleFinish(); } diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index 5690c551e..215e4fd11 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -18,6 +18,11 @@ class SentryTracer extends ISentrySpan { final Map _measurements = {}; Timer? _autoFinishAfterTimer; + Duration? _autoFinishAfter; + + @visibleForTesting + Timer? get autoFinishAfterTimer => _autoFinishAfterTimer; + Function(SentryTracer)? _onFinish; var _finishStatus = SentryTracerFinishStatus.notFinishing(); late final bool _trimEnd; @@ -56,11 +61,9 @@ class SentryTracer extends ISentrySpan { startTimestamp: startTimestamp, ); _waitForChildren = waitForChildren; - if (autoFinishAfter != null) { - _autoFinishAfterTimer = Timer(autoFinishAfter, () async { - await finish(status: status ?? SpanStatus.ok()); - }); - } + _autoFinishAfter = autoFinishAfter; + + _scheduleTimer(); name = transactionContext.name; // always default to custom if not provided transactionNameSource = transactionContext.transactionNameSource ?? @@ -117,6 +120,11 @@ class SentryTracer extends ISentrySpan { } }); + // if it's an idle transaction which has no children, we drop it to save user's quota + if (children.isEmpty && _autoFinishAfter != null) { + return; + } + final transaction = SentryTransaction(this); transaction.measurements.addAll(_measurements); await _hub.captureTransaction( @@ -197,6 +205,9 @@ class SentryTracer extends ISentrySpan { return NoOpSentrySpan(); } + // reset the timer if a new child is added + _scheduleTimer(); + if (children.length >= _hub.options.maxSpans) { _hub.options.logger( SentryLevel.warning, @@ -346,4 +357,24 @@ class SentryTracer extends ISentrySpan { @override SentryTracesSamplingDecision? get samplingDecision => _rootSpan.samplingDecision; + + @override + void scheduleFinish() { + if (finished) { + return; + } + if (_autoFinishAfterTimer != null) { + _scheduleTimer(); + } + } + + void _scheduleTimer() { + final autoFinishAfter = _autoFinishAfter; + if (autoFinishAfter != null) { + _autoFinishAfterTimer?.cancel(); + _autoFinishAfterTimer = Timer(autoFinishAfter, () async { + await finish(status: status ?? SpanStatus.ok()); + }); + } + } } diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index 890bc6829..35ab621de 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -79,86 +79,119 @@ void main() { }); }); - test('Breadcrumb http ctor', () { - final breadcrumb = Breadcrumb.http( - url: Uri.parse('https://example.org'), - method: 'GET', - level: SentryLevel.fatal, - reason: 'OK', - statusCode: 200, - requestDuration: Duration.zero, - timestamp: DateTime.now(), - requestBodySize: 2, - responseBodySize: 3, - ); - final json = breadcrumb.toJson(); - - expect(json, { - 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), - 'category': 'http', - 'data': { - 'url': 'https://example.org', - 'method': 'GET', - 'status_code': 200, - 'reason': 'OK', - 'duration': '0:00:00.000000', - 'request_body_size': 2, - 'response_body_size': 3, - }, - 'level': 'fatal', - 'type': 'http', + group('ctor', () { + test('Breadcrumb http', () { + final breadcrumb = Breadcrumb.http( + url: Uri.parse('https://example.org'), + method: 'GET', + level: SentryLevel.fatal, + reason: 'OK', + statusCode: 200, + requestDuration: Duration.zero, + timestamp: DateTime.now(), + requestBodySize: 2, + responseBodySize: 3, + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'http', + 'data': { + 'url': 'https://example.org', + 'method': 'GET', + 'status_code': 200, + 'reason': 'OK', + 'duration': '0:00:00.000000', + 'request_body_size': 2, + 'response_body_size': 3, + }, + 'level': 'fatal', + 'type': 'http', + }); }); - }); - test('Minimal Breadcrumb http ctor', () { - final breadcrumb = Breadcrumb.http( - url: Uri.parse('https://example.org'), - method: 'GET', - ); - final json = breadcrumb.toJson(); - - expect(json, { - 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), - 'category': 'http', - 'data': { - 'url': 'https://example.org', - 'method': 'GET', - }, - 'level': 'info', - 'type': 'http', + test('Minimal Breadcrumb http', () { + final breadcrumb = Breadcrumb.http( + url: Uri.parse('https://example.org'), + method: 'GET', + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'http', + 'data': { + 'url': 'https://example.org', + 'method': 'GET', + }, + 'level': 'info', + 'type': 'http', + }); }); - }); - test('Breadcrumb console ctor', () { - final breadcrumb = Breadcrumb.console( - message: 'Foo Bar', - ); - final json = breadcrumb.toJson(); - - expect(json, { - 'message': 'Foo Bar', - 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), - 'category': 'console', - 'type': 'debug', - 'level': 'info', + test('Breadcrumb console', () { + final breadcrumb = Breadcrumb.console( + message: 'Foo Bar', + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'message': 'Foo Bar', + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'console', + 'type': 'debug', + 'level': 'info', + }); }); - }); - test('extensive Breadcrumb console ctor', () { - final breadcrumb = Breadcrumb.console( - message: 'Foo Bar', - level: SentryLevel.error, - data: {'foo': 'bar'}, - ); - final json = breadcrumb.toJson(); - - expect(json, { - 'message': 'Foo Bar', - 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), - 'category': 'console', - 'type': 'debug', - 'level': 'error', - 'data': {'foo': 'bar'}, + test('extensive Breadcrumb console', () { + final breadcrumb = Breadcrumb.console( + message: 'Foo Bar', + level: SentryLevel.error, + data: {'foo': 'bar'}, + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'message': 'Foo Bar', + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'console', + 'type': 'debug', + 'level': 'error', + 'data': {'foo': 'bar'}, + }); + }); + + test('extensive Breadcrumb user interaction', () { + final time = DateTime.now().toUtc(); + final breadcrumb = Breadcrumb.userInteraction( + message: 'Foo Bar', + level: SentryLevel.error, + timestamp: time, + data: {'foo': 'bar'}, + subCategory: 'click', + viewId: 'foo', + viewClass: 'bar', + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'message': 'Foo Bar', + 'timestamp': formatDateAsIso8601WithMillisPrecision(time), + 'category': 'ui.click', + 'type': 'user', + 'level': 'error', + 'data': { + 'foo': 'bar', + 'view.id': 'foo', + 'view.class': 'bar', + }, + }); }); }); } diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index 90949ac26..db2dca76f 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -95,4 +95,10 @@ void main() { expect(options.tracePropagationTargets, ['.*']); }); + + test('SentryOptions has default idleTimeout', () { + final options = SentryOptions.empty(); + + expect(options.idleTimeout?.inSeconds, Duration(seconds: 3).inSeconds); + }); } diff --git a/dart/test/sentry_span_test.dart b/dart/test/sentry_span_test.dart index 588d57428..03b057cf4 100644 --- a/dart/test/sentry_span_test.dart +++ b/dart/test/sentry_span_test.dart @@ -244,6 +244,18 @@ void main() { expect(sut.endTimestamp, endTimestamp); }); + + test('child span reschedule finish timer', () async { + final sut = fixture.getSut(autoFinishAfter: Duration(seconds: 5)); + + final currentTimer = fixture.tracer.autoFinishAfterTimer!; + + sut.scheduleFinish(); + + final newTimer = fixture.tracer.autoFinishAfterTimer!; + + expect(currentTimer, isNot(equals(newTimer))); + }); } class Fixture { @@ -254,11 +266,13 @@ class Fixture { late SentryTracer tracer; final hub = MockHub(); - SentrySpan getSut( - {DateTime? startTimestamp, - bool? sampled = true, - Function({DateTime? endTimestamp})? finishedCallback}) { - tracer = SentryTracer(context, hub); + SentrySpan getSut({ + DateTime? startTimestamp, + bool? sampled = true, + Function({DateTime? endTimestamp})? finishedCallback, + Duration? autoFinishAfter, + }) { + tracer = SentryTracer(context, hub, autoFinishAfter: autoFinishAfter); return SentrySpan( tracer, diff --git a/dart/test/sentry_tracer_test.dart b/dart/test/sentry_tracer_test.dart index 06185e9e4..8a1d386a2 100644 --- a/dart/test/sentry_tracer_test.dart +++ b/dart/test/sentry_tracer_test.dart @@ -269,6 +269,32 @@ void main() { expect(sut.finished, true); }); + test('tracer reschedule finish timer', () async { + final sut = fixture.getSut(autoFinishAfter: Duration(milliseconds: 200)); + + final currentTimer = sut.autoFinishAfterTimer!; + + sut.scheduleFinish(); + + final newTimer = sut.autoFinishAfterTimer!; + + expect(currentTimer, isNot(equals(newTimer))); + }); + + test('tracer do not reschedule if finished', () async { + final sut = fixture.getSut(autoFinishAfter: Duration(milliseconds: 200)); + + final currentTimer = sut.autoFinishAfterTimer!; + + await sut.finish(); + + sut.scheduleFinish(); + + final newTimer = sut.autoFinishAfterTimer!; + + expect(currentTimer, newTimer); + }); + test('tracer finish needs child to finish', () async { final sut = fixture.getSut(waitForChildren: true); @@ -374,6 +400,14 @@ void main() { expect(sut.startChild('child3'), isA()); }); + test('do not capture idle transaction without children', () async { + final sut = fixture.getSut(autoFinishAfter: Duration(milliseconds: 200)); + + await sut.finish(); + + expect(fixture.hub.captureTransactionCalls.isEmpty, true); + }); + test('tracer sets measurement', () async { final sut = fixture.getSut(); @@ -407,24 +441,15 @@ void main() { }); group('$SentryBaggageHeader', () { - final _options = SentryOptions(dsn: fakeDsn) - ..release = 'release' - ..environment = 'environment'; - + late Fixture _fixture; late Hub _hub; - final _client = MockSentryClient(); - - final _user = SentryUser( - id: 'id', - segment: 'segment', - ); - setUp(() async { - _hub = Hub(_options); - _hub.configureScope((scope) => scope.setUser(_user)); + _fixture = Fixture(); + _hub = Hub(_fixture.options); + _hub.configureScope((scope) => scope.setUser(_fixture.user)); - _hub.bindClient(_client); + _hub.bindClient(_fixture.client); }); SentryTracer getSut({SentryTracesSamplingDecision? samplingDecision}) { @@ -492,24 +517,15 @@ void main() { }); group('$SentryTraceContextHeader', () { - final _options = SentryOptions(dsn: fakeDsn) - ..release = 'release' - ..environment = 'environment'; - + late Fixture _fixture; late Hub _hub; - final _client = MockSentryClient(); - - final _user = SentryUser( - id: 'id', - segment: 'segment', - ); - setUp(() async { - _hub = Hub(_options); - _hub.configureScope((scope) => scope.setUser(_user)); + _fixture = Fixture(); + _hub = Hub(_fixture.options); + _hub.configureScope((scope) => scope.setUser(_fixture.user)); - _hub.bindClient(_client); + _hub.bindClient(_fixture.client); }); SentryTracer getSut({SentryTracesSamplingDecision? samplingDecision}) { @@ -544,6 +560,17 @@ void main() { } class Fixture { + final options = SentryOptions(dsn: fakeDsn) + ..release = 'release' + ..environment = 'environment'; + + final client = MockSentryClient(); + + final user = SentryUser( + id: 'id', + segment: 'segment', + ); + final hub = MockHub(); SentryTracer getSut({ diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 503b71958..c60642d02 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -23,9 +23,11 @@ final _channel = const MethodChannel('example.flutter.sentry.io'); Future main() async { await setupSentry(() => runApp( SentryScreenshotWidget( - child: DefaultAssetBundle( - bundle: SentryAssetBundle(enableStructuredDataTracing: true), - child: MyApp(), + child: SentryUserInteractionWidget( + child: DefaultAssetBundle( + bundle: SentryAssetBundle(enableStructuredDataTracing: true), + child: MyApp(), + ), ), ), )); @@ -41,6 +43,10 @@ Future setupSentry(AppRunner appRunner) async { options.attachThreads = true; options.enableWindowMetricBreadcrumbs = true; options.addIntegration(LoggingIntegration()); + options.sendDefaultPii = true; + options.reportSilentFlutterErrors = true; + options.enableNdkScopeSync = true; + options.enableUserInteractionTracing = true; options.attachScreenshot = true; // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out @@ -124,6 +130,7 @@ class MainScaffold extends StatelessWidget { ), ElevatedButton( onPressed: () => tryCatch(), + key: Key('dart_try_catch'), child: const Text('Dart: try catch'), ), ElevatedButton( @@ -201,7 +208,8 @@ class MainScaffold extends StatelessWidget { child: const Text('Flutter: Load assets'), ), ElevatedButton( - onPressed: () => makeWebRequestWithDio(context), + key: Key('dio_web_request'), + onPressed: () async => await makeWebRequestWithDio(context), child: const Text('Dio: Web request'), ), ElevatedButton( @@ -560,6 +568,7 @@ Future makeWebRequestWithDio(BuildContext context) async { dio.addSentry( captureFailedRequests: true, maxRequestBodySize: MaxRequestBodySize.always, + maxResponseBodySize: MaxResponseBodySize.always, ); final transaction = Sentry.getSpan() ?? @@ -568,16 +577,20 @@ Future makeWebRequestWithDio(BuildContext context) async { 'request', bindToScope: true, ); + final span = transaction.startChild( + 'dio', + description: 'desc', + ); Response? response; try { response = await dio.get('https://flutter.dev/'); - transaction.status = SpanStatus.ok(); + span.status = SpanStatus.ok(); } catch (exception, stackTrace) { - transaction.throwable = exception; - transaction.status = SpanStatus.internalError(); + span.throwable = exception; + span.status = SpanStatus.internalError(); await Sentry.captureException(exception, stackTrace: stackTrace); } finally { - await transaction.finish(); + await span.finish(); } await showDialog( diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 439e4e60a..98f0d1229 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -9,3 +9,4 @@ export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart'; export 'src/integrations/on_error_integration.dart'; export 'src/screenshot/sentry_screenshot_widget.dart'; +export 'src/user_interaction/sentry_user_interaction_widget.dart'; diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index efd9326dc..1f5318f96 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -188,6 +188,20 @@ class SentryFlutterOptions extends SentryOptions { /// The [SentryScreenshotWidget] has to be the root widget of the app. bool attachScreenshot = false; + /// Enable or disable automatic breadcrumbs for User interactions Using [Listener] + /// + /// Requires adding the [SentryUserInteractionWidget] to the widget tree. + /// Example: + /// runApp(SentryUserInteractionWidget(child: App())); + bool enableUserInteractionBreadcrumbs = true; + + /// Enables the Auto instrumentation for user interaction tracing. + /// + /// Requires adding the [SentryUserInteractionWidget] to the widget tree. + /// Example: + /// runApp(SentryUserInteractionWidget(child: App())); + bool enableUserInteractionTracing = false; + @internal late RendererWrapper rendererWrapper = RendererWrapper(); diff --git a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart new file mode 100644 index 000000000..86240954c --- /dev/null +++ b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart @@ -0,0 +1,333 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'user_interaction_widget.dart'; + +// Adapted from https://github.com/ueman/sentry-dart-tools/blob/8e41418c0f2c62dc88292cf32a4f22e79112b744/sentry_flutter_plus/lib/src/widgets/click_tracker.dart + +const _tapDeltaArea = 20 * 20; +Element? _clickTrackerElement; + +/// Enables the Auto instrumentation for user interaction tracing. +/// It starts a transaction and finishes after the timeout. +/// It adds a breadcrumb as well. +/// +/// It's supported by the most common [Widget], for example: +/// [ButtonStyleButton], [MaterialButton], [CupertinoButton], [InkWell], +/// and [IconButton]. +/// Mostly for onPressed, onTap, and onLongPress events +/// +/// Example on how to set up: +/// runApp(SentryUserInteractionWidget(child: App())); +/// +/// For transactions, enable it in the [SentryFlutterOptions.enableUserInteractionTracing]. +/// The idle timeout can be configured in the [SentryOptions.idleTimeout]. +/// +/// For breadcrumbs, disable it in the [SentryFlutterOptions.enableUserInteractionBreadcrumbs]. +/// +/// If you are using the [SentryScreenshotWidget] as well, make sure to add +/// [SentryUserInteractionWidget] as a child of [SentryScreenshotWidget]. +@experimental +class SentryUserInteractionWidget extends StatefulWidget { + SentryUserInteractionWidget({ + Key? key, + required this.child, + @internal Hub? hub, + }) : super(key: key) { + _hub = hub ?? HubAdapter(); + + if (_options?.enableUserInteractionTracing ?? false) { + _options?.sdk.addIntegration('UserInteractionTracing'); + } + } + + final Widget child; + + late final Hub _hub; + + SentryFlutterOptions? get _options => + // ignore: invalid_use_of_internal_member + _hub.options as SentryFlutterOptions?; + + @override + StatefulElement createElement() { + final element = super.createElement(); + _clickTrackerElement = element; + return element; + } + + @override + _SentryUserInteractionWidgetState createState() => + _SentryUserInteractionWidgetState(); +} + +class _SentryUserInteractionWidgetState + extends State { + int? _lastPointerId; + Offset? _lastPointerDownLocation; + UserInteractionWidget? _lastTappedWidget; + ISentrySpan? _activeTransaction; + + Hub get _hub => widget._hub; + + SentryFlutterOptions? get _options => widget._options; + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + child: widget.child, + ); + } + + void _onPointerDown(PointerDownEvent event) { + _lastPointerId = event.pointer; + _lastPointerDownLocation = event.localPosition; + } + + void _onPointerUp(PointerUpEvent event) { + // Figure out if something was tapped + final location = _lastPointerDownLocation; + if (location == null || event.pointer != _lastPointerId) { + return; + } + final delta = Offset( + location.dx - event.localPosition.dx, + location.dy - event.localPosition.dy, + ); + + if (delta.distanceSquared < _tapDeltaArea) { + // Widget was tapped + _onTappedAt(event.localPosition); + } + } + + void _onTappedAt(Offset position) { + final tappedWidget = _getElementAt(position); + final keyValue = tappedWidget?.keyValue; + if (tappedWidget == null || keyValue == null) { + return; + } + final element = tappedWidget.element; + + Map? data; + // ignore: invalid_use_of_internal_member + if ((_options?.sendDefaultPii ?? false) && + tappedWidget.description.isNotEmpty) { + data = {}; + data['label'] = tappedWidget.description; + } + + const category = 'click'; + // ignore: invalid_use_of_internal_member + if (_options?.enableUserInteractionBreadcrumbs ?? false) { + final crumb = Breadcrumb.userInteraction( + subCategory: category, + viewId: keyValue, + viewClass: tappedWidget.type, // to avoid minification + data: data, + ); + _hub.addBreadcrumb(crumb, hint: element.widget); + } + + // ignore: invalid_use_of_internal_member + if (!(_options?.isTracingEnabled() ?? false) || + !(_options?.enableUserInteractionTracing ?? false)) { + return; + } + + // getting the name of the screen using ModalRoute.of(context).settings.name + // is expensive, so we expect that the keys are unique across the app + final transactionContext = SentryTransactionContext( + keyValue, + 'ui.action.$category', + transactionNameSource: SentryTransactionNameSource.component, + ); + + final activeTransaction = _activeTransaction; + if (activeTransaction != null) { + if (_lastTappedWidget?.element.widget == element.widget && + _lastTappedWidget?.eventType == tappedWidget.eventType && + !activeTransaction.finished) { + // ignore: invalid_use_of_internal_member + activeTransaction.scheduleFinish(); + return; + } else { + activeTransaction.finish(); + _hub.configureScope((scope) { + if (scope.span == activeTransaction) { + scope.span = null; + } + }); + _activeTransaction = null; + _lastTappedWidget = null; + } + } + + _lastTappedWidget = tappedWidget; + + bool hasRunningTransaction = false; + _hub.configureScope((scope) { + if (scope.span != null) { + hasRunningTransaction = true; + } + }); + + if (hasRunningTransaction) { + return; + } + + // TODO: mobile vitals + _activeTransaction = _hub.startTransactionWithContext( + transactionContext, + waitForChildren: true, + autoFinishAfter: + // ignore: invalid_use_of_internal_member + _options?.idleTimeout, + trimEnd: true, + ); + + // if _enableAutoTransactions is enabled but there's no traces sample rate + if (_activeTransaction is NoOpSentrySpan) { + return; + } + + _hub.configureScope((scope) { + scope.span ??= _activeTransaction; + }); + } + + String _findDescriptionOf(Element element, bool allowText) { + var description = ''; + + // traverse tree to find a suiting element + void descriptionFinder(Element element) { + bool foundDescription = false; + + final widget = element.widget; + if (allowText && widget is Text) { + final data = widget.data; + if (data != null && data.isNotEmpty) { + description = data; + foundDescription = true; + } + } else if (widget is Semantics) { + if (widget.properties.label?.isNotEmpty ?? false) { + description = widget.properties.label!; + foundDescription = true; + } + } else if (widget is Icon) { + if (widget.semanticLabel?.isNotEmpty ?? false) { + description = widget.semanticLabel!; + foundDescription = true; + } + } + + if (!foundDescription) { + element.visitChildren(descriptionFinder); + } + } + + element.visitChildren(descriptionFinder); + + return description; + } + + UserInteractionWidget? _getElementAt(Offset position) { + // WidgetsBinding.instance.renderViewElement does not work, so using + // the element from createElement + final rootElement = _clickTrackerElement; + if (rootElement == null || rootElement.widget != widget) { + return null; + } + + UserInteractionWidget? tappedWidget; + + void elementFinder(Element element) { + if (tappedWidget != null) { + // element was found + return; + } + + final renderObject = element.renderObject; + if (renderObject == null) { + return; + } + + final transform = renderObject.getTransformTo(rootElement.renderObject); + final paintBounds = + MatrixUtils.transformRect(transform, renderObject.paintBounds); + + if (!paintBounds.contains(position)) { + return; + } + + tappedWidget = _getDescriptionFrom(element); + + if (tappedWidget == null) { + element.visitChildElements(elementFinder); + } + } + + rootElement.visitChildElements(elementFinder); + + return tappedWidget; + } + + UserInteractionWidget? _getDescriptionFrom(Element element) { + final widget = element.widget; + // Used by ElevatedButton, TextButton, OutlinedButton. + if (widget is ButtonStyleButton) { + if (widget.enabled) { + return UserInteractionWidget( + element: element, + description: _findDescriptionOf(element, true), + type: 'ButtonStyleButton', + eventType: 'onClick', + ); + } + } else if (widget is MaterialButton) { + if (widget.enabled) { + return UserInteractionWidget( + element: element, + description: _findDescriptionOf(element, true), + type: 'MaterialButton', + eventType: 'onClick', + ); + } + } else if (widget is CupertinoButton) { + if (widget.enabled) { + return UserInteractionWidget( + element: element, + description: _findDescriptionOf(element, true), + type: 'CupertinoButton', + eventType: 'onPressed', + ); + } + } else if (widget is InkWell) { + if (widget.onTap != null) { + return UserInteractionWidget( + element: element, + description: _findDescriptionOf(element, false), + type: 'InkWell', + eventType: 'onTap', + ); + } + } else if (widget is IconButton) { + if (widget.onPressed != null) { + return UserInteractionWidget( + element: element, + description: _findDescriptionOf(element, false), + type: 'IconButton', + eventType: 'onPressed', + ); + } + } + + return null; + } +} diff --git a/flutter/lib/src/user_interaction/user_interaction_widget.dart b/flutter/lib/src/user_interaction/user_interaction_widget.dart new file mode 100644 index 000000000..f0c5e3b66 --- /dev/null +++ b/flutter/lib/src/user_interaction/user_interaction_widget.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; + +class UserInteractionWidget { + final Element element; + final String description; + final String type; + final String eventType; + + const UserInteractionWidget({ + required this.element, + required this.description, + required this.type, + required this.eventType, + }); + + String? get keyValue { + final key = element.widget.key; + if (key == null) { + return null; + } + if (key is ValueKey) { + return key.value; + } else if (key is ValueKey) { + return key.value?.toString(); + } + return key.toString(); + } +} diff --git a/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart new file mode 100644 index 000000000..e7ce8bf60 --- /dev/null +++ b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart @@ -0,0 +1,338 @@ +@TestOn('vm') + +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry/src/sentry_tracer.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + group('$SentryUserInteractionWidget crumbs', () { + late Fixture fixture; + setUp(() async { + fixture = Fixture(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('Add crumb for MaterialButton', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(); + + await tapMe(tester, sut, 'btn_1'); + + Breadcrumb? crumb; + fixture.hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + expect(crumb?.category, 'ui.click'); + expect(crumb?.data?['view.id'], 'btn_1'); + expect(crumb?.data?['view.class'], 'MaterialButton'); + }); + }); + + testWidgets('Add crumb for MaterialButton with label', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tapMe(tester, sut, 'btn_1'); + + Breadcrumb? crumb; + fixture.hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + expect(crumb?.data?['label'], 'Button 1'); + }); + }); + + testWidgets('Add crumb for Icon with label', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tapMe(tester, sut, 'btn_3'); + + Breadcrumb? crumb; + fixture.hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + expect(crumb?.data?['label'], 'My Icon'); + }); + }); + + testWidgets('Add crumb for CupertinoButton with label', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tapMe(tester, sut, 'btn_2'); + + Breadcrumb? crumb; + fixture.hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + expect(crumb?.data?['label'], 'Button 2'); + }); + }); + + testWidgets('Do not add crumb if disabled', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(enableUserInteractionBreadcrumbs: false); + + await tapMe(tester, sut, 'btn_1'); + + List? crumbs; + fixture.hub.configureScope((scope) { + crumbs = scope.breadcrumbs; + }); + expect(crumbs?.isEmpty, true); + }); + }); + + testWidgets( + 'Add crumb for ElevatedButton within a GestureDetector with label', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tapMe(tester, sut, 'btn_5'); + + Breadcrumb? crumb; + fixture.hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + expect(crumb?.data?['label'], 'Button 5'); + }); + }); + }); + + group('$SentryUserInteractionWidget performance', () { + late Fixture fixture; + setUp(() async { + fixture = Fixture(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('Adds integration if enabled', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut( + enableUserInteractionTracing: true, + enableUserInteractionBreadcrumbs: false); + + await tester.pumpWidget(sut); + + expect( + fixture._options.sdk.integrations + .contains('UserInteractionTracing'), + true); + }); + }); + + testWidgets('Do not add integration if disabled', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(enableUserInteractionBreadcrumbs: false); + + await tester.pumpWidget(sut); + + expect( + fixture._options.sdk.integrations + .contains('UserInteractionTracing'), + false); + }); + }); + + testWidgets('Start transaction and set in the scope', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut( + enableUserInteractionTracing: true, + enableUserInteractionBreadcrumbs: false); + + await tapMe(tester, sut, 'btn_1'); + + SentryTracer? tracer; + fixture.hub.configureScope((scope) { + tracer = (scope.span as SentryTracer); + }); + expect(tracer?.name, 'btn_1'); + expect(tracer?.context.operation, 'ui.action.click'); + expect(tracer?.transactionNameSource, + SentryTransactionNameSource.component); + expect(tracer?.autoFinishAfterTimer, isNotNull); + }); + }); + + testWidgets('Start transaction and do not set in the scope if any', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut( + enableUserInteractionTracing: true, + enableUserInteractionBreadcrumbs: false); + + fixture.hub.configureScope((scope) { + scope.span = NoOpSentrySpan(); + }); + + await tapMe(tester, sut, 'btn_1'); + + ISentrySpan? span; + fixture.hub.configureScope((scope) { + span = scope.span; + }); + expect(span, NoOpSentrySpan()); + }); + }); + + testWidgets('Extend timer if transaction already started for same widget', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut( + enableUserInteractionTracing: true, + enableUserInteractionBreadcrumbs: false); + + await tapMe(tester, sut, 'btn_1'); + Timer? currentTimer; + + fixture.hub.configureScope((scope) { + final tracer = (scope.span as SentryTracer); + currentTimer = tracer.autoFinishAfterTimer; + }); + + await tapMe(tester, sut, 'btn_1'); + + Timer? autoFinishAfterTimer; + fixture.hub.configureScope((scope) { + final tracer = (scope.span as SentryTracer); + autoFinishAfterTimer = tracer.autoFinishAfterTimer; + }); + expect(currentTimer, isNot(equals(autoFinishAfterTimer))); + }); + }); + + testWidgets('Finish transaction and start new one if new tap', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut( + enableUserInteractionTracing: true, + enableUserInteractionBreadcrumbs: false); + + await tapMe(tester, sut, 'btn_1'); + SentryTracer? currentTracer; + + fixture.hub.configureScope((scope) { + currentTracer = (scope.span as SentryTracer); + }); + + await tapMe(tester, sut, 'btn_2'); + + SentryTracer? tracer; + fixture.hub.configureScope((scope) { + tracer = (scope.span as SentryTracer); + }); + expect(currentTracer, isNot(equals(tracer))); + }); + }); + }); +} + +Future tapMe(WidgetTester tester, Widget widget, String key) async { + await tester.pumpWidget(widget); + + await tester.tap(find.byKey(Key(key))); +} + +class Fixture { + final _options = SentryFlutterOptions(dsn: fakeDsn); + final _transport = MockTransport(); + late Hub hub; + + SentryUserInteractionWidget getSut({ + bool enableUserInteractionTracing = false, + bool enableUserInteractionBreadcrumbs = true, + double? tracesSampleRate = 1.0, + bool sendDefaultPii = false, + }) { + _options.transport = _transport; + _options.tracesSampleRate = tracesSampleRate; + _options.enableUserInteractionTracing = enableUserInteractionTracing; + _options.enableUserInteractionBreadcrumbs = + enableUserInteractionBreadcrumbs; + _options.sendDefaultPii = sendDefaultPii; + + hub = Hub(_options); + + return SentryUserInteractionWidget( + hub: hub, + child: MyApp(), + ); + } +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Welcome to Flutter', + home: Scaffold( + appBar: AppBar( + title: const Text('Welcome to Flutter'), + ), + body: Center( + child: Column( + children: [ + MaterialButton( + key: Key('btn_1'), + onPressed: () { + // print('button pressed'); + }, + child: const Text('Button 1'), + ), + CupertinoButton( + key: Key('btn_2'), + onPressed: () { + // print('button pressed 2'); + }, + child: const Text('Button 2'), + ), + IconButton( + key: Key('btn_3'), + onPressed: () { + // print('button pressed 3'); + }, + icon: Icon( + Icons.dark_mode, + semanticLabel: 'My Icon', + ), + ), + Card( + child: GestureDetector( + key: Key('btn_4'), + onTap: () => { + // print('button pressed 4'), + }, + child: Stack( + children: [ + //fancy card layout + ElevatedButton( + key: Key('btn_5'), + onPressed: () => { + // print('button pressed 5'), + }, + child: const Text('Button 5'), + ), + ], + ), + ), + ) + ], + ), + ), + ), + ); + } +}