Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: PlatformDispatcher.onError integration #915

Merged
merged 14 commits into from
Jul 26, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Add integration for `PlatformDispatcher.onError` ([#915](https://github.com/getsentry/sentry-dart/pull/915))
ueman marked this conversation as resolved.
Show resolved Hide resolved
- Bump Android SDK to v6.1.4 ([#900](https://github.com/getsentry/sentry-dart/pull/900))
- [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#614)
- [diff](https://github.com/getsentry/sentry-java/compare/6.1.2...6.1.4)
Expand Down
11 changes: 11 additions & 0 deletions flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ class MainScaffold extends StatelessWidget {
},
child: const Text('Capture from FlutterError.onError'),
),
ElevatedButton(
ueman marked this conversation as resolved.
Show resolved Hide resolved
onPressed: () {
(WidgetsBinding.instance.platformDispatcher as dynamic)
.onError
?.call(
Exception('PlatformDispatcher.onError'),
StackTrace.current,
);
ueman marked this conversation as resolved.
Show resolved Hide resolved
},
child: const Text('Capture from PlatformDispatcher.onError'),
),
ElevatedButton(
onPressed: () => makeWebRequest(context),
child: const Text('Dart: Web request'),
Expand Down
123 changes: 123 additions & 0 deletions flutter/lib/src/integrations/on_error_integration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:ui';

import 'package:flutter/widgets.dart';
import 'package:sentry/sentry.dart';
import '../sentry_flutter_options.dart';

typedef ErrorCallback = bool Function(Object exception, StackTrace stackTrace);

/// Integration which captures `PlatformDispatcher.onError`
/// See:
/// - https://master-api.flutter.dev/flutter/dart-ui/PlatformDispatcher/onError.html
///
/// Remarks:
/// - Only usable on Flutter >= 3.1.0
ueman marked this conversation as resolved.
Show resolved Hide resolved
///
/// This can be used instead of the [RunZonedGuardedIntegration]. Removing the
/// [RunZonedGuardedIntegration] results in a minimal improved startup time,
/// since creating [Zone]s is not cheap.
class OnErrorIntegration implements Integration<SentryFlutterOptions> {
OnErrorIntegration({this.dispatchWrapper});

ErrorCallback? _defaultOnError;
ErrorCallback? _integrationOnError;
PlatformDispatcherWrapper? dispatchWrapper;
SentryFlutterOptions? _options;

@override
void call(Hub hub, SentryFlutterOptions options) {
_options = options;
final wrapper = dispatchWrapper ??
// WidgetsBinding works with WidgetsFlutterBinding and other custom bindings
PlatformDispatcherWrapper(WidgetsBinding.instance.platformDispatcher);

if (!wrapper.isOnErrorSupported(options)) {
return;
}
_defaultOnError = wrapper.onError;

_integrationOnError = (Object exception, StackTrace stackTrace) {
final handled = _defaultOnError?.call(exception, stackTrace) ?? false;
ueman marked this conversation as resolved.
Show resolved Hide resolved

// As per docs, the app might crash on some platforms
// after this is called.
// https://master-api.flutter.dev/flutter/dart-ui/PlatformDispatcher/onError.html
// https://master-api.flutter.dev/flutter/dart-ui/ErrorCallback.html
final mechanism = Mechanism(
type: 'PlatformDispatcher.onError',
handled: handled,
);
final throwableMechanism = ThrowableMechanism(mechanism, exception);

var event = SentryEvent(
throwable: throwableMechanism,
level: SentryLevel.fatal,
);

// unawaited future
hub.captureEvent(event, stackTrace: stackTrace);

return handled;
};

wrapper.onError = _integrationOnError;
dispatchWrapper = wrapper;

options.sdk.addIntegration('OnErrorIntegration');
}

@override
void close() async {
if (!(dispatchWrapper?.isOnErrorSupported(_options!) == true)) {
// bail out
return;
}

/// Restore default if the integration error is still set.
if (dispatchWrapper?.onError == _integrationOnError) {
dispatchWrapper?.onError = _defaultOnError;
_defaultOnError = null;
_integrationOnError = null;
}
}
}

/// This class wraps the `this as dynamic` hack in a type-safe manner.
/// It helps to introduce code, which uses newer features from Flutter
/// without breaking Sentry on older versions of Flutter.
// Should not become part of public API.
@visibleForTesting
class PlatformDispatcherWrapper {
PlatformDispatcherWrapper(this._dispatcher);

final PlatformDispatcher _dispatcher;

/// Should not be accessed if [isOnErrorSupported] == false
ErrorCallback? get onError =>
(_dispatcher as dynamic).onError as ErrorCallback?;

/// Should not be accessed if [isOnErrorSupported] == false
set onError(ErrorCallback? callback) {
(_dispatcher as dynamic).onError = callback;
}

bool isOnErrorSupported(SentryFlutterOptions options) {
try {
onError;
} on NoSuchMethodError {
// This error is expected on pre 3.1 Flutter version
return false;
} catch (exception, stacktrace) {
// This error is neither expected on pre 3.1 nor on >= 3.1 Flutter versions
options.logger(
SentryLevel.debug,
'An unexpected exception was thrown, please create an issue at https://github.com/getsentry/sentry-dart/issues',
exception: exception,
stackTrace: stacktrace,
);
return false;
}
return true;
}
}
4 changes: 4 additions & 0 deletions flutter/lib/src/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:meta/meta.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sentry/sentry.dart';
import 'event_processor/android_platform_exception_event_processor.dart';
import 'integrations/on_error_integration.dart';
import 'native_scope_observer.dart';
import 'sentry_native.dart';
import 'sentry_native_channel.dart';
Expand Down Expand Up @@ -105,6 +106,9 @@ mixin SentryFlutter {
// Will catch any errors that may occur in the Flutter framework itself.
integrations.add(FlutterErrorIntegration());

// Will catch any errors that may occur in the root zone
integrations.add(OnErrorIntegration());

// This tracks Flutter application events, such as lifecycle events.
integrations.add(WidgetsBindingIntegration());

Expand Down
163 changes: 163 additions & 0 deletions flutter/test/integrations/on_error_integration_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
@TestOn('vm')
// Run only on vm for now because of
ueman marked this conversation as resolved.
Show resolved Hide resolved
// https://github.com/flutter/engine/pull/34428
import 'dart:ui';

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sentry/sentry.dart';
import 'package:sentry_flutter/src/integrations/on_error_integration.dart';
import 'package:sentry_flutter/src/sentry_flutter_options.dart';

import '../mocks.dart';
import '../mocks.mocks.dart';

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

late Fixture fixture;

setUp(() {
fixture = Fixture();
});

void _reportError({
required Object exception,
required StackTrace stackTrace,
ErrorCallback? handler,
}) {
fixture.platformDispatcherWrapper.onError = handler ??
(_, __) {
return fixture.onErrorReturnValue;
};

when(fixture.hub.captureEvent(captureAny,
stackTrace: captureAnyNamed('stackTrace')))
.thenAnswer((_) => Future.value(SentryId.empty()));

OnErrorIntegration(dispatchWrapper: fixture.platformDispatcherWrapper)(
fixture.hub,
fixture.options,
);

fixture.platformDispatcherWrapper.onError?.call(exception, stackTrace);
}

test('onError capture errors', () async {
final exception = StateError('error');

_reportError(exception: exception, stackTrace: StackTrace.current);

final event = verify(
await fixture.hub
.captureEvent(captureAny, stackTrace: captureAnyNamed('stackTrace')),
).captured.first as SentryEvent;

expect(event.level, SentryLevel.fatal);

final throwableMechanism = event.throwableMechanism as ThrowableMechanism;
expect(throwableMechanism.mechanism.type, 'PlatformDispatcher.onError');
expect(throwableMechanism.mechanism.handled, true);
expect(throwableMechanism.throwable, exception);
});

test('onError: handled is true if onError returns true', () async {
fixture.onErrorReturnValue = true;
final exception = StateError('error');
_reportError(exception: exception, stackTrace: StackTrace.current);

final event = verify(
await fixture.hub
.captureEvent(captureAny, stackTrace: captureAnyNamed('stackTrace')),
).captured.first as SentryEvent;

final throwableMechanism = event.throwableMechanism as ThrowableMechanism;
expect(throwableMechanism.mechanism.handled, true);
});

test('onError: handled is false if onError returns false', () async {
fixture.onErrorReturnValue = false;
final exception = StateError('error');
_reportError(exception: exception, stackTrace: StackTrace.current);

final event = verify(
await fixture.hub
.captureEvent(captureAny, stackTrace: captureAnyNamed('stackTrace')),
).captured.first as SentryEvent;

final throwableMechanism = event.throwableMechanism as ThrowableMechanism;
expect(throwableMechanism.mechanism.handled, false);
});

test('onError calls default error', () async {
var called = false;
final defaultError = (_, __) {
called = true;
return true;
};

_reportError(
exception: Exception(),
stackTrace: StackTrace.current,
handler: defaultError,
);

verify(await fixture.hub.captureEvent(
captureAny,
stackTrace: captureAnyNamed('stackTrace'),
));

expect(called, true);
});

test('onError close restored default onError', () async {
ErrorCallback defaultOnError = (_, __) {
return true;
};
fixture.platformDispatcherWrapper.onError = defaultOnError;

final integration =
OnErrorIntegration(dispatchWrapper: fixture.platformDispatcherWrapper);
integration.call(fixture.hub, fixture.options);
expect(false, defaultOnError == fixture.platformDispatcherWrapper.onError);

integration.close();
expect(fixture.platformDispatcherWrapper.onError, defaultOnError);
});

test('FlutterError adds integration', () {
OnErrorIntegration(dispatchWrapper: fixture.platformDispatcherWrapper)(
fixture.hub, fixture.options);

expect(
fixture.options.sdk.integrations.contains('OnErrorIntegration'),
true,
);
});
}

class Fixture {
final hub = MockHub();
final options = SentryFlutterOptions(dsn: fakeDsn);
late final platformDispatcherWrapper =
PlatformDispatcherWrapper(MockPlatformDispatcher());

bool onErrorReturnValue = true;
}

class MockPlatformDispatcher implements PlatformDispatcher {
ErrorCallback? onErrorHandler;

@override
// ignore: override_on_non_overriding_member
ErrorCallback? get onError => onErrorHandler;

@override
// ignore: override_on_non_overriding_member
set onError(ErrorCallback? onError) {
onErrorHandler = onError;
}

@override
void noSuchMethod(Invocation invocation) {}
}