diff --git a/CHANGELOG.md b/CHANGELOG.md index 268db81b94..2b6e3433d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## Unreleased + +### Improvements + +- Add error type identifier to improve obfuscated Flutter issue titles ([#2170](https://github.com/getsentry/sentry-dart/pull/2170)) + - Example: transforms issue titles from `GA` to `FlutterError` or `minified:nE` to `FlutterError` + - This is enabled automatically and will change grouping if you already have issues with obfuscated titles + - If you want to disable this feature, set `enableExceptionTypeIdentification` to `false` in your Sentry options + - You can add your custom exception identifier if there are exceptions that we do not identify out of the box +```dart +// How to add your own custom exception identifier +class MyCustomExceptionIdentifier implements ExceptionIdentifier { + @override + String? identifyType(Exception exception) { + if (exception is MyCustomException) { + return 'MyCustomException'; + } + if (exception is MyOtherCustomException) { + return 'MyOtherCustomException'; + } + return null; + } +} + +SentryFlutter.init((options) => + options..prependExceptionTypeIdentifier(MyCustomExceptionIdentifier())); +``` + ## 8.5.0 ### Features diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index fd88a49fc5..a43fc08fc6 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -40,6 +40,7 @@ export 'src/sentry_baggage.dart'; export 'src/exception_cause_extractor.dart'; export 'src/exception_cause.dart'; export 'src/exception_stacktrace_extractor.dart'; +export 'src/exception_type_identifier.dart'; // URL // ignore: invalid_export_of_internal_element export 'src/utils/http_sanitizer.dart'; diff --git a/dart/lib/src/dart_exception_type_identifier.dart b/dart/lib/src/dart_exception_type_identifier.dart new file mode 100644 index 0000000000..9be98c608a --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier.dart @@ -0,0 +1,41 @@ +import 'package:http/http.dart' show ClientException; +import 'dart:async' show TimeoutException, AsyncError, DeferredLoadException; +import '../sentry.dart'; + +import 'dart_exception_type_identifier_io.dart' + if (dart.library.html) 'dart_exception_type_identifier_web.dart'; + +class DartExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + // dart:core + if (throwable is ArgumentError) return 'ArgumentError'; + if (throwable is AssertionError) return 'AssertionError'; + if (throwable is ConcurrentModificationError) { + return 'ConcurrentModificationError'; + } + if (throwable is FormatException) return 'FormatException'; + if (throwable is IndexError) return 'IndexError'; + if (throwable is NoSuchMethodError) return 'NoSuchMethodError'; + if (throwable is OutOfMemoryError) return 'OutOfMemoryError'; + if (throwable is RangeError) return 'RangeError'; + if (throwable is StackOverflowError) return 'StackOverflowError'; + if (throwable is StateError) return 'StateError'; + if (throwable is TypeError) return 'TypeError'; + if (throwable is UnimplementedError) return 'UnimplementedError'; + if (throwable is UnsupportedError) return 'UnsupportedError'; + // not adding Exception or Error because it's too generic + + // dart:async + if (throwable is TimeoutException) return 'TimeoutException'; + if (throwable is AsyncError) return 'FutureTimeout'; + if (throwable is DeferredLoadException) return 'DeferredLoadException'; + // not adding ParallelWaitError because it's not supported in dart 2.17.0 + + // dart http package + if (throwable is ClientException) return 'ClientException'; + + // platform specific exceptions + return identifyPlatformSpecificException(throwable); + } +} diff --git a/dart/lib/src/dart_exception_type_identifier_io.dart b/dart/lib/src/dart_exception_type_identifier_io.dart new file mode 100644 index 0000000000..1945663a01 --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier_io.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; + +@internal +String? identifyPlatformSpecificException(dynamic throwable) { + if (throwable is FileSystemException) return 'FileSystemException'; + if (throwable is HttpException) return 'HttpException'; + if (throwable is SocketException) return 'SocketException'; + if (throwable is HandshakeException) return 'HandshakeException'; + if (throwable is CertificateException) return 'CertificateException'; + if (throwable is TlsException) return 'TlsException'; + return null; +} diff --git a/dart/lib/src/dart_exception_type_identifier_web.dart b/dart/lib/src/dart_exception_type_identifier_web.dart new file mode 100644 index 0000000000..088ce9556e --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier_web.dart @@ -0,0 +1,6 @@ +import 'package:meta/meta.dart'; + +@internal +String? identifyPlatformSpecificException(dynamic throwable) { + return null; +} diff --git a/dart/lib/src/exception_type_identifier.dart b/dart/lib/src/exception_type_identifier.dart new file mode 100644 index 0000000000..7dae9db841 --- /dev/null +++ b/dart/lib/src/exception_type_identifier.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +/// An abstract class for identifying the type of Dart errors and exceptions. +/// +/// It's used in scenarios where error types need to be determined in obfuscated builds +/// as [runtimeType] is not reliable in such cases. +/// +/// Implement this class to create custom error type identifiers for errors or exceptions. +/// that we do not support out of the box. +/// +/// Example: +/// ```dart +/// class MyExceptionTypeIdentifier implements ExceptionTypeIdentifier { +/// @override +/// String? identifyType(dynamic throwable) { +/// if (throwable is MyCustomError) return 'MyCustomError'; +/// return null; +/// } +/// } +/// ``` +abstract class ExceptionTypeIdentifier { + String? identifyType(dynamic throwable); +} + +extension CacheableExceptionIdentifier on ExceptionTypeIdentifier { + ExceptionTypeIdentifier withCache() => CachingExceptionTypeIdentifier(this); +} + +@visibleForTesting +class CachingExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @visibleForTesting + ExceptionTypeIdentifier get identifier => _identifier; + final ExceptionTypeIdentifier _identifier; + + final Map _knownExceptionTypes = {}; + + CachingExceptionTypeIdentifier(this._identifier); + + @override + String? identifyType(dynamic throwable) { + final runtimeType = throwable.runtimeType; + if (_knownExceptionTypes.containsKey(runtimeType)) { + return _knownExceptionTypes[runtimeType]; + } + + final identifiedType = _identifier.identifyType(throwable); + + if (identifiedType != null) { + _knownExceptionTypes[runtimeType] = identifiedType; + } + + return identifiedType; + } +} diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 1873cd6308..a3ac51e818 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'dart_exception_type_identifier.dart'; import 'metrics/metrics_api.dart'; import 'run_zoned_guarded_integration.dart'; import 'event_processor/enricher/enricher_event_processor.dart'; @@ -85,6 +86,8 @@ class Sentry { options.addEventProcessor(EnricherEventProcessor(options)); options.addEventProcessor(ExceptionEventProcessor(options)); options.addEventProcessor(DeduplicationEventProcessor(options)); + + options.prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); } /// This method reads available environment variables and uses them diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 212996c218..c319677832 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -1,32 +1,33 @@ import 'dart:async'; import 'dart:math'; + import 'package:meta/meta.dart'; -import 'utils/stacktrace_utils.dart'; -import 'metrics/metric.dart'; -import 'metrics/metrics_aggregator.dart'; -import 'sentry_baggage.dart'; -import 'sentry_attachment/sentry_attachment.dart'; +import 'client_reports/client_report_recorder.dart'; +import 'client_reports/discard_reason.dart'; import 'event_processor.dart'; import 'hint.dart'; -import 'sentry_trace_context_header.dart'; -import 'sentry_user_feedback.dart'; -import 'transport/rate_limiter.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; import 'scope.dart'; +import 'sentry_attachment/sentry_attachment.dart'; +import 'sentry_baggage.dart'; +import 'sentry_envelope.dart'; import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; +import 'sentry_trace_context_header.dart'; +import 'sentry_user_feedback.dart'; +import 'transport/data_category.dart'; import 'transport/http_transport.dart'; import 'transport/noop_transport.dart'; +import 'transport/rate_limiter.dart'; import 'transport/spotlight_http_transport.dart'; import 'transport/task_queue.dart'; import 'utils/isolate_utils.dart'; +import 'utils/stacktrace_utils.dart'; import 'version.dart'; -import 'sentry_envelope.dart'; -import 'client_reports/client_report_recorder.dart'; -import 'client_reports/discard_reason.dart'; -import 'transport/data_category.dart'; /// Default value for [SentryUser.ipAddress]. It gets set when an event does not have /// a user and IP address. Only applies if [SentryOptions.sendDefaultPii] is set diff --git a/dart/lib/src/sentry_exception_factory.dart b/dart/lib/src/sentry_exception_factory.dart index a8e1a80498..9ee2148c14 100644 --- a/dart/lib/src/sentry_exception_factory.dart +++ b/dart/lib/src/sentry_exception_factory.dart @@ -1,10 +1,9 @@ -import 'utils/stacktrace_utils.dart'; - -import 'recursive_exception_cause_extractor.dart'; import 'protocol.dart'; +import 'recursive_exception_cause_extractor.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'throwable_mechanism.dart'; +import 'utils/stacktrace_utils.dart'; /// class to convert Dart Error and exception to SentryException class SentryExceptionFactory { @@ -62,10 +61,22 @@ class SentryExceptionFactory { final stackTraceString = stackTrace.toString(); final value = throwableString.replaceAll(stackTraceString, '').trim(); + String errorTypeName = throwable.runtimeType.toString(); + + if (_options.enableExceptionTypeIdentification) { + for (final errorTypeIdentifier in _options.exceptionTypeIdentifiers) { + final identifiedErrorType = errorTypeIdentifier.identifyType(throwable); + if (identifiedErrorType != null) { + errorTypeName = identifiedErrorType; + break; + } + } + } + // if --obfuscate feature is enabled, 'type' won't be human readable. // https://flutter.dev/docs/deployment/obfuscate#caveat return SentryException( - type: (throwable.runtimeType).toString(), + type: errorTypeName, value: value.isNotEmpty ? value : null, mechanism: mechanism, stackTrace: sentryStackTrace, diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 50da709851..5aef9de542 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -1,17 +1,17 @@ import 'dart:async'; import 'dart:developer'; -import 'package:meta/meta.dart'; import 'package:http/http.dart'; +import 'package:meta/meta.dart'; import '../sentry.dart'; import 'client_reports/client_report_recorder.dart'; import 'client_reports/noop_client_report_recorder.dart'; -import 'sentry_exception_factory.dart'; -import 'sentry_stack_trace_factory.dart'; import 'diagnostic_logger.dart'; import 'environment/environment_variables.dart'; import 'noop_client.dart'; +import 'sentry_exception_factory.dart'; +import 'sentry_stack_trace_factory.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; @@ -452,6 +452,33 @@ class SentryOptions { /// Settings this to `false` will set the `level` to [SentryLevel.error]. bool markAutomaticallyCollectedErrorsAsFatal = true; + /// Enables identification of exception types in obfuscated builds. + /// When true, the SDK will attempt to identify common exception types + /// to improve readability of obfuscated issue titles. + /// + /// If you already have events with obfuscated issue titles this will change grouping. + /// + /// Default: `true` + bool enableExceptionTypeIdentification = true; + + final List _exceptionTypeIdentifiers = []; + + List get exceptionTypeIdentifiers => + List.unmodifiable(_exceptionTypeIdentifiers); + + void addExceptionTypeIdentifierByIndex( + int index, ExceptionTypeIdentifier exceptionTypeIdentifier) { + _exceptionTypeIdentifiers.insert( + index, exceptionTypeIdentifier.withCache()); + } + + /// Adds an exception type identifier to the beginning of the list. + /// This ensures it is processed first and takes precedence over existing identifiers. + void prependExceptionTypeIdentifier( + ExceptionTypeIdentifier exceptionTypeIdentifier) { + addExceptionTypeIdentifierByIndex(0, exceptionTypeIdentifier); + } + /// The Spotlight configuration. /// Disabled by default. /// ```dart diff --git a/dart/test/exception_identifier_test.dart b/dart/test/exception_identifier_test.dart new file mode 100644 index 0000000000..b3014203dd --- /dev/null +++ b/dart/test/exception_identifier_test.dart @@ -0,0 +1,185 @@ +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; +import 'package:sentry/src/sentry_exception_factory.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks.mocks.dart'; +import 'mocks/mock_transport.dart'; +import 'sentry_client_test.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('ExceptionTypeIdentifiers', () { + test('should be processed based on order in the list', () { + fixture.options + .prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('ObfuscatedException')); + }); + + test('should return null if exception is not identified', () { + final identifier = DartExceptionTypeIdentifier(); + expect(identifier.identifyType(ObfuscatedException()), isNull); + }); + }); + + group('SentryExceptionFactory', () { + test('should process identifiers based on order in the list', () { + fixture.options + .prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('ObfuscatedException')); + }); + + test('should use runtime type when identification is disabled', () { + fixture.options.enableExceptionTypeIdentification = false; + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('PlaceHolderException')); + }); + }); + + group('CachingExceptionTypeIdentifier', () { + late MockExceptionTypeIdentifier mockIdentifier; + late CachingExceptionTypeIdentifier cachingIdentifier; + + setUp(() { + mockIdentifier = MockExceptionTypeIdentifier(); + cachingIdentifier = CachingExceptionTypeIdentifier(mockIdentifier); + }); + + test('should return cached result for known types', () { + final exception = Exception('Test'); + when(mockIdentifier.identifyType(exception)).thenReturn('TestException'); + + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + + verify(mockIdentifier.identifyType(exception)).called(1); + }); + + test('should not cache unknown types', () { + final exception = ObfuscatedException(); + + when(mockIdentifier.identifyType(exception)).thenReturn(null); + + expect(cachingIdentifier.identifyType(exception), isNull); + expect(cachingIdentifier.identifyType(exception), isNull); + expect(cachingIdentifier.identifyType(exception), isNull); + + verify(mockIdentifier.identifyType(exception)).called(3); + }); + + test('should return null for unknown exception type', () { + final exception = Exception('Unknown'); + when(mockIdentifier.identifyType(exception)).thenReturn(null); + + expect(cachingIdentifier.identifyType(exception), isNull); + }); + + test('should handle different exception types separately', () { + final exception1 = Exception('Test1'); + final exception2 = FormatException('Test2'); + + when(mockIdentifier.identifyType(exception1)).thenReturn('Exception'); + when(mockIdentifier.identifyType(exception2)) + .thenReturn('FormatException'); + + expect(cachingIdentifier.identifyType(exception1), equals('Exception')); + expect(cachingIdentifier.identifyType(exception2), + equals('FormatException')); + + // Call again to test caching + expect(cachingIdentifier.identifyType(exception1), equals('Exception')); + expect(cachingIdentifier.identifyType(exception2), + equals('FormatException')); + + verify(mockIdentifier.identifyType(exception1)).called(1); + verify(mockIdentifier.identifyType(exception2)).called(1); + }); + }); + + group('Integration test', () { + setUp(() { + fixture.options.transport = MockTransport(); + }); + + test( + 'should capture CustomException as exception type with custom identifier', + () async { + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final client = SentryClient(fixture.options); + + await client.captureException(ObfuscatedException()); + + final transport = fixture.options.transport as MockTransport; + final capturedEnvelope = transport.envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect( + capturedEvent.exceptions!.first.type, equals('ObfuscatedException')); + }); + + test( + 'should capture PlaceHolderException as exception type without custom identifier', + () async { + final client = SentryClient(fixture.options); + + await client.captureException(ObfuscatedException()); + + final transport = fixture.options.transport as MockTransport; + final capturedEnvelope = transport.envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect( + capturedEvent.exceptions!.first.type, equals('PlaceHolderException')); + }); + }); +} + +class Fixture { + SentryOptions options = SentryOptions(dsn: fakeDsn); +} + +// We use this PlaceHolder exception to mimic an obfuscated runtimeType +class PlaceHolderException implements Exception {} + +class ObfuscatedException implements Exception { + @override + Type get runtimeType => PlaceHolderException; +} + +class ObfuscatedExceptionIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + if (throwable is ObfuscatedException) return 'ObfuscatedException'; + return null; + } +} diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index b05843be8e..fc68270295 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -207,5 +207,6 @@ class MockRateLimiter implements RateLimiter { SentryProfilerFactory, SentryProfiler, SentryProfileInfo, + ExceptionTypeIdentifier, ]) void main() {} diff --git a/dart/test/mocks.mocks.dart b/dart/test/mocks.mocks.dart index 5f2556400e..58133b794b 100644 --- a/dart/test/mocks.mocks.dart +++ b/dart/test/mocks.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in sentry/test/mocks.dart. // Do not manually edit this file. @@ -13,6 +13,8 @@ import 'package:sentry/src/profiling.dart' as _i3; // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -66,6 +68,7 @@ class MockSentryProfiler extends _i1.Mock implements _i3.SentryProfiler { ), returnValue: _i4.Future<_i3.SentryProfileInfo?>.value(), ) as _i4.Future<_i3.SentryProfileInfo?>); + @override void dispose() => super.noSuchMethod( Invocation.method( @@ -99,3 +102,13 @@ class MockSentryProfileInfo extends _i1.Mock implements _i3.SentryProfileInfo { ), ) as _i2.SentryEnvelopeItem); } + +/// A class which mocks [ExceptionTypeIdentifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockExceptionTypeIdentifier extends _i1.Mock + implements _i2.ExceptionTypeIdentifier { + MockExceptionTypeIdentifier() { + _i1.throwOnMissingStub(this); + } +} diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index 273366e442..3097597a92 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -140,6 +140,12 @@ void main() { expect(options.enableMetrics, false); }); + test('enableExceptionTypeIdentification is enabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + + expect(options.enableExceptionTypeIdentification, true); + }); + test('default tags for metrics are enabled by default', () { final options = SentryOptions(dsn: fakeDsn); options.enableMetrics = true; diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 0a8dd5db27..1b363b99a0 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry/src/event_processor/deduplication_event_processor.dart'; import 'package:test/test.dart'; @@ -318,6 +319,28 @@ void main() { expect(completed, true); }); + + test('should add DartExceptionTypeIdentifier by default', () async { + final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + await Sentry.init( + options: options, + (options) { + options.dsn = fakeDsn; + }, + ); + + expect(options.exceptionTypeIdentifiers.length, 1); + final cachingIdentifier = options.exceptionTypeIdentifiers.first + as CachingExceptionTypeIdentifier; + expect( + cachingIdentifier, + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), + ), + ); + }); }); test('should complete when appRunner is not called in runZonedGuarded', diff --git a/flutter/lib/src/flutter_exception_type_identifier.dart b/flutter/lib/src/flutter_exception_type_identifier.dart new file mode 100644 index 0000000000..755f09e544 --- /dev/null +++ b/flutter/lib/src/flutter_exception_type_identifier.dart @@ -0,0 +1,22 @@ +import 'package:flutter/cupertino.dart' + show FlutterError, NetworkImageLoadException, TickerCanceled; +import 'package:flutter/services.dart' + show PlatformException, MissingPluginException; + +import '../sentry_flutter.dart'; + +class FlutterExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + // FlutterError check should run before AssertionError check because + // it's a subclass of AssertionError + if (throwable is FlutterError) return 'FlutterError'; + if (throwable is PlatformException) return 'PlatformException'; + if (throwable is MissingPluginException) return 'MissingPluginException'; + if (throwable is NetworkImageLoadException) { + return 'NetworkImageLoadException'; + } + if (throwable is TickerCanceled) return 'TickerCanceled'; + return null; + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 42d3cd1ea2..c688f9862b 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -3,26 +3,25 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'span_frame_metrics_collector.dart'; + import '../sentry_flutter.dart'; import 'event_processor/android_platform_exception_event_processor.dart'; +import 'event_processor/flutter_enricher_event_processor.dart'; import 'event_processor/flutter_exception_event_processor.dart'; import 'event_processor/platform_exception_event_processor.dart'; import 'event_processor/widget_event_processor.dart'; +import 'file_system_transport.dart'; +import 'flutter_exception_type_identifier.dart'; import 'frame_callback_handler.dart'; import 'integrations/connectivity/connectivity_integration.dart'; +import 'integrations/integrations.dart'; import 'integrations/screenshot_integration.dart'; import 'native/factory.dart'; import 'native/native_scope_observer.dart'; import 'native/sentry_native_binding.dart'; import 'profiling.dart'; import 'renderer/renderer.dart'; - -import 'integrations/integrations.dart'; -import 'event_processor/flutter_enricher_event_processor.dart'; - -import 'file_system_transport.dart'; - +import 'span_frame_metrics_collector.dart'; import 'version.dart'; import 'view_hierarchy/view_hierarchy_integration.dart'; @@ -114,6 +113,11 @@ mixin SentryFlutter { // ignore: invalid_use_of_internal_member SentryNativeProfilerFactory.attachTo(Sentry.currentHub, _native!); } + + // Insert it at the start of the list, before the Dart Exceptions that are set in Sentry.init + // so we can identify Flutter exceptions first. + flutterOptions + .prependExceptionTypeIdentifier(FlutterExceptionTypeIdentifier()); } static Future _initDefaultValues(SentryFlutterOptions options) async { diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index 3987cdfb7e..c05bf7b7cf 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -4,7 +4,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry/src/platform/platform.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/flutter_exception_type_identifier.dart'; import 'package:sentry_flutter/src/integrations/connectivity/connectivity_integration.dart'; import 'package:sentry_flutter/src/integrations/integrations.dart'; import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; @@ -634,6 +636,8 @@ void main() { await SentryFlutter.resumeAppHangTracking(); verify(SentryFlutter.native?.resumeAppHangTracking()).called(1); + + SentryFlutter.native = null; }); test('resumeAppHangTracking does nothing when native is null', () async { @@ -651,6 +655,8 @@ void main() { await SentryFlutter.pauseAppHangTracking(); verify(SentryFlutter.native?.pauseAppHangTracking()).called(1); + + SentryFlutter.native = null; }); test('pauseAppHangTracking does nothing when native is null', () async { @@ -659,6 +665,41 @@ void main() { // This should complete without throwing an error await expectLater(SentryFlutter.pauseAppHangTracking(), completes); }); + + test( + 'should add DartExceptionTypeIdentifier and FlutterExceptionTypeIdentifier by default', + () async { + SentryOptions? actualOptions; + await SentryFlutter.init( + (options) { + options.dsn = fakeDsn; + options.automatedTestMode = true; + actualOptions = options; + }, + appRunner: appRunner, + ); + + expect(actualOptions!.exceptionTypeIdentifiers.length, 2); + // Flutter identifier should be first as it's more specific + expect( + actualOptions!.exceptionTypeIdentifiers.first, + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), + ), + ); + expect( + actualOptions!.exceptionTypeIdentifiers[1], + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), + ), + ); + + await Sentry.close(); + }); } void appRunner() {}