diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 40fc29d8e8..6a5435d009 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -27,3 +27,4 @@ export 'src/sentry_attachment/sentry_attachment.dart'; export 'src/sentry_user_feedback.dart'; // tracing export 'src/tracing.dart'; +export 'src/sentry_measurement.dart'; diff --git a/dart/lib/src/protocol/sentry_transaction.dart b/dart/lib/src/protocol/sentry_transaction.dart index 2bbdbfc7bb..865248890d 100644 --- a/dart/lib/src/protocol/sentry_transaction.dart +++ b/dart/lib/src/protocol/sentry_transaction.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import '../protocol.dart'; import '../sentry_tracer.dart'; import '../utils.dart'; +import '../sentry_measurement.dart'; @immutable class SentryTransaction extends SentryEvent { @@ -30,6 +31,7 @@ class SentryTransaction extends SentryEvent { SdkVersion? sdk, SentryRequest? request, String? type, + this.measurements, }) : super( eventId: eventId, timestamp: timestamp ?? _tracer.endTimestamp, @@ -70,6 +72,15 @@ class SentryTransaction extends SentryEvent { json['start_timestamp'] = formatDateAsIso8601WithMillisPrecision(startTimestamp); + final ms = measurements; + if (ms != null && ms.isNotEmpty) { + final map = {}; + for (final m in ms) { + map[m.name] = m.toJson(); + } + json['measurements'] = map; + } + return json; } @@ -77,6 +88,8 @@ class SentryTransaction extends SentryEvent { bool get sampled => contexts.trace?.sampled == true; + final List? measurements; + @override SentryTransaction copyWith({ SentryId? eventId, @@ -105,6 +118,7 @@ class SentryTransaction extends SentryEvent { List? exceptions, List? threads, String? type, + List? measurements, }) => SentryTransaction( _tracer, @@ -126,5 +140,9 @@ class SentryTransaction extends SentryEvent { sdk: sdk ?? this.sdk, request: request ?? this.request, type: type ?? this.type, + measurements: (measurements != null + ? List.from(measurements) + : null) ?? + this.measurements, ); } diff --git a/dart/lib/src/sentry_measurement.dart b/dart/lib/src/sentry_measurement.dart new file mode 100644 index 0000000000..69378fb416 --- /dev/null +++ b/dart/lib/src/sentry_measurement.dart @@ -0,0 +1,53 @@ +class SentryMeasurement { + SentryMeasurement(this.name, this.value); + + // Mobile / Desktop Vitals + + /// Amount of frames drawn during a transaction + SentryMeasurement.totalFrames(this.value) : name = 'frames_total'; + + /// Amount of slow frames drawn during a transaction. + /// A slow frame is any frame longer than 1s / refreshrate. + /// So for example any frame slower than 16ms for a refresh rate of 60hz. + SentryMeasurement.slowFrames(this.value) : name = 'frames_slow'; + + /// Amount of frozen frames drawn during a transaction. + /// Typically defined as frames slower than 500ms. + SentryMeasurement.frozenFrames(this.value) : name = 'frames_frozen'; + + SentryMeasurement.coldAppStart(Duration duration) + : assert(!duration.isNegative), + name = 'app_start_cold', + value = duration.inMilliseconds; + + SentryMeasurement.warmAppStart(Duration duration) + : assert(!duration.isNegative), + name = 'app_start_warm', + value = duration.inMilliseconds; + + // Web Vitals + + SentryMeasurement.firstContentfulPaint(Duration duration) + : assert(!duration.isNegative), + name = 'fcp', + value = duration.inMilliseconds; + + SentryMeasurement.firstPaint(Duration duration) + : assert(!duration.isNegative), + name = 'fp', + value = duration.inMilliseconds; + + SentryMeasurement.timeToFirstByte(Duration duration) + : assert(!duration.isNegative), + name = 'ttfb', + value = duration.inMilliseconds; + + final String name; + final num value; + + Map toJson() { + return { + 'value': value, + }; + } +} diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index 47e0054605..aeca395626 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -91,7 +91,7 @@ class SentryTracer extends ISentrySpan { } }); - final transaction = SentryTransaction(this); + final transaction = SentryTransaction(this, measurements: measurements); await _hub.captureTransaction(transaction); } } @@ -132,6 +132,8 @@ class SentryTracer extends ISentrySpan { _rootSpan.setTag(key, value); } + final List measurements = []; + @override ISentrySpan startChild( String operation, { diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 6e666e8a57..92f0f8474e 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -44,6 +44,8 @@ class _MyAppState extends State { @override void initState() { super.initState(); + //var frameTracker = FrameTracker(); + //frameTracker.start(); } @override diff --git a/flutter/lib/src/navigation/frame_tracker.dart b/flutter/lib/src/navigation/frame_tracker.dart new file mode 100644 index 0000000000..e53c5e8dff --- /dev/null +++ b/flutter/lib/src/navigation/frame_tracker.dart @@ -0,0 +1,70 @@ +import 'dart:ui'; +import 'package:flutter/widgets.dart'; +import 'package:sentry/sentry.dart'; + +class FrameTracker { + FrameTracker({ + this.slowFrameThreshold = const Duration(milliseconds: 16), + this.frozenFrameThreshold = const Duration(milliseconds: 500), + required WidgetsBinding binding, + }) : _binding = binding; + + final WidgetsBinding _binding; + + SingletonFlutterWindow get window => _binding.window; + + final Duration slowFrameThreshold; + final Duration frozenFrameThreshold; + + DateTime? _timeStamp; + + var _frameCount = 0; + var _slowCount = 0; + var _frozenCount = 0; + + void _frameCallback(Duration _) { + final timeStamp = _timeStamp; + if (timeStamp == null) { + return; + } + + // postFrameCallbacks are called just once, + // so we have to add it each frame. + _binding.addPostFrameCallback(_frameCallback); + + final now = DateTime.now(); + _timeStamp = now; + final duration = timeStamp.difference(now).abs(); + + if (duration > frozenFrameThreshold) { + _frozenCount++; + } else if (duration > slowFrameThreshold) { + _slowCount++; + } + + _frameCount++; + } + + void start() { + _timeStamp = DateTime.now(); + _binding.addPostFrameCallback(_frameCallback); + } + + List finish() { + final metrics = [ + SentryMeasurement.totalFrames(_frameCount), + SentryMeasurement.frozenFrames(_frozenCount), + SentryMeasurement.slowFrames(_slowCount), + ]; + + _reset(); + return metrics; + } + + void _reset() { + _timeStamp = null; + _frameCount = 0; + _slowCount = 0; + _frozenCount = 0; + } +} diff --git a/flutter/lib/src/navigation/sentry_navigator_observer.dart b/flutter/lib/src/navigation/sentry_navigator_observer.dart index 929ded4ec8..ac556f19bd 100644 --- a/flutter/lib/src/navigation/sentry_navigator_observer.dart +++ b/flutter/lib/src/navigation/sentry_navigator_observer.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; -import 'package:sentry/sentry.dart'; +// ignore: implementation_imports +import 'package:sentry/src/sentry_tracer.dart'; import '../../sentry_flutter.dart'; +import 'frame_tracker.dart'; /// This key must be used so that the web interface displays the events nicely /// See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ @@ -75,6 +77,8 @@ class SentryNavigatorObserver extends RouteObserver> { final bool _setRouteNameAsTransaction; final RouteNameExtractor? _routeNameExtractor; final AdditionalInfoExtractor? _additionalInfoProvider; + final FrameTracker _frameTracker = + FrameTracker(binding: WidgetsBinding.instance!); ISentrySpan? _transaction; @@ -159,6 +163,7 @@ class SentryNavigatorObserver extends RouteObserver> { autoFinishAfter: _autoFinishAfter, trimEnd: true, ); + _frameTracker.start(); if (arguments != null) { _transaction?.setData('route_settings_arguments', arguments); } @@ -170,6 +175,13 @@ class SentryNavigatorObserver extends RouteObserver> { Future _finishTransaction() async { _transaction?.status ??= SpanStatus.ok(); + // ignore: invalid_use_of_internal_member + if (_transaction is SentryTracer) { + // ignore: invalid_use_of_internal_member + final transaction = _transaction as SentryTracer; + final measurements = _frameTracker.finish(); + transaction.measurements.addAll(measurements); + } return await _transaction?.finish(); } } diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index c289116524..84f5e914b9 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: package_info_plus: ^1.0.0 dev_dependencies: - build_runner: ^2.1.5 + build_runner: ^2.1.0 flutter_test: sdk: flutter mockito: ^5.0.0