Skip to content
3 changes: 3 additions & 0 deletions packages/dart/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ abstract class SemanticAttributesConstants {
/// The type of the app start. (cold or warm)
static const appVitalsStartType = 'app.vitals.start.type';

/// The screen displayed by the app start.
static const appVitalsStartScreen = 'app.vitals.start.screen';

/// The user ID.
/// Users are always manually set and never automatically inferred,
/// therefore this is not gated by `sendDefaultPii`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ class NativeAppStartHandlerV2 {
appStartInfo.appStartTypeDescription,
parentSpan: rootSpan,
startTimestamp: appStartInfo.start,
attributes: attributes,
attributes: {
...attributes,
SemanticAttributesConstants.appVitalsStartScreen:
SentryAttribute.string(rootSpan.name),
Comment thread
lucas-zimerman marked this conversation as resolved.
Resolved
Comment thread
sentry[bot] marked this conversation as resolved.
Resolved
Comment thread
buenaflor marked this conversation as resolved.
Resolved
},
);

final pluginRegistrationSpan = hub.startInactiveSpan(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
_addWebSessions(from: previousRoute, to: route);

final routeName = _getRouteName(route) ?? _currentRouteName;
final isInitialAppStartRoute = previousRoute == null &&
_hub.options.traceLifecycle == SentryTraceLifecycle.stream &&
(_timeToDisplayTrackerV2?.setAppStartRouteName(routeName) ?? false);
if (isInitialAppStartRoute) {
return;
}

if (routeName != null && routeName != '/') {
// Don't generate a new trace on initial app start / root
// During SentryFlutter.init a traceId is already created
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class TimeToDisplayTrackerV2 {
final Hub _hub;
final FrameCallbackHandler _frameCallbackHandler;
SentrySpanV2? _ttfdSpan;
String _appStartRouteName = _rootRouteName;
bool _isAppStartRouteNamePending = false;

/// Prepared root idle span, consumed by [trackAppStart].
///
Expand Down Expand Up @@ -40,12 +42,34 @@ class TimeToDisplayTrackerV2 {
'prepareRootNavigation called while a prepared span is still pending');

cancelCurrentRoute();
_appStartRouteName = _rootRouteName;
_isAppStartRouteNamePending = true;

final routeSpan = _createRouteSpan(_rootRouteName);
_preparedRootNavigationSpan = routeSpan;
_ensureTtfdSpan(routeSpan, _rootRouteName);
}

/// Updates the prepared app-start spans with the first rendered route.
///
/// Returns whether a pending app start consumed the route name.
bool setAppStartRouteName(String? routeName) {
if (!_isAppStartRouteNamePending) return false;

_isAppStartRouteNamePending = false;
final resolvedRouteName =
routeName == null || routeName == '/' ? _rootRouteName : routeName;
_appStartRouteName = resolvedRouteName;

if (_preparedRootNavigationSpan case final prepared?) {
prepared.name = resolvedRouteName;
}
if (_ttfdSpan case final ttfd?) {
ttfd.name = '$resolvedRouteName full display';
}
return true;
}

/// Tracks the app start (native or generic).
///
/// If [prepareAppStart] was called earlier, reuses that span and
Expand All @@ -56,6 +80,8 @@ class TimeToDisplayTrackerV2 {
DateTime? startTimestamp,
DateTime? ttidEndTimestamp,
}) {
final routeName = _appStartRouteName;
_isAppStartRouteNamePending = false;
final SentrySpanV2 routeSpan;
switch (_preparedRootNavigationSpan) {
case final prepared?:
Expand All @@ -70,13 +96,12 @@ class TimeToDisplayTrackerV2 {
}
routeSpan = prepared;
case null:
routeSpan =
_createRouteSpan(_rootRouteName, startTimestamp: startTimestamp);
routeSpan = _createRouteSpan(routeName, startTimestamp: startTimestamp);
}

_trackDisplaySpans(
routeSpan,
_rootRouteName,
routeName,
startTimestamp: startTimestamp,
ttidEndTimestamp: ttidEndTimestamp,
);
Expand Down Expand Up @@ -197,6 +222,7 @@ class TimeToDisplayTrackerV2 {
void cancelCurrentRoute() {
_ttfdSpan = null;
_preparedRootNavigationSpan = null;
_isAppStartRouteNamePending = false;

final activeSpan = _hub.getActiveSpan();
if (activeSpan is IdleRecordingSentrySpanV2) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,65 @@ void main() {
});

group('when emitting app start vitals', () {
test('does not add initial screen to breakdown app start spans',
() async {
await fixture.call();

final appStartSpan = fixture.findSpanByName('Cold Start')!;
final phaseSpan =
fixture.findSpanByName('App start to plugin registration')!;
final nativeSpan = fixture.findSpanByName('native span 1')!;

expect(
appStartSpan.attributes['app.vitals.start.screen']?.value,
'root /',
);
expect(
phaseSpan.attributes['app.vitals.start.screen'],
isNull,
);
expect(
nativeSpan.attributes['app.vitals.start.screen'],
isNull,
);
});

test('adds initial screen to the warm app start span', () async {
when(fixture.nativeBinding.fetchNativeAppStart())
.thenAnswer((_) async => fixture.warmNativeAppStart);

await fixture.call();

final appStartSpan = fixture.findSpanByName('Warm Start')!;
expect(
appStartSpan.attributes['app.vitals.start.screen']?.value,
Comment thread
buenaflor marked this conversation as resolved.
Resolved
'root /',
);
});

test('adds initial screen to the cold app start span', () async {
await fixture.call();

final appStartSpan = fixture.findSpanByName('Cold Start')!;
expect(
appStartSpan.attributes['app.vitals.start.screen']?.value,
Comment thread
buenaflor marked this conversation as resolved.
Resolved
'root /',
);
});

test('adds a custom initial route to the app start span', () async {
fixture.options.timeToDisplayTrackerV2.prepareAppStart();
fixture.options.timeToDisplayTrackerV2.setAppStartRouteName('/login');

await fixture.call();

final appStartSpan = fixture.findSpanByName('Cold Start')!;
expect(
appStartSpan.attributes['app.vitals.start.screen']?.value,
'/login',
);
});

test('cold start emits legacy cold value and unified value and type',
() async {
await fixture.call();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:sentry_flutter/src/navigation/time_to_display_tracker_v2.dart';

class FakeTimeToDisplayTrackerV2 extends TimeToDisplayTrackerV2 {
final List<String> trackRouteChangeCalls = [];
final List<String?> setAppStartRouteNameCalls = [];
int cancelCurrentRouteCalls = 0;

@override
Expand All @@ -13,6 +14,12 @@ class FakeTimeToDisplayTrackerV2 extends TimeToDisplayTrackerV2 {
return NoOpSentrySpanV2();
}

@override
bool setAppStartRouteName(String? routeName) {
setAppStartRouteNameCalls.add(routeName);
return true;
}

@override
void cancelCurrentRoute() {
cancelCurrentRouteCalls++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,12 +1155,27 @@ void main() {
test('didPush tracks the new route change', () {
final sut = streamingFixture.getSut();

sut.didPush(route(RouteSettings(name: '/dashboard')), null);
sut.didPush(
route(RouteSettings(name: '/dashboard')),
route(RouteSettings(name: '/')),
);

expect(
streamingFixture.fakeTracker.trackRouteChangeCalls, ['/dashboard']);
});

test('didPush records a custom initial route for app start', () {
final sut = streamingFixture.getSut();

sut.didPush(route(RouteSettings(name: '/login')), null);

expect(
streamingFixture.fakeTracker.setAppStartRouteNameCalls,
['/login'],
);
expect(streamingFixture.fakeTracker.trackRouteChangeCalls, isEmpty);
});

test('didPush does not call tracker for root route', () {
final sut = streamingFixture.getSut();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,54 @@ void main() {
expect(ttidSpan.isEnded, isTrue);
expect(ttidSpan.endTimestamp, equals(ttidEnd));
});

test('keeps the first initial route name', () {
final sut = fixture.getSut();

sut.prepareAppStart();
final firstRouteWasSet = sut.setAppStartRouteName('/login');
final secondRouteWasSet = sut.setAppStartRouteName('/dashboard');
final routeSpan = sut.trackAppStart();

expect(firstRouteWasSet, isTrue);
expect(secondRouteWasSet, isFalse);
expect(routeSpan.name, '/login');
});

test('uses a custom initial route for prepared app start spans', () {
final sut = fixture.getSut();
final childSpans = fixture.captureChildSpans();

sut.prepareAppStart();
sut.setAppStartRouteName('/login');
final routeSpan = sut.trackAppStart();

expect(routeSpan.name, '/login');
expect(
childSpans.map((span) => span.name),
containsAll([
'/login initial display',
'/login full display',
]),
);
});

test('uses root name for an unknown or slash initial route', () {
for (final routeName in <String?>[null, '/']) {
final localFixture = Fixture();
final sut = localFixture.getSut();

sut.prepareAppStart();
sut.setAppStartRouteName(routeName);
final routeSpan = sut.trackAppStart();

expect(
routeSpan.name,
'root /',
reason: 'Unexpected app start name for route $routeName',
);
}
});
});

group('when reporting fully displayed', () {
Expand Down
Loading