Skip to content

Commit d089990

Browse files
feat: ttfd (#1920)
* 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 child timestamp trimming * Update CHANGELOG * Run formatting * Update docs * Revert * Fix test * Move clear to the beginning of function * initial commit * Fix start time * Fix analyze * remove comment * Formatting * update * fix test * Add changelog * Update * Update * fix analyze * fix tests * formatting * 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 * Get display tracker as static for reportFullyDisplayed() * Add @internal * Fix test * Improve tests * Reduce fake frame finishing time and improve tests * Improve test names * Fix tests * Apply formatting * Add extra assertion in tests * Improve * Use utc date time * Fix test * Fix dartdoc * Update test * Update test * Fix tests * Fix changelog * Update * Improve * Update * Improve tests * Update * Change function to private * Update CHANGELOG.md * Rename function * add improvements (not all) * Fix tests * Update changelog * Finish after setting scope span to null * update * updaet * update * clear first in didPush * update * update example * add improvements --------- Co-authored-by: Philipp Hofmann <[email protected]>
1 parent e8603bb commit d089990

16 files changed

+886
-125
lines changed

CHANGELOG.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
# Changelog
22

33
## Unreleased
4-
4+
55
## Features
66

7+
- Add TTFD (time to full display), which allows you to measure the time it takes to render the full screen ([#1920](https://github.com/getsentry/sentry-dart/pull/1920))
8+
- Requires using the [routing instrumentation](https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/).
9+
- Set `enableTimeToFullDisplayTracing = true` in your `SentryFlutterOptions` to enable TTFD
10+
- Manually report the end of the full display by calling `SentryFlutter.reportFullyDisplayed()`
11+
- If not reported within 30 seconds, the span will be automatically finish with the status `deadline_exceeded`
12+
- 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))
13+
- Requires using the [routing instrumentation](https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/).
14+
- Introduces two modes:
15+
- `automatic` mode is enabled by default for all screens and will yield only an approximation result.
16+
- `manual` mode requires manual instrumentation and will yield a more accurate result.
17+
- To use `manual` mode, you need to wrap your desired widget: `SentryDisplayWidget(child: MyScreen())`.
18+
- You can mix and match both modes in your app.
19+
- Other significant fixes
20+
- `didPop` doesn't trigger a new transaction
21+
- Change transaction operation name to `ui.load` instead of `navigation`
722
- Add override `captureFailedRequests` option ([#1931](https://github.com/getsentry/sentry-dart/pull/1931))
823
- The `dio` integration and `SentryHttpClient` now take an additional `captureFailedRequests` option.
924
- This is useful if you want to disable this option on native and only enable it on `dio` for example.
@@ -23,17 +38,7 @@
2338
- remove transitive dart:io reference for web ([#1898](https://github.com/getsentry/sentry-dart/pull/1898))
2439

2540
### Features
26-
27-
- 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))
28-
- Requires using the [routing instrumentation](https://docs.sentry.io/platforms/flutter/integrations/routing-instrumentation/).
29-
- Introduces two modes:
30-
- `automatic` mode is enabled by default for all screens and will yield only an approximation result.
31-
- `manual` mode requires manual instrumentation and will yield a more accurate result.
32-
- To use `manual` mode, you need to wrap your desired widget: `SentryDisplayWidget(child: MyScreen())`.
33-
- You can mix and match both modes in your app.
34-
- Other significant fixes
35-
- `didPop` doesn't trigger a new transaction
36-
- Change transaction operation name to `ui.load` instead of `navigation`
41+
-
3742
- Use `recordHttpBreadcrumbs` to set iOS `enableNetworkBreadcrumbs` ([#1884](https://github.com/getsentry/sentry-dart/pull/1884))
3843
- Apply `beforeBreadcrumb` on native iOS crumbs ([#1914](https://github.com/getsentry/sentry-dart/pull/1914))
3944
- Add `maxQueueSize` to limit the number of unawaited events sent to Sentry ([#1868](https://github.com/getsentry/sentry-dart/pull/1868))

dart/lib/src/sentry_measurement.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ class SentryMeasurement {
4646
value = duration.inMilliseconds,
4747
unit = DurationSentryMeasurementUnit.milliSecond;
4848

49+
/// Duration of the time to full display in milliseconds
50+
SentryMeasurement.timeToFullDisplay(Duration duration)
51+
: assert(!duration.isNegative),
52+
name = 'time_to_full_display',
53+
value = duration.inMilliseconds,
54+
unit = DurationSentryMeasurementUnit.milliSecond;
55+
4956
final String name;
5057
final num value;
5158
final SentryMeasurementUnit? unit;

dart/lib/src/sentry_span_operations.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ import 'package:meta/meta.dart';
44
class SentrySpanOperations {
55
static const String uiLoad = 'ui.load';
66
static const String uiTimeToInitialDisplay = 'ui.load.initial_display';
7+
static const String uiTimeToFullDisplay = 'ui.load.full_display';
78
}

dart/test/sentry_tracer_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ void main() {
389389
test('end trimmed to latest child end timestamp', () async {
390390
final sut = fixture.getSut(trimEnd: true);
391391
final rootEndInitial = getUtcDateTime();
392+
392393
final childAEnd = rootEndInitial;
393394
final childBEnd = rootEndInitial.add(Duration(seconds: 1));
394395
final childCEnd = rootEndInitial;

flutter/example/lib/auto_close_screen.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import 'package:dio/dio.dart';
12
import 'package:flutter/material.dart';
2-
import 'package:sentry/sentry.dart';
3+
import 'package:sentry_dio/sentry_dio.dart';
4+
import 'package:sentry_flutter/sentry_flutter.dart';
5+
6+
import 'main.dart';
37

48
/// This screen is only used to demonstrate how route navigation works.
59
/// Init will create a child span and pop the screen after 3 seconds.
@@ -21,11 +25,14 @@ class AutoCloseScreenState extends State<AutoCloseScreen> {
2125
}
2226

2327
Future<void> _doComplexOperationThenClose() async {
24-
final activeSpan = Sentry.getSpan();
25-
final childSpan = activeSpan?.startChild('complex operation',
26-
description: 'running a $delayInSeconds seconds operation');
27-
await Future.delayed(const Duration(seconds: delayInSeconds));
28-
childSpan?.finish();
28+
final dio = Dio();
29+
dio.addSentry();
30+
try {
31+
await dio.get<String>(exampleUrl);
32+
} catch (exception, stackTrace) {
33+
await Sentry.captureException(exception, stackTrace: stackTrace);
34+
}
35+
SentryFlutter.reportFullyDisplayed();
2936
// ignore: use_build_context_synchronously
3037
Navigator.of(context).pop();
3138
}

flutter/example/lib/main.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Future<void> setupSentry(
8282
// configuration issues, e.g. finding out why your events are not uploaded.
8383
options.debug = true;
8484
options.spotlight = Spotlight(enabled: true);
85+
options.enableTimeToFullDisplayTracing = true;
8586

8687
options.maxRequestBodySize = MaxRequestBodySize.always;
8788
options.maxResponseBodySize = MaxResponseBodySize.always;

flutter/lib/src/navigation/sentry_navigator_observer.dart

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import '../../sentry_flutter.dart';
1212
import '../event_processor/flutter_enricher_event_processor.dart';
1313
import '../native/sentry_native.dart';
1414

15+
// ignore: implementation_imports
16+
import 'package:sentry/src/sentry_tracer.dart';
17+
1518
/// This key must be used so that the web interface displays the events nicely
1619
/// See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/
1720
const _navigationKey = 'navigation';
@@ -82,11 +85,23 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
8285
_setRouteNameAsTransaction = setRouteNameAsTransaction,
8386
_routeNameExtractor = routeNameExtractor,
8487
_additionalInfoProvider = additionalInfoProvider,
85-
_native = SentryFlutter.native,
86-
_timeToDisplayTracker = timeToDisplayTracker ?? TimeToDisplayTracker() {
88+
_native = SentryFlutter.native {
8789
if (enableAutoTransactions) {
8890
_hub.options.sdk.addIntegration('UINavigationTracing');
8991
}
92+
_timeToDisplayTracker =
93+
timeToDisplayTracker ?? _initializeTimeToDisplayTracker();
94+
}
95+
96+
/// Initializes the TimeToDisplayTracker with the option to enable time to full display tracing.
97+
TimeToDisplayTracker _initializeTimeToDisplayTracker() {
98+
bool enableTimeToFullDisplayTracing = false;
99+
final options = _hub.options;
100+
if (options is SentryFlutterOptions) {
101+
enableTimeToFullDisplayTracing = options.enableTimeToFullDisplayTracing;
102+
}
103+
return TimeToDisplayTracker(
104+
enableTimeToFullDisplayTracing: enableTimeToFullDisplayTracing);
90105
}
91106

92107
final Hub _hub;
@@ -96,7 +111,11 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
96111
final RouteNameExtractor? _routeNameExtractor;
97112
final AdditionalInfoExtractor? _additionalInfoProvider;
98113
final SentryNative? _native;
99-
final TimeToDisplayTracker? _timeToDisplayTracker;
114+
static TimeToDisplayTracker? _timeToDisplayTracker;
115+
116+
@internal
117+
static TimeToDisplayTracker? get timeToDisplayTracker =>
118+
_timeToDisplayTracker;
100119

101120
ISentrySpan? _transaction;
102121

@@ -105,7 +124,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
105124
@internal
106125
static String? get currentRouteName => _currentRouteName;
107126

108-
Completer<void>? _completedDisplayTracking;
127+
Completer<void>? _completedDisplayTracking = Completer();
109128

110129
// Since didPush does not have a future, we can keep track of when the display tracking has finished
111130
@visibleForTesting
@@ -124,6 +143,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
124143
to: route.settings,
125144
);
126145

146+
// Clearing the display tracker here is safe since didPush happens before the Widget is built
147+
_timeToDisplayTracker?.clear();
127148
_finishTimeToDisplayTracking();
128149
_startTimeToDisplayTracking(route);
129150
}
@@ -155,7 +176,7 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
155176
to: previousRoute?.settings,
156177
);
157178

158-
_finishTimeToDisplayTracking();
179+
_finishTimeToDisplayTracking(clearAfter: true);
159180
}
160181

161182
void _addBreadcrumb({
@@ -253,56 +274,96 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
253274
await _native?.beginNativeFramesCollection();
254275
}
255276

256-
Future<void> _finishTimeToDisplayTracking() async {
257-
_timeToDisplayTracker?.clear();
258-
277+
Future<void> _finishTimeToDisplayTracking({bool clearAfter = false}) async {
259278
final transaction = _transaction;
260279
_transaction = null;
261-
if (transaction == null || transaction.finished) {
262-
return;
280+
try {
281+
_hub.configureScope((scope) {
282+
if (scope.span == transaction) {
283+
scope.span = null;
284+
}
285+
});
286+
287+
if (transaction == null || transaction.finished) {
288+
return;
289+
}
290+
291+
// Cancel unfinished TTID/TTFD spans, e.g this might happen if the user navigates
292+
// away from the current route before TTFD or TTID is finished.
293+
for (final child in (transaction as SentryTracer).children) {
294+
final isTTIDSpan = child.context.operation ==
295+
SentrySpanOperations.uiTimeToInitialDisplay;
296+
final isTTFDSpan =
297+
child.context.operation == SentrySpanOperations.uiTimeToFullDisplay;
298+
if (!child.finished && (isTTIDSpan || isTTFDSpan)) {
299+
await child.finish(status: SpanStatus.deadlineExceeded());
300+
}
301+
}
302+
} catch (exception, stacktrace) {
303+
_hub.options.logger(
304+
SentryLevel.error,
305+
'Error while finishing time to display tracking',
306+
exception: exception,
307+
stackTrace: stacktrace,
308+
);
309+
} finally {
310+
await transaction?.finish();
311+
if (clearAfter) {
312+
_clear();
313+
}
263314
}
264-
transaction.status ??= SpanStatus.ok();
265-
await transaction.finish();
266315
}
267316

268317
Future<void> _startTimeToDisplayTracking(Route<dynamic>? route) async {
269-
if (!_enableAutoTransactions) {
270-
return;
271-
}
318+
try {
319+
final routeName = _getRouteName(route) ?? _currentRouteName;
320+
if (!_enableAutoTransactions || routeName == null) {
321+
return;
322+
}
272323

273-
_completedDisplayTracking = Completer<void>();
274-
String? routeName = _currentRouteName;
275-
if (routeName == null) return;
324+
bool isAppStart = routeName == '/';
325+
DateTime startTimestamp = _hub.options.clock();
326+
DateTime? endTimestamp;
276327

277-
DateTime startTimestamp = _hub.options.clock();
278-
DateTime? endTimestamp;
328+
if (isAppStart) {
329+
final appStartInfo = await NativeAppStartIntegration.getAppStartInfo();
330+
if (appStartInfo == null) return;
279331

280-
if (routeName == '/') {
281-
final appStartInfo = await NativeAppStartIntegration.getAppStartInfo();
282-
if (appStartInfo == null) {
283-
return;
332+
startTimestamp = appStartInfo.start;
333+
endTimestamp = appStartInfo.end;
284334
}
285335

286-
startTimestamp = appStartInfo.start;
287-
endTimestamp = appStartInfo.end;
288-
}
336+
await _startTransaction(route, startTimestamp);
289337

290-
await _startTransaction(route, startTimestamp);
291-
final transaction = _transaction;
292-
if (transaction == null) {
293-
return;
294-
}
338+
final transaction = _transaction;
339+
if (transaction == null) {
340+
return;
341+
}
295342

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);
343+
if (isAppStart && endTimestamp != null) {
344+
await _timeToDisplayTracker?.trackAppStartTTD(transaction,
345+
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
346+
} else {
347+
await _timeToDisplayTracker?.trackRegularRouteTTD(transaction,
348+
startTimestamp: startTimestamp);
349+
}
350+
} catch (exception, stacktrace) {
351+
_hub.options.logger(
352+
SentryLevel.error,
353+
'Error while tracking time to display',
354+
exception: exception,
355+
stackTrace: stacktrace,
356+
);
357+
} finally {
358+
_clear();
302359
}
360+
}
303361

304-
// Mark the tracking as completed and clear any temporary state.
305-
_completedDisplayTracking?.complete();
362+
void _clear() {
363+
if (_completedDisplayTracking?.isCompleted == false) {
364+
_completedDisplayTracking?.complete();
365+
}
366+
_completedDisplayTracking = Completer();
306367
_timeToDisplayTracker?.clear();
307368
}
308369
}
Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,55 @@
11
// ignore_for_file: invalid_use_of_internal_member
22

3-
import 'dart:async';
4-
53
import 'package:meta/meta.dart';
64

75
import '../../sentry_flutter.dart';
6+
import 'time_to_full_display_tracker.dart';
87
import 'time_to_initial_display_tracker.dart';
98

109
@internal
1110
class TimeToDisplayTracker {
1211
final TimeToInitialDisplayTracker _ttidTracker;
12+
final TimeToFullDisplayTracker? _ttfdTracker;
13+
final bool enableTimeToFullDisplayTracing;
1314

1415
TimeToDisplayTracker({
1516
TimeToInitialDisplayTracker? ttidTracker,
16-
}) : _ttidTracker = ttidTracker ?? TimeToInitialDisplayTracker();
17+
TimeToFullDisplayTracker? ttfdTracker,
18+
required this.enableTimeToFullDisplayTracing,
19+
}) : _ttidTracker = ttidTracker ?? TimeToInitialDisplayTracker(),
20+
_ttfdTracker = enableTimeToFullDisplayTracing
21+
? ttfdTracker ?? TimeToFullDisplayTracker()
22+
: null;
1723

1824
Future<void> trackAppStartTTD(ISentrySpan transaction,
1925
{required DateTime startTimestamp,
2026
required DateTime endTimestamp}) async {
2127
// We start and immediately finish the spans since we cannot mutate the history of spans.
2228
await _ttidTracker.trackAppStart(transaction,
2329
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
30+
await _trackTTFDIfEnabled(transaction, startTimestamp);
2431
}
2532

2633
Future<void> trackRegularRouteTTD(ISentrySpan transaction,
2734
{required DateTime startTimestamp}) async {
2835
await _ttidTracker.trackRegularRoute(transaction, startTimestamp);
36+
await _trackTTFDIfEnabled(transaction, startTimestamp);
37+
}
38+
39+
Future<void> _trackTTFDIfEnabled(
40+
ISentrySpan transaction, DateTime startTimestamp) async {
41+
if (enableTimeToFullDisplayTracing) {
42+
await _ttfdTracker?.track(transaction, startTimestamp);
43+
}
44+
}
45+
46+
@internal
47+
Future<void> reportFullyDisplayed() async {
48+
return _ttfdTracker?.reportFullyDisplayed();
2949
}
3050

3151
void clear() {
3252
_ttidTracker.clear();
53+
_ttfdTracker?.clear();
3354
}
3455
}

0 commit comments

Comments
 (0)