diff --git a/CHANGELOG.md b/CHANGELOG.md index efeef18b76..e6cd65e435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased * Feat: Add current route as transaction (#615) +* Feat: Add Breadcrumbs for Flutters `debugPrint` (#618) # 6.1.0-alpha.2 diff --git a/dart/lib/src/default_integrations.dart b/dart/lib/src/default_integrations.dart index 18cf0cb079..3138d2703d 100644 --- a/dart/lib/src/default_integrations.dart +++ b/dart/lib/src/default_integrations.dart @@ -67,11 +67,9 @@ class RunZonedGuardedIntegration extends Integration { _isPrinting = true; try { - hub.addBreadcrumb(Breadcrumb( + hub.addBreadcrumb(Breadcrumb.console( message: line, level: SentryLevel.debug, - category: 'console', - type: 'debug', )); parent.print(zone, line); diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index 2e41b52d5f..e1ac8cd4fc 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -66,6 +66,22 @@ class Breadcrumb { ); } + factory Breadcrumb.console({ + String? message, + SentryLevel? level, + DateTime? timestamp, + Map? data, + }) { + return Breadcrumb( + message: message, + level: level, + category: 'console', + type: 'debug', + timestamp: timestamp, + data: data, + ); + } + /// Describes the breadcrumb. /// /// This field is optional and may be set to null. diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index 64adcbe381..890bc68296 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -128,4 +128,37 @@ void main() { 'type': 'http', }); }); + + test('Breadcrumb console ctor', () { + final breadcrumb = Breadcrumb.console( + message: 'Foo Bar', + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'message': 'Foo Bar', + 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'console', + 'type': 'debug', + 'level': 'info', + }); + }); + + test('extensive Breadcrumb console ctor', () { + final breadcrumb = Breadcrumb.console( + message: 'Foo Bar', + level: SentryLevel.error, + data: {'foo': 'bar'}, + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'message': 'Foo Bar', + 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'console', + 'type': 'debug', + 'level': 'error', + 'data': {'foo': 'bar'}, + }); + }); } diff --git a/flutter/lib/src/integrations/debug_print_integration.dart b/flutter/lib/src/integrations/debug_print_integration.dart new file mode 100644 index 0000000000..93142b9217 --- /dev/null +++ b/flutter/lib/src/integrations/debug_print_integration.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:sentry/sentry.dart'; + +import '../sentry_flutter_options.dart'; + +/// Integration which intercepts Flutters [debugPrint] method. +/// If this integration is added, all calls to [debugPrint] a redirected to +/// add a [Breadcrumb]. [debugPrint] is not outputting to the console anymore! +/// This integration fixes the issue described in +/// [#492](https://github.com/getsentry/sentry-dart/issues/492). +class DebugPrintIntegration extends Integration { + DebugPrintCallback? _debugPrintBackup; + late Hub _hub; + late SentryFlutterOptions _options; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + _hub = hub; + _options = options; + + final isDebug = options.platformChecker.isDebugMode(); + final enablePrintBreadcrumbs = options.enablePrintBreadcrumbs; + if (isDebug || !enablePrintBreadcrumbs) { + return Future.value(); + } + + _debugPrintBackup = debugPrint; + + // We're simply replacing debugPrint here. The default implementation is a + // a throttling system which prints using Darts print method. It's basically + // a fire and forget method which completes sometime in the future. We can't + // observe when it's done. + // + // This makes it impossible to just disable adding print() breadcrumbs + // before debugPrint is called and re-enable it after debugPrint was called. + // See the docs for more information. + // https://api.flutter.dev/flutter/foundation/debugPrint.html + debugPrint = _debugPrint; + + options.sdk.addIntegration('debugPrintIntegration'); + } + + @override + void close() { + if (_debugPrintBackup != null) { + debugPrint = _debugPrintBackup!; + } + } + + void _debugPrint(String? message, {int? wrapWidth}) { + if (message == null) { + _options.logger( + SentryLevel.debug, + 'debugPrint Integration received "null" as message. ' + 'The message is dropped.', + ); + return; + } + _hub.addBreadcrumb(Breadcrumb.console( + message: message, + level: SentryLevel.debug, + )); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index d4fb00a048..b42893cf8b 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -5,6 +5,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry/sentry.dart'; import 'flutter_enricher_event_processor.dart'; +import 'integrations/debug_print_integration.dart'; import 'sentry_flutter_options.dart'; import 'default_integrations.dart'; @@ -107,6 +108,8 @@ mixin SentryFlutter { integrations.add(LoadAndroidImageListIntegration(channel)); } + integrations.add(DebugPrintIntegration()); + // This is an Integration because we want to execute it after all the // error handlers are in place. Calling a MethodChannel might result // in errors. diff --git a/flutter/test/integrations/debug_print_integration_test.dart b/flutter/test/integrations/debug_print_integration_test.dart new file mode 100644 index 0000000000..b7804abeeb --- /dev/null +++ b/flutter/test/integrations/debug_print_integration_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/debug_print_integration.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + tearDown(() { + debugPrint = debugPrintSynchronously; + }); + + test('$DebugPrintIntegration: debugPrint adds a breadcrumb', () { + final integration = fixture.getSut(); + integration.call(fixture.hub, fixture.getOptions()); + + debugPrint('Foo Bar'); + + final breadcrumb = verify( + fixture.hub.addBreadcrumb(captureAny), + ).captured.first as Breadcrumb; + + expect(breadcrumb.message, 'Foo Bar'); + }); + + test( + '$DebugPrintIntegration: debugPrint does not add a breadcrumb after close', + () { + final integration = fixture.getSut(); + integration.call(fixture.hub, fixture.getOptions()); + integration.close(); + + debugPrint('Foo Bar'); + + verifyNever(fixture.hub.addBreadcrumb(captureAny)); + }); + + test( + '$DebugPrintIntegration: close changes debugPrint back to default implementation', + () { + final original = debugPrint; + + final integration = fixture.getSut(); + integration.call(fixture.hub, fixture.getOptions()); + integration.close(); + + expect(debugPrint, original); + }); + + test('$DebugPrintIntegration: disabled in debug builds', () { + final integration = fixture.getSut(); + integration.call(fixture.hub, fixture.getOptions(debug: true)); + + debugPrint('Foo Bar'); + + verifyNever(fixture.hub.addBreadcrumb(captureAny)); + }); + + test('$DebugPrintIntegration: disabled if enablePrintBreadcrumbs = false', + () { + final integration = fixture.getSut(); + integration.call( + fixture.hub, + fixture.getOptions(enablePrintBreadcrumbs: false), + ); + + debugPrint('Foo Bar'); + + verifyNever(fixture.hub.addBreadcrumb(captureAny)); + }); + + test( + '$DebugPrintIntegration: close works if debugPrintIntegration.call was not called', + () { + final integration = fixture.getSut(); + + // test is successful if no exception is thrown + expect(() => integration.close(), returnsNormally); + }); +} + +class Fixture { + final hub = MockHub(); + + SentryFlutterOptions getOptions({ + bool debug = false, + bool enablePrintBreadcrumbs = true, + }) { + return SentryFlutterOptions( + dsn: fakeDsn, + checker: MockPlatformChecker(isDebug: debug), + )..enablePrintBreadcrumbs = enablePrintBreadcrumbs; + } + + DebugPrintIntegration getSut() { + return DebugPrintIntegration(); + } +} diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index c92293ca3a..012f439eda 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -4,6 +4,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry/src/platform_checker.dart'; +import 'package:sentry_flutter/src/integrations/debug_print_integration.dart'; import 'package:sentry_flutter/src/version.dart'; import 'mocks.dart'; import 'sentry_flutter_util.dart'; @@ -13,6 +14,7 @@ import 'sentry_flutter_util.dart'; final platformAgnosticIntegrations = [ FlutterErrorIntegration, LoadReleaseIntegration, + DebugPrintIntegration, ]; // These should only be added to Android