Skip to content

Commit

Permalink
feat: ttid (#1910)
Browse files Browse the repository at this point in the history
* Change app start integration in a way that works with ttid as well

* Formatting

* Update

* add visibleForTesting

* Update

* update

* Add app start info test

* Remove set app start info null

* Review improvements

* Add TTID

* Improvements

* Improvements

* Fix integration test

* Update

* Clear after tracking

* Update CHANGELOG

* Format

* Update

* Update

* remove import

* Update sentry tracer

* Add (not all) improvements for pr review

* combine transaction handler

* Refactor trackAppStart and trackRegularRoute to use private method

* Fix dart analyzer

* Remove clear

* Clear in tearDown

* Apply suggestions from code review

Co-authored-by: Philipp Hofmann <philipp.hofmann@sentry.io>

* Apply PR suggestions

* fix analyze

* update

* update

* Fix tests

* Fix analyze

* revert sample

* Update

* Update

* Fix test

* Move clear to the beginning of function

* Fix start time

* Fix analyze

* remove comment

* Formatting

* fix test

* add ttid duration assertion and determineEndTime timeout

* Rename finish transaction and do an early exit with enableAutoTransactions

* Rename function

* Remove static and getter for  in navigator observer

* Expose SentryDisplayWidget as public api and add it to example app

* Fix dart analyze

* Fix dart doc

* Improve tests

* Reduce fake frame finishing time and improve tests

* Improve test names

* Fix tests

* Apply formatting

* Add extra assertion in tests

---------

Co-authored-by: Philipp Hofmann <philipp.hofmann@sentry.io>
  • Loading branch information
buenaflor and philipphofmann committed Mar 7, 2024
1 parent 1d9ee98 commit 31b2afb
Show file tree
Hide file tree
Showing 18 changed files with 961 additions and 72 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Expand Up @@ -10,6 +10,16 @@

### Features

- Add TTID (time to initial display), which allows you to measure the time it takes to render the first frame of your screen ([#1910](https://github.com/getsentry/sentry-dart/pull/1910))
- Requires using the [routing instrumentation](https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/).
- Introduces two modes:
- `automatic` mode is enabled by default for all screens and will yield only an approximation result.
- `manual` mode requires manual instrumentation and will yield a more accurate result.
- To use `manual` mode, you need to wrap your desired widget: `SentryDisplayWidget(child: MyScreen())`.
- You can mix and match both modes in your app.
- Other significant fixes
- `didPop` doesn't trigger a new transaction
- Change transaction operation name to `ui.load` instead of `navigation`
- Use `recordHttpBreadcrumbs` to set iOS `enableNetworkBreadcrumbs` ([#1884](https://github.com/getsentry/sentry-dart/pull/1884))
- Apply `beforeBreadcrumb` on native iOS crumbs ([#1914](https://github.com/getsentry/sentry-dart/pull/1914))
- Add `maxQueueSize` to limit the number of unawaited events sent to Sentry ([#1868](https://github.com/getsentry/sentry-dart/pull/1868))
Expand Down
2 changes: 2 additions & 0 deletions dart/lib/sentry.dart
Expand Up @@ -49,6 +49,8 @@ export 'src/utils/http_header_utils.dart';
// ignore: invalid_export_of_internal_element
export 'src/sentry_trace_origins.dart';
// ignore: invalid_export_of_internal_element
export 'src/sentry_span_operations.dart';
// ignore: invalid_export_of_internal_element
export 'src/utils.dart';
// spotlight debugging
export 'src/spotlight.dart';
7 changes: 7 additions & 0 deletions dart/lib/src/sentry_measurement.dart
Expand Up @@ -39,6 +39,13 @@ class SentryMeasurement {
value = duration.inMilliseconds,
unit = DurationSentryMeasurementUnit.milliSecond;

/// Duration of the time to initial display in milliseconds
SentryMeasurement.timeToInitialDisplay(Duration duration)
: assert(!duration.isNegative),
name = 'time_to_initial_display',
value = duration.inMilliseconds,
unit = DurationSentryMeasurementUnit.milliSecond;

final String name;
final num value;
final SentryMeasurementUnit? unit;
Expand Down
7 changes: 7 additions & 0 deletions dart/lib/src/sentry_span_operations.dart
@@ -0,0 +1,7 @@
import 'package:meta/meta.dart';

@internal
class SentrySpanOperations {
static const String uiLoad = 'ui.load';
static const String uiTimeToInitialDisplay = 'ui.load.initial_display';
}
2 changes: 2 additions & 0 deletions dart/lib/src/sentry_trace_origins.dart
Expand Up @@ -27,4 +27,6 @@ class SentryTraceOrigins {
static const autoDbDriftQueryExecutor = 'auto.db.drift.query.executor';
static const autoDbDriftTransactionExecutor =
'auto.db.drift.transaction.executor';
static const autoUiTimeToDisplay = 'auto.ui.time_to_display';
static const manualUiTimeToDisplay = 'manual.ui.time_to_display';
}
2 changes: 1 addition & 1 deletion flutter/example/android/app/build.gradle
Expand Up @@ -48,7 +48,7 @@ android {

defaultConfig {
applicationId "io.sentry.samples.flutter"
minSdkVersion 19
minSdkVersion flutter.minSdkVersion
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
Expand Down
4 changes: 3 additions & 1 deletion flutter/example/lib/main.dart
Expand Up @@ -13,6 +13,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_isar/sentry_isar.dart';
import 'package:sentry_sqflite/sentry_sqflite.dart';
import 'package:sqflite/sqflite.dart';

// import 'package:sqflite_common_ffi/sqflite_ffi.dart';
// import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart';
import 'package:universal_platform/universal_platform.dart';
Expand Down Expand Up @@ -80,6 +81,7 @@ Future<void> setupSentry(
// going to log too much for your app, but can be useful when figuring out
// configuration issues, e.g. finding out why your events are not uploaded.
options.debug = true;
options.spotlight = Spotlight(enabled: true);

options.maxRequestBodySize = MaxRequestBodySize.always;
options.maxResponseBodySize = MaxResponseBodySize.always;
Expand Down Expand Up @@ -732,7 +734,7 @@ void navigateToAutoCloseScreen(BuildContext context) {
context,
MaterialPageRoute(
settings: const RouteSettings(name: 'AutoCloseScreen'),
builder: (context) => const AutoCloseScreen(),
builder: (context) => SentryDisplayWidget(child: const AutoCloseScreen()),
),
);
}
Expand Down
1 change: 1 addition & 0 deletions flutter/lib/sentry_flutter.dart
Expand Up @@ -16,3 +16,4 @@ export 'src/screenshot/sentry_screenshot_quality.dart';
export 'src/user_interaction/sentry_user_interaction_widget.dart';
export 'src/binding_wrapper.dart';
export 'src/sentry_widget.dart';
export 'src/navigation/sentry_display_widget.dart';
Expand Up @@ -114,9 +114,9 @@ class AppStartInfo {
final AppStartType type;
final DateTime start;
final DateTime end;
Duration get duration => end.difference(start);

SentryMeasurement toMeasurement() {
final duration = end.difference(start);
return type == AppStartType.cold
? SentryMeasurement.coldAppStart(duration)
: SentryMeasurement.warmAppStart(duration);
Expand Down
60 changes: 60 additions & 0 deletions flutter/lib/src/navigation/sentry_display_widget.dart
@@ -0,0 +1,60 @@
import 'package:flutter/cupertino.dart';
import 'time_to_initial_display_tracker.dart';

import '../frame_callback_handler.dart';

/// A widget that reports the Time To Initially Displayed (TTID) of its child widget.
///
/// This widget wraps around another widget to measure and report the time it takes
/// for the child widget to be initially displayed on the screen. This method
/// allows a more accurate measurement than what the default TTID implementation
/// provides. The TTID measurement begins when the route to the widget is pushed and ends
/// when `addPostFramecallback` is triggered.
///
/// Wrap the widget you want to measure with [SentryDisplayWidget], and ensure that you
/// have set up Sentry's routing instrumentation according to the Sentry documentation.
///
/// ```dart
/// SentryDisplayWidget(
/// child: MyWidget(),
/// )
/// ```
///
/// Make sure to configure Sentry's routing instrumentation in your app by following
/// the guidelines provided in Sentry's documentation for Flutter integrations:
/// https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/
///
/// See also:
/// - [Sentry's documentation on Flutter integrations](https://docs.sentry.io/platforms/flutter/)
/// for more information on how to integrate Sentry into your Flutter application.
class SentryDisplayWidget extends StatefulWidget {
final Widget child;
final FrameCallbackHandler _frameCallbackHandler;

SentryDisplayWidget({
super.key,
required this.child,
@visibleForTesting FrameCallbackHandler? frameCallbackHandler,
}) : _frameCallbackHandler =
frameCallbackHandler ?? DefaultFrameCallbackHandler();

@override
_SentryDisplayWidgetState createState() => _SentryDisplayWidgetState();
}

class _SentryDisplayWidgetState extends State<SentryDisplayWidget> {
@override
void initState() {
super.initState();
TimeToInitialDisplayTracker().markAsManual();

widget._frameCallbackHandler.addPostFrameCallback((_) {
TimeToInitialDisplayTracker().completeTracking();
});
}

@override
Widget build(BuildContext context) {
return widget.child;
}
}
106 changes: 80 additions & 26 deletions flutter/lib/src/navigation/sentry_navigator_observer.dart
@@ -1,5 +1,12 @@
// ignore_for_file: invalid_use_of_internal_member

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import '../integrations/integrations.dart';
import 'time_to_display_tracker.dart';

import '../../sentry_flutter.dart';
import '../event_processor/flutter_enricher_event_processor.dart';
Expand All @@ -19,6 +26,8 @@ typedef AdditionalInfoExtractor = Map<String, dynamic>? Function(
/// This is a navigation observer to record navigational breadcrumbs.
/// For now it only records navigation events and no gestures.
///
/// It also records Time to Initial Display (TTID).
///
/// [Route]s can always be null and their [Route.settings] can also always be null.
/// For example, if the application starts, there is no previous route.
/// The [RouteSettings] is null if a developer has not specified any
Expand All @@ -44,38 +53,38 @@ typedef AdditionalInfoExtractor = Map<String, dynamic>? Function(
/// )
/// ```
///
/// See the constructor docs for the argument documentation.
/// The option [enableAutoTransactions] is enabled by default. For every new
/// route a transaction is started. It's automatically finished after
/// [autoFinishAfter] duration or when all child spans are finished,
/// if those happen to take longer. The transaction will be set to [Scope.span]
/// if the latter is empty.
///
/// Enabling the [setRouteNameAsTransaction] option overrides the current
/// [Scope.transaction] which will also override the name of the current
/// [Scope.span]. So be careful when this is used together with performance
/// monitoring.
///
/// See also:
/// - [RouteObserver](https://api.flutter.dev/flutter/widgets/RouteObserver-class.html)
/// - [Navigating with arguments](https://flutter.dev/docs/cookbook/navigation/navigate-with-arguments)
class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
/// The option [enableAutoTransactions] is enabled by default.
/// For every new route a transaction is started. It's automatically finished
/// after [autoFinishAfter] duration or when all child spans are
/// finished, if those happen to take longer.
/// The transaction will be set to [Scope.span] if the latter is empty.
///
/// Enabling the [setRouteNameAsTransaction] option overrides the
/// current [Scope.transaction] which will also override the name of the current
/// [Scope.span]. So be careful when this is used together with performance
/// monitoring.
SentryNavigatorObserver({
Hub? hub,
bool enableAutoTransactions = true,
Duration autoFinishAfter = const Duration(seconds: 3),
bool setRouteNameAsTransaction = false,
RouteNameExtractor? routeNameExtractor,
AdditionalInfoExtractor? additionalInfoProvider,
@visibleForTesting TimeToDisplayTracker? timeToDisplayTracker,
}) : _hub = hub ?? HubAdapter(),
_enableAutoTransactions = enableAutoTransactions,
_autoFinishAfter = autoFinishAfter,
_setRouteNameAsTransaction = setRouteNameAsTransaction,
_routeNameExtractor = routeNameExtractor,
_additionalInfoProvider = additionalInfoProvider,
_native = SentryFlutter.native {
_native = SentryFlutter.native,
_timeToDisplayTracker = timeToDisplayTracker ?? TimeToDisplayTracker() {
if (enableAutoTransactions) {
// ignore: invalid_use_of_internal_member
_hub.options.sdk.addIntegration('UINavigationTracing');
}
}
Expand All @@ -87,6 +96,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
final RouteNameExtractor? _routeNameExtractor;
final AdditionalInfoExtractor? _additionalInfoProvider;
final SentryNative? _native;
final TimeToDisplayTracker? _timeToDisplayTracker;

ISentrySpan? _transaction;

Expand All @@ -95,6 +105,12 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
@internal
static String? get currentRouteName => _currentRouteName;

Completer<void>? _completedDisplayTracking;

// Since didPush does not have a future, we can keep track of when the display tracking has finished
@visibleForTesting
Completer<void>? get completedDisplayTracking => _completedDisplayTracking;

@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
Expand All @@ -108,8 +124,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
to: route.settings,
);

_finishTransaction();
_startTransaction(route);
_finishTimeToDisplayTracking();
_startTimeToDisplayTracking(route);
}

@override
Expand Down Expand Up @@ -139,8 +155,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
to: previousRoute?.settings,
);

_finishTransaction();
_startTransaction(previousRoute);
_finishTimeToDisplayTracking();
}

void _addBreadcrumb({
Expand All @@ -152,7 +167,6 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
navigationType: type,
from: _routeNameExtractor?.call(from) ?? from,
to: _routeNameExtractor?.call(to) ?? to,
// ignore: invalid_use_of_internal_member
timestamp: _hub.options.clock(),
data: _additionalInfoProvider?.call(from, to),
));
Expand All @@ -179,11 +193,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
}
}

Future<void> _startTransaction(Route<dynamic>? route) async {
if (!_enableAutoTransactions) {
return;
}

Future<void> _startTransaction(
Route<dynamic>? route, DateTime startTimestamp) async {
String? name = _getRouteName(route);
final arguments = route?.settings.arguments;

Expand All @@ -196,14 +207,14 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
}
final transactionContext = SentryTransactionContext(
name,
'navigation',
SentrySpanOperations.uiLoad,
transactionNameSource: SentryTransactionNameSource.component,
// ignore: invalid_use_of_internal_member
origin: SentryTraceOrigins.autoNavigationRouteObserver,
);

_transaction = _hub.startTransactionWithContext(
transactionContext,
startTimestamp: startTimestamp,
waitForChildren: true,
autoFinishAfter: _autoFinishAfter,
trimEnd: true,
Expand Down Expand Up @@ -242,7 +253,9 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
await _native?.beginNativeFramesCollection();
}

Future<void> _finishTransaction() async {
Future<void> _finishTimeToDisplayTracking() async {
_timeToDisplayTracker?.clear();

final transaction = _transaction;
_transaction = null;
if (transaction == null || transaction.finished) {
Expand All @@ -251,6 +264,47 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
transaction.status ??= SpanStatus.ok();
await transaction.finish();
}

Future<void> _startTimeToDisplayTracking(Route<dynamic>? route) async {
if (!_enableAutoTransactions) {
return;
}

_completedDisplayTracking = Completer<void>();
String? routeName = _currentRouteName;
if (routeName == null) return;

DateTime startTimestamp = _hub.options.clock();
DateTime? endTimestamp;

if (routeName == '/') {
final appStartInfo = await NativeAppStartIntegration.getAppStartInfo();
if (appStartInfo == null) {
return;
}

startTimestamp = appStartInfo.start;
endTimestamp = appStartInfo.end;
}

await _startTransaction(route, startTimestamp);
final transaction = _transaction;
if (transaction == null) {
return;
}

if (routeName == '/' && endTimestamp != null) {
await _timeToDisplayTracker?.trackAppStartTTD(transaction,
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
} else {
await _timeToDisplayTracker?.trackRegularRouteTTD(transaction,
startTimestamp: startTimestamp);
}

// Mark the tracking as completed and clear any temporary state.
_completedDisplayTracking?.complete();
_timeToDisplayTracker?.clear();
}
}

/// This class makes it easier to record breadcrumbs for events of Flutters
Expand Down

0 comments on commit 31b2afb

Please sign in to comment.