Skip to content

Commit 31b2afb

Browse files
feat: ttid (#1910)
* 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 <[email protected]> * 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 <[email protected]>
1 parent 1d9ee98 commit 31b2afb

18 files changed

+961
-72
lines changed

CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010

1111
### Features
1212

13+
- 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))
14+
- Requires using the [routing instrumentation](https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/).
15+
- Introduces two modes:
16+
- `automatic` mode is enabled by default for all screens and will yield only an approximation result.
17+
- `manual` mode requires manual instrumentation and will yield a more accurate result.
18+
- To use `manual` mode, you need to wrap your desired widget: `SentryDisplayWidget(child: MyScreen())`.
19+
- You can mix and match both modes in your app.
20+
- Other significant fixes
21+
- `didPop` doesn't trigger a new transaction
22+
- Change transaction operation name to `ui.load` instead of `navigation`
1323
- Use `recordHttpBreadcrumbs` to set iOS `enableNetworkBreadcrumbs` ([#1884](https://github.com/getsentry/sentry-dart/pull/1884))
1424
- Apply `beforeBreadcrumb` on native iOS crumbs ([#1914](https://github.com/getsentry/sentry-dart/pull/1914))
1525
- Add `maxQueueSize` to limit the number of unawaited events sent to Sentry ([#1868](https://github.com/getsentry/sentry-dart/pull/1868))

dart/lib/sentry.dart

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export 'src/utils/http_header_utils.dart';
4949
// ignore: invalid_export_of_internal_element
5050
export 'src/sentry_trace_origins.dart';
5151
// ignore: invalid_export_of_internal_element
52+
export 'src/sentry_span_operations.dart';
53+
// ignore: invalid_export_of_internal_element
5254
export 'src/utils.dart';
5355
// spotlight debugging
5456
export 'src/spotlight.dart';

dart/lib/src/sentry_measurement.dart

+7
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class SentryMeasurement {
3939
value = duration.inMilliseconds,
4040
unit = DurationSentryMeasurementUnit.milliSecond;
4141

42+
/// Duration of the time to initial display in milliseconds
43+
SentryMeasurement.timeToInitialDisplay(Duration duration)
44+
: assert(!duration.isNegative),
45+
name = 'time_to_initial_display',
46+
value = duration.inMilliseconds,
47+
unit = DurationSentryMeasurementUnit.milliSecond;
48+
4249
final String name;
4350
final num value;
4451
final SentryMeasurementUnit? unit;
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import 'package:meta/meta.dart';
2+
3+
@internal
4+
class SentrySpanOperations {
5+
static const String uiLoad = 'ui.load';
6+
static const String uiTimeToInitialDisplay = 'ui.load.initial_display';
7+
}

dart/lib/src/sentry_trace_origins.dart

+2
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ class SentryTraceOrigins {
2727
static const autoDbDriftQueryExecutor = 'auto.db.drift.query.executor';
2828
static const autoDbDriftTransactionExecutor =
2929
'auto.db.drift.transaction.executor';
30+
static const autoUiTimeToDisplay = 'auto.ui.time_to_display';
31+
static const manualUiTimeToDisplay = 'manual.ui.time_to_display';
3032
}

flutter/example/android/app/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ android {
4848

4949
defaultConfig {
5050
applicationId "io.sentry.samples.flutter"
51-
minSdkVersion 19
51+
minSdkVersion flutter.minSdkVersion
5252
targetSdkVersion 33
5353
versionCode flutterVersionCode.toInteger()
5454
versionName flutterVersionName

flutter/example/lib/main.dart

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
1313
import 'package:sentry_isar/sentry_isar.dart';
1414
import 'package:sentry_sqflite/sentry_sqflite.dart';
1515
import 'package:sqflite/sqflite.dart';
16+
1617
// import 'package:sqflite_common_ffi/sqflite_ffi.dart';
1718
// import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart';
1819
import 'package:universal_platform/universal_platform.dart';
@@ -80,6 +81,7 @@ Future<void> setupSentry(
8081
// going to log too much for your app, but can be useful when figuring out
8182
// configuration issues, e.g. finding out why your events are not uploaded.
8283
options.debug = true;
84+
options.spotlight = Spotlight(enabled: true);
8385

8486
options.maxRequestBodySize = MaxRequestBodySize.always;
8587
options.maxResponseBodySize = MaxResponseBodySize.always;
@@ -732,7 +734,7 @@ void navigateToAutoCloseScreen(BuildContext context) {
732734
context,
733735
MaterialPageRoute(
734736
settings: const RouteSettings(name: 'AutoCloseScreen'),
735-
builder: (context) => const AutoCloseScreen(),
737+
builder: (context) => SentryDisplayWidget(child: const AutoCloseScreen()),
736738
),
737739
);
738740
}

flutter/lib/sentry_flutter.dart

+1
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export 'src/screenshot/sentry_screenshot_quality.dart';
1616
export 'src/user_interaction/sentry_user_interaction_widget.dart';
1717
export 'src/binding_wrapper.dart';
1818
export 'src/sentry_widget.dart';
19+
export 'src/navigation/sentry_display_widget.dart';

flutter/lib/src/integrations/native_app_start_integration.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ class AppStartInfo {
114114
final AppStartType type;
115115
final DateTime start;
116116
final DateTime end;
117+
Duration get duration => end.difference(start);
117118

118119
SentryMeasurement toMeasurement() {
119-
final duration = end.difference(start);
120120
return type == AppStartType.cold
121121
? SentryMeasurement.coldAppStart(duration)
122122
: SentryMeasurement.warmAppStart(duration);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'package:flutter/cupertino.dart';
2+
import 'time_to_initial_display_tracker.dart';
3+
4+
import '../frame_callback_handler.dart';
5+
6+
/// A widget that reports the Time To Initially Displayed (TTID) of its child widget.
7+
///
8+
/// This widget wraps around another widget to measure and report the time it takes
9+
/// for the child widget to be initially displayed on the screen. This method
10+
/// allows a more accurate measurement than what the default TTID implementation
11+
/// provides. The TTID measurement begins when the route to the widget is pushed and ends
12+
/// when `addPostFramecallback` is triggered.
13+
///
14+
/// Wrap the widget you want to measure with [SentryDisplayWidget], and ensure that you
15+
/// have set up Sentry's routing instrumentation according to the Sentry documentation.
16+
///
17+
/// ```dart
18+
/// SentryDisplayWidget(
19+
/// child: MyWidget(),
20+
/// )
21+
/// ```
22+
///
23+
/// Make sure to configure Sentry's routing instrumentation in your app by following
24+
/// the guidelines provided in Sentry's documentation for Flutter integrations:
25+
/// https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/
26+
///
27+
/// See also:
28+
/// - [Sentry's documentation on Flutter integrations](https://docs.sentry.io/platforms/flutter/)
29+
/// for more information on how to integrate Sentry into your Flutter application.
30+
class SentryDisplayWidget extends StatefulWidget {
31+
final Widget child;
32+
final FrameCallbackHandler _frameCallbackHandler;
33+
34+
SentryDisplayWidget({
35+
super.key,
36+
required this.child,
37+
@visibleForTesting FrameCallbackHandler? frameCallbackHandler,
38+
}) : _frameCallbackHandler =
39+
frameCallbackHandler ?? DefaultFrameCallbackHandler();
40+
41+
@override
42+
_SentryDisplayWidgetState createState() => _SentryDisplayWidgetState();
43+
}
44+
45+
class _SentryDisplayWidgetState extends State<SentryDisplayWidget> {
46+
@override
47+
void initState() {
48+
super.initState();
49+
TimeToInitialDisplayTracker().markAsManual();
50+
51+
widget._frameCallbackHandler.addPostFrameCallback((_) {
52+
TimeToInitialDisplayTracker().completeTracking();
53+
});
54+
}
55+
56+
@override
57+
Widget build(BuildContext context) {
58+
return widget.child;
59+
}
60+
}

flutter/lib/src/navigation/sentry_navigator_observer.dart

+80-26
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
// ignore_for_file: invalid_use_of_internal_member
2+
3+
import 'dart:async';
4+
5+
import 'package:flutter/material.dart';
16
import 'package:flutter/widgets.dart';
27
import 'package:meta/meta.dart';
8+
import '../integrations/integrations.dart';
9+
import 'time_to_display_tracker.dart';
310

411
import '../../sentry_flutter.dart';
512
import '../event_processor/flutter_enricher_event_processor.dart';
@@ -19,6 +26,8 @@ typedef AdditionalInfoExtractor = Map<String, dynamic>? Function(
1926
/// This is a navigation observer to record navigational breadcrumbs.
2027
/// For now it only records navigation events and no gestures.
2128
///
29+
/// It also records Time to Initial Display (TTID).
30+
///
2231
/// [Route]s can always be null and their [Route.settings] can also always be null.
2332
/// For example, if the application starts, there is no previous route.
2433
/// The [RouteSettings] is null if a developer has not specified any
@@ -44,38 +53,38 @@ typedef AdditionalInfoExtractor = Map<String, dynamic>? Function(
4453
/// )
4554
/// ```
4655
///
47-
/// See the constructor docs for the argument documentation.
56+
/// The option [enableAutoTransactions] is enabled by default. For every new
57+
/// route a transaction is started. It's automatically finished after
58+
/// [autoFinishAfter] duration or when all child spans are finished,
59+
/// if those happen to take longer. The transaction will be set to [Scope.span]
60+
/// if the latter is empty.
61+
///
62+
/// Enabling the [setRouteNameAsTransaction] option overrides the current
63+
/// [Scope.transaction] which will also override the name of the current
64+
/// [Scope.span]. So be careful when this is used together with performance
65+
/// monitoring.
4866
///
4967
/// See also:
5068
/// - [RouteObserver](https://api.flutter.dev/flutter/widgets/RouteObserver-class.html)
5169
/// - [Navigating with arguments](https://flutter.dev/docs/cookbook/navigation/navigate-with-arguments)
5270
class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
53-
/// The option [enableAutoTransactions] is enabled by default.
54-
/// For every new route a transaction is started. It's automatically finished
55-
/// after [autoFinishAfter] duration or when all child spans are
56-
/// finished, if those happen to take longer.
57-
/// The transaction will be set to [Scope.span] if the latter is empty.
58-
///
59-
/// Enabling the [setRouteNameAsTransaction] option overrides the
60-
/// current [Scope.transaction] which will also override the name of the current
61-
/// [Scope.span]. So be careful when this is used together with performance
62-
/// monitoring.
6371
SentryNavigatorObserver({
6472
Hub? hub,
6573
bool enableAutoTransactions = true,
6674
Duration autoFinishAfter = const Duration(seconds: 3),
6775
bool setRouteNameAsTransaction = false,
6876
RouteNameExtractor? routeNameExtractor,
6977
AdditionalInfoExtractor? additionalInfoProvider,
78+
@visibleForTesting TimeToDisplayTracker? timeToDisplayTracker,
7079
}) : _hub = hub ?? HubAdapter(),
7180
_enableAutoTransactions = enableAutoTransactions,
7281
_autoFinishAfter = autoFinishAfter,
7382
_setRouteNameAsTransaction = setRouteNameAsTransaction,
7483
_routeNameExtractor = routeNameExtractor,
7584
_additionalInfoProvider = additionalInfoProvider,
76-
_native = SentryFlutter.native {
85+
_native = SentryFlutter.native,
86+
_timeToDisplayTracker = timeToDisplayTracker ?? TimeToDisplayTracker() {
7787
if (enableAutoTransactions) {
78-
// ignore: invalid_use_of_internal_member
7988
_hub.options.sdk.addIntegration('UINavigationTracing');
8089
}
8190
}
@@ -87,6 +96,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
8796
final RouteNameExtractor? _routeNameExtractor;
8897
final AdditionalInfoExtractor? _additionalInfoProvider;
8998
final SentryNative? _native;
99+
final TimeToDisplayTracker? _timeToDisplayTracker;
90100

91101
ISentrySpan? _transaction;
92102

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

108+
Completer<void>? _completedDisplayTracking;
109+
110+
// Since didPush does not have a future, we can keep track of when the display tracking has finished
111+
@visibleForTesting
112+
Completer<void>? get completedDisplayTracking => _completedDisplayTracking;
113+
98114
@override
99115
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
100116
super.didPush(route, previousRoute);
@@ -108,8 +124,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
108124
to: route.settings,
109125
);
110126

111-
_finishTransaction();
112-
_startTransaction(route);
127+
_finishTimeToDisplayTracking();
128+
_startTimeToDisplayTracking(route);
113129
}
114130

115131
@override
@@ -139,8 +155,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
139155
to: previousRoute?.settings,
140156
);
141157

142-
_finishTransaction();
143-
_startTransaction(previousRoute);
158+
_finishTimeToDisplayTracking();
144159
}
145160

146161
void _addBreadcrumb({
@@ -152,7 +167,6 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
152167
navigationType: type,
153168
from: _routeNameExtractor?.call(from) ?? from,
154169
to: _routeNameExtractor?.call(to) ?? to,
155-
// ignore: invalid_use_of_internal_member
156170
timestamp: _hub.options.clock(),
157171
data: _additionalInfoProvider?.call(from, to),
158172
));
@@ -179,11 +193,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
179193
}
180194
}
181195

182-
Future<void> _startTransaction(Route<dynamic>? route) async {
183-
if (!_enableAutoTransactions) {
184-
return;
185-
}
186-
196+
Future<void> _startTransaction(
197+
Route<dynamic>? route, DateTime startTimestamp) async {
187198
String? name = _getRouteName(route);
188199
final arguments = route?.settings.arguments;
189200

@@ -196,14 +207,14 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
196207
}
197208
final transactionContext = SentryTransactionContext(
198209
name,
199-
'navigation',
210+
SentrySpanOperations.uiLoad,
200211
transactionNameSource: SentryTransactionNameSource.component,
201-
// ignore: invalid_use_of_internal_member
202212
origin: SentryTraceOrigins.autoNavigationRouteObserver,
203213
);
204214

205215
_transaction = _hub.startTransactionWithContext(
206216
transactionContext,
217+
startTimestamp: startTimestamp,
207218
waitForChildren: true,
208219
autoFinishAfter: _autoFinishAfter,
209220
trimEnd: true,
@@ -242,7 +253,9 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
242253
await _native?.beginNativeFramesCollection();
243254
}
244255

245-
Future<void> _finishTransaction() async {
256+
Future<void> _finishTimeToDisplayTracking() async {
257+
_timeToDisplayTracker?.clear();
258+
246259
final transaction = _transaction;
247260
_transaction = null;
248261
if (transaction == null || transaction.finished) {
@@ -251,6 +264,47 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
251264
transaction.status ??= SpanStatus.ok();
252265
await transaction.finish();
253266
}
267+
268+
Future<void> _startTimeToDisplayTracking(Route<dynamic>? route) async {
269+
if (!_enableAutoTransactions) {
270+
return;
271+
}
272+
273+
_completedDisplayTracking = Completer<void>();
274+
String? routeName = _currentRouteName;
275+
if (routeName == null) return;
276+
277+
DateTime startTimestamp = _hub.options.clock();
278+
DateTime? endTimestamp;
279+
280+
if (routeName == '/') {
281+
final appStartInfo = await NativeAppStartIntegration.getAppStartInfo();
282+
if (appStartInfo == null) {
283+
return;
284+
}
285+
286+
startTimestamp = appStartInfo.start;
287+
endTimestamp = appStartInfo.end;
288+
}
289+
290+
await _startTransaction(route, startTimestamp);
291+
final transaction = _transaction;
292+
if (transaction == null) {
293+
return;
294+
}
295+
296+
if (routeName == '/' && endTimestamp != null) {
297+
await _timeToDisplayTracker?.trackAppStartTTD(transaction,
298+
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
299+
} else {
300+
await _timeToDisplayTracker?.trackRegularRouteTTD(transaction,
301+
startTimestamp: startTimestamp);
302+
}
303+
304+
// Mark the tracking as completed and clear any temporary state.
305+
_completedDisplayTracking?.complete();
306+
_timeToDisplayTracker?.clear();
307+
}
254308
}
255309

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

0 commit comments

Comments
 (0)