diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 8fcbdc36f8..e30cffd639 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -59,6 +60,10 @@ class MainScaffold extends StatelessWidget { child: const Text('Open another Scaffold'), onPressed: () => SecondaryScaffold.openSecondaryScaffold(context), ), + RaisedButton( + child: const Text('Open Scaffold with slow drawing'), + onPressed: () => SlowScaffold.open(context), + ), RaisedButton( child: const Text('Dart: try catch'), onPressed: () => tryCatch(), @@ -327,3 +332,44 @@ Future makeWebRequest(BuildContext context) async { }, ); } + +class SlowScaffold extends StatelessWidget { + static Future open(BuildContext context) { + return Navigator.push( + context, + MaterialPageRoute( + settings: + const RouteSettings(name: 'SlowScaffold', arguments: 'sloooowwwww'), + builder: (context) { + return SlowScaffold(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + sleep(Duration(milliseconds: 20)); + return Scaffold( + appBar: AppBar( + title: const Text('SecondaryScaffold'), + ), + body: Center( + child: Column( + children: [ + const Text( + 'You have added a navigation event ' + 'to the crash reports breadcrumbs.', + ), + MaterialButton( + child: const Text('Go back'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/src/integrations/frame_timing_integration.dart b/flutter/lib/src/integrations/frame_timing_integration.dart new file mode 100644 index 0000000000..c35695a664 --- /dev/null +++ b/flutter/lib/src/integrations/frame_timing_integration.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:sentry/sentry.dart'; + +import '../sentry_flutter_options.dart'; + +/// Records the time which a frame takes to draw, if it's above +/// a certain threshold, i.e. [FrameTimingIntegration.badFrameThreshold]. +/// +/// Should not be added in debug mode because the performance of the debug mode +/// is not indicativ of the performance in release mode. +/// +/// Remarks: +/// See [SchedulerBinding.addTimingsCallback](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addTimingsCallback.html) +/// to learn more about the performance impact of using this. +/// +/// Adding a `timingsCallback` has a real significant performance impact as +/// noted above. Thus this integration should only be added if it's enabled. +/// The enabled check should not happen inside the `timingsCallback`. +class FrameTimingIntegration extends Integration { + FrameTimingIntegration({ + required this.reporter, + this.badFrameThreshold = const Duration(milliseconds: 16), + }); + + final Duration badFrameThreshold; + final FrameTimingReporter reporter; + + late Hub _hub; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + // We don't need to call `WidgetsFlutterBinding.ensureInitialized()` + // because `WidgetsFlutterBindingIntegration` already calls it. + // If the instance is not created, we skip it to keep going. + final instance = WidgetsBinding.instance; + if (instance != null) { + _hub = hub; + instance.addTimingsCallback(_timingsCallback); + options.sdk.addIntegration('FrameTimingsIntegration'); + } else { + options.logger( + SentryLevel.error, + 'FrameTimingsIntegration failed to be installed', + ); + } + } + + void _timingsCallback(List timings) { + var count = 0; + var worstFrameDuration = Duration.zero; + for (final timing in timings) { + if (timing.totalSpan > badFrameThreshold) { + count = count + 1; + if (timing.totalSpan > worstFrameDuration) { + worstFrameDuration = timing.totalSpan; + } + } + } + if (count > 0) { + final totalDuration = + timings.map((e) => e.totalSpan).reduce((a, b) => a + b); + + final message = _message(count, worstFrameDuration, totalDuration); + + if (reporter == FrameTimingReporter.breadcrumb) { + _reportAsBreadcrumb(message); + } else { + // This callback does not allow async, so we captureMessage as + // a fire and forget action. + _reportAsEvent(message); + } + } + } + + Future _reportAsEvent(String message) { + return _hub.captureMessage( + message, + level: SentryLevel.warning, + ); + } + + void _reportAsBreadcrumb(String message) { + _hub.addBreadcrumb(Breadcrumb( + type: 'info', + category: 'ui', + message: message, + level: SentryLevel.warning, + )); + } + + String _message( + int badFrameCount, + Duration worstFrameDuration, + Duration totalDuration, + ) { + return '$badFrameCount frames exceeded ${_formatMS(badFrameThreshold)} ' + 'in the last ${_formatMS(totalDuration)}. ' + 'The worst frame time in this time span was ' + '${_formatMS(worstFrameDuration)}'; + } + + @override + FutureOr close() { + WidgetsBinding.instance?.removeTimingsCallback(_timingsCallback); + } + + /// Format milliseconds with more precision than absolut milliseconds + String _formatMS(Duration duration) => '${duration.inMicroseconds * 0.001}ms'; +} + +enum FrameTimingReporter { + breadcrumb, + event, +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index d4fb00a048..6c02c8f974 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/frame_timing_integration.dart'; import 'sentry_flutter_options.dart'; import 'default_integrations.dart';