diff --git a/packages/dart/lib/src/constants.dart b/packages/dart/lib/src/constants.dart index a628fb44ca..9cd518bfd3 100644 --- a/packages/dart/lib/src/constants.dart +++ b/packages/dart/lib/src/constants.dart @@ -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`. diff --git a/packages/flutter/lib/src/integrations/native_app_start_handler_v2.dart b/packages/flutter/lib/src/integrations/native_app_start_handler_v2.dart index 9d478d1d3c..fd70907ad2 100644 --- a/packages/flutter/lib/src/integrations/native_app_start_handler_v2.dart +++ b/packages/flutter/lib/src/integrations/native_app_start_handler_v2.dart @@ -51,7 +51,11 @@ class NativeAppStartHandlerV2 { appStartInfo.appStartTypeDescription, parentSpan: rootSpan, startTimestamp: appStartInfo.start, - attributes: attributes, + attributes: { + ...attributes, + SemanticAttributesConstants.appVitalsStartScreen: + SentryAttribute.string(rootSpan.name), + }, ); final pluginRegistrationSpan = hub.startInactiveSpan( diff --git a/packages/flutter/lib/src/navigation/sentry_navigator_observer.dart b/packages/flutter/lib/src/navigation/sentry_navigator_observer.dart index fb21af7a41..0a5404e77a 100644 --- a/packages/flutter/lib/src/navigation/sentry_navigator_observer.dart +++ b/packages/flutter/lib/src/navigation/sentry_navigator_observer.dart @@ -175,6 +175,13 @@ class SentryNavigatorObserver extends RouteObserver> { _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 diff --git a/packages/flutter/lib/src/navigation/time_to_display_tracker_v2.dart b/packages/flutter/lib/src/navigation/time_to_display_tracker_v2.dart index 818eaa2311..b830dcb5cb 100644 --- a/packages/flutter/lib/src/navigation/time_to_display_tracker_v2.dart +++ b/packages/flutter/lib/src/navigation/time_to_display_tracker_v2.dart @@ -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]. /// @@ -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 @@ -56,6 +80,8 @@ class TimeToDisplayTrackerV2 { DateTime? startTimestamp, DateTime? ttidEndTimestamp, }) { + final routeName = _appStartRouteName; + _isAppStartRouteNamePending = false; final SentrySpanV2 routeSpan; switch (_preparedRootNavigationSpan) { case final prepared?: @@ -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, ); @@ -197,6 +222,7 @@ class TimeToDisplayTrackerV2 { void cancelCurrentRoute() { _ttfdSpan = null; _preparedRootNavigationSpan = null; + _isAppStartRouteNamePending = false; final activeSpan = _hub.getActiveSpan(); if (activeSpan is IdleRecordingSentrySpanV2) { diff --git a/packages/flutter/test/integrations/native_app_start_handler_v2_test.dart b/packages/flutter/test/integrations/native_app_start_handler_v2_test.dart index dd77465b2c..ac27f5df38 100644 --- a/packages/flutter/test/integrations/native_app_start_handler_v2_test.dart +++ b/packages/flutter/test/integrations/native_app_start_handler_v2_test.dart @@ -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, + '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, + '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(); diff --git a/packages/flutter/test/navigation/fake_time_to_display_tracker_v2.dart b/packages/flutter/test/navigation/fake_time_to_display_tracker_v2.dart index ab0d307cc4..19f4946a6c 100644 --- a/packages/flutter/test/navigation/fake_time_to_display_tracker_v2.dart +++ b/packages/flutter/test/navigation/fake_time_to_display_tracker_v2.dart @@ -5,6 +5,7 @@ import 'package:sentry_flutter/src/navigation/time_to_display_tracker_v2.dart'; class FakeTimeToDisplayTrackerV2 extends TimeToDisplayTrackerV2 { final List trackRouteChangeCalls = []; + final List setAppStartRouteNameCalls = []; int cancelCurrentRouteCalls = 0; @override @@ -13,6 +14,12 @@ class FakeTimeToDisplayTrackerV2 extends TimeToDisplayTrackerV2 { return NoOpSentrySpanV2(); } + @override + bool setAppStartRouteName(String? routeName) { + setAppStartRouteNameCalls.add(routeName); + return true; + } + @override void cancelCurrentRoute() { cancelCurrentRouteCalls++; diff --git a/packages/flutter/test/navigation/sentry_navigator_observer_test.dart b/packages/flutter/test/navigation/sentry_navigator_observer_test.dart index 829beaf0df..970da9106d 100644 --- a/packages/flutter/test/navigation/sentry_navigator_observer_test.dart +++ b/packages/flutter/test/navigation/sentry_navigator_observer_test.dart @@ -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(); diff --git a/packages/flutter/test/navigation/time_to_display_tracker_v2_test.dart b/packages/flutter/test/navigation/time_to_display_tracker_v2_test.dart index 5691605abc..f30bdf1143 100644 --- a/packages/flutter/test/navigation/time_to_display_tracker_v2_test.dart +++ b/packages/flutter/test/navigation/time_to_display_tracker_v2_test.dart @@ -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 [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', () {