Skip to content

Commit eca355d

Browse files
authored
fix: TTID/TTFD transaction for the root page (#3099)
* Fix TTID/TTFD not being created for root * Update * Update test * Fix analyze * Update * Update * Update test * Update test * Update * Update * Update * Update test * Update assert * Set origin to native app start as well * Update * Update * Update * Update * Update * Add comment * Update * Update * Update * Review * Review * Review * Review * Update * Update * Update * Update * Analyze * Update
1 parent 66e5d67 commit eca355d

12 files changed

+759
-97
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
### Fixes
66

77
- Debug meta not loaded for split debug info only builds ([#3104](https://github.com/getsentry/sentry-dart/pull/3104))
8+
- TTID/TTFD root transactions ([#3099](https://github.com/getsentry/sentry-dart/pull/3099))
9+
- Web, Linux and Windows now create a UI transaction for the root page
10+
- iOS, Android now correctly create idle transactions
811

912
### Dependencies
1013

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import 'package:sentry/sentry.dart';
2+
import 'package:sentry/src/platform/mock_platform.dart';
3+
import 'package:sentry/src/sentry_tracer.dart';
4+
import 'package:test/test.dart';
5+
6+
import 'mocks/mock_client_report_recorder.dart';
7+
import 'mocks/mock_log_batcher.dart';
8+
import 'mocks/mock_transport.dart';
9+
import 'sentry_client_test.dart';
10+
import 'test_utils.dart';
11+
import 'utils/url_details_test.dart';
12+
13+
void main() {
14+
group('SDK lifecycle callbacks', () {
15+
late Fixture fixture;
16+
17+
setUp(() => fixture = Fixture());
18+
19+
group('Logs', () {
20+
SentryLog givenLog() {
21+
return SentryLog(
22+
timestamp: DateTime.now(),
23+
traceId: SentryId.newId(),
24+
level: SentryLogLevel.info,
25+
body: 'test',
26+
attributes: {
27+
'attribute': SentryLogAttribute.string('value'),
28+
},
29+
);
30+
}
31+
32+
test('captureLog triggers OnBeforeCaptureLog', () async {
33+
fixture.options.enableLogs = true;
34+
fixture.options.environment = 'test-environment';
35+
fixture.options.release = 'test-release';
36+
37+
final log = givenLog();
38+
39+
final scope = Scope(fixture.options);
40+
final span = MockSpan();
41+
scope.span = span;
42+
43+
final client = fixture.getSut();
44+
fixture.options.logBatcher = MockLogBatcher();
45+
46+
client.lifeCycleRegistry.registerCallback<OnBeforeCaptureLog>((event) {
47+
event.log.attributes['test'] =
48+
SentryLogAttribute.string('test-value');
49+
});
50+
51+
await client.captureLog(log, scope: scope);
52+
53+
final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher;
54+
expect(mockLogBatcher.addLogCalls.length, 1);
55+
final capturedLog = mockLogBatcher.addLogCalls.first;
56+
57+
expect(capturedLog.attributes['test']?.value, "test-value");
58+
expect(capturedLog.attributes['test']?.type, 'string');
59+
});
60+
});
61+
62+
group('SentryEvent', () {
63+
test('captureEvent triggers OnBeforeSendEvent', () async {
64+
fixture.options.enableLogs = true;
65+
fixture.options.environment = 'test-environment';
66+
fixture.options.release = 'test-release';
67+
68+
final event = SentryEvent();
69+
70+
final scope = Scope(fixture.options);
71+
final span = MockSpan();
72+
scope.span = span;
73+
74+
final client = fixture.getSut();
75+
fixture.options.logBatcher = MockLogBatcher();
76+
77+
client.lifeCycleRegistry.registerCallback<OnBeforeSendEvent>((event) {
78+
event.event.release = '999';
79+
});
80+
81+
await client.captureEvent(event, scope: scope);
82+
83+
final capturedEnvelope = (fixture.transport).envelopes.first;
84+
final capturedEvent = await eventFromEnvelope(capturedEnvelope);
85+
86+
expect(capturedEvent.release, '999');
87+
});
88+
});
89+
});
90+
}
91+
92+
class Fixture {
93+
final recorder = MockClientReportRecorder();
94+
final transport = MockTransport();
95+
96+
final options = defaultTestOptions()
97+
..platform = MockPlatform.iOS()
98+
..groupExceptions = true;
99+
100+
late SentryTransactionContext _context;
101+
late SentryTracer tracer;
102+
103+
SentryLevel? loggedLevel;
104+
Object? loggedException;
105+
106+
SentryClient getSut({
107+
bool sendDefaultPii = false,
108+
bool attachStacktrace = true,
109+
bool attachThreads = false,
110+
double? sampleRate,
111+
BeforeSendCallback? beforeSend,
112+
BeforeSendTransactionCallback? beforeSendTransaction,
113+
BeforeSendCallback? beforeSendFeedback,
114+
EventProcessor? eventProcessor,
115+
bool provideMockRecorder = true,
116+
bool debug = false,
117+
Transport? transport,
118+
}) {
119+
options.tracesSampleRate = 1.0;
120+
options.sendDefaultPii = sendDefaultPii;
121+
options.attachStacktrace = attachStacktrace;
122+
options.attachThreads = attachThreads;
123+
options.sampleRate = sampleRate;
124+
options.beforeSend = beforeSend;
125+
options.beforeSendTransaction = beforeSendTransaction;
126+
options.beforeSendFeedback = beforeSendFeedback;
127+
options.debug = debug;
128+
options.log = mockLogger;
129+
130+
if (eventProcessor != null) {
131+
options.addEventProcessor(eventProcessor);
132+
}
133+
134+
// Internally also creates a SentryClient instance
135+
final hub = Hub(options);
136+
_context = SentryTransactionContext(
137+
'name',
138+
'op',
139+
);
140+
tracer = SentryTracer(_context, hub);
141+
142+
// Reset transport
143+
options.transport = transport ?? this.transport;
144+
145+
// Again create SentryClient instance
146+
final client = SentryClient(options);
147+
148+
if (provideMockRecorder) {
149+
options.recorder = recorder;
150+
}
151+
return client;
152+
}
153+
154+
Future<SentryEvent?> droppingBeforeSend(SentryEvent event, Hint hint) async {
155+
return null;
156+
}
157+
158+
SentryTransaction fakeTransaction() {
159+
return SentryTransaction(
160+
tracer,
161+
sdk: SdkVersion(name: 'sdk1', version: '1.0.0'),
162+
breadcrumbs: [],
163+
);
164+
}
165+
166+
SentryEvent fakeFeedbackEvent() {
167+
return SentryEvent(
168+
type: 'feedback',
169+
contexts: Contexts(feedback: fakeFeedback()),
170+
level: SentryLevel.info,
171+
);
172+
}
173+
174+
SentryFeedback fakeFeedback() {
175+
return SentryFeedback(
176+
message: 'fixture-message',
177+
contactEmail: 'fixture-contactEmail',
178+
name: 'fixture-name',
179+
replayId: 'fixture-replayId',
180+
url: "https://fixture-url.com",
181+
associatedEventId: SentryId.fromId('1d49af08b6e2c437f9052b1ecfd83dca'),
182+
);
183+
}
184+
185+
void mockLogger(
186+
SentryLevel level,
187+
String message, {
188+
String? logger,
189+
Object? exception,
190+
StackTrace? stackTrace,
191+
}) {
192+
loggedLevel = level;
193+
loggedException = exception;
194+
}
195+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// ignore_for_file: invalid_use_of_internal_member
2+
3+
import 'package:meta/meta.dart';
4+
5+
import '../../sentry_flutter.dart';
6+
import '../frame_callback_handler.dart';
7+
8+
// TODO(buenaflor): marking this internal until we can find a robust way to unify the TTID/TTFD implementation as currently it is very fragmented.
9+
10+
/// A fallback app–start integration for platforms without built-in app-start timing.
11+
///
12+
/// The Sentry Cocoa and Android SDKs include calls to capture the
13+
/// exact application start timestamp. Other platforms—such as web, desktop,
14+
/// or any SDK that doesn’t (yet) expose app-start instrumentation can use this
15+
/// integration as a reasonable alternative. It measures the duration from
16+
/// integration call to the first completed frame.
17+
@internal
18+
class GenericAppStartIntegration extends Integration<SentryFlutterOptions> {
19+
GenericAppStartIntegration([FrameCallbackHandler? frameHandler])
20+
: _framesHandler = frameHandler ?? DefaultFrameCallbackHandler();
21+
22+
final FrameCallbackHandler _framesHandler;
23+
24+
static const String integrationName = 'GenericAppStart';
25+
26+
@override
27+
void call(Hub hub, SentryFlutterOptions options) {
28+
if (!options.isTracingEnabled()) return;
29+
30+
final transactionContext = SentryTransactionContext(
31+
'root /',
32+
SentrySpanOperations.uiLoad,
33+
origin: SentryTraceOrigins.autoUiTimeToDisplay,
34+
);
35+
36+
final startTimeStamp = options.clock();
37+
final transaction = hub.startTransactionWithContext(
38+
transactionContext,
39+
startTimestamp: startTimeStamp,
40+
waitForChildren: true,
41+
autoFinishAfter: Duration(seconds: 3),
42+
bindToScope: true,
43+
trimEnd: true,
44+
);
45+
46+
options.timeToDisplayTracker.transactionId = transactionContext.spanId;
47+
48+
_framesHandler.addPostFrameCallback((_) async {
49+
try {
50+
final endTimestamp = options.clock();
51+
await options.timeToDisplayTracker.track(
52+
transaction,
53+
ttidEndTimestamp: endTimestamp,
54+
);
55+
56+
// Note: we do not set app start transaction measurements (yet) on purpose
57+
// This integration is used for TTID/TTFD mainly
58+
// However this may change in the future.
59+
} catch (exception, stackTrace) {
60+
options.log(
61+
SentryLevel.error,
62+
'An exception occurred while executing the $GenericAppStartIntegration',
63+
exception: exception,
64+
stackTrace: stackTrace,
65+
);
66+
if (options.automatedTestMode) {
67+
rethrow;
68+
}
69+
}
70+
});
71+
72+
options.sdk.addIntegration(integrationName);
73+
}
74+
}

flutter/lib/src/integrations/native_app_start_handler.dart

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,10 @@ class NativeAppStartHandler {
4444
final rootScreenTransaction = _hub.startTransactionWithContext(
4545
context,
4646
startTimestamp: appStartInfo.start,
47-
);
48-
49-
// Bind to scope if null
50-
await _hub.configureScope((scope) {
51-
scope.span ??= rootScreenTransaction;
52-
});
53-
54-
await options.timeToDisplayTracker.track(
55-
rootScreenTransaction,
56-
ttidEndTimestamp: appStartInfo.end,
47+
waitForChildren: true,
48+
autoFinishAfter: Duration(seconds: 3),
49+
bindToScope: true,
50+
trimEnd: true,
5751
);
5852

5953
SentryTracer sentryTracer;
@@ -63,20 +57,17 @@ class NativeAppStartHandler {
6357
return;
6458
}
6559

66-
// Enrich Transaction
60+
// We need to add the measurements before we add the child spans
61+
// If the child span finish the transaction will finish and then we cannot add measurements
62+
// TODO(buenaflor): eventually we can move this to the onFinish callback
6763
SentryMeasurement? measurement = appStartInfo.toMeasurement();
6864
sentryTracer.measurements[measurement.name] = appStartInfo.toMeasurement();
69-
await _attachAppStartSpans(appStartInfo, sentryTracer);
7065

71-
// Remove from scope
72-
await _hub.configureScope((scope) {
73-
if (scope.span == rootScreenTransaction) {
74-
scope.span = null;
75-
}
76-
});
77-
78-
// Finish Transaction
79-
await rootScreenTransaction.finish(endTimestamp: appStartInfo.end);
66+
await options.timeToDisplayTracker.track(
67+
rootScreenTransaction,
68+
ttidEndTimestamp: appStartInfo.end,
69+
);
70+
await _attachAppStartSpans(appStartInfo, sentryTracer);
8071
}
8172

8273
_AppStartInfo? _infoNativeAppStart(

flutter/lib/src/integrations/native_app_start_integration.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// ignore_for_file: invalid_use_of_internal_member
2+
13
import 'dart:ui';
24

35
import 'package:meta/meta.dart';
@@ -31,8 +33,8 @@ class NativeAppStartIntegration extends Integration<SentryFlutterOptions> {
3133
// Create context early so we have an id to refernce for reporting full display
3234
final context = SentryTransactionContext(
3335
'root /',
34-
// ignore: invalid_use_of_internal_member
3536
SentrySpanOperations.uiLoad,
37+
origin: SentryTraceOrigins.autoUiTimeToDisplay,
3638
);
3739
options.timeToDisplayTracker.transactionId = context.spanId;
3840

@@ -45,7 +47,6 @@ class NativeAppStartIntegration extends Integration<SentryFlutterOptions> {
4547
_allowProcessing = false;
4648

4749
try {
48-
// ignore: invalid_use_of_internal_member
4950
final appStartEnd = DateTime.fromMicrosecondsSinceEpoch(timings.first
5051
.timestampInMicroseconds(FramePhase.rasterFinishWallTime));
5152
await _nativeAppStartHandler.call(

flutter/lib/src/navigation/sentry_navigator_observer.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,8 @@ class SentryNavigatorObserver extends RouteObserver<PageRoute<dynamic>> {
287287
final routeName = _getRouteName(route) ?? _currentRouteName;
288288
final arguments = route?.settings.arguments;
289289

290-
final isRoot = routeName ==
291-
'/'; // Root transaction is already created by the app start integration.
290+
// Skip root - app start integrations create TTID/TTFD for root
291+
final isRoot = routeName == '/';
292292
if (!_enableAutoTransactions || routeName == null || isRoot) {
293293
return;
294294
}

0 commit comments

Comments
 (0)