diff --git a/CHANGELOG.md b/CHANGELOG.md index 760c14d8c4..433bd0e240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* Feat: Collect more information for exceptions collected via `FlutterError.onError` (#538) + # 6.0.0-beta.3 * Fix: Re-initialization of Flutter SDK (#526) diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 8abb7580c1..2790265c27 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -146,6 +146,26 @@ class MainScaffold extends StatelessWidget { onPressed: () => Future.delayed(Duration(milliseconds: 100), () => throw Exception('Throws in Future.delayed')), ), + RaisedButton( + child: const Text('Capture from FlutterError.onError'), + onPressed: () { + // modeled after a real exception + FlutterError.onError?.call(FlutterErrorDetails( + exception: Exception('A really bad exception'), + silent: false, + context: DiagnosticsNode.message('while handling a gesture'), + library: 'gesture', + informationCollector: () => [ + DiagnosticsNode.message( + 'Handler: "onTap" Recognizer: TapGestureRecognizer'), + DiagnosticsNode.message( + 'Handler: "onTap" Recognizer: TapGestureRecognizer'), + DiagnosticsNode.message( + 'Handler: "onTap" Recognizer: TapGestureRecognizer'), + ], + )); + }, + ), RaisedButton( child: const Text('Dart: Web request'), onPressed: () => makeWebRequest(context), diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart index 82794d5678..ac62ed158b 100644 --- a/flutter/lib/src/default_integrations.dart +++ b/flutter/lib/src/default_integrations.dart @@ -44,7 +44,7 @@ class FlutterErrorIntegration extends Integration { void call(Hub hub, SentryFlutterOptions options) { _defaultOnError = FlutterError.onError; _integrationOnError = (FlutterErrorDetails errorDetails) async { - dynamic exception = errorDetails.exception; + final exception = errorDetails.exception; options.logger( SentryLevel.debug, @@ -52,13 +52,42 @@ class FlutterErrorIntegration extends Integration { ); if (errorDetails.silent != true || options.reportSilentFlutterErrors) { + final context = errorDetails.context?.toDescription(); + + final collector = errorDetails.informationCollector?.call() ?? []; + final information = + (StringBuffer()..writeAll(collector, '\n')).toString(); + // errorDetails.library defaults to 'Flutter framework' even though it + // is nullable. We do null checks anyway, just to be sure. + final library = errorDetails.library; + + final flutterErrorDetails = { + // This is a message which should make sense if written after the + // word `thrown`: + // https://api.flutter.dev/flutter/foundation/FlutterErrorDetails/context.html + if (context != null) 'context': 'thrown $context', + if (collector.isNotEmpty) 'information': information, + if (library != null) 'library': library, + }; + // FlutterError doesn't crash the App. - final mechanism = Mechanism(type: 'FlutterError', handled: true); + final mechanism = Mechanism( + type: 'FlutterError', + handled: true, + data: { + if (flutterErrorDetails.isNotEmpty) + 'hint': + 'See "flutter_error_details" down below for more information' + }, + ); final throwableMechanism = ThrowableMechanism(mechanism, exception); var event = SentryEvent( throwable: throwableMechanism, level: SentryLevel.fatal, + contexts: flutterErrorDetails.isNotEmpty + ? (Contexts()..['flutter_error_details'] = flutterErrorDetails) + : null, ); await hub.captureEvent(event, stackTrace: errorDetails.stack); diff --git a/flutter/test/default_integrations_test.dart b/flutter/test/default_integrations_test.dart index 3522489e02..2f2be6e499 100644 --- a/flutter/test/default_integrations_test.dart +++ b/flutter/test/default_integrations_test.dart @@ -32,6 +32,7 @@ void main() { bool silent = false, FlutterExceptionHandler? handler, dynamic exception, + FlutterErrorDetails? optionalDetails, }) { // replace default error otherwise it fails on testing FlutterError.onError = @@ -46,8 +47,11 @@ void main() { final details = FlutterErrorDetails( exception: throwable, silent: silent, + context: DiagnosticsNode.message('while handling a gesture'), + library: 'sentry', + informationCollector: () => [DiagnosticsNode.message('foo bar')], ); - FlutterError.reportError(details); + FlutterError.reportError(optionalDetails ?? details); } test('FlutterError capture errors', () async { @@ -64,7 +68,70 @@ void main() { final throwableMechanism = event.throwableMechanism as ThrowableMechanism; expect(throwableMechanism.mechanism.type, 'FlutterError'); expect(throwableMechanism.mechanism.handled, true); + expect(throwableMechanism.mechanism.data['hint'], + 'See "flutter_error_details" down below for more information'); expect(throwableMechanism.throwable, exception); + + expect(event.contexts['flutter_error_details']['library'], 'sentry'); + expect(event.contexts['flutter_error_details']['context'], + 'thrown while handling a gesture'); + expect(event.contexts['flutter_error_details']['information'], 'foo bar'); + }); + + test('FlutterError capture errors with long FlutterErrorDetails.information', + () async { + final details = FlutterErrorDetails( + exception: StateError('error'), + silent: false, + context: DiagnosticsNode.message('while handling a gesture'), + library: 'sentry', + informationCollector: () => [ + DiagnosticsNode.message('foo bar'), + DiagnosticsNode.message('Hello World!') + ], + ); + + // exception is ignored in this case + _reportError(exception: StateError('error'), optionalDetails: details); + + final event = verify( + await fixture.hub.captureEvent(captureAny), + ).captured.first as SentryEvent; + + expect(event.level, SentryLevel.fatal); + + final throwableMechanism = event.throwableMechanism as ThrowableMechanism; + expect(throwableMechanism.mechanism.type, 'FlutterError'); + expect(throwableMechanism.mechanism.handled, true); + expect(throwableMechanism.mechanism.data['hint'], + 'See "flutter_error_details" down below for more information'); + + expect(event.contexts['flutter_error_details']['library'], 'sentry'); + expect(event.contexts['flutter_error_details']['context'], + 'thrown while handling a gesture'); + expect(event.contexts['flutter_error_details']['information'], + 'foo bar\nHello World!'); + }); + + test('FlutterError capture errors with no FlutterErrorDetails', () async { + final details = FlutterErrorDetails( + exception: StateError('error'), silent: false, library: null); + + // exception is ignored in this case + _reportError(exception: StateError('error'), optionalDetails: details); + + final event = verify( + await fixture.hub.captureEvent(captureAny), + ).captured.first as SentryEvent; + + expect(event.level, SentryLevel.fatal); + + final throwableMechanism = event.throwableMechanism as ThrowableMechanism; + expect(throwableMechanism.mechanism.type, 'FlutterError'); + expect(throwableMechanism.mechanism.handled, true); + expect(throwableMechanism.mechanism.data['hint'], isNull); + + expect(event.contexts['flutter_error_details'], isNull); }); test('FlutterError calls default error', () async {