diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2fb4e2b7..61e1bed3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Features + - Add `enableDartSymbolication` option to Sentry.init() for **Flutter iOS, macOS and Android** ([#2256](https://github.com/getsentry/sentry-dart/pull/2256)) + - This flag enables symbolication of Dart stack traces when native debug images are not available. + - Useful when using Sentry.init() instead of SentryFlutter.init() in Flutter projects for example due to size limitations. + - `true` by default but automatically set to `false` when using SentryFlutter.init() because the SentryFlutter fetches debug images from the native SDK integrations. +- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) - Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208), [#2269](https://github.com/getsentry/sentry-dart/pull/2269), [#2236](https://github.com/getsentry/sentry-dart/pull/2236)). To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)): diff --git a/dart/lib/src/debug_image_extractor.dart b/dart/lib/src/debug_image_extractor.dart new file mode 100644 index 0000000000..99776ee12b --- /dev/null +++ b/dart/lib/src/debug_image_extractor.dart @@ -0,0 +1,195 @@ +import 'dart:typed_data'; +import 'package:meta/meta.dart'; +import 'package:uuid/uuid.dart'; + +import '../sentry.dart'; + +// Regular expressions for parsing header lines +const String _headerStartLine = + '*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***'; +final RegExp _buildIdRegex = RegExp(r"build_id(?:=|: )'([\da-f]+)'"); +final RegExp _isolateDsoBaseLineRegex = + RegExp(r'isolate_dso_base(?:=|: )([\da-f]+)'); + +/// Extracts debug information from stack trace header. +/// Needed for symbolication of Dart stack traces without native debug images. +@internal +class DebugImageExtractor { + DebugImageExtractor(this._options); + + final SentryOptions _options; + + // We don't need to always parse the debug image, so we cache it here. + DebugImage? _debugImage; + + @visibleForTesting + DebugImage? get debugImageForTesting => _debugImage; + + DebugImage? extractFrom(String stackTraceString) { + if (_debugImage != null) { + return _debugImage; + } + _debugImage = _extractDebugInfoFrom(stackTraceString).toDebugImage(); + return _debugImage; + } + + _DebugInfo _extractDebugInfoFrom(String stackTraceString) { + String? buildId; + String? isolateDsoBase; + + final lines = stackTraceString.split('\n'); + + for (final line in lines) { + if (_isHeaderStartLine(line)) { + continue; + } + // Stop parsing as soon as we get to the stack frames + // This should never happen but is a safeguard to avoid looping + // through every line of the stack trace + if (line.contains("#00 abs")) { + break; + } + + buildId ??= _extractBuildId(line); + isolateDsoBase ??= _extractIsolateDsoBase(line); + + // Early return if all needed information is found + if (buildId != null && isolateDsoBase != null) { + return _DebugInfo(buildId, isolateDsoBase, _options); + } + } + + return _DebugInfo(buildId, isolateDsoBase, _options); + } + + bool _isHeaderStartLine(String line) { + return line.contains(_headerStartLine); + } + + String? _extractBuildId(String line) { + final buildIdMatch = _buildIdRegex.firstMatch(line); + return buildIdMatch?.group(1); + } + + String? _extractIsolateDsoBase(String line) { + final isolateMatch = _isolateDsoBaseLineRegex.firstMatch(line); + return isolateMatch?.group(1); + } +} + +class _DebugInfo { + final String? buildId; + final String? isolateDsoBase; + final SentryOptions _options; + + _DebugInfo(this.buildId, this.isolateDsoBase, this._options); + + DebugImage? toDebugImage() { + if (buildId == null || isolateDsoBase == null) { + _options.logger(SentryLevel.warning, + 'Cannot create DebugImage without buildId and isolateDsoBase.'); + return null; + } + + String type; + String? imageAddr; + String? debugId; + String? codeId; + + final platform = _options.platformChecker.platform; + + // Default values for all platforms + imageAddr = '0x$isolateDsoBase'; + + if (platform.isAndroid) { + type = 'elf'; + debugId = _convertCodeIdToDebugId(buildId!); + codeId = buildId; + } else if (platform.isIOS || platform.isMacOS) { + type = 'macho'; + debugId = _formatHexToUuid(buildId!); + // `codeId` is not needed for iOS/MacOS. + } else { + _options.logger( + SentryLevel.warning, + 'Unsupported platform for creating Dart debug images.', + ); + return null; + } + + return DebugImage( + type: type, + imageAddr: imageAddr, + debugId: debugId, + codeId: codeId, + ); + } + + // Debug identifier is the little-endian UUID representation of the first 16-bytes of + // the build ID on ELF images. + String? _convertCodeIdToDebugId(String codeId) { + codeId = codeId.replaceAll(' ', ''); + if (codeId.length < 32) { + _options.logger(SentryLevel.warning, + 'Code ID must be at least 32 hexadecimal characters long'); + return null; + } + + final first16Bytes = codeId.substring(0, 32); + final byteData = _parseHexToBytes(first16Bytes); + + if (byteData == null || byteData.isEmpty) { + _options.logger( + SentryLevel.warning, 'Failed to convert code ID to debug ID'); + return null; + } + + return bigToLittleEndianUuid(UuidValue.fromByteList(byteData).uuid); + } + + Uint8List? _parseHexToBytes(String hex) { + if (hex.length % 2 != 0) { + _options.logger( + SentryLevel.warning, 'Invalid hex string during debug image parsing'); + return null; + } + if (hex.startsWith('0x')) { + hex = hex.substring(2); + } + + var bytes = Uint8List(hex.length ~/ 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16); + } + return bytes; + } + + String bigToLittleEndianUuid(String bigEndianUuid) { + final byteArray = + Uuid.parse(bigEndianUuid, validationMode: ValidationMode.nonStrict); + + final reversedByteArray = Uint8List.fromList([ + ...byteArray.sublist(0, 4).reversed, + ...byteArray.sublist(4, 6).reversed, + ...byteArray.sublist(6, 8).reversed, + ...byteArray.sublist(8, 10), + ...byteArray.sublist(10), + ]); + + return Uuid.unparse(reversedByteArray); + } + + String? _formatHexToUuid(String hex) { + if (hex.length != 32) { + _options.logger(SentryLevel.warning, + 'Hex input must be a 32-character hexadecimal string'); + return null; + } + + return '${hex.substring(0, 8)}-' + '${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } +} diff --git a/dart/lib/src/load_dart_debug_images_integration.dart b/dart/lib/src/load_dart_debug_images_integration.dart new file mode 100644 index 0000000000..81cf7cc679 --- /dev/null +++ b/dart/lib/src/load_dart_debug_images_integration.dart @@ -0,0 +1,74 @@ +import '../sentry.dart'; +import 'debug_image_extractor.dart'; + +class LoadDartDebugImagesIntegration extends Integration { + @override + void call(Hub hub, SentryOptions options) { + options.addEventProcessor(_LoadImageIntegrationEventProcessor( + DebugImageExtractor(options), options)); + options.sdk.addIntegration('loadDartImageIntegration'); + } +} + +const hintRawStackTraceKey = 'raw_stacktrace'; + +class _LoadImageIntegrationEventProcessor implements EventProcessor { + _LoadImageIntegrationEventProcessor(this._debugImageExtractor, this._options); + + final SentryOptions _options; + final DebugImageExtractor _debugImageExtractor; + + @override + Future apply(SentryEvent event, Hint hint) async { + final rawStackTrace = hint.get(hintRawStackTraceKey) as String?; + if (!_options.enableDartSymbolication || + !event.needsSymbolication() || + rawStackTrace == null) { + return event; + } + + try { + final syntheticImage = _debugImageExtractor.extractFrom(rawStackTrace); + if (syntheticImage == null) { + return event; + } + + return event.copyWith(debugMeta: DebugMeta(images: [syntheticImage])); + } catch (e, stackTrace) { + _options.logger( + SentryLevel.info, + "Couldn't add Dart debug image to event. " + 'The event will still be reported.', + exception: e, + stackTrace: stackTrace, + ); + return event; + } + } +} + +extension NeedsSymbolication on SentryEvent { + bool needsSymbolication() { + if (this is SentryTransaction) { + return false; + } + final frames = _getStacktraceFrames(); + if (frames == null) { + return false; + } + return frames.any((frame) => 'native' == frame?.platform); + } + + Iterable? _getStacktraceFrames() { + if (exceptions?.isNotEmpty == true) { + return exceptions?.first.stackTrace?.frames; + } + if (threads?.isNotEmpty == true) { + var stacktraces = threads?.map((e) => e.stacktrace); + return stacktraces + ?.where((element) => element != null) + .expand((element) => element!.frames); + } + return null; + } +} diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index a3ac51e818..29217abe40 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'dart_exception_type_identifier.dart'; +import 'load_dart_debug_images_integration.dart'; import 'metrics/metrics_api.dart'; import 'run_zoned_guarded_integration.dart'; import 'event_processor/enricher/enricher_event_processor.dart'; @@ -83,6 +84,10 @@ class Sentry { options.addIntegrationByIndex(0, IsolateErrorIntegration()); } + if (options.enableDartSymbolication) { + options.addIntegration(LoadDartDebugImagesIntegration()); + } + options.addEventProcessor(EnricherEventProcessor(options)); options.addEventProcessor(ExceptionEventProcessor(options)); options.addEventProcessor(DeduplicationEventProcessor(options)); diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index b66c0d25b5..e3809568da 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -7,6 +7,7 @@ import 'client_reports/client_report_recorder.dart'; import 'client_reports/discard_reason.dart'; import 'event_processor.dart'; import 'hint.dart'; +import 'load_dart_debug_images_integration.dart'; import 'metrics/metric.dart'; import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; @@ -118,6 +119,7 @@ class SentryClient { SentryEvent? preparedEvent = _prepareEvent(event, stackTrace: stackTrace); hint ??= Hint(); + hint.set(hintRawStackTraceKey, stackTrace.toString()); if (scope != null) { preparedEvent = await scope.applyToEvent(preparedEvent, hint); diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 5cfbd0fdc7..c9a9511c29 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -360,6 +360,16 @@ class SentryOptions { _ignoredExceptionsForType.contains(exception.runtimeType); } + /// Enables Dart symbolication for stack traces in Flutter. + /// + /// If true, the SDK will attempt to symbolicate Dart stack traces when + /// [Sentry.init] is used instead of `SentryFlutter.init`. This is useful + /// when native debug images are not available. + /// + /// Automatically set to `false` when using `SentryFlutter.init`, as it uses + /// native SDKs for setting up symbolication on iOS, macOS, and Android. + bool enableDartSymbolication = true; + @internal late ClientReportRecorder recorder = NoOpClientReportRecorder(); diff --git a/dart/test/debug_image_extractor_test.dart b/dart/test/debug_image_extractor_test.dart new file mode 100644 index 0000000000..6218c726a9 --- /dev/null +++ b/dart/test/debug_image_extractor_test.dart @@ -0,0 +1,119 @@ +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/debug_image_extractor.dart'; + +import 'mocks/mock_platform.dart'; +import 'mocks/mock_platform_checker.dart'; + +void main() { + group(DebugImageExtractor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('returns null for invalid stack trace', () { + final stackTrace = 'Invalid stack trace'; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect(debugImage, isNull); + }); + + test('extracts correct debug ID for Android with short debugId', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 20000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('89cb80b6-9e0f-123c-a24b-172d050dec73')); + }); + + test('extracts correct debug ID for Android with long debugId', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'f1c3bcc0279865fe3058404b2831d9e64135386c' +isolate_dso_base: 30000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('c0bcc3f1-9827-fe65-3058-404b2831d9e6')); + }); + + test('extracts correct debug ID for iOS', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 30000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.iOS()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('b680cb89-0f9e-3c12-a24b-172d050dec73')); + expect(debugImage?.codeId, isNull); + }); + + test('sets correct type based on platform', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 40000000 +'''; + final androidExtractor = fixture.getSut(platform: MockPlatform.android()); + final iosExtractor = fixture.getSut(platform: MockPlatform.iOS()); + + final androidDebugImage = androidExtractor.extractFrom(stackTrace); + final iosDebugImage = iosExtractor.extractFrom(stackTrace); + + expect(androidDebugImage?.type, equals('elf')); + expect(iosDebugImage?.type, equals('macho')); + }); + + test('debug image is null on unsupported platforms', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 40000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.linux()); + + final debugImage = extractor.extractFrom(stackTrace); + + expect(debugImage, isNull); + }); + + test('debugImage is cached after first extraction', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 10000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + + // First extraction + final debugImage1 = extractor.extractFrom(stackTrace); + expect(debugImage1, isNotNull); + expect(extractor.debugImageForTesting, equals(debugImage1)); + + // Second extraction + final debugImage2 = extractor.extractFrom(stackTrace); + expect(debugImage2, equals(debugImage1)); + }); + }); +} + +class Fixture { + DebugImageExtractor getSut({required MockPlatform platform}) { + final options = SentryOptions(dsn: 'https://public@sentry.example.com/1') + ..platformChecker = MockPlatformChecker(platform: platform); + return DebugImageExtractor(options); + } +} diff --git a/dart/test/load_dart_debug_images_integration_test.dart b/dart/test/load_dart_debug_images_integration_test.dart new file mode 100644 index 0000000000..8b10a62328 --- /dev/null +++ b/dart/test/load_dart_debug_images_integration_test.dart @@ -0,0 +1,109 @@ +@TestOn('vm') +library dart_test; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/load_dart_debug_images_integration.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_platform.dart'; +import 'mocks/mock_platform_checker.dart'; + +void main() { + group(LoadDartDebugImagesIntegration, () { + late Fixture fixture; + + final platforms = [ + MockPlatform.iOS(), + MockPlatform.macOS(), + MockPlatform.android(), + ]; + + for (final platform in platforms) { + setUp(() { + fixture = Fixture(); + fixture.options.platformChecker = + MockPlatformChecker(platform: platform); + }); + + test('adds itself to sdk.integrations', () { + expect( + fixture.options.sdk.integrations.contains('loadDartImageIntegration'), + true, + ); + }); + + test('Event processor is added to options', () { + expect(fixture.options.eventProcessors.length, 1); + expect( + fixture.options.eventProcessors.first.runtimeType.toString(), + '_LoadImageIntegrationEventProcessor', + ); + }); + + test( + 'Event processor does not add debug image if symbolication is not needed', + () async { + final event = _getEvent(needsSymbolication: false); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test('Event processor does not add debug image if stackTrace is null', + () async { + final event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test( + 'Event processor does not add debug image if enableDartSymbolication is false', + () async { + fixture.options.enableDartSymbolication = false; + final event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test('Event processor adds debug image when symbolication is needed', + () async { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 10000000 +'''; + SentryEvent event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply( + event, Hint()..set(hintRawStackTraceKey, stackTrace)); + + expect(resultEvent?.debugMeta?.images.length, 1); + final debugImage = resultEvent?.debugMeta?.images.first; + expect(debugImage?.debugId, isNotEmpty); + expect(debugImage?.imageAddr, equals('0x10000000')); + }); + } + }); +} + +class Fixture { + final options = SentryOptions(dsn: 'https://public@sentry.example.com/1'); + + Fixture() { + final integration = LoadDartDebugImagesIntegration(); + integration.call(Hub(options), options); + } +} + +SentryEvent _getEvent({bool needsSymbolication = true}) { + final frame = + SentryStackFrame(platform: needsSymbolication ? 'native' : 'dart'); + final st = SentryStackTrace(frames: [frame]); + return SentryEvent( + threads: [SentryThread(stacktrace: st)], debugMeta: DebugMeta()); +} diff --git a/dart/test/mocks/mock_platform.dart b/dart/test/mocks/mock_platform.dart index a045f794af..9f8f391c88 100644 --- a/dart/test/mocks/mock_platform.dart +++ b/dart/test/mocks/mock_platform.dart @@ -13,6 +13,14 @@ class MockPlatform extends Platform with NoSuchMethodProvider { return MockPlatform(os: 'ios'); } + factory MockPlatform.macOS() { + return MockPlatform(os: 'macos'); + } + + factory MockPlatform.linux() { + return MockPlatform(os: 'linux'); + } + @override String operatingSystem; } diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index ccd06d5bf0..bb2db9db24 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -192,4 +192,10 @@ void main() { expect(options.enableSpanLocalMetricAggregation, true); }); + + test('enablePureDartSymbolication is enabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + + expect(options.enableDartSymbolication, true); + }); } diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 0f1cd9279d..3454696b3f 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -1046,6 +1046,7 @@ Future showDialogWithTextAndImage(BuildContext context) async { final imageBytes = await DefaultAssetBundle.of(context).load('assets/sentry-wordmark.png'); await showDialog( + // ignore: use_build_context_synchronously context: context, // gets tracked if using SentryNavigatorObserver routeSettings: const RouteSettings( diff --git a/flutter/lib/src/integrations/load_image_list_integration.dart b/flutter/lib/src/integrations/load_image_list_integration.dart index a3a1c9fc9d..776c86640d 100644 --- a/flutter/lib/src/integrations/load_image_list_integration.dart +++ b/flutter/lib/src/integrations/load_image_list_integration.dart @@ -4,8 +4,13 @@ import 'package:sentry/sentry.dart'; import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; +// ignore: implementation_imports +import 'package:sentry/src/load_dart_debug_images_integration.dart' + show NeedsSymbolication; + /// Loads the native debug image list for stack trace symbolication. class LoadImageListIntegration extends Integration { + /// TODO: rename to LoadNativeDebugImagesIntegration in the next major version final SentryNativeBinding _native; LoadImageListIntegration(this._native); @@ -20,32 +25,6 @@ class LoadImageListIntegration extends Integration { } } -extension _NeedsSymbolication on SentryEvent { - bool needsSymbolication() { - if (this is SentryTransaction) { - return false; - } - final frames = _getStacktraceFrames(); - if (frames == null) { - return false; - } - return frames.any((frame) => 'native' == frame?.platform); - } - - Iterable? _getStacktraceFrames() { - if (exceptions?.isNotEmpty == true) { - return exceptions?.first.stackTrace?.frames; - } - if (threads?.isNotEmpty == true) { - var stacktraces = threads?.map((e) => e.stacktrace); - return stacktraces - ?.where((element) => element != null) - .expand((element) => element!.frames); - } - return null; - } -} - class _LoadImageListIntegrationEventProcessor implements EventProcessor { _LoadImageListIntegrationEventProcessor(this._native); diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 29f533d082..6d035b3740 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -183,6 +183,7 @@ mixin SentryFlutter { integrations.add(NativeSdkIntegration(native)); integrations.add(LoadContextsIntegration(native)); integrations.add(LoadImageListIntegration(native)); + options.enableDartSymbolication = false; } final renderer = options.rendererWrapper.getRenderer(); diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index 1d78b947d1..534dd6ac1e 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -626,6 +626,27 @@ void main() { await Sentry.close(); }); + + test( + 'enablePureDartSymbolication is set to false during SentryFlutter init', + () async { + SentryFlutter.native = MockSentryNativeBinding(); + await SentryFlutter.init( + (options) { + options.dsn = fakeDsn; + options.automatedTestMode = true; + + expect(options.enableDartSymbolication, false); + }, + appRunner: appRunner, + platformChecker: getPlatformChecker( + platform: MockPlatform.android(), + isWeb: true, + ), + ); + + await Sentry.close(); + }); }); test('resumeAppHangTracking calls native method when available', () async {