diff --git a/.gitignore b/.gitignore index 00f642ea82..53bf364912 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ build/ .test_coverage.dart dart/coverage/* +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index df026aba05..45fa08f7b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,32 @@ -# `package:sentry` and `package:sentry-flutter` changelog +# `package:sentry` and `package:sentry_flutter` changelog -## 4.0.0 -- Development +## 4.0.0-alpha.3 (Next) - -# `package:sentry` changelog +- Development. ## 4.0.0-alpha.2 -- feat: add contexts to scope -- feat: add missing protocol classes -- fix: logger method and refactoring little things -- fix: sentry protocol is v7 -- feat: add an attachStackTrace options +- Enhancement: `Contexts` were added to the `Scope` #154 +- Fix: App. would hang if `debug` mode was enabled and refactoring ##157 +- Enhancement: Sentry Protocol v7 +- Enhancement: Added missing Protocol fields, `Request`, `SentryStackTrace`...) #155 +- Feat: Added `attachStackTrace` options to attach stack traces on `captureMessage` calls +- Feat: Flutter SDK has the Native SDKs embedded (Android and Apple) #158 + +### Breaking changes + +- `Sentry.init` returns a `Future`. +- Dart min. SDK is `2.8.0` +- Flutter min. SDK is `1.17.0` +- Timestamp has millis precision. +- For better groupping, add your own package to the `addInAppInclude` list, e.g. `options.addInAppInclude('sentry_flutter_example');` +- A few classes of the `Protocol` were renamed. + +#### Sentry Self Hosted Compatibility + +- Since version `4.0.0` of the `sentry_flutter`, `Sentry` version >= `v20.6.0` is required. This only applies to on-premise Sentry, if you are using sentry.io no action is needed. + +# `package:sentry` changelog ## 4.0.0-alpha.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c8fa344cf8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +### Dart + +All you need is the [sentry-dart](https://github.com/getsentry/sentry-dart/tree/main/dart). The `sentry` package doesn't depend on the Flutter SDK. + +### Flutter + +All you need is the [sentry-flutter](https://github.com/getsentry/sentry-dart/tree/main/flutter) and `sentry-dart` as stated above. + +The SDK currently supports Android, iOS and Web. We build the example app for these targets in 3 platforms: Windows, macOS and Linux. +This is to make sure you'd be able to contribute to this project if you're using any of these operating systems. + +We also run CI against the Flutter `stable` and `beta` channels so you should be able to build it if you're in one of those. + +The Flutter SDK has our Native SDKs embedded, if you wish to learn more about them, they sit at: + +[sentry-java](https://github.com/getsentry/sentry-java) for the Android integration. +[sentry-cocoa](https://github.com/getsentry/sentry-cocoa) for the Apple integration. +[sentry-native](https://github.com/getsentry/sentry-native) for the Android NDK integration. + +### Dependencies + +* The Dart SDK (if you want to change `sentry-dart`) +* The Flutter SDK (if you want to change `sentry-dart` or `sentry-flutter`) +* Android: Android SDK (`sentry-java`) with NDK (`sentry-native`): The example project includes C++. +* iOS: Cocoa SDK (`sentry-cocoa`), you'll need a Mac with xcode installed. +* Web: No additional dependencies. diff --git a/README.md b/README.md index d56bb3acba..9636566551 100644 --- a/README.md +++ b/README.md @@ -10,32 +10,8 @@ Sentry SDK for Dart and Flutter | package | build | pub | likes | popularity | pub points | | ------- | ------- | ------- | ------- | ------- | ------- | -| sentry | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-dart/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-dart) | [![pub package](https://img.shields.io/pub/v/sentry.svg)](https://pub.dev/packages/sentry) | [![likes](https://badges.bar/sentry/likes)](https://pub.dev/packages/sentry/score) | [![popularity](https://badges.bar/sentry/popularity)](https://pub.dev/packages/sentry/score) | [![pub points](https://badges.bar/sentry/pub%20points)](https://pub.dev/packages/sentry/score) -| sentry-flutter | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-flutter/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-flutter) | | | | - -## Contributing - -### Dart - -All you need is the Dart SDK. The `sentry` package doesn't depend on the Flutter SDK. - -### Flutter - -The SDK currently supports Android, iOS and Web. We build the example app for these targets in 3 platforms: Windows, macOS and Linux. -This is to make sure you'd be able to contribute to this project if you're using any of these operating systems. - -We also run CI against the Flutter `stable` and `beta` channels so you should be able to build it if you're in one of those. - -### Dependencies - -* The Dart SDK (if you want to change `sentry-dart`) -* The Flutter SDK (if you want to change `sentry-dart` or `sentry-flutter`) -* Android: Android SDK with NDK: The example project includes C++. -* iOS: You'll need a Mac with xcode installed. -* Web: No additional dependencies. - -#### Sentry Dart - +| sentry | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-dart/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-dart) | [![pub package](https://img.shields.io/pub/v/sentry.svg)](https://pub.dev/packages/sentry) | [![likes](https://badges.bar/sentry/likes)](https://pub.dev/packages/sentry/score) | [![popularity](https://badges.bar/sentry/popularity)](https://pub.dev/packages/sentry/score) | [![pub points](https://badges.bar/sentry/pub%20points)](https://pub.dev/packages/sentry/score) +| sentry_flutter | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-flutter/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-flutter) | | | | ##### Versions @@ -44,6 +20,10 @@ command-line/server Dart VM, and `AngularDart`. Versions below `3.0.1` are deprecated. +###### Preleases (v4.0.0) + +- If you wish to try out our Prelease versions, check out the inner [dart](https://github.com/getsentry/sentry-dart/tree/main/dart) and [flutter](https://github.com/getsentry/sentry-dart/tree/main/flutter) folders. + ##### Usage Sign up for a Sentry.io account and get a DSN at http://sentry.io. diff --git a/dart/README.md b/dart/README.md index 311db1b0ad..c6c5220f33 100644 --- a/dart/README.md +++ b/dart/README.md @@ -8,25 +8,37 @@ Sentry SDK for Dart and Flutter =========== -##### Usage +| package | build | pub | likes | popularity | pub points | +| ------- | ------- | ------- | ------- | ------- | ------- | +| sentry | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-dart/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-dart) | [![pub package](https://img.shields.io/pub/v/sentry.svg)](https://pub.dev/packages/sentry) | [![likes](https://badges.bar/sentry/likes)](https://pub.dev/packages/sentry/score) | [![popularity](https://badges.bar/sentry/popularity)](https://pub.dev/packages/sentry/score) | [![pub points](https://badges.bar/sentry/pub%20points)](https://pub.dev/packages/sentry/score) -Sign up for a Sentry.io account and get a DSN at http://sentry.io. +#### Versions -In your Dart code, import `package:sentry/sentry.dart` and initialize the Sentry SDK using the DSN issued by Sentry.io: +Versions `^4.0.0` are `Prereleases` and are under improvements/testing. -```dart -import 'package:sentry/sentry.dart'; +The current stable version is the Dart SDK, [3.0.1](https://pub.dev/packages/sentry). -Sentry.init((options) => options.dsn = 'https://example@sentry.io/add-your-dsn-here'); -``` +#### Usage + +- Sign up for a Sentry.io account and get a DSN at http://sentry.io. -In an exception handler, call `captureException()`: +- Follow the installing instructions on [pub.dev](https://pub.dev/packages/sentry/install). + +- The code snippet below reflects the latest `Prerelease` version. + +- Initialize the Sentry SDK using the DSN issued by Sentry.io: ```dart import 'dart:async'; import 'package:sentry/sentry.dart'; -void main() async { +Future main() async { + await Sentry.init((options) { + options.dsn = 'https://example@sentry.io/add-your-dsn-here'; + // For better groupping, change the 'example' below with your own App's package. + options.addInAppInclude('example'); + }); + try { aMethodThatMightFail(); } catch (exception, stackTrace) { @@ -36,50 +48,15 @@ void main() async { ); } } -``` - -##### Tips for catching errors - -- Use a `try/catch` block, like in the example above. -- Create a `Zone` with an error handler, e.g. using `runZonedGuarded`. - -```dart -import 'dart:async'; -import 'package:flutter/widgets.dart'; -import 'package:sentry/sentry.dart'; - -// Wrap your 'runApp(MyApp())' as follows: -void main() { - runZonedGuarded(() { - runApp(MyApp()); - }, (exception, stackTrace) async { - await Sentry.captureException( - exception, - stackTrace: stackTrace, - ); - }); +void aMethodThatMightFail() { + throw null; } ``` -- For Flutter-specific errors (such as layout failures), use `FlutterError.onError`. For example: +#### Flutter SDK Integration -```dart -import 'package:flutter/foundation.dart'; -import 'package:sentry/sentry.dart'; - -void main() { - FlutterError.onError = (FlutterErrorDetails details) async { - await Sentry.captureException( - details.exception, - stackTrace: details.stack, - ); - }; -} -``` - -- Use `Isolate.current.addErrorListener` to capture uncaught errors - in the root zone. +- Check out the [Flutter SDK](https://github.com/getsentry/sentry-dart/tree/main/flutter) with the Native integrations (Android/Apple). #### Resources diff --git a/dart/example/main.dart b/dart/example/main.dart index aabcf2ea54..2428da1d21 100644 --- a/dart/example/main.dart +++ b/dart/example/main.dart @@ -17,7 +17,7 @@ Future main() async { SentryEvent processTagEvent(SentryEvent event, Object hint) => event..tags.addAll({'page-locale': 'en-us'}); - Sentry.init((options) => options + await Sentry.init((options) => options ..dsn = dsn ..addEventProcessor(processTagEvent)); diff --git a/dart/example_web/pubspec.yaml b/dart/example_web/pubspec.yaml index 533d1135d6..ddbf040a70 100644 --- a/dart/example_web/pubspec.yaml +++ b/dart/example_web/pubspec.yaml @@ -1,8 +1,10 @@ name: sentry_dart_web_example description: An absolute bare-bones web app. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + environment: - sdk: ^2.0.0 + sdk: ^2.8.0 dependencies: sentry: diff --git a/dart/example_web/web/main.dart b/dart/example_web/web/main.dart index dce2e37fcf..4dee82afc7 100644 --- a/dart/example_web/web/main.dart +++ b/dart/example_web/web/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:html'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/version.dart'; import 'event.dart'; @@ -9,7 +10,7 @@ import 'event.dart'; const dsn = 'https://cb0fad6f5d4e42ebb9c956cb0463edc9@o447951.ingest.sentry.io/5428562'; -void main() { +Future main() async { querySelector('#output').text = 'Your Dart app is running.'; querySelector('#btEvent') @@ -18,14 +19,14 @@ void main() { querySelector('#btMessage').onClick.listen((event) => captureMessage()); querySelector('#btException').onClick.listen((event) => captureException()); - initSentry(); + await initSentry(); } -void initSentry() { +Future initSentry() async { SentryEvent processTagEvent(SentryEvent event, Object hint) => event..tags.addAll({'page-locale': 'en-us'}); - Sentry.init((options) => options + await Sentry.init((options) => options ..dsn = dsn ..addEventProcessor(processTagEvent)); diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index fd3193e4b6..374265d077 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -10,4 +10,5 @@ export 'src/sentry_client.dart'; export 'src/hub.dart'; export 'src/sentry_options.dart'; export 'src/transport/transport.dart'; -export 'src/version.dart'; +// useful for integrations +export 'src/throwable_mechanism.dart'; diff --git a/dart/lib/src/protocol/noop_origin.dart b/dart/lib/src/noop_origin.dart similarity index 100% rename from dart/lib/src/protocol/noop_origin.dart rename to dart/lib/src/noop_origin.dart diff --git a/dart/lib/src/protocol/origin.dart b/dart/lib/src/origin.dart similarity index 100% rename from dart/lib/src/protocol/origin.dart rename to dart/lib/src/origin.dart diff --git a/dart/lib/src/protocol/app.dart b/dart/lib/src/protocol/app.dart index 526bcff2ca..14916f44b5 100644 --- a/dart/lib/src/protocol/app.dart +++ b/dart/lib/src/protocol/app.dart @@ -15,6 +15,18 @@ class App { this.deviceAppHash, }); + factory App.fromJson(Map data) => App( + name: data['app_name'], + version: data['app_version'], + identifier: data['app_identifier'], + build: data['app_build'], + buildType: data['build_type'], + startTime: data['app_start_time'] != null + ? DateTime.tryParse(data['app_start_time']) + : null, + deviceAppHash: data['device_app_hash'], + ); + /// Human readable application name, as it appears on the platform. final String name; diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index 57e99d8421..01617f89a1 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -76,7 +76,7 @@ class Breadcrumb { /// to the Sentry protocol. Map toJson() { final json = { - 'timestamp': formatDateAsIso8601WithSecondPrecision(timestamp), + 'timestamp': formatDateAsIso8601WithMillisPrecision(timestamp), }; if (message != null) { json['message'] = message; diff --git a/dart/lib/src/protocol/browser.dart b/dart/lib/src/protocol/browser.dart index 7b8111d895..b3a75cf184 100644 --- a/dart/lib/src/protocol/browser.dart +++ b/dart/lib/src/protocol/browser.dart @@ -8,6 +8,11 @@ class Browser { /// Creates an instance of [Browser]. const Browser({this.name, this.version}); + factory Browser.fromJson(Map data) => Browser( + name: data['name'], + version: data['version'], + ); + /// Human readable application name, as it appears on the platform. final String name; diff --git a/dart/lib/src/protocol/contexts.dart b/dart/lib/src/protocol/contexts.dart index 0a26b62498..17028a0b67 100644 --- a/dart/lib/src/protocol/contexts.dart +++ b/dart/lib/src/protocol/contexts.dart @@ -25,6 +25,40 @@ class Contexts extends MapView { Gpu.type: gpu, }); + factory Contexts.fromJson(Map data) { + final contexts = Contexts( + device: data[Device.type] != null + ? Device.fromJson(Map.from(data[Device.type])) + : null, + operatingSystem: data[OperatingSystem.type] != null + ? OperatingSystem.fromJson( + Map.from(data[OperatingSystem.type])) + : null, + app: data[App.type] != null + ? App.fromJson(Map.from(data[App.type])) + : null, + browser: data[Browser.type] != null + ? Browser.fromJson(Map.from(data[Browser.type])) + : null, + gpu: data[Gpu.type] != null + ? Gpu.fromJson(Map.from(data[Gpu.type])) + : null, + runtimes: data[SentryRuntime.type] != null + ? [ + SentryRuntime.fromJson( + Map.from(data[SentryRuntime.type]), + ), + ] + : null, + ); + + data.keys + .where((key) => !_defaultFields.contains(key) && data[key] != null) + .forEach((key) => contexts[key] = data[key]); + + return contexts; + } + /// This describes the device that caused the event. Device get device => this[Device.type]; diff --git a/dart/lib/src/protocol/device.dart b/dart/lib/src/protocol/device.dart index 9c83d68102..fdbfae11da 100644 --- a/dart/lib/src/protocol/device.dart +++ b/dart/lib/src/protocol/device.dart @@ -33,6 +33,36 @@ class Device { }) : assert( batteryLevel == null || (batteryLevel >= 0 && batteryLevel <= 100)); + factory Device.fromJson(Map data) => Device( + name: data['name'], + family: data['family'], + model: data['model'], + modelId: data['model_id'], + arch: data['arch'], + batteryLevel: data['battery_level'], + orientation: data['orientation'], + manufacturer: data['manufacturer'], + brand: data['brand'], + screenResolution: data['screen_resolution'], + screenDensity: data['screen_density'], + screenDpi: data['screen_dpi'], + online: data['online'], + charging: data['charging'], + lowMemory: data['low_memory'], + simulator: data['simulator'], + memorySize: data['memory_size'], + freeMemory: data['free_memory'], + usableMemory: data['usable_memory'], + storageSize: data['storage_size'], + freeStorage: data['free_storage'], + externalStorageSize: data['external_storage_size'], + externalFreeStorage: data['external_free_storage'], + bootTime: data['boot_time'] != null + ? DateTime.tryParse(data['boot_time']) + : null, + timezone: data['timezone'], + ); + /// The name of the device. This is typically a hostname. final String name; diff --git a/dart/lib/src/protocol/gpu.dart b/dart/lib/src/protocol/gpu.dart index 82ecb33db3..1e3702a4c0 100644 --- a/dart/lib/src/protocol/gpu.dart +++ b/dart/lib/src/protocol/gpu.dart @@ -52,6 +52,18 @@ class Gpu { this.npotSupport, }); + factory Gpu.fromJson(Map data) => Gpu( + name: data['name'], + id: data['id'], + vendorId: data['vendor_id'], + vendorName: data['vendor_name'], + memorySize: data['memory_size'], + apiType: data['api_type'], + multiThreadedRendering: data['multi_threaded_rendering'], + version: data['version'], + npotSupport: data['npot_support'], + ); + Gpu clone() => Gpu( name: name, id: id, diff --git a/dart/lib/src/protocol/operating_system.dart b/dart/lib/src/protocol/operating_system.dart index d38b062ded..8602ccf77e 100644 --- a/dart/lib/src/protocol/operating_system.dart +++ b/dart/lib/src/protocol/operating_system.dart @@ -14,6 +14,16 @@ class OperatingSystem { this.rawDescription, }); + factory OperatingSystem.fromJson(Map data) => + OperatingSystem( + name: data['name'], + version: data['version'], + build: data['build'], + kernelVersion: data['kernel_version'], + rooted: data['rooted'], + rawDescription: data['raw_description'], + ); + /// The name of the operating system. final String name; diff --git a/dart/lib/src/protocol/sdk_version.dart b/dart/lib/src/protocol/sdk_version.dart index a481773de7..a38c5e06dc 100644 --- a/dart/lib/src/protocol/sdk_version.dart +++ b/dart/lib/src/protocol/sdk_version.dart @@ -35,12 +35,14 @@ import 'sentry_package.dart'; @immutable class SdkVersion { /// Creates an [SdkVersion] object which represents the SDK that created an [Event]. - const SdkVersion({ + SdkVersion({ @required this.name, @required this.version, - this.integrations, - this.packages, - }) : assert(name != null || version != null); + List integrations, + List packages, + }) : assert(name != null || version != null), + _integrations = integrations ?? [], + _packages = packages ?? []; /// The name of the SDK. final String name; @@ -48,11 +50,15 @@ class SdkVersion { /// The version of the SDK. final String version; + final List _integrations; + /// A list of integrations enabled in the SDK that created the [Event]. - final List integrations; + List get integrations => List.unmodifiable(_integrations); + + final List _packages; /// A list of packages that compose this SDK. - final List packages; + List get packages => List.unmodifiable(_packages); String get identifier => '${name}/${version}'; @@ -61,13 +67,26 @@ class SdkVersion { final json = {}; json['name'] = name; json['version'] = version; + if (packages != null && packages.isNotEmpty) { json['packages'] = packages.map((p) => p.toJson()).toList(growable: false); } + if (integrations != null && integrations.isNotEmpty) { json['integrations'] = integrations; } return json; } + + /// Adds a package + void addPackage(String name, String version) { + final package = SentryPackage(name, version); + _packages.add(package); + } + + // Adds an integration + void addIntegration(String integration) { + _integrations.add(integration); + } } diff --git a/dart/lib/src/protocol/sentry_event.dart b/dart/lib/src/protocol/sentry_event.dart index acef5c3ed6..4cc0be6934 100644 --- a/dart/lib/src/protocol/sentry_event.dart +++ b/dart/lib/src/protocol/sentry_event.dart @@ -218,7 +218,7 @@ class SentryEvent { } if (timestamp != null) { - json['timestamp'] = formatDateAsIso8601WithSecondPrecision(timestamp); + json['timestamp'] = formatDateAsIso8601WithMillisPrecision(timestamp); } if (platform != null) { diff --git a/dart/lib/src/protocol/sentry_runtime.dart b/dart/lib/src/protocol/sentry_runtime.dart index f50d672d95..23af95d8d6 100644 --- a/dart/lib/src/protocol/sentry_runtime.dart +++ b/dart/lib/src/protocol/sentry_runtime.dart @@ -10,6 +10,12 @@ class SentryRuntime { const SentryRuntime({this.key, this.name, this.version, this.rawDescription}) : assert(key == null || key.length >= 1); + factory SentryRuntime.fromJson(Map data) => SentryRuntime( + name: data['name'], + version: data['version'], + rawDescription: data['raw_description'], + ); + /// Key used in the JSON and which will be displayed /// in the Sentry UI. Defaults to lower case version of [name]. /// diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 00a4b8f1ed..8470f33e1c 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -8,7 +8,7 @@ import 'sentry_client.dart'; import 'sentry_options.dart'; /// Configuration options callback -typedef OptionsConfiguration = void Function(SentryOptions); +typedef OptionsConfiguration = FutureOr Function(SentryOptions); /// Sentry SDK main entry point /// @@ -21,19 +21,22 @@ class Sentry { static Hub get currentHub => _hub; /// Initializes the SDK - static void init(OptionsConfiguration optionsConfiguration) { + static Future init(OptionsConfiguration optionsConfiguration) async { + if (optionsConfiguration == null) { + throw ArgumentError('OptionsConfiguration is required.'); + } final options = SentryOptions(); - optionsConfiguration(options); + await optionsConfiguration(options); if (options == null) { throw ArgumentError('SentryOptions is required.'); } - _init(options); + await _init(options); } /// Initializes the SDK - static void _init(SentryOptions options) { + static Future _init(SentryOptions options) async { if (isEnabled) { options.logger( SentryLevel.warning, @@ -51,9 +54,9 @@ class Sentry { hub.close(); // execute integrations after hub being enabled - options.integrations.forEach((integration) { - integration(HubAdapter(), options); - }); + for (final integration in options.integrations) { + await integration(HubAdapter(), options); + } } /// Reports an [event] to Sentry.io. @@ -95,7 +98,7 @@ class Sentry { static void close() { final hub = currentHub; _hub = NoOpHub(); - return hub.close(); + hub.close(); } /// Check if the current Hub is enabled/active. diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index e49b892140..dbe3da6551 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -52,7 +52,8 @@ class SentryClient { dynamic stackTrace, dynamic hint, }) async { - event = _processEvent(event, eventProcessors: _options.eventProcessors); + event = + await _processEvent(event, eventProcessors: _options.eventProcessors); // dropped by sampling or event processors if (event == null) { @@ -82,7 +83,10 @@ class SentryClient { ); } if (event == null) { - _options.logger(SentryLevel.debug, 'Event was dropped by a processor'); + _options.logger( + SentryLevel.debug, + 'Event was dropped by BeforeSend callback', + ); return _sentryId; } } @@ -162,11 +166,11 @@ class SentryClient { void close() => _options.httpClient?.close(); - SentryEvent _processEvent( + Future _processEvent( SentryEvent event, { dynamic hint, List eventProcessors, - }) { + }) async { if (_sampleRate()) { _options.logger( SentryLevel.debug, @@ -177,7 +181,7 @@ class SentryClient { for (final processor in eventProcessors) { try { - event = processor(event, hint); + event = await processor(event, hint); } catch (err) { _options.logger( SentryLevel.error, diff --git a/dart/lib/src/sentry_exception_factory.dart b/dart/lib/src/sentry_exception_factory.dart index 7a62047d12..d63a1fec92 100644 --- a/dart/lib/src/sentry_exception_factory.dart +++ b/dart/lib/src/sentry_exception_factory.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import 'protocol.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; +import 'throwable_mechanism.dart'; /// class to convert Dart Error and exception to SentryException class SentryExceptionFactory { @@ -27,26 +28,29 @@ class SentryExceptionFactory { SentryException getSentryException( dynamic exception, { dynamic stackTrace, - Mechanism mechanism, }) { + var throwable = exception; + var mechanism; + if (exception is ThrowableMechanism) { + throwable = exception.throwable; + mechanism = exception.mechanism; + } + + if (throwable is Error) { + stackTrace ??= throwable.stackTrace; + } else if (_options.attachStackTrace) { + stackTrace ??= StackTrace.current; + } + SentryStackTrace sentryStackTrace; if (stackTrace != null) { sentryStackTrace = SentryStackTrace( frames: _stacktraceFactory.getStackFrames(stackTrace), ); - } else if (exception is Error && exception.stackTrace != null) { - sentryStackTrace = SentryStackTrace( - frames: _stacktraceFactory.getStackFrames(exception.stackTrace), - ); - } else if (_options.attachStackTrace) { - sentryStackTrace = SentryStackTrace( - frames: _stacktraceFactory.getStackFrames(StackTrace.current), - ); } - final sentryException = SentryException( - type: '${exception.runtimeType}', - value: '$exception', + type: '${throwable.runtimeType}', + value: '$throwable', mechanism: mechanism, stackTrace: sentryStackTrace, ); diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index e152f37c09..fbbc150844 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:http/http.dart'; import 'diagnostic_logger.dart'; @@ -143,6 +145,7 @@ class SentryOptions { Transport _transport = NoOpTransport(); + /// The transport is an internal construct of the client that abstracts away the event sending. Transport get transport => _transport; set transport(Transport transport) => _transport = transport ?? _transport; @@ -162,6 +165,103 @@ class SentryOptions { _sdk = sdk ?? _sdk; } + bool _enableAutoSessionTracking = true; + + /// Enable or disable the Auto session tracking on the Native SDKs (Android/iOS) + bool get enableAutoSessionTracking => _enableAutoSessionTracking; + + set enableAutoSessionTracking(bool enableAutoSessionTracking) { + _enableAutoSessionTracking = + enableAutoSessionTracking ?? _enableAutoSessionTracking; + } + + bool _enableNativeCrashHandling = true; + + /// Enable or Disable the Crash handling on the Native SDKs (Android/iOS) + bool get enableNativeCrashHandling => _enableNativeCrashHandling; + + set enableNativeCrashHandling(bool nativeCrashHandling) { + _enableNativeCrashHandling = + nativeCrashHandling ?? _enableNativeCrashHandling; + } + + bool _attachStacktrace = true; + + /// When enabled, stack traces are automatically attached to all logged events. Stack traces are + /// always attached to exceptions but when this is set stack traces are also sent with messages. If + /// no stack traces are logged, we log the current stack trace automatically. + bool get attachStacktrace => _attachStacktrace; + + set attachStacktrace(bool attachStacktrace) { + _attachStacktrace = attachStacktrace ?? _attachStacktrace; + } + + int _autoSessionTrackingIntervalMillis = 30000; + + /// The session tracking interval in millis. This is the interval to end a session if the App goes + /// to the background. + /// See: enableAutoSessionTracking + int get autoSessionTrackingIntervalMillis => + _autoSessionTrackingIntervalMillis; + + set autoSessionTrackingIntervalMillis(int autoSessionTrackingIntervalMillis) { + _autoSessionTrackingIntervalMillis = + (autoSessionTrackingIntervalMillis != null && + autoSessionTrackingIntervalMillis >= 0) + ? autoSessionTrackingIntervalMillis + : _autoSessionTrackingIntervalMillis; + } + + bool _anrEnabled = false; + + /// Enable or disable ANR (Application Not Responding) Default is enabled Used by AnrIntegration. + /// Available only for Android. + /// Disabled by default as the stack trace most of the time is hanging on + /// the MessageChannel from Flutter, but you can enable it if you have + /// Java/Kotlin code as well. + bool get anrEnabled => _anrEnabled; + + set anrEnabled(bool anrEnabled) { + _anrEnabled = anrEnabled ?? _anrEnabled; + } + + int _anrTimeoutIntervalMillis = 5000; + + /// ANR Timeout internal in Millis Default is 5000 = 5s Used by AnrIntegration. + /// Available only for Android. + /// See: anrEnabled + int get anrTimeoutIntervalMillis => _anrTimeoutIntervalMillis; + + set anrTimeoutIntervalMillis(int anrTimeoutIntervalMillis) { + _anrTimeoutIntervalMillis = + (anrTimeoutIntervalMillis != null && anrTimeoutIntervalMillis >= 0) + ? anrTimeoutIntervalMillis + : _anrTimeoutIntervalMillis; + } + + bool _enableAutoNativeBreadcrumbs = true; + + /// Enable or disable the Automatic breadcrumbs on the Native platforms (Android/iOS) + /// Screen's lifecycle, App's lifecycle, System events, etc... + bool get enableAutoNativeBreadcrumbs => _enableAutoNativeBreadcrumbs; + + set enableAutoNativeBreadcrumbs(bool enableAutoNativeBreadcrumbs) { + _enableAutoNativeBreadcrumbs = + enableAutoNativeBreadcrumbs ?? _enableAutoNativeBreadcrumbs; + } + + int _cacheDirSize = 30; + + /// The cache dir. size for capping the number of events Default is 30. + /// Only available for Android. + int get cacheDirSize => _cacheDirSize; + + set cacheDirSize(int cacheDirSize) { + _cacheDirSize = (cacheDirSize != null && cacheDirSize >= 0) + ? cacheDirSize + : _cacheDirSize; + } + bool _attachStackTrace = true; /// When enabled, stack traces are automatically attached to all messages logged. @@ -183,8 +283,9 @@ class SentryOptions { // TODO: sendDefaultPii - // TODO: those ctor params could be set on Sentry._setDefaultConfiguration or instantiate by default here - SentryOptions({this.dsn}); + SentryOptions({this.dsn}) { + sdk.addPackage('pub:sentry', sdkVersion); + } /// Adds an event processor void addEventProcessor(EventProcessor eventProcessor) { @@ -225,9 +326,10 @@ typedef BeforeBreadcrumbCallback = Breadcrumb Function( dynamic hint, ); -typedef EventProcessor = SentryEvent Function(SentryEvent event, dynamic hint); +typedef EventProcessor = FutureOr Function( + SentryEvent event, dynamic hint); -typedef Integration = Function(Hub hub, SentryOptions options); +typedef Integration = FutureOr Function(Hub hub, SentryOptions options); typedef Logger = Function(SentryLevel level, String message); @@ -237,5 +339,5 @@ typedef ClockProvider = DateTime Function(); void noOpLogger(SentryLevel level, String message) {} void dartLogger(SentryLevel level, String message) { - print('[$level] $message'); + print('[${level.name}] $message'); } diff --git a/dart/lib/src/sentry_stack_trace_factory.dart b/dart/lib/src/sentry_stack_trace_factory.dart index b7df056c1d..a64aa8d355 100644 --- a/dart/lib/src/sentry_stack_trace_factory.dart +++ b/dart/lib/src/sentry_stack_trace_factory.dart @@ -1,38 +1,47 @@ import 'package:meta/meta.dart'; import 'package:stack_trace/stack_trace.dart'; -import 'protocol/noop_origin.dart' - if (dart.library.html) 'protocol/origin.dart'; +import 'noop_origin.dart' if (dart.library.html) 'origin.dart'; import 'protocol.dart'; import 'sentry_options.dart'; /// converts [StackTrace] to [SentryStackFrames] class SentryStackTraceFactory { - /// A list of string prefixes of module names that do not belong to the app, but rather third-party - /// packages. Modules considered not to be part of the app will be hidden from stack traces by - /// default. - List _inAppExcludes; - - /// A list of string prefixes of module names that belong to the app. This option takes precedence - /// over inAppExcludes. - List _inAppIncludes; + SentryOptions _options; SentryStackTraceFactory(SentryOptions options) { if (options == null) { throw ArgumentError('SentryOptions is required.'); } - - _inAppExcludes = options.inAppExcludes; - _inAppIncludes = options.inAppIncludes; + _options = options; } /// returns the [SentryStackFrame] list from a stackTrace ([StackTrace] or [String]) List getStackFrames(dynamic stackTrace) { if (stackTrace == null) return null; - final chain = stackTrace is StackTrace + final chain = (stackTrace is StackTrace) ? Chain.forTrace(stackTrace) - : Chain.parse(stackTrace as String); + : (stackTrace is String) + ? Chain.parse(stackTrace) + : Chain.parse(''); + + // TODO: if strip symbols are enabled, thats what we see: + // warning: This VM has been configured to produce stack traces that violate the Dart standard. + // *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** + // unparsed pid: 30930, tid: 30990, name 1.ui + // unparsed build_id: '5346e01103ffeed44e97094ff7bfcc19' + // unparsed isolate_dso_base: 723d447000, vm_dso_base: 723d447000 + // unparsed isolate_instructions: 723d452000, vm_instructions: 723d449000 + // unparsed #00 abs 000000723d6346d7 virt 00000000001ed6d7 _kDartIsolateSnapshotInstructions+0x1e26d7 + // unparsed #01 abs 000000723d637527 virt 00000000001f0527 _kDartIsolateSnapshotInstructions+0x1e5527 + // unparsed #02 abs 000000723d4a41a7 virt 000000000005d1a7 _kDartIsolateSnapshotInstructions+0x521a7 + // unparsed #03 abs 000000723d624663 virt 00000000001dd663 _kDartIsolateSnapshotInstructions+0x1d2663 + // unparsed #04 abs 000000723d4b8c3b virt 0000000000071c3b _kDartIsolateSnapshotInstructions+0x66c3b + // unparsed #05 abs 000000723d5ffe27 virt 00000000001b8e27 _kDartIsolateSna... + + // abs => instruction_addr + // build_id => debug_images final frames = []; for (var t = 0; t < chain.traces.length; t += 1) { @@ -57,14 +66,17 @@ class SentryStackTraceFactory { final fileName = frame.uri.pathSegments.isNotEmpty ? frame.uri.pathSegments.last : null; + final abs = '$eventOrigin${_absolutePathForCrashReport(frame)}'; + var sentryStackFrame = SentryStackFrame( - absPath: '$eventOrigin${_absolutePathForCrashReport(frame)}', + absPath: abs, function: frame.member, // https://docs.sentry.io/development/sdk-dev/features/#in-app-frames inApp: isInApp(frame), fileName: fileName, package: frame.package, ); + // platform: 'native' if strip symbols are enabled if (frame.line != null && frame.line >= 0) { sentryStackFrame = sentryStackFrame.copyWith(lineNo: frame.line); @@ -103,15 +115,15 @@ class SentryStackTraceFactory { return true; } - if (_inAppIncludes != null) { - for (final include in _inAppIncludes) { + if (_options.inAppIncludes != null) { + for (final include in _options.inAppIncludes) { if (frame.package != null && frame.package == include) { return true; } } } - if (_inAppExcludes != null) { - for (final exclude in _inAppExcludes) { + if (_options.inAppExcludes != null) { + for (final exclude in _options.inAppExcludes) { if (frame.package != null && frame.package == exclude) { return false; } diff --git a/dart/lib/src/throwable_mechanism.dart b/dart/lib/src/throwable_mechanism.dart new file mode 100644 index 0000000000..43ca9856cc --- /dev/null +++ b/dart/lib/src/throwable_mechanism.dart @@ -0,0 +1,13 @@ +import 'protocol/mechanism.dart'; + +/// An Error decorator that holds a Mechanism related to the decorated Error +class ThrowableMechanism extends Error { + final Mechanism _mechanism; + final dynamic _throwable; + + ThrowableMechanism(this._mechanism, this._throwable); + + Mechanism get mechanism => _mechanism; + + dynamic get throwable => _throwable; +} diff --git a/dart/lib/src/utils.dart b/dart/lib/src/utils.dart index 96b382321a..622f24ac3a 100644 --- a/dart/lib/src/utils.dart +++ b/dart/lib/src/utils.dart @@ -6,13 +6,16 @@ /// submitted in UTC timezone. DateTime getUtcDateTime() => DateTime.now().toUtc(); -String formatDateAsIso8601WithSecondPrecision(DateTime date) { +/// Formats a Date as ISO8601 and UTC with millis precision +String formatDateAsIso8601WithMillisPrecision(DateTime date) { var iso = date.toIso8601String(); final millisecondSeparatorIndex = iso.lastIndexOf('.'); if (millisecondSeparatorIndex != -1) { - iso = iso.substring(0, millisecondSeparatorIndex); + // + 4 for millis precision + iso = iso.substring(0, millisecondSeparatorIndex + 4); } - return iso; + // appends Z because the substring removed it + return '${iso}Z'; } /// helper to detect a browser context diff --git a/dart/lib/src/version.dart b/dart/lib/src/version.dart index eb7bbcc0ae..8a3def4f30 100644 --- a/dart/lib/src/version.dart +++ b/dart/lib/src/version.dart @@ -29,7 +29,7 @@ String get sdkPlatform => isWeb ? _browserPlatform : _ioSdkPlatform; /// The name of the SDK platform reported to Sentry.io in the submitted events. /// /// Used for IO version. -const String _ioSdkPlatform = 'dart'; +const String _ioSdkPlatform = 'other'; /// Used to report browser Stacktrace to sentry. const String _browserPlatform = 'javascript'; diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 4949751bd4..d335765bc1 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -4,9 +4,10 @@ description: > A crash reporting library for Dart that sends crash reports to Sentry.io. This library supports Dart Native, and Flutter for mobile, web, and desktop. homepage: https://github.com/getsentry/sentry-dart +repository: https://github.com/getsentry/sentry-dart environment: - sdk: ">=2.0.0 <3.0.0" + sdk: ">=2.8.0 <3.0.0" dependencies: http: ^0.12.0 diff --git a/dart/test/contexts_test.dart b/dart/test/contexts_test.dart index 9643587329..c89e3f00c2 100644 --- a/dart/test/contexts_test.dart +++ b/dart/test/contexts_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/protocol/gpu.dart'; @@ -128,4 +130,117 @@ void main() { expect(clone['version'], {'value': 9}); }); }); + + group('parse contexts', () { + test('should parse json context', () { + final contexts = Contexts.fromJson(jsonDecode(jsonContexts)); + expect( + MapEquality().equals( + contexts.operatingSystem.toJson(), + { + 'build': '19H2', + 'rooted': false, + 'kernel_version': + 'Darwin Kernel Version 19.6.0: Mon Aug 31 22:12:52 PDT 2020; root:xnu-6153.141.2~1/RELEASE_X86_64', + 'name': 'iOS', + 'version': '14.2' + }, + ), + true, + ); + expect( + MapEquality().equals(contexts.device.toJson(), { + 'simulator': true, + 'model_id': 'simulator', + 'arch': 'x86', + 'free_memory': 232132608, + 'family': 'iOS', + 'model': 'iPhone13,4', + 'memory_size': 17179869184, + 'storage_size': 1023683072000, + 'boot_time': '2020-11-18T13:28:11.000Z', + 'timezone': 'GMT+1', + 'usable_memory': 17114120192 + }), + true, + ); + + expect( + MapEquality().equals( + contexts.app.toJson(), + { + 'app_name': 'sentry_flutter_example', + 'app_version': '0.1.2', + 'app_identifier': 'io.sentry.flutter.example', + 'app_start_time': '2020-11-18T13:56:58.000Z', + 'device_app_hash': '59ca66aa7ac0bdc3d82f77041643036f6323bd6d', + 'app_build': '3', + 'build_type': 'simulator', + }, + ), + true, + ); + expect( + MapEquality().equals(contexts.runtimes.first.toJson(), { + 'name': 'testRT1', + 'version': '1.0', + 'raw_description': 'runtime description RT1 1.0' + }), + true, + ); + expect( + MapEquality().equals(contexts.browser.toJson(), {'version': '12.3.4'}), + true, + ); + expect( + MapEquality() + .equals(contexts.gpu.toJson(), {'name': 'Radeon', 'version': '1'}), + true, + ); + }); + }); +} + +const jsonContexts = ''' +{ + "os": { + "build": "19H2", + "rooted": false, + "kernel_version": "Darwin Kernel Version 19.6.0: Mon Aug 31 22:12:52 PDT 2020; root:xnu-6153.141.2~1/RELEASE_X86_64", + "name": "iOS", + "version": "14.2" + }, + "device": { + "simulator": true, + "model_id": "simulator", + "arch": "x86", + "free_memory": 232132608, + "family": "iOS", + "model": "iPhone13,4", + "memory_size": 17179869184, + "storage_size": 1023683072000, + "boot_time": "2020-11-18T13:28:11Z", + "timezone": "GMT+1", + "usable_memory": 17114120192 + }, + "app": { + "app_id": "D533244D-985D-3996-9FC2-9FA353D28586", + "app_version": "0.1.2", + "app_identifier": "io.sentry.flutter.example", + "app_start_time": "2020-11-18T13:56:58Z", + "device_app_hash": "59ca66aa7ac0bdc3d82f77041643036f6323bd6d", + "app_build": "3", + "build_type": "simulator", + "app_name": "sentry_flutter_example" + }, + "runtime": + { + "name":"testRT1", + "version":"1.0", + "raw_description":"runtime description RT1 1.0" + }, + "browser": {"version": "12.3.4"}, + "gpu": {"name": "Radeon", "version": "1"} + } +'''; diff --git a/dart/test/exception_factory_test.dart b/dart/test/exception_factory_test.dart index d75340d265..15f255363e 100644 --- a/dart/test/exception_factory_test.dart +++ b/dart/test/exception_factory_test.dart @@ -14,13 +14,8 @@ void main() { try { throw StateError('a state error'); } catch (err, stacktrace) { - final mechanism = Mechanism( - type: 'example', - description: 'a mechanism', - ); sentryException = exceptionFactory.getSentryException( err, - mechanism: mechanism, stackTrace: stacktrace, ); } @@ -34,19 +29,13 @@ void main() { try { throw StateError('a state error'); } catch (err) { - final mechanism = Mechanism( - type: 'example', - description: 'a mechanism', - ); - final stackTrace = ''' + sentryException = exceptionFactory.getSentryException( + err, + stackTrace: ''' #0 baz (file:///pathto/test.dart:50:3) #1 bar (file:///pathto/test.dart:46:9) - '''; - sentryException = exceptionFactory.getSentryException( - err, - mechanism: mechanism, - stackTrace: stackTrace, + ''', ); } diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 2470fe17a4..42c944e928 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -51,8 +51,8 @@ void main() { test( 'should capture event with the default scope', - () { - hub.captureEvent(fakeEvent); + () async { + await hub.captureEvent(fakeEvent); expect( scopeEquals( verify( @@ -68,15 +68,15 @@ void main() { }, ); - test('should capture exception', () { - hub.captureException(fakeException); + test('should capture exception', () async { + await hub.captureException(fakeException); verify(client.captureException(fakeException, scope: anyNamed('scope'))) .called(1); }); - test('should capture message', () { - hub.captureMessage(fakeMessage.formatted, level: SentryLevel.info); + test('should capture message', () async { + await hub.captureMessage(fakeMessage.formatted, level: SentryLevel.info); verify( client.captureMessage( fakeMessage.formatted, @@ -109,16 +109,16 @@ void main() { hub.bindClient(client); }); - test('should configure its scope', () { + test('should configure its scope', () async { hub.configureScope((Scope scope) { scope ..level = SentryLevel.debug ..user = fakeUser ..fingerprint = ['1', '2']; }); - hub.captureEvent(fakeEvent); + await hub.captureEvent(fakeEvent); - hub.captureEvent(fakeEvent); + await hub.captureEvent(fakeEvent); expect( scopeEquals( verify( @@ -160,10 +160,10 @@ void main() { hub.bindClient(client); }); - test('should bind a new client', () { + test('should bind a new client', () async { final client2 = MockSentryClient(); hub.bindClient(client2); - hub.captureEvent(fakeEvent); + await hub.captureEvent(fakeEvent); verify( client2.captureEvent( fakeEvent, diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index 852d19113b..233304ee95 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -6,6 +6,8 @@ class MockSentryClient extends Mock implements SentryClient {} class MockTransport extends Mock implements Transport {} +class MockHub extends Mock implements Hub {} + final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; final fakeException = Exception('Error'); diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index ed2650c903..306c7a6cf8 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -3,7 +3,11 @@ import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; void main() { - final fixture = Fixture(); + Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); test('sets $SentryLevel', () { final sut = fixture.getSut(); diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 13232699c7..92acb79029 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -424,26 +424,26 @@ void main() { options.transport = MockTransport(); }); - test('captures event, sample rate is 100% enabled', () { + test('captures event, sample rate is 100% enabled', () async { options.sampleRate = 1.0; final client = SentryClient(options); - client.captureEvent(fakeEvent); + await client.captureEvent(fakeEvent); verify(options.transport.send(any)).called(1); }); - test('do not capture event, sample rate is 0% disabled', () { + test('do not capture event, sample rate is 0% disabled', () async { options.sampleRate = 0.0; final client = SentryClient(options); - client.captureEvent(fakeEvent); + await client.captureEvent(fakeEvent); verifyNever(options.transport.send(any)); }); - test('captures event, sample rate is null, disabled', () { + test('captures event, sample rate is null, disabled', () async { options.sampleRate = null; final client = SentryClient(options); - client.captureEvent(fakeEvent); + await client.captureEvent(fakeEvent); verify(options.transport.send(any)).called(1); }); @@ -457,18 +457,18 @@ void main() { options.transport = MockTransport(); }); - test('before send drops event', () { + test('before send drops event', () async { options.beforeSend = beforeSendCallbackDropEvent; final client = SentryClient(options); - client.captureEvent(fakeEvent); + await client.captureEvent(fakeEvent); verifyNever(options.transport.send(any)); }); - test('before send returns an event and event is captured', () { + test('before send returns an event and event is captured', () async { options.beforeSend = beforeSendCallback; final client = SentryClient(options); - client.captureEvent(fakeEvent); + await client.captureEvent(fakeEvent); verify(options.transport.send(any)).called(1); }); diff --git a/dart/test/sentry_event_test.dart b/dart/test/sentry_event_test.dart index a80737fd91..548fc280bf 100644 --- a/dart/test/sentry_event_test.dart +++ b/dart/test/sentry_event_test.dart @@ -7,6 +7,7 @@ import 'package:sentry/src/protocol/request.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; +import 'package:sentry/src/version.dart'; void main() { group(SentryEvent, () { @@ -19,7 +20,7 @@ void main() { category: 'test', ).toJson(), { - 'timestamp': '2019-01-01T00:00:00', + 'timestamp': '2019-01-01T00:00:00.000Z', 'message': 'example log', 'category': 'test', 'level': 'debug', @@ -41,9 +42,9 @@ void main() { ), ); expect(event.toJson(), { - 'platform': isWeb ? 'javascript' : 'dart', + 'platform': isWeb ? 'javascript' : 'other', 'event_id': '00000000000000000000000000000000', - 'timestamp': '2019-01-01T00:00:00', + 'timestamp': '2019-01-01T00:00:00.000Z', 'sdk': { 'name': 'sentry.dart.flutter', 'version': '4.3.2', @@ -120,9 +121,9 @@ void main() { ), ).toJson(), { - 'platform': isWeb ? 'javascript' : 'dart', + 'platform': isWeb ? 'javascript' : 'other', 'event_id': '00000000000000000000000000000000', - 'timestamp': '2019-01-01T00:00:00', + 'timestamp': '2019-01-01T00:00:00.000Z', 'sdk': {'version': sdkVersion, 'name': sdkName}, 'message': { 'formatted': 'test-message 1 2', @@ -145,7 +146,7 @@ void main() { 'breadcrumbs': { 'values': [ { - 'timestamp': '2019-01-01T00:00:00', + 'timestamp': '2019-01-01T00:00:00.000Z', 'message': 'test log', 'category': 'test', 'level': 'debug', diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 592be78526..65f2b8b7fc 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -10,8 +10,8 @@ void main() { Exception anException; - setUp(() { - Sentry.init((options) => options.dsn = fakeDsn); + setUp(() async { + await Sentry.init((options) => options.dsn = fakeDsn); anException = Exception('anException'); client = MockSentryClient(); @@ -60,19 +60,19 @@ void main() { group('Sentry is enabled or disabled', () { test('null DSN', () { expect( - () => Sentry.init((options) => options.dsn = null), + () async => await Sentry.init((options) => options.dsn = null), throwsArgumentError, ); expect(Sentry.isEnabled, false); }); - test('empty DSN', () { - Sentry.init((options) => options.dsn = ''); + test('empty DSN', () async { + await Sentry.init((options) => options.dsn = ''); expect(Sentry.isEnabled, false); }); - test('close disables the SDK', () { - Sentry.init((options) => options.dsn = fakeDsn); + test('close disables the SDK', () async { + await Sentry.init((options) => options.dsn = fakeDsn); Sentry.bindClient(MockSentryClient()); @@ -89,11 +89,11 @@ void main() { Sentry.close(); }); - test('should install integrations', () { + test('should install integrations', () async { var called = false; void integration(Hub hub, SentryOptions options) => called = true; - Sentry.init((options) { + await Sentry.init((options) { options.dsn = fakeDsn; options.addIntegration(integration); }); @@ -103,6 +103,7 @@ void main() { }); test("options can't be null", () { - expect(() => Sentry.init((options) => options = null), throwsArgumentError); + expect(() async => await Sentry.init((options) => options = null), + throwsArgumentError); }); } diff --git a/dart/test/stack_trace_test.dart b/dart/test/stack_trace_test.dart index 4bb45e9496..4445b73c57 100644 --- a/dart/test/stack_trace_test.dart +++ b/dart/test/stack_trace_test.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'package:sentry/sentry.dart'; -import 'package:sentry/src/protocol/noop_origin.dart' - if (dart.library.html) 'package:sentry/src/protocol/origin.dart'; +import 'package:sentry/src/noop_origin.dart' + if (dart.library.html) 'package:sentry/src/origin.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:test/test.dart'; diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index 70f6fbeb26..d503284897 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/version.dart'; import 'package:test/test.dart'; const String testDsn = 'https://public:secret@sentry.example.com/1'; @@ -135,9 +136,15 @@ Future testCaptureException( expect(topFrame['function'], 'Object.wrapException'); expect(data['event_id'], sentryId.toString()); - expect(data['timestamp'], '2017-01-02T00:00:00'); + expect(data['timestamp'], '2017-01-02T00:00:00.000Z'); expect(data['platform'], 'javascript'); - expect(data['sdk'], {'version': sdkVersion, 'name': sdkName}); + expect(data['sdk'], { + 'version': sdkVersion, + 'name': sdkName, + 'packages': [ + {'name': 'pub:sentry', 'version': '4.0.0-alpha.2'} + ] + }); expect(data['server_name'], 'test.server.com'); expect(data['release'], '1.2.3'); expect(data['environment'], 'staging'); @@ -151,9 +158,15 @@ Future testCaptureException( expect(topFrame['function'], 'testCaptureException'); expect(data['event_id'], sentryId.toString()); - expect(data['timestamp'], '2017-01-02T00:00:00'); - expect(data['platform'], 'dart'); - expect(data['sdk'], {'version': sdkVersion, 'name': 'sentry.dart'}); + expect(data['timestamp'], '2017-01-02T00:00:00.000Z'); + expect(data['platform'], 'other'); + expect(data['sdk'], { + 'version': sdkVersion, + 'name': 'sentry.dart', + 'packages': [ + {'name': 'pub:sentry', 'version': sdkVersion} + ] + }); expect(data['server_name'], 'test.server.com'); expect(data['release'], '1.2.3'); expect(data['environment'], 'staging'); @@ -333,7 +346,7 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { if (request.method == 'POST') { final bodyData = request.bodyBytes; final decoded = const Utf8Codec().decode(bodyData); - final dynamic decodedJson = const JsonDecoder().convert(decoded); + final dynamic decodedJson = jsonDecode(decoded); loggedUserId = decodedJson['user']['id'] as String; return http.Response('', 401, headers: { 'x-sentry-error': 'Invalid api key', diff --git a/dart/test/utils_test.dart b/dart/test/utils_test.dart index 6607fe1b65..c2ed5bd5f9 100644 --- a/dart/test/utils_test.dart +++ b/dart/test/utils_test.dart @@ -12,8 +12,8 @@ void main() { final testDate = DateTime.fromMillisecondsSinceEpoch(1502467721598, isUtc: true); expect(testDate.toIso8601String(), '2017-08-11T16:08:41.598Z'); - expect(formatDateAsIso8601WithSecondPrecision(testDate), - '2017-08-11T16:08:41'); + expect(formatDateAsIso8601WithMillisPrecision(testDate), + '2017-08-11T16:08:41.598Z'); }); }); } diff --git a/flutter/README.md b/flutter/README.md deleted file mode 120000 index 32d46ee883..0000000000 --- a/flutter/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/flutter/README.md b/flutter/README.md new file mode 100644 index 0000000000..7645624f41 --- /dev/null +++ b/flutter/README.md @@ -0,0 +1,91 @@ +

+ + + +
+

+ +Sentry SDK for Flutter and its Native integrations (Android/Apple) +=========== + +| package | build | +| ------- | ------- | +| sentry_flutter | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-flutter/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-flutter) | + +#### Versions + +Versions `^4.0.0` are `Prereleases` and are under improvements/testing. + +Versions `^4.0.0` integrate our Native SDKs ([Android](https://github.com/getsentry/sentry-java) and [Apple](https://github.com/getsentry/sentry-cocoa)), so you are able to capture errors on Native code as well (Java/Kotlin/C/C++ for Android and Objective-C/Swift for Apple). + +The current stable version is the Dart SDK, [3.0.1](https://pub.dev/packages/sentry). + +#### Usage + +- Sign up for a Sentry.io account and get a DSN at http://sentry.io. + +- Follow the installing instructions on [pub.dev](https://pub.dev/packages/sentry_flutter/install). + +- The code snippet below reflects the latest `Prerelease` version. + +- Initialize the Sentry SDK using the DSN issued by Sentry.io: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +Future main() async { + await SentryFlutter.init( + (options) { + options.dsn = 'https://example@sentry.io/add-your-dsn-here'; + // For better groupping, change the 'example' below with your own App's package. + options.addInAppInclude('sentry_flutter_example'); + }, + () { + // Init your App. + runApp(MyApp()), + }, + ); + + try { + aMethodThatMightFail(); + } catch (exception, stackTrace) { + await Sentry.captureException( + exception, + stackTrace: stackTrace, + ); + } +} + +void aMethodThatMightFail() { + throw null; +} +``` + +##### Known limitations + +- We don't support the Flutter `split-debug-info` yet, if this feature is enabled, it'll give useless stack traces. +- For the Flutter [obfuscate](https://flutter.dev/docs/deployment/obfuscate) feature, you'll need to upload the Debug symbols manually yet, See the section below. + +##### Debug Symbols for the Native integrations (Android and Apple) + +[Uploading Debug Symbols](https://docs.sentry.io/platforms/apple/dsym/) for Apple. + +[Uploading Proguard Mappings and Debug Symbols](https://docs.sentry.io/platforms/android/proguard/) for Android. + +##### Tips for catching errors + +- Use a `try/catch` block, like in the example above. +- Use a `catchError` block for `Futures`, examples on [dart.dev](https://dart.dev/guides/libraries/futures-error-handling). +- The SDK already runs your `callback` on an error handler, e.g. using [runZonedGuarded](https://api.flutter.dev/flutter/dart-async/runZonedGuarded.html), events caught by the `runZonedGuarded` are captured automatically. +- [Flutter-specific errors](https://api.flutter.dev/flutter/foundation/FlutterError/onError.html) (such as layout failures) are captured automatically. +- [Current Isolate errors](https://api.flutter.dev/flutter/dart-isolate/Isolate/addErrorListener.html) which is the equivalent of a main or UI thread, are captured automatically. +- For your own `Isolates`, add an [Error Listener](https://api.flutter.dev/flutter/dart-isolate/Isolate/addErrorListener.html) and call `Sentry.captureException`. + +#### Resources + +* [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/flutter/) +* [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) +* [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) +* [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](https://stackoverflow.com/questions/tagged/sentry) +* [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 8d5982a28b..d7187d684a 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -2,14 +2,14 @@ group 'io.sentry.flutter' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.4.10' repositories { google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:4.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -25,20 +25,31 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 28 + compileSdkVersion 30 sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { minSdkVersion 16 + targetSdkVersion 30 + + ndk { + // Flutter does not currently support building for x86 Android (See Issue 9253). + abiFilters("armeabi-v7a", "x86_64", "arm64-v8a") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } - lintOptions { - disable 'InvalidPackage' + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 } } dependencies { - api 'io.sentry:sentry-android:2.3.0' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + api 'io.sentry:sentry-android:3.2.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" } diff --git a/flutter/android/gradle.properties b/flutter/android/gradle.properties index 38c8d4544f..8bd86f6805 100644 --- a/flutter/android/gradle.properties +++ b/flutter/android/gradle.properties @@ -1,4 +1 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true diff --git a/flutter/android/gradle/wrapper/gradle-wrapper.properties b/flutter/android/gradle/wrapper/gradle-wrapper.properties index 01a286e96a..12d38de6a4 100644 --- a/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip diff --git a/flutter/android/src/main/AndroidManifest.xml b/flutter/android/src/main/AndroidManifest.xml index 3e12b37356..b30d1aadaf 100644 --- a/flutter/android/src/main/AndroidManifest.xml +++ b/flutter/android/src/main/AndroidManifest.xml @@ -1,3 +1,11 @@ + xmlns:tools="http://schemas.android.com/tools" + package="io.sentry.flutter"> + + + diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 9f7180fa41..d88d443114 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -7,15 +7,27 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry.Registrar +import io.sentry.android.core.SentryAndroid +import android.content.Context +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SdkVersion +import java.io.File +import java.util.UUID +import java.util.Locale -public class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler { +class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler { private lateinit var channel: MethodChannel + private lateinit var context: Context + private lateinit var options: SentryOptions override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), "sentry_flutter") + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "sentry_flutter") channel.setMethodCallHandler(this) } - + // Should we remove this if we do minSDK flutter >= that? // Required by Flutter Android projects v1.12 and older companion object { @JvmStatic @@ -26,14 +38,167 @@ public class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler { } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else { - result.notImplemented() + when (call.method) { + "initNativeSdk" -> initNativeSdk(call, result) + "captureEnvelope" -> captureEnvelope(call, result) + else -> result.notImplemented() } } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + if (!this::channel.isInitialized) { + return + } + channel.setMethodCallHandler(null) } + + private fun writeEnvelope(envelope: String): Boolean { + if (!this::options.isInitialized || options.outboxPath.isNullOrEmpty()) { + return false + } + + val file = File(options.outboxPath, UUID.randomUUID().toString()) + file.writeText(envelope, Charsets.UTF_8) + + return true + } + + private fun initNativeSdk(call: MethodCall, result: Result) { + if (!this::context.isInitialized) { + result.error("1", "Context is null", null) + return + } + + val args = call.arguments() as Map + if (args.isEmpty()) { + result.error("4", "Arguments is null or empty", null) + return + } + + SentryAndroid.init(context) { options -> + // TODO: check if args exist before assigning the values + + options.dsn = args["dsn"] as String? + options.isDebug = args["debug"] as Boolean + options.environment = args["environment"] as String? + options.release = args["release"] as String? + options.dist = args["dist"] as String? + options.isEnableSessionTracking = args["enableAutoSessionTracking"] as Boolean + options.sessionTrackingIntervalMillis = (args["autoSessionTrackingIntervalMillis"] as Int).toLong() + options.anrTimeoutIntervalMillis = (args["anrTimeoutIntervalMillis"] as Int).toLong() + options.isAttachThreads = false // expose options for Android? + options.isAttachStacktrace = args["attachStacktrace"] as Boolean + + val enableAutoNativeBreadcrumbs = args["enableAutoNativeBreadcrumbs"] as Boolean + options.isEnableActivityLifecycleBreadcrumbs = enableAutoNativeBreadcrumbs + options.isEnableAppLifecycleBreadcrumbs = enableAutoNativeBreadcrumbs + options.isEnableSystemEventBreadcrumbs = enableAutoNativeBreadcrumbs + options.isEnableAppComponentBreadcrumbs = enableAutoNativeBreadcrumbs + + options.maxBreadcrumbs = args["maxBreadcrumbs"] as Int + options.cacheDirSize = args["cacheDirSize"] as Int + + val level = args["diagnosticLevel"] as String + val sentryLevel = SentryLevel.valueOf(level.toUpperCase(Locale.ROOT)) + options.setDiagnosticLevel(sentryLevel) + + val anrEnabled = args["anrEnabled"] as Boolean + options.isEnableNdk = anrEnabled + + val nativeCrashHandling = args["enableNativeCrashHandling"] as Boolean + + // nativeCrashHandling has priority over anrEnabled + if (!nativeCrashHandling) { + options.isEnableUncaughtExceptionHandler = false + options.isAnrEnabled = false + + // if split symbols are enabled, we need Ndk integration so we can't really offer the option + // to turn it off + // options.isEnableNdk = false + } + + options.setBeforeSend { event, _ -> + setEventOriginTag(event) + addPackages(event, options.sdkVersion) + removeThreadsIfNotAndroid(event) + + // TODO: merge debug images from Native + + event + } + + // missing proxy, sendDefaultPii, enableScopeSync + + this.options = options + } + + result.success("") + } + + private fun captureEnvelope(call: MethodCall, result: Result) { + val args = call.arguments() as List + if (args.isNotEmpty()) { + val event = args.first() as String? + + if (!event.isNullOrEmpty()) { + if (!writeEnvelope(event)) { + result.error("3", "SentryOptions or outboxPath are null or empty", null) + } + result.success("") + return + } + } + + result.error("2", "Envelope is null or empty", null) + } + + private val flutterSdk = "sentry.dart.flutter" + private val androidSdk = "sentry.java.android" + private val nativeSdk = "sentry.native" + + private fun setEventOriginTag(event: SentryEvent) { + val sdk = event.sdk + if (isValidSdk(sdk)) { + when (sdk.name) { + flutterSdk -> setEventEnvironmentTag(event, "flutter", "dart") + androidSdk -> setEventEnvironmentTag(event, environment = "java") + nativeSdk -> setEventEnvironmentTag(event, environment = "native") + } + } + } + + private fun setEventEnvironmentTag(event: SentryEvent, origin: String = "android", environment: String) { + event.setTag("event.origin", origin) + event.setTag("event.environment", environment) + } + + private fun isValidSdk(sdk: SdkVersion?): Boolean { + return (sdk != null && !sdk.name.isNullOrEmpty()) + } + + private fun addPackages(event: SentryEvent, sdk: SdkVersion?) { + if (isValidSdk(event.sdk)) { + when (event.sdk.name) { + flutterSdk -> { + sdk?.packages?.forEach { sentryPackage -> + event.sdk.addPackage(sentryPackage.name, sentryPackage.version) + } + sdk?.integrations?.forEach { integration -> + event.sdk.addIntegration(integration) + } + } + } + } + } + + private fun removeThreadsIfNotAndroid(event: SentryEvent) { + if (isValidSdk(event.sdk)) { + // we do not want the thread list if not an android event, the thread info is mostly about + // the file observer anyway + if (event.sdk.name != androidSdk && event.threads != null) { + event.threads.clear() + } + } + } } diff --git a/flutter/example/android/app/build.gradle b/flutter/example/android/app/build.gradle index 14de4b971c..670250090f 100644 --- a/flutter/example/android/app/build.gradle +++ b/flutter/example/android/app/build.gradle @@ -39,20 +39,16 @@ android { jvmTarget = JavaVersion.VERSION_1_8 } - compileSdkVersion 29 + compileSdkVersion 30 sourceSets { main.java.srcDirs += 'src/main/kotlin' } - lintOptions { - disable 'InvalidPackage' - } - defaultConfig { applicationId "io.sentry.flutter.example" minSdkVersion 16 - targetSdkVersion 29 + targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -63,9 +59,13 @@ android { } ndk { - abiFilters("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + // Flutter does not currently support building for x86 Android (See Issue 9253). + abiFilters("armeabi-v7a", "x86_64", "arm64-v8a") } } + + // TODO: we need to fix CI as the version 21.1 (default) is not installed by default on + // GH Actions. ndkVersion "21.3.6528147" externalNativeBuild { @@ -76,6 +76,9 @@ android { buildTypes { release { + // looks like Flutter requires minifyEnabled to be enabled or it throws + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' signingConfig signingConfigs.debug } } @@ -86,9 +89,7 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.work:work-runtime:2.4.0' - implementation 'androidx.work:work-runtime-ktx:2.4.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.annotation:annotation:1.1.0" } diff --git a/flutter/example/android/app/src/main/AndroidManifest.xml b/flutter/example/android/app/src/main/AndroidManifest.xml index a64ae1e8dd..69ebe1a9fe 100644 --- a/flutter/example/android/app/src/main/AndroidManifest.xml +++ b/flutter/example/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ @@ -19,7 +20,8 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:extractNativeLibs="true"> - - - - - - + { - throw Exception("Thrown from Kotlin!") - } - "background" -> { - WorkManager.getInstance(this) - .enqueue( - OneTimeWorkRequestBuilder() - .build() - ) + thread(isDaemon = true) { + throw Exception("Thrown from Kotlin!") + } } "anr" -> { Thread.sleep(6_000) @@ -50,22 +41,16 @@ class MainActivity : FlutterActivity() { result.notImplemented() } } + result.success("") } } - external fun crash(): Unit? - external fun message(): Unit? + private external fun crash() + private external fun message() companion object { init { System.loadLibrary("native-sample") } } - - class BrokenWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { - override fun doWork(): Result { - throw RuntimeException("Kotlin background task") - return Result.success() - } - } } diff --git a/flutter/example/android/build.gradle b/flutter/example/android/build.gradle index 8065b60e75..106a4b20ea 100644 --- a/flutter/example/android/build.gradle +++ b/flutter/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.4.0' + ext.kotlin_version = '1.4.10' repositories { google() jcenter() @@ -7,10 +7,12 @@ buildscript { } dependencies { - classpath 'io.sentry:sentry-android-gradle-plugin:1.7.35' - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'io.sentry:sentry-android-gradle-plugin:1.7.36' + // Flutter is incompatible with AGP 4.1.x yet + // No such property: scope for class: com.android.build.gradle.internal.variant.ApplicationVariantData + classpath 'com.android.tools.build:gradle:4.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.ydq.android.gradle.build.tool:nativeBundle:1.0.6' + classpath 'com.ydq.android.gradle.build.tool:nativeBundle:1.0.7' } } diff --git a/flutter/example/android/gradle.properties b/flutter/example/android/gradle.properties index 38c8d4544f..46ec45ad33 100644 --- a/flutter/example/android/gradle.properties +++ b/flutter/example/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true -android.enableJetifier=true +android.enableR8=true diff --git a/flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/flutter/example/android/gradle/wrapper/gradle-wrapper.properties index a1c144dfec..11a0fed99a 100644 --- a/flutter/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Aug 24 18:39:28 EDT 2020 +#Fri Nov 06 13:45:17 CET 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip diff --git a/flutter/example/android/sentry.properties b/flutter/example/android/sentry.properties index b611bfdf77..011895790f 100644 --- a/flutter/example/android/sentry.properties +++ b/flutter/example/android/sentry.properties @@ -1,5 +1,5 @@ -defaults.project=flutter -defaults.org=sentry-test +defaults.project=sentry-flutter +defaults.org=sentry-sdks #auth.token= set in the file ~/.sentryclirc with the content: #[auth] #token=528be1f... \ No newline at end of file diff --git a/flutter/example/ios/Podfile.lock b/flutter/example/ios/Podfile.lock index ec15b442d5..9ee8ff23cc 100644 --- a/flutter/example/ios/Podfile.lock +++ b/flutter/example/ios/Podfile.lock @@ -1,11 +1,13 @@ PODS: - Flutter (1.0.0) - - Sentry (6.0.1): - - Sentry/Core (= 6.0.1) - - Sentry/Core (6.0.1) + - package_info (0.0.1): + - Flutter + - Sentry (6.0.9): + - Sentry/Core (= 6.0.9) + - Sentry/Core (6.0.9) - sentry_flutter (0.0.1): - Flutter - - Sentry (~> 6.0.1) + - Sentry (~> 6.0.9) DEPENDENCIES: - Flutter (from `Flutter`) @@ -23,8 +25,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: 0e3d915762c693b495b44d77113d4970485de6ec - Sentry: 2130c956d0d6824d4d9aaa98fd55140b1d55c030 - sentry_flutter: 9207b0a464df3e7e501650267d61f0a40670ce2d + package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 + Sentry: 388c9dc093b2fd3a264466a5c5b21e25959610a9 + sentry_flutter: 5526584667efa760b715a0356ca89cf369bfaddb PODFILE CHECKSUM: a75497545d4391e2d394c3668e20cfb1c2bbd4aa diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 472f0796ac..654e526115 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -1,80 +1,29 @@ import 'dart:async'; -import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:universal_platform/universal_platform.dart'; -const String _release = - String.fromEnvironment('SENTRY_RELEASE', defaultValue: 'unknown'); - // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io -const String exampleDsn = +const String _exampleDsn = 'https://cb0fad6f5d4e42ebb9c956cb0463edc9@o447951.ingest.sentry.io/5428562'; -// NOTE: Add your DSN below to get the events in your Sentry project. -final SentryClient _sentry = SentryClient(SentryOptions(dsn: exampleDsn)); - // Proposed init: // https://github.com/bruno-garcia/badges.bar/blob/2450ed9125f7b73d2baad1fa6d676cc71858116c/lib/src/sentry.dart#L9-L32 Future main() async { - // Needs to move into the library - FlutterError.onError = (FlutterErrorDetails details) async { - print('Capture from FlutterError ${details.exception}'); - Zone.current.handleUncaughtError(details.exception, details.stack); - }; - - if (!kIsWeb) { - // Throws when running on the browser - Isolate.current.addSentryErrorListener(_sentry); - } - - runZonedGuarded>(() async { - runApp(MyApp()); - }, (error, stackTrace) async { - print('Capture from runZonedGuarded $error'); - final event = SentryEvent( - throwable: error, - // release is required on Web to match the source maps - release: _release, - - // sdk: const Sdk(name: sdkName, version: sdkVersion), - ); - await _sentry.captureEvent(event, stackTrace: stackTrace); - }); -} - -// Candidate API for the SDK -extension IsolateExtensions on Isolate { - void addSentryErrorListener(SentryClient sentry) { - final receivePort = RawReceivePort( - (dynamic values) async { - await sentry.captureIsolateError(values); - }, - ); - - Isolate.current.addErrorListener(receivePort.sendPort); - } -} - -// Candidate API for the SDK -extension SentryExtensions on SentryClient { - Future captureIsolateError(dynamic error) { - print('Capture from IsolateError $error'); - - if (error is List && error.length != 2) { - dynamic stackTrace = error[1]; - if (stackTrace != null) { - stackTrace = StackTrace.fromString(stackTrace as String); - } - return captureException(error[0], stackTrace: stackTrace); - } else { - return Future.value(); - } - } + await SentryFlutter.init( + (options) { + options.dsn = _exampleDsn; + // Change the 'sentry_flutter_example' below with your own package. + options.addInAppInclude('sentry_flutter_example'); + }, + () { + // Init your App. + runApp(MyApp()); + }, + ); } class MyApp extends StatefulWidget { @@ -83,32 +32,9 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - String _platformVersion = 'Unknown'; - @override void initState() { super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - try { - platformVersion = await SentryFlutter.platformVersion; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) { - return; - } - - setState(() => _platformVersion = platformVersion); } @override @@ -118,8 +44,11 @@ class _MyAppState extends State { appBar: AppBar(title: const Text('Sentry Flutter Example')), body: Column( children: [ - Center(child: Text('Running on: $_platformVersion\n')), - const Center(child: Text('Release: $_release\n')), + const Center(child: Text('Trigger an action:\n')), + RaisedButton( + child: const Text('Dart: try catch'), + onPressed: () => tryCatch(), + ), RaisedButton( child: const Text('Dart: throw null'), // Warning : not captured if a debugger is attached @@ -135,22 +64,21 @@ class _MyAppState extends State { assert(false, 'assert failure'); }, ), + RaisedButton( + child: const Text('Dart: async throws'), + onPressed: () async => asyncThrows().catchError(handleError)), RaisedButton( child: const Text('Dart: Fail in microtask.'), onPressed: () async => { await Future.microtask( () => throw StateError('Failure in a microtask'), - ) + ).catchError(handleError) }, ), RaisedButton( - child: const Text('Dart: Fail in isolate'), - onPressed: () async => { - await compute( - (Object _) => throw StateError('from an isolate'), - null, - ) - }, + child: const Text('Dart: Fail in compute'), + onPressed: () async => + {await compute(loop, 10).catchError(handleError)}, ), if (UniversalPlatform.isIOS) const CocoaExample(), if (UniversalPlatform.isAndroid) const AndroidExample(), @@ -165,53 +93,68 @@ class _MyAppState extends State { class AndroidExample extends StatelessWidget { const AndroidExample({Key key}) : super(key: key); + final channel = const MethodChannel('example.flutter.sentry.io'); + @override Widget build(BuildContext context) { return Column(children: [ RaisedButton( child: const Text('Kotlin Throw unhandled exception'), onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('throw'); + await execute('throw'); }, ), RaisedButton( child: const Text('Kotlin Capture Exception'), onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('capture'); - }, - ), - RaisedButton( - child: const Text('Kotlin Background thread error'), - onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('background'); + await execute('capture'); }, ), RaisedButton( + // ANR is disabled by default, enable it to test it child: const Text('ANR: UI blocked 6 seconds'), onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('anr'); + await execute('anr'); }, ), RaisedButton( child: const Text('C++ Capture message'), onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('cpp_capture_message'); + await execute('cpp_capture_message'); }, ), RaisedButton( child: const Text('C++ SEGFAULT'), onPressed: () async { - const channel = MethodChannel('example.flutter.sentry.io'); - await channel.invokeMethod('crash'); + await execute('crash'); }, ), ]); } + + Future execute(String method) async { + try { + await channel.invokeMethod(method); + } catch (error, stackTrace) { + await Sentry.captureException(error, stackTrace: stackTrace); + } + } +} + +Future tryCatch() async { + try { + throw StateError('try catch'); + } catch (error, stackTrace) { + await Sentry.captureException(error, stackTrace: stackTrace); + } +} + +Future handleError(dynamic error, dynamic stackTrace) async { + await Sentry.captureException(error, stackTrace: stackTrace); +} + +Future asyncThrows() async { + throw StateError('async throws'); } class CocoaExample extends StatelessWidget { @@ -279,3 +222,14 @@ class WebExample extends StatelessWidget { ); } } + +/// compute can only take a top-level function, but not instance or static methods. +// Top-level functions are functions declared not inside a class and not inside another function +int loop(int val) { + int count = 0; + for (int i = 1; i <= val; i++) { + count += i; + } + + throw StateError('from a compute isolate $count'); +} diff --git a/flutter/example/pubspec.lock b/flutter/example/pubspec.lock deleted file mode 100644 index 3a434bcf59..0000000000 --- a/flutter/example/pubspec.lock +++ /dev/null @@ -1,215 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.0-nullsafety.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.1" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.3" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.1" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0-nullsafety.3" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.5" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.2" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.4" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.10-nullsafety.1" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.3" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.1" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.0" - sentry: - dependency: transitive - description: - path: "../../dart" - relative: true - source: path - version: "4.0.0" - sentry_flutter: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0-nullsafety.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.19-nullsafety.2" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.3" - universal_platform: - dependency: "direct main" - description: - name: universal_platform - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.3" - uuid: - dependency: transitive - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.2" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.3" -sdks: - dart: ">=2.10.0-110 <2.11.0" - flutter: ">=1.17.0 <2.0.0" diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index c99dd76b69..673d5a6151 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -5,7 +5,7 @@ version: 0.1.2+3 publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.8.0 <3.0.0" dependencies: flutter: diff --git a/flutter/example/run.sh b/flutter/example/run.sh index 5b731077d1..d8731f4839 100755 --- a/flutter/example/run.sh +++ b/flutter/example/run.sh @@ -3,9 +3,9 @@ set -e # Build a release version of the app for a platform and upload symbols -export SENTRY_PROJECT=flutter -export SENTRY_ORG=sentry-test -# export SENTRY_LOG_LEVEL=debug +export SENTRY_PROJECT=sentry-flutter +export SENTRY_ORG=sentry-sdks +export SENTRY_LOG_LEVEL=info export OUTPUT_FOLDER_WEB=./build/web/ export SENTRY_RELEASE=$(date +%Y-%m-%d_%H-%M-%S) diff --git a/flutter/ios/Classes/SwiftSentryFlutterPlugin.swift b/flutter/ios/Classes/SwiftSentryFlutterPlugin.swift index b33a1567ef..6d79e37a8e 100644 --- a/flutter/ios/Classes/SwiftSentryFlutterPlugin.swift +++ b/flutter/ios/Classes/SwiftSentryFlutterPlugin.swift @@ -3,6 +3,9 @@ import Sentry import UIKit public class SwiftSentryFlutterPlugin: NSObject, FlutterPlugin { + + private var sentryOptions: Options? + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "sentry_flutter", binaryMessenger: registrar.messenger()) let instance = SwiftSentryFlutterPlugin() @@ -10,20 +13,233 @@ public class SwiftSentryFlutterPlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result("iOS " + UIDevice.current.systemVersion) + switch call.method as String { + case "loadContexts": + loadContexts(result: result) + + case "initNativeSdk": + initNativeSdk(call, result: result) + + case "captureEnvelope": + captureEnvelope(call, result: result) + + default: + result(FlutterMethodNotImplemented) + } } - override init() { - if let path = Bundle.main.path(forResource: "Info", ofType: "plist") { - if let resource = NSDictionary(contentsOfFile: path) { - if let dsn = resource.object(forKey: "SentryDsn") { - SentrySDK.start { options in - options.dsn = dsn as? String - options.debug = resource["SentryDebug"] as? Bool ?? false - options.attachStacktrace = true + private func loadContexts(result: @escaping FlutterResult) { + SentrySDK.configureScope { scope in + let serializedScope = scope.serialize() + let context = serializedScope["context"] + + var infos = ["contexts": context] + + if let integrations = self.sentryOptions?.integrations { + infos["integrations"] = integrations + } + + // TODO get the sdkVersion from SDK + infos["package"] = ["version": "6.0.9", "sdk_name": "cocoapods:sentry-cocoa"] + + result(infos) + } + } + + private func initNativeSdk(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any], !arguments.isEmpty else { + print("Arguments is null or empty") + result(FlutterError(code: "4", message: "Arguments is null or empty", details: nil)) + return + } + + SentrySDK.start { options in + if let dsn = arguments["dsn"] as? String { + options.dsn = dsn + } + + if let isDebug = arguments["debug"] as? Bool { + options.debug = isDebug + } + + if let environment = arguments["environment"] as? String { + options.environment = environment + } + + if let releaseName = arguments["release"] as? String { + options.releaseName = releaseName + } + + if let enableAutoSessionTracking = arguments["enableAutoSessionTracking"] as? Bool { + options.enableAutoSessionTracking = enableAutoSessionTracking + } + + if let attachStacktrace = arguments["attachStacktrace"] as? Bool { + options.attachStacktrace = attachStacktrace + } + + if let diagnosticLevel = arguments["diagnosticLevel"] as? String, options.debug == true { + options.logLevel = self.logLevelFrom(diagnosticLevel: diagnosticLevel) + } + + if let sessionTrackingIntervalMillis = arguments["sessionTrackingIntervalMillis"] as? UInt { + options.sessionTrackingIntervalMillis = sessionTrackingIntervalMillis + } + + if let dist = arguments["dist"] as? String { + options.dist = dist + } + + if let enableAutoNativeBreadcrumbs = arguments["enableAutoNativeBreadcrumbs"] as? Bool, + enableAutoNativeBreadcrumbs == false { + options.integrations = options.integrations?.filter { (name) -> Bool in + name != "SentryAutoBreadcrumbTrackingIntegration" + } + } + + if let maxBreadcrumbs = arguments["maxBreadcrumbs"] as? UInt { + options.maxBreadcrumbs = maxBreadcrumbs + } + + self.sentryOptions = options + + // note : for now, in sentry-cocoa, beforeSend is not called before captureEnvelope + options.beforeSend = { event in + self.setEventOriginTag(event: event) + + if var sdk = event.sdk, self.isValidSdk(sdk: sdk) { + if let packages = arguments["packages"] as? [String] { + if var sdkPackages = sdk["packages"] as? [String] { + sdk["packages"] = sdkPackages.append(contentsOf: packages) + } else { + sdk["packages"] = packages + } } + + if let integrations = arguments["integrations"] as? [String] { + if var sdkIntegrations = sdk["integrations"] as? [String] { + sdk["integrations"] = sdkIntegrations.append(contentsOf: integrations) + } else { + sdk["integrations"] = integrations + } + } + event.sdk = sdk } + + return event + } + } + + result("") + } + + private func logLevelFrom(diagnosticLevel: String) -> SentryLogLevel { + switch diagnosticLevel { + case "fatal", "error": + return .error + case "debug": + return .debug + case "warning", "info": + return .verbose + default: + return .none + } + } + + private func setEventOriginTag(event: Event) { + guard let sdk = event.sdk else { + return + } + if self.isValidSdk(sdk: sdk) { + + switch sdk["name"] as? String { + case "sentry.dart.flutter": + setEventEnvironmentTag(event: event, origin: "flutter", environment: "dart") + case "sentry.cocoa": + setEventEnvironmentTag(event: event, origin: "flutter", environment: "dart") + case "sentry.native": + setEventEnvironmentTag(event: event, origin: "flutter", environment: "dart") + default: + return + } + } + } + + private func setEventEnvironmentTag(event: Event, origin: String = "ios", environment: String) { + event.tags?["event.origin"] = origin + event.tags?["event.environment"] = environment + } + + private func isValidSdk(sdk: [String: Any]) -> Bool { + guard let name = sdk["name"] as? String else { + return false + } + return !name.isEmpty + } + + private func captureEnvelope(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [Any], + !arguments.isEmpty, + let event = arguments.first as? String else { + print("Envelope is null or empty !") + result(FlutterError(code: "2", message: "Envelope is null or empty", details: nil)) + return + } + + do { + let envelope = try parseJsonEnvelope(event) + + SentrySDK.currentHub().getClient()?.capture(envelope: envelope) + result("") + } catch { + print("Cannot parse the envelope json !") + result(FlutterError(code: "3", message: "Cannot parse the envelope json", details: nil)) + return + } + } + + private func parseJsonEnvelope(_ data: String) throws -> SentryEnvelope { + let parts = data.split(separator: "\n") + + let envelopeParts: [[String: Any]] = try parts.map({ part in + guard let dict = parseJson(text: "\(part)") else { + throw NSError() } + return dict + }) + + let rawEnvelopeHeader = envelopeParts[0] + guard let eventId = rawEnvelopeHeader["event_id"] as? String, + let itemType = envelopeParts[1]["type"] as? String else { + throw NSError() + } + + let sdkInfo = SentrySdkInfo(dict: rawEnvelopeHeader) + let sentryId = SentryId(uuidString: eventId) + let envelopeHeader = SentryEnvelopeHeader.init(id: sentryId, andSdkInfo: sdkInfo) + + let payload = envelopeParts[2] + + let data = try JSONSerialization.data(withJSONObject: payload, options: .init(rawValue: 0)) + + let itemHeader = SentryEnvelopeItemHeader(type: itemType, length: UInt(data.count)) + let sentryItem = SentryEnvelopeItem(header: itemHeader, data: data) + + return SentryEnvelope.init(header: envelopeHeader, singleItem: sentryItem) + } + + func parseJson(text: String) -> [String: Any]? { + guard let data = text.data(using: .utf8) else { + print("Invalid UTF8 String : \(text)") + return nil + } + + do { + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + return json + } catch { + print("json parsing error !") } + return nil } } diff --git a/flutter/ios/sentry_flutter.podspec b/flutter/ios/sentry_flutter.podspec index afd9d09cd1..afabd4c9b7 100644 --- a/flutter/ios/sentry_flutter.podspec +++ b/flutter/ios/sentry_flutter.podspec @@ -11,7 +11,7 @@ Sentry SDK for Flutter with support to native through sentry-cocoa. s.source = { :git => "https://github.com/getsentry/sentry-dart.git", :tag => s.version.to_s } s.source_files = 'Classes/**/*' - s.dependency 'Sentry', '~> 6.0.1' + s.dependency 'Sentry', '~> 6.0.9' s.dependency 'Flutter' s.platform = :ios, '9.0' diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index ef83049c0b..ba6831738d 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -1,12 +1,5 @@ -import 'dart:async'; +/// A Flutter client for Sentry.io crash reporting. +export 'package:sentry/sentry.dart'; -import 'package:flutter/services.dart'; - -mixin SentryFlutter { - static const _channel = MethodChannel('sentry_flutter'); - - static Future get platformVersion async { - final String version = await _channel.invokeMethod('getPlatformVersion'); - return version; - } -} +export 'src/default_integrations.dart'; +export 'src/sentry_flutter.dart'; diff --git a/flutter/lib/sentry_flutter_web.dart b/flutter/lib/sentry_flutter_web.dart index ca4cd4272c..9de925f236 100644 --- a/flutter/lib/sentry_flutter_web.dart +++ b/flutter/lib/sentry_flutter_web.dart @@ -1,9 +1,4 @@ import 'dart:async'; -// In order to *not* need this ignore, consider extracting the "web" version -// of your plugin as a separate package, instead of inlining it in the same -// package as the core of your plugin. -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html show window; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -25,22 +20,6 @@ class SentryFlutterWeb { /// Note: Check the "federated" architecture for a new way of doing this: /// https://flutter.dev/go/federated-plugins Future handleMethodCall(MethodCall call) async { - switch (call.method) { - case 'getPlatformVersion': - return getPlatformVersion(); - break; - default: - throw PlatformException( - code: 'Unimplemented', - details: - 'sentry_flutter for web doesn\'t implement \'${call.method}\'', - ); - } - } - - /// Returns a [String] containing the version of the platform. - Future getPlatformVersion() { - final version = html.window.navigator.userAgent; - return Future.value(version); + return ''; } } diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart new file mode 100644 index 0000000000..490ac98307 --- /dev/null +++ b/flutter/lib/src/default_integrations.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; + +/// integration that capture errors on the current Isolate Error handler +/// which is the main thread. +void isolateErrorIntegration(Hub hub, SentryOptions options) { + final receivePort = _createPort(hub, options); + + Isolate.current.addErrorListener(receivePort.sendPort); + + options.sdk.addIntegration('isolateErrorIntegration'); +} + +RawReceivePort _createPort(Hub hub, SentryOptions options) { + return RawReceivePort( + (dynamic error) async { + await handleIsolateError(hub, options, error); + }, + ); +} + +/// Parse and raise an event out of the Isolate error. +/// Visible for testing. +Future handleIsolateError( + Hub hub, + SentryOptions options, + dynamic error, +) async { + options.logger(SentryLevel.debug, 'Capture from IsolateError $error'); + + // https://api.dartlang.org/stable/2.7.0/dart-isolate/Isolate/addErrorListener.html + // error is a list of 2 elements + if (error is List && error.length == 2) { + final dynamic throwable = error.first; + final dynamic stackTrace = error.last; + + // Isolate errors don't crash the App. + const mechanism = Mechanism(type: 'isolateError', handled: true); + final throwableMechanism = ThrowableMechanism(mechanism, throwable); + final event = SentryEvent( + throwable: throwableMechanism, + level: SentryLevel.fatal, + ); + + await hub.captureEvent(event, stackTrace: stackTrace); + } +} + +/// integration that capture errors on the FlutterError handler +void flutterErrorIntegration(Hub hub, SentryOptions options) { + final defaultOnError = FlutterError.onError; + + FlutterError.onError = (FlutterErrorDetails errorDetails) async { + options.logger( + SentryLevel.debug, 'Capture from onError ${errorDetails.exception}'); + + // FlutterError doesn't crash the App. + const mechanism = Mechanism(type: 'FlutterError', handled: true); + final throwableMechanism = + ThrowableMechanism(mechanism, errorDetails.exception); + + final event = SentryEvent( + throwable: throwableMechanism, + level: SentryLevel.fatal, + ); + + await hub.captureEvent(event, stackTrace: errorDetails.stack); + + // call original handler + if (defaultOnError != null) { + defaultOnError(errorDetails); + } + + // we don't call Zone.current.handleUncaughtError because we'd like + // to set a specific mechanism for FlutterError.onError. + }; + + options.sdk.addIntegration('flutterErrorIntegration'); +} + +/// integration that capture errors on the runZonedGuarded error handler +Integration runZonedGuardedIntegration( + Function callback, +) { + void integration(Hub hub, SentryOptions options) { + runZonedGuarded(() { + callback(); + }, (exception, stackTrace) async { + // runZonedGuarded doesn't crash the App. + const mechanism = Mechanism(type: 'runZonedGuarded', handled: true); + final throwableMechanism = ThrowableMechanism(mechanism, exception); + + final event = SentryEvent( + throwable: throwableMechanism, + level: SentryLevel.fatal, + ); + + await hub.captureEvent(event, stackTrace: stackTrace); + }); + + options.sdk.addIntegration('runZonedGuardedIntegration'); + } + + return integration; +} + +/// (iOS only) +/// add an event processor to call a native channel method to load : +/// - the device Contexts, +/// - and the native sdk integrations and packages +/// +Integration loadContextsIntegration( + SentryOptions options, + MethodChannel channel, +) { + Future integration(Hub hub, SentryOptions options) async { + options.addEventProcessor( + (event, dynamic hint) async { + try { + final Map infos = Map.from( + await channel.invokeMethod('loadContexts'), + ); + if (infos['contexts'] != null) { + final contexts = Contexts.fromJson( + Map.from(infos['contexts'] as Map), + ); + final eventContexts = event.contexts.clone(); + + contexts.forEach( + (key, dynamic value) { + if (value != null) { + if (key == SentryRuntime.listType) { + contexts.runtimes.forEach(eventContexts.addRuntime); + } else if (eventContexts[key] == null) { + eventContexts[key] = value; + } + } + }, + ); + event = event.copyWith(contexts: eventContexts); + } + + if (infos['integrations'] != null) { + final integrations = + List.from(infos['integrations'] as List); + final sdk = event.sdk ?? options.sdk; + integrations.forEach(sdk.addIntegration); + event = event.copyWith(sdk: sdk); + } + + if (infos['package'] != null) { + final package = Map.from(infos['package'] as Map); + final sdk = event.sdk ?? options.sdk; + sdk.addPackage(package['name'], package['version']); + event = event.copyWith(sdk: sdk); + } + } catch (error) { + options.logger( + SentryLevel.error, + 'loadContextsIntegration failed : $error', + ); + } + + return event; + }, + ); + + options.sdk.addIntegration('loadContextsIntegration'); + } + + return integration; +} + +Integration nativeSdkIntegration(SentryOptions options, MethodChannel channel) { + Future integration(Hub hub, SentryOptions options) async { + 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, + 'autoSessionTrackingIntervalMillis': + options.autoSessionTrackingIntervalMillis, + '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.anrTimeoutIntervalMillis, + 'enableAutoNativeBreadcrumbs': options.enableAutoNativeBreadcrumbs, + 'cacheDirSize': options.cacheDirSize, + }); + + options.sdk.addIntegration('nativeSdkIntegration'); + } catch (error) { + options.logger( + SentryLevel.fatal, + 'nativeSdkIntegration failed to be installed: $error', + ); + } + } + + return integration; +} diff --git a/flutter/lib/src/file_system_transport.dart b/flutter/lib/src/file_system_transport.dart new file mode 100644 index 0000000000..1eff3f758d --- /dev/null +++ b/flutter/lib/src/file_system_transport.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; + +class FileSystemTransport implements Transport { + FileSystemTransport(this._channel, this._options); + + final MethodChannel _channel; + final SentryOptions _options; + + @override + Future send(SentryEvent event) async { + final headerMap = { + 'event_id': event.eventId.toString(), + 'sdk': _options.sdk.toJson() + }; + + final eventMap = event.toJson(); + + final eventString = jsonEncode(eventMap); + + final itemHeaderMap = { + 'content_type': 'application/json', + 'type': 'event', + 'length': eventString.length, + }; + + final headerString = jsonEncode(headerMap); + final itemHeaderString = jsonEncode(itemHeaderMap); + final envelopeString = '$headerString\n$itemHeaderString\n$eventString'; + + final args = [envelopeString]; + try { + await _channel.invokeMethod('captureEnvelope', args); + } catch (error) { + _options.logger( + SentryLevel.error, + 'Failed to save envelope: $error', + ); + return SentryId.empty(); + } + + return event.eventId; + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart new file mode 100644 index 0000000000..cdb891bf6e --- /dev/null +++ b/flutter/lib/src/sentry_flutter.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:package_info/package_info.dart'; +import 'package:sentry/sentry.dart'; + +import 'default_integrations.dart'; +import 'file_system_transport.dart'; +import 'version.dart'; + +mixin SentryFlutter { + static const _channel = MethodChannel('sentry_flutter'); + + static Future init( + OptionsConfiguration optionsConfiguration, + Function callback, { + PackageLoader packageLoader = _loadPackageInfo, + }) async { + await Sentry.init((options) async { + await _initDefaultValues(options, callback, packageLoader); + + await optionsConfiguration(options); + }); + } + + static Future _initDefaultValues( + SentryOptions options, + Function callback, + PackageLoader packageLoader, + ) async { + // it is necessary to initialize Flutter method channels so that + // our plugin can call into the native code. + WidgetsFlutterBinding.ensureInitialized(); + + options.debug = kDebugMode; + + // web still uses a http transport for Web which is set by default + if (!kIsWeb) { + options.transport = FileSystemTransport(_channel, options); + } + + // if no environment is set, we set 'production' by default, but if we know it's + // a non-release build, or the SENTRY_ENVIRONMENT is set, we read from it. + if (const bool.hasEnvironment('SENTRY_ENVIRONMENT') || !kReleaseMode) { + options.environment = const String.fromEnvironment('SENTRY_ENVIRONMENT', + defaultValue: 'debug'); + } + + // if the SENTRY_DSN is set, we read from it. + options.dsn = const bool.hasEnvironment('SENTRY_DSN') + ? const String.fromEnvironment('SENTRY_DSN') + : options.dsn; + + // TODO: load debug images when split symbols are enabled. + + // first step is to install the native integration and set default values, + // so we are able to capture future errors. + _addDefaultIntegrations(options, callback); + + await _setReleaseAndDist(options, packageLoader); + + _setSdk(options); + } + + static Future _setReleaseAndDist( + SentryOptions options, + PackageLoader packageLoader, + ) async { + try { + if (!kIsWeb) { + if (packageLoader == null) { + options.logger(SentryLevel.debug, 'Package loader is null.'); + return; + } + final packageInfo = await packageLoader(); + final release = + '${packageInfo.packageName}@${packageInfo.version}+${packageInfo.buildNumber}'; + options.logger(SentryLevel.debug, 'release: $release'); + + options.release = release; + options.dist = packageInfo.buildNumber; + } else { + // for non-mobile builds, we read the release and dist from the + // system variables (SENTRY_RELEASE and SENTRY_DIST). + options.release = const bool.hasEnvironment('SENTRY_RELEASE') + ? const String.fromEnvironment('SENTRY_RELEASE') + : options.release; + options.dist = const bool.hasEnvironment('SENTRY_DIST') + ? const String.fromEnvironment('SENTRY_DIST') + : options.dist; + } + } catch (error) { + options.logger( + SentryLevel.error, 'Failed to load release and dist: $error'); + } + } + + /// Install default integrations + /// https://medium.com/flutter-community/error-handling-in-flutter-98fce88a34f0 + static void _addDefaultIntegrations( + SentryOptions options, + Function callback, + ) { + // the ordering here matters, as we'd like to first start the native integration + // that allow us to send events to the network and then the Flutter integrations. + // Flutter Web doesn't need that, only Android and iOS. + if (!kIsWeb) { + options.addIntegration(nativeSdkIntegration(options, _channel)); + } + + // will catch any errors that may occur in the Flutter framework itself. + options.addIntegration(flutterErrorIntegration); + + // Throws when running on the browser + if (!kIsWeb) { + // catch any errors that may occur within the entry function, main() + // in the ‘root zone’ where all Dart programs start + options.addIntegration(isolateErrorIntegration); + } + + // TODO: make it testable/mockable + if (Platform.isIOS) { + options.addIntegration(loadContextsIntegration(options, _channel)); + } + // finally the runZonedGuarded, catch any errors in Dart code running + // ‘outside’ the Flutter framework + options.addIntegration(runZonedGuardedIntegration(callback)); + } + + static void _setSdk(SentryOptions options) { + // overwrite sdk info with current flutter sdk + final sdk = SdkVersion( + name: sdkName, + version: sdkVersion, + integrations: List.from(options.sdk.integrations), + packages: List.from(options.sdk.packages), + ); + sdk.addPackage('pub:sentry_flutter', sdkVersion); + options.sdk = sdk; + } +} + +typedef PackageLoader = Future Function(); + +/// Package info loader. +Future _loadPackageInfo() async { + return await PackageInfo.fromPlatform(); +} diff --git a/flutter/lib/src/version.dart b/flutter/lib/src/version.dart new file mode 100644 index 0000000000..f2d427030f --- /dev/null +++ b/flutter/lib/src/version.dart @@ -0,0 +1,5 @@ +/// The SDK version reported to Sentry.io in the submitted events. +const String sdkVersion = '4.0.0-alpha.2'; + +/// The default SDK name reported to Sentry.io in the submitted events. +const String sdkName = 'sentry.dart.flutter'; diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock deleted file mode 100644 index d67d979e37..0000000000 --- a/flutter/pubspec.lock +++ /dev/null @@ -1,201 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.0-nullsafety.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.1" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.3" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.1" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0-nullsafety.3" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.5" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.2" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.4" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.10-nullsafety.1" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.3" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.1" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.0" - sentry: - dependency: "direct main" - description: - path: "../dart" - relative: true - source: path - version: "4.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0-nullsafety.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.19-nullsafety.2" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.3" - uuid: - dependency: transitive - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.2" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.3" -sdks: - dart: ">=2.10.0-110 <2.11.0" - flutter: ">=1.17.0 <2.0.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 2731c9a194..ce01d7fdb7 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,11 +1,11 @@ name: sentry_flutter +version: 4.0.0-alpha.2 description: Sentry SDK for Flutter. This package aims to support different Flutter targets by relying on the many platforms supported by Sentry with native SDKs. -version: 0.0.1 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart environment: - sdk: ">=2.0.0 <3.0.0" + sdk: ">=2.8.0 <3.0.0" flutter: ^1.17.0 dependencies: @@ -13,12 +13,17 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - sentry: #^4.0.0 uncomment before publishing + sentry: #4.0.0-alpha.2 + # TODO: we need the specific version for publishing it, but the relative path for development. + # Figure out how to do it via Craft for releases. path: ../dart + package_info: ^0.4.0 dev_dependencies: flutter_test: sdk: flutter + mockito: ^4.1.1 + test: ^1.15.4 flutter: plugin: diff --git a/flutter/test/default_integrations_test.dart b/flutter/test/default_integrations_test.dart new file mode 100644 index 0000000000..fd5c841b38 --- /dev/null +++ b/flutter/test/default_integrations_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'mocks.dart'; + +void main() { + const MethodChannel _channel = MethodChannel('sentry_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + _channel.setMockMethodCallHandler(null); + }); + + test('FlutterError capture errors', () async { + // replace default error otherwise it fails on testing + FlutterError.onError = (FlutterErrorDetails errorDetails) async {}; + + flutterErrorIntegration(fixture.hub, fixture.options); + + final throwable = StateError('error'); + final details = FlutterErrorDetails(exception: throwable); + FlutterError.reportError(details); + + final event = verify( + await fixture.hub.captureEvent(captureAny), + ).captured.first as SentryEvent; + + expect(SentryLevel.fatal, event.level); + + final throwableMechanism = event.throwable as ThrowableMechanism; + expect('FlutterError', throwableMechanism.mechanism.type); + expect(true, throwableMechanism.mechanism.handled); + expect(throwable, throwableMechanism.throwable); + }); + + test('FlutterError calls default error', () async { + var called = false; + final defaultError = (FlutterErrorDetails errorDetails) async { + called = true; + }; + FlutterError.onError = defaultError; + + flutterErrorIntegration(fixture.hub, fixture.options); + + final throwable = StateError('error'); + final details = FlutterErrorDetails(exception: throwable); + FlutterError.reportError(details); + + verify( + await fixture.hub.captureEvent(captureAny), + ).captured.first as SentryEvent; + + expect(true, called); + }); + + test('FlutterError adds integration', () async { + flutterErrorIntegration(fixture.hub, fixture.options); + + expect(true, + fixture.options.sdk.integrations.contains('flutterErrorIntegration')); + }); + + test('Isolate error adds integration', () async { + isolateErrorIntegration(fixture.hub, fixture.options); + + expect(true, + fixture.options.sdk.integrations.contains('isolateErrorIntegration')); + }); + + test('Isolate error capture errors', () async { + final throwable = StateError('error'); + final stackTrace = StackTrace.current; + final error = [throwable, stackTrace]; + + // we could not find a way to trigger an error to the current Isolate + // and unit test its error handling, so instead we exposed the method, + // that handles and captures it. + await handleIsolateError(fixture.hub, fixture.options, error); + + final event = verify( + await fixture.hub + .captureEvent(captureAny, stackTrace: captureAnyNamed('stackTrace')), + ).captured.first as SentryEvent; + + expect(SentryLevel.fatal, event.level); + + final throwableMechanism = event.throwable as ThrowableMechanism; + expect('isolateError', throwableMechanism.mechanism.type); + expect(true, throwableMechanism.mechanism.handled); + expect(throwable, throwableMechanism.throwable); + }); + + test('Run zoned guarded adds integration', () async { + isolateErrorIntegration(fixture.hub, fixture.options); + + void callback() {} + final integration = runZonedGuardedIntegration(callback); + + await integration(fixture.hub, fixture.options); + + expect( + true, + fixture.options.sdk.integrations + .contains('runZonedGuardedIntegration')); + }); + + test('Run zoned guarded calls callback', () async { + isolateErrorIntegration(fixture.hub, fixture.options); + + var called = false; + void callback() { + called = true; + } + + final integration = runZonedGuardedIntegration(callback); + + await integration(fixture.hub, fixture.options); + + expect(true, called); + }); + + test('Run zoned guarded calls catches error', () async { + final throwable = StateError('error'); + void callback() { + throw throwable; + } + + final integration = runZonedGuardedIntegration(callback); + await integration(fixture.hub, fixture.options); + + final event = verify( + await fixture.hub + .captureEvent(captureAny, stackTrace: captureAnyNamed('stackTrace')), + ).captured.first as SentryEvent; + + expect(SentryLevel.fatal, event.level); + + final throwableMechanism = event.throwable as ThrowableMechanism; + expect('runZonedGuarded', throwableMechanism.mechanism.type); + expect(true, throwableMechanism.mechanism.handled); + expect(throwable, throwableMechanism.throwable); + }); + + test('nativeSdkIntegration adds integration', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + + final integration = nativeSdkIntegration(fixture.options, _channel); + + await integration(fixture.hub, fixture.options); + + expect(true, + fixture.options.sdk.integrations.contains('nativeSdkIntegration')); + }); + + test('nativeSdkIntegration do not throw', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + throw null; + }); + + final integration = nativeSdkIntegration(fixture.options, _channel); + + await integration(fixture.hub, fixture.options); + + expect(false, + fixture.options.sdk.integrations.contains('nativeSdkIntegration')); + }); + + test('loadContextsIntegration adds integration on ios', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + + final integration = loadContextsIntegration(fixture.options, _channel); + + await integration(fixture.hub, fixture.options); + + expect(true, + fixture.options.sdk.integrations.contains('loadContextsIntegration')); + }); + + test('loadContextsIntegration do not throw', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + throw null; + }); + + final integration = loadContextsIntegration(fixture.options, _channel); + + await integration(fixture.hub, fixture.options); + + expect(true, + fixture.options.sdk.integrations.contains('loadContextsIntegration')); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryOptions(); +} diff --git a/flutter/test/file_system_transport_test.dart b/flutter/test/file_system_transport_test.dart new file mode 100644 index 0000000000..0892c4493d --- /dev/null +++ b/flutter/test/file_system_transport_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/src/file_system_transport.dart'; + +void main() { + const MethodChannel _channel = MethodChannel('sentry_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + _channel.setMockMethodCallHandler(null); + }); + + test('FileSystemTransport wont throw', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + + final transport = fixture.getSut(_channel); + final event = SentryEvent(); + + final sentryId = await transport.send(event); + + expect(sentryId, sentryId); + }); + + test('FileSystemTransport returns emptyId if channel throws', () async { + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + throw null; + }); + + final transport = fixture.getSut(_channel); + + final sentryId = await transport.send(SentryEvent()); + + expect(SentryId.empty(), sentryId); + }); + + test('FileSystemTransport asserts the event', () async { + dynamic arguments; + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + arguments = methodCall.arguments; + }); + + final transport = fixture.getSut(_channel); + + final event = SentryEvent(); + await transport.send(event); + + final envelopeList = arguments as List; + final envelopeString = envelopeList.first as String; + final lines = envelopeString.split('\n'); + final envelopeHeader = lines.first; + final itemHeader = lines[1]; + final item = lines[2]; + + final envelopeHeaderMap = + jsonDecode(envelopeHeader) as Map; + expect(event.eventId.toString(), envelopeHeaderMap['event_id']); + + // just checking its there, the sdk serialization is already unit tested on + // the dart module + expect(envelopeHeaderMap.containsKey('sdk'), isNotNull); + + final itemHeaderMap = jsonDecode(itemHeader) as Map; + + final eventString = jsonEncode(event.toJson()); + + expect('application/json', itemHeaderMap['content_type']); + expect('event', itemHeaderMap['type']); + expect(eventString.length, itemHeaderMap['length']); + + expect(item, eventString); + }); +} + +class Fixture { + FileSystemTransport getSut(MethodChannel channel) { + final options = SentryOptions(); + return FileSystemTransport(channel, options); + } +} diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart new file mode 100644 index 0000000000..0bb963738a --- /dev/null +++ b/flutter/test/mocks.dart @@ -0,0 +1,6 @@ +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; + +class MockHub extends Mock implements Hub {} + +const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index d31743f406..12a0fbcaa9 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -1,23 +1,41 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info/package_info.dart'; +import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'sentry_flutter_util.dart'; + void main() { - const MethodChannel channel = MethodChannel('sentry_flutter'); + const MethodChannel _channel = MethodChannel('sentry_flutter'); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); + _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); }); tearDown(() { - channel.setMockMethodCallHandler(null); + _channel.setMockMethodCallHandler(null); + Sentry.close(); }); - test('getPlatformVersion', () async { - expect(await SentryFlutter.platformVersion, '42'); + test('Flutter init for mobile will run default configurations', () async { + await SentryFlutter.init( + configurationTester, + callback, + packageLoader: loadTestPackage, + ); }); } + +void callback() {} + +Future loadTestPackage() async { + return PackageInfo( + appName: 'appName', + packageName: 'packageName', + version: 'version', + buildNumber: 'buildNumber', + ); +} diff --git a/flutter/test/sentry_flutter_util.dart b/flutter/test/sentry_flutter_util.dart new file mode 100644 index 0000000000..d9f9fe3c28 --- /dev/null +++ b/flutter/test/sentry_flutter_util.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/file_system_transport.dart'; +import 'package:sentry_flutter/src/version.dart'; +import 'mocks.dart'; + +FutureOr configurationTester( + SentryOptions options, { + bool isWeb = false, +}) async { + options.dsn = fakeDsn; + + expect(kDebugMode, options.debug); + expect('debug', options.environment); + + expect(true, options.transport is FileSystemTransport); + + expect( + options.integrations + .where((element) => element == flutterErrorIntegration), + isNotEmpty); + + expect( + options.integrations + .where((element) => element == isolateErrorIntegration), + isNotEmpty); + + expect(4, options.integrations.length); + + expect(sdkName, options.sdk.name); + expect(sdkVersion, options.sdk.version); + expect('pub:sentry_flutter', options.sdk.packages.last.name); + expect(sdkVersion, options.sdk.packages.last.version); + + expect('packageName@version+buildNumber', options.release); + expect('buildNumber', options.dist); +} diff --git a/flutter/test/sentry_flutter_web_test.dart b/flutter/test/sentry_flutter_web_test.dart new file mode 100644 index 0000000000..ddf4d5c6a2 --- /dev/null +++ b/flutter/test/sentry_flutter_web_test.dart @@ -0,0 +1,16 @@ +// import 'package:sentry/sentry.dart'; +// import 'package:sentry_flutter/sentry_flutter.dart'; + +@TestOn('browser') +import 'package:test/test.dart'; + +// import 'sentry_flutter_util_test.dart'; + +Future main() async { + // TODO: validate if flag isWeb will work as intended + + // final options = SentryOptions(); + // await SentryFlutter.init(configurationTester(options, isWeb: true), callback); +} + +void callback() {}