diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index b7a9be7891..1dfd553789 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -1,7 +1,7 @@ /// A Flutter client for Sentry.io crash reporting. export 'package:sentry/sentry.dart'; -export 'src/default_integrations.dart'; +export 'src/integrations/load_release_integration.dart'; export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart deleted file mode 100644 index 348d5aa419..0000000000 --- a/flutter/lib/src/default_integrations.dart +++ /dev/null @@ -1,586 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:sentry/sentry.dart'; -import 'binding_utils.dart'; -import 'sentry_flutter_options.dart'; -import 'widgets_binding_observer.dart'; - -/// It is necessary to initialize Flutter method channels so that our plugin can -/// call into the native code. -class WidgetsFlutterBindingIntegration - extends Integration { - WidgetsFlutterBindingIntegration( - [WidgetsBinding Function()? ensureInitialized]) - : _ensureInitialized = - ensureInitialized ?? WidgetsFlutterBinding.ensureInitialized; - - final WidgetsBinding Function() _ensureInitialized; - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) { - _ensureInitialized(); - options.sdk.addIntegration('widgetsFlutterBindingIntegration'); - } -} - -/// Integration that capture errors on the [FlutterError.onError] handler. -/// -/// Remarks: -/// - Most UI and layout related errors (such as -/// [these](https://flutter.dev/docs/testing/common-errors)) are AssertionErrors -/// and are stripped in release mode. See [Flutter build modes](https://flutter.dev/docs/testing/build-modes). -/// So they only get caught in debug mode. -class FlutterErrorIntegration extends Integration { - /// Reference to the original handler. - FlutterExceptionHandler? _defaultOnError; - - /// The error handler set by this integration. - FlutterExceptionHandler? _integrationOnError; - - @override - void call(Hub hub, SentryFlutterOptions options) { - _defaultOnError = FlutterError.onError; - _integrationOnError = (FlutterErrorDetails errorDetails) async { - final exception = errorDetails.exception; - - options.logger( - SentryLevel.debug, - 'Capture from onError $exception', - ); - - 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, - }; - - options.logger( - SentryLevel.error, - errorDetails.toStringShort(), - logger: 'sentry.flutterError', - exception: exception, - stackTrace: errorDetails.stack, - ); - - // FlutterError doesn't crash the App. - 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); - // we don't call Zone.current.handleUncaughtError because we'd like - // to set a specific mechanism for FlutterError.onError. - } else { - options.logger( - SentryLevel.debug, - 'Error not captured due to [FlutterErrorDetails.silent], ' - 'Enable [SentryFlutterOptions.reportSilentFlutterErrors] ' - 'if you wish to capture silent errors', - ); - } - // Call original handler, regardless of `errorDetails.silent` or - // `reportSilentFlutterErrors`. This ensures, that we don't swallow - // messages. - if (_defaultOnError != null) { - _defaultOnError!(errorDetails); - } - }; - FlutterError.onError = _integrationOnError; - - options.sdk.addIntegration('flutterErrorIntegration'); - } - - @override - FutureOr close() async { - /// Restore default if the integration error is still set. - if (FlutterError.onError == _integrationOnError) { - FlutterError.onError = _defaultOnError; - _defaultOnError = null; - _integrationOnError = null; - } - } -} - -/// Load Device's Contexts from the iOS SDK. -/// -/// This integration calls the iOS SDK via Message channel to load the -/// Device's contexts before sending the event back to the iOS SDK via -/// Message channel (already enriched with all the information). -/// -/// The Device's contexts are: -/// App, Device and OS. -/// -/// ps. This integration won't be run on Android because the Device's Contexts -/// is set on Android when the event is sent to the Android SDK via -/// the Message channel. -/// We intend to unify this behaviour in the future. -/// -/// This integration is only executed on iOS & MacOS Apps. -class LoadContextsIntegration extends Integration { - final MethodChannel _channel; - - LoadContextsIntegration(this._channel); - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) async { - options.addEventProcessor( - _LoadContextsIntegrationEventProcessor(_channel, options), - ); - options.sdk.addIntegration('loadContextsIntegration'); - } -} - -class _LoadContextsIntegrationEventProcessor extends EventProcessor { - _LoadContextsIntegrationEventProcessor(this._channel, this._options); - - final MethodChannel _channel; - final SentryFlutterOptions _options; - - @override - FutureOr apply(SentryEvent event, {hint}) async { - try { - final infos = Map.from( - await (_channel.invokeMethod('loadContexts')), - ); - final contextsMap = infos['contexts'] as Map?; - if (contextsMap != null && contextsMap.isNotEmpty) { - final contexts = Contexts.fromJson( - Map.from(contextsMap), - ); - final eventContexts = event.contexts.clone(); - - contexts.forEach( - (key, dynamic value) { - if (value != null) { - final currentValue = eventContexts[key]; - if (key == SentryRuntime.listType) { - contexts.runtimes.forEach(eventContexts.addRuntime); - } else if (currentValue == null) { - eventContexts[key] = value; - } else { - if (key == SentryOperatingSystem.type && - currentValue is SentryOperatingSystem && - value is SentryOperatingSystem) { - final osMap = {...value.toJson(), ...currentValue.toJson()}; - final os = SentryOperatingSystem.fromJson(osMap); - eventContexts[key] = os; - } - } - } - }, - ); - event = event.copyWith(contexts: eventContexts); - } - - final tagsMap = infos['tags'] as Map?; - if (tagsMap != null && tagsMap.isNotEmpty) { - final tags = event.tags ?? {}; - final newTags = Map.from(tagsMap); - - for (final tag in newTags.entries) { - if (!tags.containsKey(tag.key)) { - tags[tag.key] = tag.value; - } - } - event = event.copyWith(tags: tags); - } - - final extraMap = infos['extra'] as Map?; - if (extraMap != null && extraMap.isNotEmpty) { - final extras = event.extra ?? {}; - final newExtras = Map.from(extraMap); - - for (final extra in newExtras.entries) { - if (!extras.containsKey(extra.key)) { - extras[extra.key] = extra.value; - } - } - event = event.copyWith(extra: extras); - } - - final userMap = infos['user'] as Map?; - if (event.user == null && userMap != null && userMap.isNotEmpty) { - final user = Map.from(userMap); - event = event.copyWith(user: SentryUser.fromJson(user)); - } - - final distString = infos['dist'] as String?; - if (event.dist == null && distString != null) { - event = event.copyWith(dist: distString); - } - - final environmentString = infos['environment'] as String?; - if (event.environment == null && environmentString != null) { - event = event.copyWith(environment: environmentString); - } - - final fingerprintList = infos['fingerprint'] as List?; - if (fingerprintList != null && fingerprintList.isNotEmpty) { - final eventFingerprints = event.fingerprint ?? []; - final newFingerprint = List.from(fingerprintList); - - for (final fingerprint in newFingerprint) { - if (!eventFingerprints.contains(fingerprint)) { - eventFingerprints.add(fingerprint); - } - } - event = event.copyWith(fingerprint: eventFingerprints); - } - - final levelString = infos['level'] as String?; - if (event.level == null && levelString != null) { - event = event.copyWith(level: SentryLevel.fromName(levelString)); - } - - final breadcrumbsList = infos['breadcrumbs'] as List?; - if (breadcrumbsList != null && breadcrumbsList.isNotEmpty) { - final breadcrumbs = event.breadcrumbs ?? []; - final newBreadcrumbs = List.from(breadcrumbsList); - - for (final breadcrumb in newBreadcrumbs) { - final newBreadcrumb = Map.from(breadcrumb); - final crumb = Breadcrumb.fromJson(newBreadcrumb); - breadcrumbs.add(crumb); - } - - breadcrumbs.sort((a, b) { - return a.timestamp.compareTo(b.timestamp); - }); - - event = event.copyWith(breadcrumbs: breadcrumbs); - } - - final integrationsList = infos['integrations'] as List?; - if (integrationsList != null && integrationsList.isNotEmpty) { - final integrations = List.from(integrationsList); - final sdk = event.sdk ?? _options.sdk; - - for (final integration in integrations) { - if (!sdk.integrations.contains(integration)) { - sdk.addIntegration(integration); - } - } - - event = event.copyWith(sdk: sdk); - } - - final packageMap = infos['package'] as Map?; - if (packageMap != null && packageMap.isNotEmpty) { - final package = Map.from(packageMap); - final sdk = event.sdk ?? _options.sdk; - - final name = package['sdk_name']; - final version = package['version']; - if (name != null && - version != null && - !sdk.packages.any((element) => - element.name == name && element.version == version)) { - sdk.addPackage(name, version); - } - - event = event.copyWith(sdk: sdk); - } - - // on iOS, captureEnvelope does not call the beforeSend callback, - // hence we need to add these tags here. - if (event.sdk?.name == 'sentry.dart.flutter') { - final tags = event.tags ?? {}; - tags['event.origin'] = 'flutter'; - tags['event.environment'] = 'dart'; - event = event.copyWith(tags: tags); - } - } catch (exception, stackTrace) { - _options.logger( - SentryLevel.error, - 'loadContextsIntegration failed', - exception: exception, - stackTrace: stackTrace, - ); - } - return event; - } -} - -/// Enables Sentry's native SDKs (Android and iOS) with options. -class NativeSdkIntegration extends Integration { - NativeSdkIntegration(this._channel); - - final MethodChannel _channel; - SentryFlutterOptions? _options; - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) async { - _options = options; - if (!options.autoInitializeNativeSdk) { - return; - } - try { - await _channel.invokeMethod('initNativeSdk', { - 'dsn': options.dsn, - 'debug': options.debug, - 'environment': options.environment, - 'release': options.release, - 'enableAutoSessionTracking': options.enableAutoSessionTracking, - 'enableNativeCrashHandling': options.enableNativeCrashHandling, - 'attachStacktrace': options.attachStacktrace, - 'attachThreads': options.attachThreads, - 'autoSessionTrackingIntervalMillis': - options.autoSessionTrackingInterval.inMilliseconds, - 'dist': options.dist, - 'integrations': options.sdk.integrations, - 'packages': - options.sdk.packages.map((e) => e.toJson()).toList(growable: false), - 'diagnosticLevel': options.diagnosticLevel.name, - 'maxBreadcrumbs': options.maxBreadcrumbs, - 'anrEnabled': options.anrEnabled, - 'anrTimeoutIntervalMillis': options.anrTimeoutInterval.inMilliseconds, - 'enableAutoNativeBreadcrumbs': options.enableAutoNativeBreadcrumbs, - 'maxCacheItems': options.maxCacheItems, - 'sendDefaultPii': options.sendDefaultPii, - 'enableOutOfMemoryTracking': options.enableOutOfMemoryTracking, - 'enableNdkScopeSync': options.enableNdkScopeSync, - 'enableAutoPerformanceTracking': options.enableAutoPerformanceTracking, - 'sendClientReports': options.sendClientReports, - }); - - options.sdk.addIntegration('nativeSdkIntegration'); - } catch (exception, stackTrace) { - options.logger( - SentryLevel.fatal, - 'nativeSdkIntegration failed to be installed', - exception: exception, - stackTrace: stackTrace, - ); - } - } - - @override - FutureOr close() async { - final options = _options; - if (options != null && !options.autoInitializeNativeSdk) { - return; - } - try { - await _channel.invokeMethod('closeNativeSdk'); - } catch (exception, stackTrace) { - _options?.logger( - SentryLevel.fatal, - 'nativeSdkIntegration failed to be closed', - exception: exception, - stackTrace: stackTrace, - ); - } - } -} - -/// Integration that captures certain window and device events. -/// See also: -/// - [SentryWidgetsBindingObserver] -/// - [WidgetsBindingObserver](https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html) -class WidgetsBindingIntegration extends Integration { - SentryWidgetsBindingObserver? _observer; - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) { - _observer = SentryWidgetsBindingObserver( - hub: hub, - options: options, - ); - - // We don't need to call `WidgetsFlutterBinding.ensureInitialized()` - // because `WidgetsFlutterBindingIntegration` already calls it. - // If the instance is not created, we skip it to keep going. - final instance = BindingUtils.getWidgetsBindingInstance(); - if (instance != null) { - instance.addObserver(_observer!); - options.sdk.addIntegration('widgetsBindingIntegration'); - } else { - options.logger( - SentryLevel.error, - 'widgetsBindingIntegration failed to be installed', - ); - } - } - - @override - FutureOr close() { - final instance = BindingUtils.getWidgetsBindingInstance(); - if (instance != null && _observer != null) { - instance.removeObserver(_observer!); - } - } -} - -/// Loads the native debug image list for stack trace symbolication. -class LoadImageListIntegration extends Integration { - final MethodChannel _channel; - - LoadImageListIntegration(this._channel); - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) { - options.addEventProcessor( - _LoadImageListIntegrationEventProcessor(_channel, options), - ); - - options.sdk.addIntegration('loadImageListIntegration'); - } -} - -extension _NeedsSymbolication on SentryEvent { - bool needsSymbolication() { - if (this is SentryTransaction) return false; - final frames = exceptions?.first.stackTrace?.frames; - if (frames == null) return false; - return frames.any((frame) => 'native' == frame.platform); - } -} - -class _LoadImageListIntegrationEventProcessor extends EventProcessor { - _LoadImageListIntegrationEventProcessor(this._channel, this._options); - - final MethodChannel _channel; - final SentryFlutterOptions _options; - - @override - FutureOr apply(SentryEvent event, {hint}) async { - if (event.needsSymbolication()) { - try { - // we call on every event because the loaded image list is cached - // and it could be changed on the Native side. - final imageList = List>.from( - await _channel.invokeMethod('loadImageList'), - ); - return copyWithDebugImages(event, imageList); - } catch (exception, stackTrace) { - _options.logger( - SentryLevel.error, - 'loadImageList failed', - exception: exception, - stackTrace: stackTrace, - ); - } - } - - return event; - } - - static SentryEvent copyWithDebugImages( - SentryEvent event, List imageList) { - if (imageList.isEmpty) { - return event; - } - - final newDebugImages = []; - for (final obj in imageList) { - final jsonMap = Map.from(obj as Map); - final image = DebugImage.fromJson(jsonMap); - newDebugImages.add(image); - } - - return event.copyWith(debugMeta: DebugMeta(images: newDebugImages)); - } -} - -/// a PackageInfo wrapper to make it testable -typedef PackageLoader = Future Function(); - -/// An [Integration] that loads the release version from native apps -class LoadReleaseIntegration extends Integration { - final PackageLoader _packageLoader; - - LoadReleaseIntegration(this._packageLoader); - - @override - FutureOr call(Hub hub, SentryFlutterOptions options) async { - try { - if (options.release == null || options.dist == null) { - final packageInfo = await _packageLoader(); - var name = _cleanString(packageInfo.packageName); - if (name.isEmpty) { - // Not all platforms have a packageName. - // If no packageName is available, use the appName instead. - name = _cleanString(packageInfo.appName); - } - - final version = _cleanString(packageInfo.version); - final buildNumber = _cleanString(packageInfo.buildNumber); - - var release = name; - if (version.isNotEmpty) { - release = '$release@$version'; - } - // At least windows sometimes does not have a buildNumber - if (buildNumber.isNotEmpty) { - release = '$release+$buildNumber'; - } - - options.logger(SentryLevel.debug, 'release: $release'); - - options.release = options.release ?? release; - if (buildNumber.isNotEmpty) { - options.dist = options.dist ?? buildNumber; - } - } - } catch (exception, stackTrace) { - options.logger( - SentryLevel.error, - 'Failed to load release and dist', - exception: exception, - stackTrace: stackTrace, - ); - } - - options.sdk.addIntegration('loadReleaseIntegration'); - } - - /// This method cleans the given string from characters which should not be - /// used. - /// For example https://docs.sentry.io/platforms/flutter/configuration/releases/#bind-the-version - /// imposes some requirements. Also Windows uses some characters which - /// should not be used. - String _cleanString(String appName) { - // Replace disallowed chars with an underscore '_' - return appName - .replaceAll('/', '_') - .replaceAll('\\', '_') - .replaceAll('\t', '_') - .replaceAll('\r\n', '_') - .replaceAll('\r', '_') - .replaceAll('\n', '_') - // replace Unicode NULL character with an empty string - .replaceAll('\u{0000}', ''); - } -} diff --git a/flutter/lib/src/integrations/flutter_error_integration.dart b/flutter/lib/src/integrations/flutter_error_integration.dart new file mode 100644 index 0000000000..fb98b32ab4 --- /dev/null +++ b/flutter/lib/src/integrations/flutter_error_integration.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// Integration that capture errors on the [FlutterError.onError] handler. +/// +/// Remarks: +/// - Most UI and layout related errors (such as +/// [these](https://flutter.dev/docs/testing/common-errors)) are AssertionErrors +/// and are stripped in release mode. See [Flutter build modes](https://flutter.dev/docs/testing/build-modes). +/// So they only get caught in debug mode. +class FlutterErrorIntegration extends Integration { + /// Reference to the original handler. + FlutterExceptionHandler? _defaultOnError; + + /// The error handler set by this integration. + FlutterExceptionHandler? _integrationOnError; + + @override + void call(Hub hub, SentryFlutterOptions options) { + _defaultOnError = FlutterError.onError; + _integrationOnError = (FlutterErrorDetails errorDetails) async { + final exception = errorDetails.exception; + + options.logger( + SentryLevel.debug, + 'Capture from onError $exception', + ); + + 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, + }; + + options.logger( + SentryLevel.error, + errorDetails.toStringShort(), + logger: 'sentry.flutterError', + exception: exception, + stackTrace: errorDetails.stack, + ); + + // FlutterError doesn't crash the App. + 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); + // we don't call Zone.current.handleUncaughtError because we'd like + // to set a specific mechanism for FlutterError.onError. + } else { + options.logger( + SentryLevel.debug, + 'Error not captured due to [FlutterErrorDetails.silent], ' + 'Enable [SentryFlutterOptions.reportSilentFlutterErrors] ' + 'if you wish to capture silent errors', + ); + } + // Call original handler, regardless of `errorDetails.silent` or + // `reportSilentFlutterErrors`. This ensures, that we don't swallow + // messages. + if (_defaultOnError != null) { + _defaultOnError!(errorDetails); + } + }; + FlutterError.onError = _integrationOnError; + + options.sdk.addIntegration('flutterErrorIntegration'); + } + + @override + FutureOr close() async { + /// Restore default if the integration error is still set. + if (FlutterError.onError == _integrationOnError) { + FlutterError.onError = _defaultOnError; + _defaultOnError = null; + _integrationOnError = null; + } + } +} diff --git a/flutter/lib/src/integrations/integrations.dart b/flutter/lib/src/integrations/integrations.dart new file mode 100644 index 0000000000..ac16732f58 --- /dev/null +++ b/flutter/lib/src/integrations/integrations.dart @@ -0,0 +1,10 @@ +export 'debug_print_integration.dart'; +export 'flutter_error_integration.dart'; +export 'load_contexts_integration.dart'; +export 'load_image_list_integration.dart'; +export 'load_release_integration.dart'; +export 'native_app_start_integration.dart'; +export 'native_sdk_integration.dart'; +export 'on_error_integration.dart'; +export 'widgets_binding_integration.dart'; +export 'widgets_flutter_binding_integration.dart'; diff --git a/flutter/lib/src/integrations/load_contexts_integration.dart b/flutter/lib/src/integrations/load_contexts_integration.dart new file mode 100644 index 0000000000..36c42212dc --- /dev/null +++ b/flutter/lib/src/integrations/load_contexts_integration.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// Load Device's Contexts from the iOS SDK. +/// +/// This integration calls the iOS SDK via Message channel to load the +/// Device's contexts before sending the event back to the iOS SDK via +/// Message channel (already enriched with all the information). +/// +/// The Device's contexts are: +/// App, Device and OS. +/// +/// ps. This integration won't be run on Android because the Device's Contexts +/// is set on Android when the event is sent to the Android SDK via +/// the Message channel. +/// We intend to unify this behaviour in the future. +/// +/// This integration is only executed on iOS & MacOS Apps. +class LoadContextsIntegration extends Integration { + final MethodChannel _channel; + + LoadContextsIntegration(this._channel); + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) async { + options.addEventProcessor( + _LoadContextsIntegrationEventProcessor(_channel, options), + ); + options.sdk.addIntegration('loadContextsIntegration'); + } +} + +class _LoadContextsIntegrationEventProcessor extends EventProcessor { + _LoadContextsIntegrationEventProcessor(this._channel, this._options); + + final MethodChannel _channel; + final SentryFlutterOptions _options; + + @override + FutureOr apply(SentryEvent event, {hint}) async { + try { + final infos = Map.from( + await (_channel.invokeMethod('loadContexts')), + ); + final contextsMap = infos['contexts'] as Map?; + if (contextsMap != null && contextsMap.isNotEmpty) { + final contexts = Contexts.fromJson( + Map.from(contextsMap), + ); + final eventContexts = event.contexts.clone(); + + contexts.forEach( + (key, dynamic value) { + if (value != null) { + final currentValue = eventContexts[key]; + if (key == SentryRuntime.listType) { + contexts.runtimes.forEach(eventContexts.addRuntime); + } else if (currentValue == null) { + eventContexts[key] = value; + } else { + if (key == SentryOperatingSystem.type && + currentValue is SentryOperatingSystem && + value is SentryOperatingSystem) { + final osMap = {...value.toJson(), ...currentValue.toJson()}; + final os = SentryOperatingSystem.fromJson(osMap); + eventContexts[key] = os; + } + } + } + }, + ); + event = event.copyWith(contexts: eventContexts); + } + + final tagsMap = infos['tags'] as Map?; + if (tagsMap != null && tagsMap.isNotEmpty) { + final tags = event.tags ?? {}; + final newTags = Map.from(tagsMap); + + for (final tag in newTags.entries) { + if (!tags.containsKey(tag.key)) { + tags[tag.key] = tag.value; + } + } + event = event.copyWith(tags: tags); + } + + final extraMap = infos['extra'] as Map?; + if (extraMap != null && extraMap.isNotEmpty) { + final extras = event.extra ?? {}; + final newExtras = Map.from(extraMap); + + for (final extra in newExtras.entries) { + if (!extras.containsKey(extra.key)) { + extras[extra.key] = extra.value; + } + } + event = event.copyWith(extra: extras); + } + + final userMap = infos['user'] as Map?; + if (event.user == null && userMap != null && userMap.isNotEmpty) { + final user = Map.from(userMap); + event = event.copyWith(user: SentryUser.fromJson(user)); + } + + final distString = infos['dist'] as String?; + if (event.dist == null && distString != null) { + event = event.copyWith(dist: distString); + } + + final environmentString = infos['environment'] as String?; + if (event.environment == null && environmentString != null) { + event = event.copyWith(environment: environmentString); + } + + final fingerprintList = infos['fingerprint'] as List?; + if (fingerprintList != null && fingerprintList.isNotEmpty) { + final eventFingerprints = event.fingerprint ?? []; + final newFingerprint = List.from(fingerprintList); + + for (final fingerprint in newFingerprint) { + if (!eventFingerprints.contains(fingerprint)) { + eventFingerprints.add(fingerprint); + } + } + event = event.copyWith(fingerprint: eventFingerprints); + } + + final levelString = infos['level'] as String?; + if (event.level == null && levelString != null) { + event = event.copyWith(level: SentryLevel.fromName(levelString)); + } + + final breadcrumbsList = infos['breadcrumbs'] as List?; + if (breadcrumbsList != null && breadcrumbsList.isNotEmpty) { + final breadcrumbs = event.breadcrumbs ?? []; + final newBreadcrumbs = List.from(breadcrumbsList); + + for (final breadcrumb in newBreadcrumbs) { + final newBreadcrumb = Map.from(breadcrumb); + final crumb = Breadcrumb.fromJson(newBreadcrumb); + breadcrumbs.add(crumb); + } + + breadcrumbs.sort((a, b) { + return a.timestamp.compareTo(b.timestamp); + }); + + event = event.copyWith(breadcrumbs: breadcrumbs); + } + + final integrationsList = infos['integrations'] as List?; + if (integrationsList != null && integrationsList.isNotEmpty) { + final integrations = List.from(integrationsList); + final sdk = event.sdk ?? _options.sdk; + + for (final integration in integrations) { + if (!sdk.integrations.contains(integration)) { + sdk.addIntegration(integration); + } + } + + event = event.copyWith(sdk: sdk); + } + + final packageMap = infos['package'] as Map?; + if (packageMap != null && packageMap.isNotEmpty) { + final package = Map.from(packageMap); + final sdk = event.sdk ?? _options.sdk; + + final name = package['sdk_name']; + final version = package['version']; + if (name != null && + version != null && + !sdk.packages.any((element) => + element.name == name && element.version == version)) { + sdk.addPackage(name, version); + } + + event = event.copyWith(sdk: sdk); + } + + // on iOS, captureEnvelope does not call the beforeSend callback, + // hence we need to add these tags here. + if (event.sdk?.name == 'sentry.dart.flutter') { + final tags = event.tags ?? {}; + tags['event.origin'] = 'flutter'; + tags['event.environment'] = 'dart'; + event = event.copyWith(tags: tags); + } + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'loadContextsIntegration failed', + exception: exception, + stackTrace: stackTrace, + ); + } + return event; + } +} diff --git a/flutter/lib/src/integrations/load_image_list_integration.dart b/flutter/lib/src/integrations/load_image_list_integration.dart new file mode 100644 index 0000000000..600c8a2379 --- /dev/null +++ b/flutter/lib/src/integrations/load_image_list_integration.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// Loads the native debug image list for stack trace symbolication. +class LoadImageListIntegration extends Integration { + final MethodChannel _channel; + + LoadImageListIntegration(this._channel); + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + options.addEventProcessor( + _LoadImageListIntegrationEventProcessor(_channel, options), + ); + + options.sdk.addIntegration('loadImageListIntegration'); + } +} + +extension _NeedsSymbolication on SentryEvent { + bool needsSymbolication() { + if (this is SentryTransaction) return false; + final frames = exceptions?.first.stackTrace?.frames; + if (frames == null) return false; + return frames.any((frame) => 'native' == frame.platform); + } +} + +class _LoadImageListIntegrationEventProcessor extends EventProcessor { + _LoadImageListIntegrationEventProcessor(this._channel, this._options); + + final MethodChannel _channel; + final SentryFlutterOptions _options; + + @override + FutureOr apply(SentryEvent event, {hint}) async { + if (event.needsSymbolication()) { + try { + // we call on every event because the loaded image list is cached + // and it could be changed on the Native side. + final imageList = List>.from( + await _channel.invokeMethod('loadImageList'), + ); + return copyWithDebugImages(event, imageList); + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'loadImageList failed', + exception: exception, + stackTrace: stackTrace, + ); + } + } + + return event; + } + + static SentryEvent copyWithDebugImages( + SentryEvent event, List imageList) { + if (imageList.isEmpty) { + return event; + } + + final newDebugImages = []; + for (final obj in imageList) { + final jsonMap = Map.from(obj as Map); + final image = DebugImage.fromJson(jsonMap); + newDebugImages.add(image); + } + + return event.copyWith(debugMeta: DebugMeta(images: newDebugImages)); + } +} diff --git a/flutter/lib/src/integrations/load_release_integration.dart b/flutter/lib/src/integrations/load_release_integration.dart new file mode 100644 index 0000000000..7233f5bfc5 --- /dev/null +++ b/flutter/lib/src/integrations/load_release_integration.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// a PackageInfo wrapper to make it testable +typedef PackageLoader = Future Function(); + +/// An [Integration] that loads the release version from native apps +class LoadReleaseIntegration extends Integration { + final PackageLoader _packageLoader; + + LoadReleaseIntegration(this._packageLoader); + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) async { + try { + if (options.release == null || options.dist == null) { + final packageInfo = await _packageLoader(); + var name = _cleanString(packageInfo.packageName); + if (name.isEmpty) { + // Not all platforms have a packageName. + // If no packageName is available, use the appName instead. + name = _cleanString(packageInfo.appName); + } + + final version = _cleanString(packageInfo.version); + final buildNumber = _cleanString(packageInfo.buildNumber); + + var release = name; + if (version.isNotEmpty) { + release = '$release@$version'; + } + // At least windows sometimes does not have a buildNumber + if (buildNumber.isNotEmpty) { + release = '$release+$buildNumber'; + } + + options.logger(SentryLevel.debug, 'release: $release'); + + options.release = options.release ?? release; + if (buildNumber.isNotEmpty) { + options.dist = options.dist ?? buildNumber; + } + } + } catch (exception, stackTrace) { + options.logger( + SentryLevel.error, + 'Failed to load release and dist', + exception: exception, + stackTrace: stackTrace, + ); + } + + options.sdk.addIntegration('loadReleaseIntegration'); + } + + /// This method cleans the given string from characters which should not be + /// used. + /// For example https://docs.sentry.io/platforms/flutter/configuration/releases/#bind-the-version + /// imposes some requirements. Also Windows uses some characters which + /// should not be used. + String _cleanString(String appName) { + // Replace disallowed chars with an underscore '_' + return appName + .replaceAll('/', '_') + .replaceAll('\\', '_') + .replaceAll('\t', '_') + .replaceAll('\r\n', '_') + .replaceAll('\r', '_') + .replaceAll('\n', '_') + // replace Unicode NULL character with an empty string + .replaceAll('\u{0000}', ''); + } +} diff --git a/flutter/lib/src/integrations/native_sdk_integration.dart b/flutter/lib/src/integrations/native_sdk_integration.dart new file mode 100644 index 0000000000..a8f4c678f8 --- /dev/null +++ b/flutter/lib/src/integrations/native_sdk_integration.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// Enables Sentry's native SDKs (Android and iOS) with options. +class NativeSdkIntegration extends Integration { + NativeSdkIntegration(this._channel); + + final MethodChannel _channel; + SentryFlutterOptions? _options; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) async { + _options = options; + if (!options.autoInitializeNativeSdk) { + return; + } + try { + await _channel.invokeMethod('initNativeSdk', { + 'dsn': options.dsn, + 'debug': options.debug, + 'environment': options.environment, + 'release': options.release, + 'enableAutoSessionTracking': options.enableAutoSessionTracking, + 'enableNativeCrashHandling': options.enableNativeCrashHandling, + 'attachStacktrace': options.attachStacktrace, + 'attachThreads': options.attachThreads, + 'autoSessionTrackingIntervalMillis': + options.autoSessionTrackingInterval.inMilliseconds, + 'dist': options.dist, + 'integrations': options.sdk.integrations, + 'packages': + options.sdk.packages.map((e) => e.toJson()).toList(growable: false), + 'diagnosticLevel': options.diagnosticLevel.name, + 'maxBreadcrumbs': options.maxBreadcrumbs, + 'anrEnabled': options.anrEnabled, + 'anrTimeoutIntervalMillis': options.anrTimeoutInterval.inMilliseconds, + 'enableAutoNativeBreadcrumbs': options.enableAutoNativeBreadcrumbs, + 'maxCacheItems': options.maxCacheItems, + 'sendDefaultPii': options.sendDefaultPii, + 'enableOutOfMemoryTracking': options.enableOutOfMemoryTracking, + 'enableNdkScopeSync': options.enableNdkScopeSync, + 'enableAutoPerformanceTracking': options.enableAutoPerformanceTracking, + 'sendClientReports': options.sendClientReports, + }); + + options.sdk.addIntegration('nativeSdkIntegration'); + } catch (exception, stackTrace) { + options.logger( + SentryLevel.fatal, + 'nativeSdkIntegration failed to be installed', + exception: exception, + stackTrace: stackTrace, + ); + } + } + + @override + FutureOr close() async { + final options = _options; + if (options != null && !options.autoInitializeNativeSdk) { + return; + } + try { + await _channel.invokeMethod('closeNativeSdk'); + } catch (exception, stackTrace) { + _options?.logger( + SentryLevel.fatal, + 'nativeSdkIntegration failed to be closed', + exception: exception, + stackTrace: stackTrace, + ); + } + } +} diff --git a/flutter/lib/src/integrations/widgets_binding_integration.dart b/flutter/lib/src/integrations/widgets_binding_integration.dart new file mode 100644 index 0000000000..566a65d076 --- /dev/null +++ b/flutter/lib/src/integrations/widgets_binding_integration.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import '../binding_utils.dart'; +import '../sentry_flutter_options.dart'; +import '../widgets_binding_observer.dart'; + +/// Integration that captures certain window and device events. +/// See also: +/// - [SentryWidgetsBindingObserver] +/// - [WidgetsBindingObserver](https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html) +class WidgetsBindingIntegration extends Integration { + SentryWidgetsBindingObserver? _observer; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + _observer = SentryWidgetsBindingObserver( + hub: hub, + options: options, + ); + + // We don't need to call `WidgetsFlutterBinding.ensureInitialized()` + // because `WidgetsFlutterBindingIntegration` already calls it. + // If the instance is not created, we skip it to keep going. + final instance = BindingUtils.getWidgetsBindingInstance(); + if (instance != null) { + instance.addObserver(_observer!); + options.sdk.addIntegration('widgetsBindingIntegration'); + } else { + options.logger( + SentryLevel.error, + 'widgetsBindingIntegration failed to be installed', + ); + } + } + + @override + FutureOr close() { + final instance = BindingUtils.getWidgetsBindingInstance(); + if (instance != null && _observer != null) { + instance.removeObserver(_observer!); + } + } +} diff --git a/flutter/lib/src/integrations/widgets_flutter_binding_integration.dart b/flutter/lib/src/integrations/widgets_flutter_binding_integration.dart new file mode 100644 index 0000000000..5187f7cdea --- /dev/null +++ b/flutter/lib/src/integrations/widgets_flutter_binding_integration.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; + +/// It is necessary to initialize Flutter method channels so that our plugin can +/// call into the native code. +class WidgetsFlutterBindingIntegration + extends Integration { + WidgetsFlutterBindingIntegration( + [WidgetsBinding Function()? ensureInitialized]) + : _ensureInitialized = + ensureInitialized ?? WidgetsFlutterBinding.ensureInitialized; + + final WidgetsBinding Function() _ensureInitialized; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + _ensureInitialized(); + options.sdk.addIntegration('widgetsFlutterBindingIntegration'); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 517ad8aa5f..680afb9b4b 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -11,9 +11,8 @@ import 'native_scope_observer.dart'; import 'sentry_native.dart'; import 'sentry_native_channel.dart'; +import 'integrations/integrations.dart'; import 'event_processor/flutter_enricher_event_processor.dart'; -import 'integrations/debug_print_integration.dart'; -import 'integrations/native_app_start_integration.dart'; import 'file_system_transport.dart'; diff --git a/flutter/test/default_integrations_test.dart b/flutter/test/default_integrations_test.dart deleted file mode 100644 index 5ff56817fc..0000000000 --- a/flutter/test/default_integrations_test.dart +++ /dev/null @@ -1,469 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/binding_utils.dart'; - -import 'mocks.dart'; -import 'mocks.mocks.dart'; - -void main() { - const _channel = MethodChannel('sentry_flutter'); - - TestWidgetsFlutterBinding.ensureInitialized(); - - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - tearDown(() { - _channel.setMockMethodCallHandler(null); - }); - - void _reportError({ - bool silent = false, - FlutterExceptionHandler? handler, - dynamic exception, - FlutterErrorDetails? optionalDetails, - }) { - // replace default error otherwise it fails on testing - FlutterError.onError = - handler ?? (FlutterErrorDetails errorDetails) async {}; - - when(fixture.hub.captureEvent(captureAny)) - .thenAnswer((_) => Future.value(SentryId.empty())); - - FlutterErrorIntegration()(fixture.hub, fixture.options); - - final throwable = exception ?? StateError('error'); - final details = FlutterErrorDetails( - exception: throwable, - silent: silent, - context: DiagnosticsNode.message('while handling a gesture'), - library: 'sentry', - informationCollector: () => [DiagnosticsNode.message('foo bar')], - ); - FlutterError.reportError(optionalDetails ?? details); - } - - test('FlutterError capture errors', () async { - final exception = StateError('error'); - - _reportError(exception: exception); - - 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(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 { - var called = false; - final defaultError = (FlutterErrorDetails errorDetails) async { - called = true; - }; - - _reportError(handler: defaultError); - - verify(await fixture.hub.captureEvent(captureAny)); - - expect(called, true); - }); - - test('FlutterErrorIntegration captureEvent only called once', () async { - var numberOfDefaultCalls = 0; - final defaultError = (FlutterErrorDetails errorDetails) async { - numberOfDefaultCalls++; - }; - FlutterError.onError = defaultError; - - when(fixture.hub.captureEvent(captureAny)) - .thenAnswer((_) => Future.value(SentryId.empty())); - - final details = FlutterErrorDetails(exception: StateError('error')); - - final integrationA = FlutterErrorIntegration(); - integrationA.call(fixture.hub, fixture.options); - await integrationA.close(); - - final integrationB = FlutterErrorIntegration(); - integrationB.call(fixture.hub, fixture.options); - - FlutterError.reportError(details); - - verify(await fixture.hub.captureEvent(captureAny)).called(1); - - expect(numberOfDefaultCalls, 1); - }); - - test('FlutterErrorIntegration close restored default onError', () async { - final defaultOnError = (FlutterErrorDetails errorDetails) async {}; - FlutterError.onError = defaultOnError; - - final integration = FlutterErrorIntegration(); - integration.call(fixture.hub, fixture.options); - expect(false, defaultOnError == FlutterError.onError); - - await integration.close(); - expect(FlutterError.onError, defaultOnError); - }); - - test('FlutterErrorIntegration default not restored if set after integration', - () async { - final defaultOnError = (FlutterErrorDetails errorDetails) async {}; - FlutterError.onError = defaultOnError; - - final integration = FlutterErrorIntegration(); - integration.call(fixture.hub, fixture.options); - expect(defaultOnError == FlutterError.onError, false); - - final afterIntegrationOnError = (FlutterErrorDetails errorDetails) async {}; - FlutterError.onError = afterIntegrationOnError; - - await integration.close(); - expect(FlutterError.onError, afterIntegrationOnError); - }); - - test('FlutterError do not capture if silent error', () async { - _reportError(silent: true); - - verifyNever(await fixture.hub.captureEvent(captureAny)); - }); - - test('FlutterError captures if silent error but reportSilentFlutterErrors', - () async { - fixture.options.reportSilentFlutterErrors = true; - _reportError(silent: true); - - verify(await fixture.hub.captureEvent(captureAny)); - }); - - test('FlutterError adds integration', () { - FlutterErrorIntegration()(fixture.hub, fixture.options); - - expect(fixture.options.sdk.integrations.contains('flutterErrorIntegration'), - true); - }); - - test('nativeSdkIntegration adds integration', () async { - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - - final integration = NativeSdkIntegration(_channel); - - await integration(fixture.hub, fixture.options); - - expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), - true); - }); - - test('nativeSdkIntegration do not throw', () async { - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - throw Exception(); - }); - - final integration = NativeSdkIntegration(_channel); - - await integration(fixture.hub, fixture.options); - - expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), - false); - }); - - test('nativeSdkIntegration closes native SDK', () async { - var closeCalled = false; - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - expect(methodCall.method, 'closeNativeSdk'); - closeCalled = true; - }); - - final integration = NativeSdkIntegration(_channel); - - await integration.close(); - - expect(closeCalled, true); - }); - - test('nativeSdkIntegration does not call native sdk when auto init disabled', - () async { - var methodChannelCalled = false; - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - methodChannelCalled = true; - }); - fixture.options.autoInitializeNativeSdk = false; - - final integration = NativeSdkIntegration(_channel); - - await integration.call(fixture.hub, fixture.options); - - expect(methodChannelCalled, false); - }); - - test('nativeSdkIntegration does not close native when auto init disabled', - () async { - var methodChannelCalled = false; - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - methodChannelCalled = true; - }); - fixture.options.autoInitializeNativeSdk = false; - - final integration = NativeSdkIntegration(_channel); - - await integration(fixture.hub, fixture.options); - await integration.close(); - - expect(methodChannelCalled, false); - }); - - test('loadContextsIntegration adds integration', () async { - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - - final integration = LoadContextsIntegration(_channel); - - await integration(fixture.hub, fixture.options); - - expect(fixture.options.sdk.integrations.contains('loadContextsIntegration'), - true); - }); - - test('WidgetsFlutterBindingIntegration adds integration', () async { - final integration = WidgetsFlutterBindingIntegration(); - await integration(fixture.hub, fixture.options); - - expect( - fixture.options.sdk.integrations - .contains('widgetsFlutterBindingIntegration'), - true); - }); - - test('WidgetsFlutterBindingIntegration calls ensureInitialized', () async { - var called = false; - var ensureInitialized = () { - called = true; - return BindingUtils.getWidgetsBindingInstance()!; - }; - final integration = WidgetsFlutterBindingIntegration(ensureInitialized); - await integration(fixture.hub, fixture.options); - - expect(called, true); - }); - - group('$LoadReleaseIntegration', () { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - test('does not overwrite options', () async { - fixture.options.release = '1.0.0'; - fixture.options.dist = 'dist'; - - await fixture.getIntegration().call(MockHub(), fixture.options); - - expect(fixture.options.release, '1.0.0'); - expect(fixture.options.dist, 'dist'); - }); - - test('sets release and dist if not set on options', () async { - await fixture.getIntegration().call(MockHub(), fixture.options); - - expect(fixture.options.release, 'foo.bar@1.2.3+789'); - expect(fixture.options.dist, '789'); - }); - - test('sets app name as in release if packagename is empty', () async { - final loader = () { - return Future.value(PackageInfo( - appName: 'sentry_flutter', - packageName: '', - version: '1.2.3', - buildNumber: '789', - buildSignature: '', - )); - }; - await fixture - .getIntegration(loader: loader) - .call(MockHub(), fixture.options); - - expect(fixture.options.release, 'sentry_flutter@1.2.3+789'); - expect(fixture.options.dist, '789'); - }); - - test('release name does not contain invalid chars defined by Sentry', - () async { - final loader = () { - return Future.value(PackageInfo( - appName: '\\/sentry\tflutter \r\nfoo\nbar\r', - packageName: '', - version: '1.2.3', - buildNumber: '789', - buildSignature: '', - )); - }; - await fixture - .getIntegration(loader: loader) - .call(MockHub(), fixture.options); - - expect(fixture.options.release, '__sentry_flutter _foo_bar_@1.2.3+789'); - expect(fixture.options.dist, '789'); - }); - - /// See the following issues: - /// - https://github.com/getsentry/sentry-dart/issues/410 - /// - https://github.com/fluttercommunity/plus_plugins/issues/182 - test('does not send Unicode NULL \\u0000 character in app name or version', - () async { - final loader = () { - return Future.value(PackageInfo( - // As per - // https://api.dart.dev/stable/2.12.4/dart-core/String-class.html - // this is how \u0000 is added to a string in dart - appName: 'sentry_flutter_example\u{0000}', - packageName: '', - version: '1.0.0\u{0000}', - buildNumber: '', - buildSignature: '', - )); - }; - await fixture - .getIntegration(loader: loader) - .call(MockHub(), fixture.options); - - expect(fixture.options.release, 'sentry_flutter_example@1.0.0'); - }); - - /// See the following issues: - /// - https://github.com/getsentry/sentry-dart/issues/410 - /// - https://github.com/fluttercommunity/plus_plugins/issues/182 - test( - 'does not send Unicode NULL \\u0000 character in package name or build number', - () async { - final loader = () { - return Future.value(PackageInfo( - // As per - // https://api.dart.dev/stable/2.12.4/dart-core/String-class.html - // this is how \u0000 is added to a string in dart - appName: '', - packageName: 'sentry_flutter_example\u{0000}', - version: '', - buildNumber: '123\u{0000}', - buildSignature: '', - )); - }; - await fixture - .getIntegration(loader: loader) - .call(MockHub(), fixture.options); - - expect(fixture.options.release, 'sentry_flutter_example+123'); - }); - - test('dist is null if build number is an empty string', () async { - final loader = () { - return Future.value(PackageInfo( - appName: 'sentry_flutter_example', - packageName: 'a.b.c', - version: '1.0.0', - buildNumber: '', - buildSignature: '', - )); - }; - await fixture - .getIntegration(loader: loader) - .call(MockHub(), fixture.options); - - expect(fixture.options.dist, isNull); - }); - }); -} - -class Fixture { - final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn); - - LoadReleaseIntegration getIntegration({PackageLoader? loader}) { - return LoadReleaseIntegration(loader ?? loadRelease); - } - - Future loadRelease() { - return Future.value(PackageInfo( - appName: 'sentry_flutter', - packageName: 'foo.bar', - version: '1.2.3', - buildNumber: '789', - buildSignature: '', - )); - } -} diff --git a/flutter/test/integrations/flutter_error_integration_test.dart b/flutter/test/integrations/flutter_error_integration_test.dart new file mode 100644 index 0000000000..4fd2cee32e --- /dev/null +++ b/flutter/test/integrations/flutter_error_integration_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/flutter_error_integration.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + group(FlutterErrorIntegration, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + void _reportError({ + bool silent = false, + FlutterExceptionHandler? handler, + dynamic exception, + FlutterErrorDetails? optionalDetails, + }) { + // replace default error otherwise it fails on testing + FlutterError.onError = + handler ?? (FlutterErrorDetails errorDetails) async {}; + + when(fixture.hub.captureEvent(captureAny)) + .thenAnswer((_) => Future.value(SentryId.empty())); + + FlutterErrorIntegration()(fixture.hub, fixture.options); + + final throwable = exception ?? StateError('error'); + final details = FlutterErrorDetails( + exception: throwable, + silent: silent, + context: DiagnosticsNode.message('while handling a gesture'), + library: 'sentry', + informationCollector: () => [DiagnosticsNode.message('foo bar')], + ); + FlutterError.reportError(optionalDetails ?? details); + } + + test('FlutterError capture errors', () async { + final exception = StateError('error'); + + _reportError(exception: exception); + + 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(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 { + var called = false; + final defaultError = (FlutterErrorDetails errorDetails) async { + called = true; + }; + + _reportError(handler: defaultError); + + verify(await fixture.hub.captureEvent(captureAny)); + + expect(called, true); + }); + + test('FlutterErrorIntegration captureEvent only called once', () async { + var numberOfDefaultCalls = 0; + final defaultError = (FlutterErrorDetails errorDetails) async { + numberOfDefaultCalls++; + }; + FlutterError.onError = defaultError; + + when(fixture.hub.captureEvent(captureAny)) + .thenAnswer((_) => Future.value(SentryId.empty())); + + final details = FlutterErrorDetails(exception: StateError('error')); + + final integrationA = FlutterErrorIntegration(); + integrationA.call(fixture.hub, fixture.options); + await integrationA.close(); + + final integrationB = FlutterErrorIntegration(); + integrationB.call(fixture.hub, fixture.options); + + FlutterError.reportError(details); + + verify(await fixture.hub.captureEvent(captureAny)).called(1); + + expect(numberOfDefaultCalls, 1); + }); + + test('FlutterErrorIntegration close restored default onError', () async { + final defaultOnError = (FlutterErrorDetails errorDetails) async {}; + FlutterError.onError = defaultOnError; + + final integration = FlutterErrorIntegration(); + integration.call(fixture.hub, fixture.options); + expect(false, defaultOnError == FlutterError.onError); + + await integration.close(); + expect(FlutterError.onError, defaultOnError); + }); + + test( + 'FlutterErrorIntegration default not restored if set after integration', + () async { + final defaultOnError = (FlutterErrorDetails errorDetails) async {}; + FlutterError.onError = defaultOnError; + + final integration = FlutterErrorIntegration(); + integration.call(fixture.hub, fixture.options); + expect(defaultOnError == FlutterError.onError, false); + + final afterIntegrationOnError = + (FlutterErrorDetails errorDetails) async {}; + FlutterError.onError = afterIntegrationOnError; + + await integration.close(); + expect(FlutterError.onError, afterIntegrationOnError); + }); + + test('FlutterError do not capture if silent error', () async { + _reportError(silent: true); + + verifyNever(await fixture.hub.captureEvent(captureAny)); + }); + + test('FlutterError captures if silent error but reportSilentFlutterErrors', + () async { + fixture.options.reportSilentFlutterErrors = true; + _reportError(silent: true); + + verify(await fixture.hub.captureEvent(captureAny)); + }); + + test('FlutterError adds integration', () { + FlutterErrorIntegration()(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations.contains('flutterErrorIntegration'), + true); + }); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(dsn: fakeDsn); + + LoadReleaseIntegration getIntegration({PackageLoader? loader}) { + return LoadReleaseIntegration(loader ?? loadRelease); + } + + Future loadRelease() { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: 'foo.bar', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + } +} diff --git a/flutter/test/native_sdk_integration_test.dart b/flutter/test/integrations/init_native_sdk_integration_test.dart similarity index 97% rename from flutter/test/native_sdk_integration_test.dart rename to flutter/test/integrations/init_native_sdk_integration_test.dart index 9f757d5fba..adce28542b 100644 --- a/flutter/test/native_sdk_integration_test.dart +++ b/flutter/test/integrations/init_native_sdk_integration_test.dart @@ -3,12 +3,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/native_sdk_integration.dart'; import 'package:sentry_flutter/src/version.dart'; -import 'mocks.dart'; +import '../mocks.dart'; void main() { - group('$NativeSdkIntegration', () { + group(NativeSdkIntegration, () { late Fixture fixture; setUp(() { fixture = Fixture(); diff --git a/flutter/test/integrations/load_contexts_integration_test.dart b/flutter/test/integrations/load_contexts_integration_test.dart new file mode 100644 index 0000000000..ee11e67cb4 --- /dev/null +++ b/flutter/test/integrations/load_contexts_integration_test.dart @@ -0,0 +1,59 @@ +@TestOn('vm') + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + group(LoadContextsIntegration, () { + const _channel = MethodChannel('sentry_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + _channel.setMockMethodCallHandler(null); + }); + + test('loadContextsIntegration adds integration', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + + final integration = LoadContextsIntegration(_channel); + + await integration(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations.contains('loadContextsIntegration'), + true); + }); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(dsn: fakeDsn); + + LoadReleaseIntegration getIntegration({PackageLoader? loader}) { + return LoadReleaseIntegration(loader ?? loadRelease); + } + + Future loadRelease() { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: 'foo.bar', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + } +} diff --git a/flutter/test/load_contexts_integrations_test.dart b/flutter/test/integrations/load_contexts_integrations_test.dart similarity index 99% rename from flutter/test/load_contexts_integrations_test.dart rename to flutter/test/integrations/load_contexts_integrations_test.dart index d27c222cb8..fba428127f 100644 --- a/flutter/test/load_contexts_integrations_test.dart +++ b/flutter/test/integrations/load_contexts_integrations_test.dart @@ -3,8 +3,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; -import 'mocks.mocks.dart'; +import '../mocks.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/flutter/test/integrations/load_image_list_test.dart b/flutter/test/integrations/load_image_list_test.dart new file mode 100644 index 0000000000..f3aaa09776 --- /dev/null +++ b/flutter/test/integrations/load_image_list_test.dart @@ -0,0 +1,187 @@ +@TestOn('vm') + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/load_image_list_integration.dart'; + +import '../mocks.dart'; +import '../sentry_flutter_test.dart'; + +void main() { + group(LoadImageListIntegration, () { + TestWidgetsFlutterBinding.ensureInitialized(); + late Fixture fixture; + + tearDown(() { + fixture.channel.setMockMethodCallHandler(null); + }); + + for (var platform in [ + MockPlatform.android(), + MockPlatform.iOs(), + MockPlatform.macOs() + ]) { + group(platform.operatingSystem, () { + final imageList = [ + { + 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', + 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', + 'image_addr': '0x6f80b000', + 'image_size': 3092480, + 'type': 'elf', + 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', + 'debug_file': 'test' + } + ]; + + setUp(() { + fixture = Fixture(platform); + fixture.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + return imageList; + }); + }); + + test('$LoadImageListIntegration adds itself to sdk.integrations', + () async { + final sut = fixture.getSut(); + + sut.call(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations + .contains('loadImageListIntegration'), + true, + ); + }); + + test('Native layer is not called as the event is symbolicated', + () async { + var called = false; + + final sut = fixture.getSut(); + fixture.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + called = true; + return imageList; + }); + + sut.call(fixture.hub, fixture.options); + + expect(fixture.options.eventProcessors.length, 1); + + await fixture.hub.captureException(StateError('error'), + stackTrace: StackTrace.current); + + expect(called, false); + }); + + test('Native layer is not called if the event has no stack traces', + () async { + var called = false; + + final sut = fixture.getSut(); + fixture.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + called = true; + return imageList; + }); + + sut.call(fixture.hub, fixture.options); + + await fixture.hub.captureException(StateError('error')); + + expect(called, false); + }); + + test('Native layer is called because stack traces are not symbolicated', + () async { + var called = false; + + final sut = fixture.getSut(); + fixture.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + called = true; + return imageList; + }); + + sut.call(fixture.hub, fixture.options); + + await fixture.hub + .captureException(StateError('error'), stackTrace: ''' + warning: This VM has been configured to produce stack traces that violate the Dart standard. + *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** + pid: 30930, tid: 30990, name 1.ui + build_id: '5346e01103ffeed44e97094ff7bfcc19' + isolate_dso_base: 723d447000, vm_dso_base: 723d447000 + isolate_instructions: 723d452000, vm_instructions: 723d449000 + #00 abs 000000723d6346d7 virt 00000000001ed6d7 _kDartIsolateSnapshotInstructions+0x1e26d7 + #01 abs 000000723d637527 virt 00000000001f0527 _kDartIsolateSnapshotInstructions+0x1e5527 + '''); + + expect(called, true); + }); + + test('Event processor adds image list to the event', () async { + final sut = fixture.getSut(); + + sut.call(fixture.hub, fixture.options); + + final ep = fixture.options.eventProcessors.first; + SentryEvent? event = _getEvent(); + event = await ep.apply(event); + + expect(1, event!.debugMeta!.images.length); + }); + + test('Event processor asserts image list', () async { + final sut = fixture.getSut(); + + sut.call(fixture.hub, fixture.options); + final ep = fixture.options.eventProcessors.first; + SentryEvent? event = _getEvent(); + event = await ep.apply(event); + + final image = event!.debugMeta!.images.first; + + expect( + '/apex/com.android.art/javalib/arm64/boot.oat', image.codeFile); + expect('13577ce71153c228ecf0eb73fc39f45010d487f8', image.codeId); + expect('0x6f80b000', image.imageAddr); + expect(3092480, image.imageSize); + expect('elf', image.type); + expect('e77c5713-5311-28c2-ecf0-eb73fc39f450', image.debugId); + expect('test', image.debugFile); + }); + }); + } + }); +} + +SentryEvent _getEvent() { + final frame = SentryStackFrame(platform: 'native'); + final st = SentryStackTrace(frames: [frame]); + final ex = SentryException( + type: 'type', + value: 'value', + stackTrace: st, + ); + return SentryEvent(exceptions: [ex]); +} + +class Fixture { + late final Hub hub; + late final SentryFlutterOptions options; + final channel = MethodChannel('sentry_flutter'); + + Fixture(MockPlatform platform) { + options = SentryFlutterOptions( + dsn: fakeDsn, checker: getPlatformChecker(platform: platform)); + hub = Hub(options); + } + + LoadImageListIntegration getSut() { + return LoadImageListIntegration(channel); + } +} diff --git a/flutter/test/integrations/load_release_integration_test.dart b/flutter/test/integrations/load_release_integration_test.dart new file mode 100644 index 0000000000..0df2dda19d --- /dev/null +++ b/flutter/test/integrations/load_release_integration_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group(LoadReleaseIntegration, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('does not overwrite options', () async { + fixture.options.release = '1.0.0'; + fixture.options.dist = 'dist'; + + await fixture.getIntegration().call(MockHub(), fixture.options); + + expect(fixture.options.release, '1.0.0'); + expect(fixture.options.dist, 'dist'); + }); + + test('sets release and dist if not set on options', () async { + await fixture.getIntegration().call(MockHub(), fixture.options); + + expect(fixture.options.release, 'foo.bar@1.2.3+789'); + expect(fixture.options.dist, '789'); + }); + + test('sets app name as in release if packagename is empty', () async { + final loader = () { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: '', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + }; + await fixture + .getIntegration(loader: loader) + .call(MockHub(), fixture.options); + + expect(fixture.options.release, 'sentry_flutter@1.2.3+789'); + expect(fixture.options.dist, '789'); + }); + + test('release name does not contain invalid chars defined by Sentry', + () async { + final loader = () { + return Future.value(PackageInfo( + appName: '\\/sentry\tflutter \r\nfoo\nbar\r', + packageName: '', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + }; + await fixture + .getIntegration(loader: loader) + .call(MockHub(), fixture.options); + + expect(fixture.options.release, '__sentry_flutter _foo_bar_@1.2.3+789'); + expect(fixture.options.dist, '789'); + }); + + /// See the following issues: + /// - https://github.com/getsentry/sentry-dart/issues/410 + /// - https://github.com/fluttercommunity/plus_plugins/issues/182 + test('does not send Unicode NULL \\u0000 character in app name or version', + () async { + final loader = () { + return Future.value(PackageInfo( + // As per + // https://api.dart.dev/stable/2.12.4/dart-core/String-class.html + // this is how \u0000 is added to a string in dart + appName: 'sentry_flutter_example\u{0000}', + packageName: '', + version: '1.0.0\u{0000}', + buildNumber: '', + buildSignature: '', + )); + }; + await fixture + .getIntegration(loader: loader) + .call(MockHub(), fixture.options); + + expect(fixture.options.release, 'sentry_flutter_example@1.0.0'); + }); + + /// See the following issues: + /// - https://github.com/getsentry/sentry-dart/issues/410 + /// - https://github.com/fluttercommunity/plus_plugins/issues/182 + test( + 'does not send Unicode NULL \\u0000 character in package name or build number', + () async { + final loader = () { + return Future.value(PackageInfo( + // As per + // https://api.dart.dev/stable/2.12.4/dart-core/String-class.html + // this is how \u0000 is added to a string in dart + appName: '', + packageName: 'sentry_flutter_example\u{0000}', + version: '', + buildNumber: '123\u{0000}', + buildSignature: '', + )); + }; + await fixture + .getIntegration(loader: loader) + .call(MockHub(), fixture.options); + + expect(fixture.options.release, 'sentry_flutter_example+123'); + }); + + test('dist is null if build number is an empty string', () async { + final loader = () { + return Future.value(PackageInfo( + appName: 'sentry_flutter_example', + packageName: 'a.b.c', + version: '1.0.0', + buildNumber: '', + buildSignature: '', + )); + }; + await fixture + .getIntegration(loader: loader) + .call(MockHub(), fixture.options); + + expect(fixture.options.dist, isNull); + }); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(dsn: fakeDsn); + + LoadReleaseIntegration getIntegration({PackageLoader? loader}) { + return LoadReleaseIntegration(loader ?? loadRelease); + } + + Future loadRelease() { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: 'foo.bar', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + } +} diff --git a/flutter/test/integrations/native_sdk_integration_test.dart b/flutter/test/integrations/native_sdk_integration_test.dart new file mode 100644 index 0000000000..a977ee280e --- /dev/null +++ b/flutter/test/integrations/native_sdk_integration_test.dart @@ -0,0 +1,114 @@ +@TestOn('vm') + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/native_sdk_integration.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + const _channel = MethodChannel('sentry_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + _channel.setMockMethodCallHandler(null); + }); + + test('nativeSdkIntegration adds integration', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + + final integration = NativeSdkIntegration(_channel); + + await integration(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), + true); + }); + + test('nativeSdkIntegration do not throw', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + throw Exception(); + }); + + final integration = NativeSdkIntegration(_channel); + + await integration(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), + false); + }); + + test('nativeSdkIntegration closes native SDK', () async { + var closeCalled = false; + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + expect(methodCall.method, 'closeNativeSdk'); + closeCalled = true; + }); + + final integration = NativeSdkIntegration(_channel); + + await integration.close(); + + expect(closeCalled, true); + }); + + test('nativeSdkIntegration does not call native sdk when auto init disabled', + () async { + var methodChannelCalled = false; + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + methodChannelCalled = true; + }); + fixture.options.autoInitializeNativeSdk = false; + + final integration = NativeSdkIntegration(_channel); + + await integration.call(fixture.hub, fixture.options); + + expect(methodChannelCalled, false); + }); + + test('nativeSdkIntegration does not close native when auto init disabled', + () async { + var methodChannelCalled = false; + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + methodChannelCalled = true; + }); + fixture.options.autoInitializeNativeSdk = false; + + final integration = NativeSdkIntegration(_channel); + + await integration(fixture.hub, fixture.options); + await integration.close(); + + expect(methodChannelCalled, false); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(dsn: fakeDsn); + + LoadReleaseIntegration getIntegration({PackageLoader? loader}) { + return LoadReleaseIntegration(loader ?? loadRelease); + } + + Future loadRelease() { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: 'foo.bar', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + } +} diff --git a/flutter/test/not_initialized_widgets_binding_test.dart b/flutter/test/integrations/not_initialized_widgets_binding_test.dart similarity index 85% rename from flutter/test/not_initialized_widgets_binding_test.dart rename to flutter/test/integrations/not_initialized_widgets_binding_test.dart index 3919255506..e9001f57ed 100644 --- a/flutter/test/not_initialized_widgets_binding_test.dart +++ b/flutter/test/integrations/not_initialized_widgets_binding_test.dart @@ -1,7 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/widgets_binding_integration.dart'; -import 'mocks.mocks.dart'; +import '../mocks.mocks.dart'; /// Tests that require `WidgetsFlutterBinding.ensureInitialized();` not /// being called at all. diff --git a/flutter/test/integrations/widgets_flutter_binding_integration_test.dart b/flutter/test/integrations/widgets_flutter_binding_integration_test.dart new file mode 100644 index 0000000000..203985ceb7 --- /dev/null +++ b/flutter/test/integrations/widgets_flutter_binding_integration_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/binding_utils.dart'; +import 'package:sentry_flutter/src/integrations/widgets_flutter_binding_integration.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + const _channel = MethodChannel('sentry_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + _channel.setMockMethodCallHandler(null); + }); + + test('WidgetsFlutterBindingIntegration adds integration', () async { + final integration = WidgetsFlutterBindingIntegration(); + await integration(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations + .contains('widgetsFlutterBindingIntegration'), + true); + }); + + test('WidgetsFlutterBindingIntegration calls ensureInitialized', () async { + var called = false; + var ensureInitialized = () { + called = true; + return BindingUtils.getWidgetsBindingInstance()!; + }; + final integration = WidgetsFlutterBindingIntegration(ensureInitialized); + await integration(fixture.hub, fixture.options); + + expect(called, true); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(dsn: fakeDsn); + + LoadReleaseIntegration getIntegration({PackageLoader? loader}) { + return LoadReleaseIntegration(loader ?? loadRelease); + } + + Future loadRelease() { + return Future.value(PackageInfo( + appName: 'sentry_flutter', + packageName: 'foo.bar', + version: '1.2.3', + buildNumber: '789', + buildSignature: '', + )); + } +} diff --git a/flutter/test/load_image_list_test.dart b/flutter/test/load_image_list_test.dart deleted file mode 100644 index 6abe496026..0000000000 --- a/flutter/test/load_image_list_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -@TestOn('vm') - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - -import 'mocks.dart'; -import 'sentry_flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late Fixture fixture; - - tearDown(() { - fixture.channel.setMockMethodCallHandler(null); - }); - - for (var platform in [ - MockPlatform.android(), - MockPlatform.iOs(), - MockPlatform.macOs() - ]) { - group(platform.operatingSystem, () { - final imageList = [ - { - 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', - 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', - 'image_addr': '0x6f80b000', - 'image_size': 3092480, - 'type': 'elf', - 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', - 'debug_file': 'test' - } - ]; - - setUp(() { - fixture = Fixture(platform); - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - return imageList; - }); - }); - - test('$LoadImageListIntegration adds itself to sdk.integrations', - () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - expect( - fixture.options.sdk.integrations.contains('loadImageListIntegration'), - true, - ); - }); - - test('Native layer is not called as the event is symbolicated', () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - expect(fixture.options.eventProcessors.length, 1); - - await fixture.hub.captureException(StateError('error'), - stackTrace: StackTrace.current); - - expect(called, false); - }); - - test('Native layer is not called if the event has no stack traces', - () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub.captureException(StateError('error')); - - expect(called, false); - }); - - test('Native layer is called because stack traces are not symbolicated', - () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub.captureException(StateError('error'), stackTrace: ''' - warning: This VM has been configured to produce stack traces that violate the Dart standard. - *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** - pid: 30930, tid: 30990, name 1.ui - build_id: '5346e01103ffeed44e97094ff7bfcc19' - isolate_dso_base: 723d447000, vm_dso_base: 723d447000 - isolate_instructions: 723d452000, vm_instructions: 723d449000 - #00 abs 000000723d6346d7 virt 00000000001ed6d7 _kDartIsolateSnapshotInstructions+0x1e26d7 - #01 abs 000000723d637527 virt 00000000001f0527 _kDartIsolateSnapshotInstructions+0x1e5527 - '''); - - expect(called, true); - }); - - test('Event processor adds image list to the event', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event); - - expect(1, event!.debugMeta!.images.length); - }); - - test('Event processor asserts image list', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event); - - final image = event!.debugMeta!.images.first; - - expect('/apex/com.android.art/javalib/arm64/boot.oat', image.codeFile); - expect('13577ce71153c228ecf0eb73fc39f45010d487f8', image.codeId); - expect('0x6f80b000', image.imageAddr); - expect(3092480, image.imageSize); - expect('elf', image.type); - expect('e77c5713-5311-28c2-ecf0-eb73fc39f450', image.debugId); - expect('test', image.debugFile); - }); - }); - } -} - -SentryEvent _getEvent() { - final frame = SentryStackFrame(platform: 'native'); - final st = SentryStackTrace(frames: [frame]); - final ex = SentryException( - type: 'type', - value: 'value', - stackTrace: st, - ); - return SentryEvent(exceptions: [ex]); -} - -class Fixture { - late final Hub hub; - late final SentryFlutterOptions options; - final channel = MethodChannel('sentry_flutter'); - - Fixture(MockPlatform platform) { - options = SentryFlutterOptions( - dsn: fakeDsn, checker: getPlatformChecker(platform: platform)); - hub = Hub(options); - } - - LoadImageListIntegration getSut() { - return LoadImageListIntegration(channel); - } -} diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index 3905643048..f57d8527be 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/integrations/debug_print_integration.dart'; +import 'package:sentry_flutter/src/integrations/integrations.dart'; import 'package:sentry_flutter/src/version.dart'; import 'mocks.dart'; import 'mocks.mocks.dart';