Skip to content

Commit cba2765

Browse files
authored
fix(app-start): Fixes cold/warm start spans not attaching if TTFD takes more than 3 seconds to report (#3404)
* fix(native_app_start): ensure app start spans are attached before TTFD tracking completes This update modifies the NativeAppStartHandler to attach app start spans prior to the completion of time-to-full-display (TTFD) tracking. A regression test has been added to verify that app start spans are present when TTFD tracking is enabled, addressing a previous issue where spans could be missing if reportFullyDisplayed() was called after autoFinishAfter. * refactor(native_app_start): improve test isolation and clarity for app start spans This commit enhances the test for app start spans by ensuring they are attached before time-to-full-display (TTFD) tracking starts. A new spy wrapper is introduced to capture transaction state during tracking, improving test reliability and clarity. The test now verifies that the 'Cold Start' span is present when track() is called, addressing potential race conditions in span attachment. * Update CHANGELOG * Update CHANGELOG
1 parent 7bf0e72 commit cba2765

File tree

3 files changed

+80
-1
lines changed

3 files changed

+80
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixes
6+
7+
- Cold/warm start spans not attaching if TTFD takes more than 3 seconds to report ([#3404](https://github.com/getsentry/sentry-dart/pull/3404))
8+
39
## 9.9.0
410

511
### Features

packages/flutter/lib/src/integrations/native_app_start_handler.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ class NativeAppStartHandler {
6464
SentryMeasurement? measurement = appStartInfo.toMeasurement();
6565
sentryTracer.measurements[measurement.name] = appStartInfo.toMeasurement();
6666

67+
await _attachAppStartSpans(appStartInfo, sentryTracer);
6768
await options.timeToDisplayTracker.track(
6869
rootScreenTransaction,
6970
ttidEndTimestamp: appStartInfo.end,
7071
);
71-
await _attachAppStartSpans(appStartInfo, sentryTracer);
7272
}
7373

7474
_AppStartInfo? _infoNativeAppStart(

packages/flutter/test/integrations/native_app_start_handler_test.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:sentry_flutter/src/integrations/native_app_start_handler.dart';
1010
import 'package:sentry_flutter/src/integrations/native_app_start_integration.dart';
1111
import 'package:sentry/src/sentry_tracer.dart';
1212
import 'package:sentry_flutter/src/native/native_app_start.dart';
13+
import 'package:sentry_flutter/src/navigation/time_to_display_tracker.dart';
1314

1415
import '../mocks.dart';
1516
import '../mocks.mocks.dart';
@@ -222,6 +223,51 @@ void main() {
222223
// skip this test for now as it's extremely flaky
223224
}, skip: true);
224225

226+
// Regression test: App start spans must be attached to the transaction
227+
// BEFORE track() is called. This verifies the fix for a race condition
228+
// where calling reportFullyDisplayed() after autoFinishAfter could cause
229+
// app start spans to be missing from the captured transaction.
230+
test('app start spans are attached before TTFD tracking starts', () async {
231+
// Create fresh fixture and mocks for test isolation
232+
final testFixture = Fixture();
233+
setupMocks(testFixture);
234+
235+
testFixture.options.enableTimeToFullDisplayTracing = true;
236+
237+
// Set sentrySetupStartTime to a value consistent with the mock timestamps
238+
// (pluginRegistrationTime is 10ms, so this should be after that)
239+
SentryFlutter.sentrySetupStartTime =
240+
DateTime.fromMillisecondsSinceEpoch(15);
241+
242+
// Capture transaction state at the moment track() is called
243+
List<SentrySpan>? spansWhenTrackCalled;
244+
final originalTracker = testFixture.options.timeToDisplayTracker;
245+
testFixture.options.timeToDisplayTracker = _FakeTimeToDisplayTracker(
246+
originalTracker,
247+
onTrack: (transaction) {
248+
if (transaction is SentryTracer) {
249+
spansWhenTrackCalled = List.from(transaction.children);
250+
}
251+
},
252+
);
253+
254+
final future = testFixture.call(
255+
appStartEnd: DateTime.fromMillisecondsSinceEpoch(50),
256+
);
257+
258+
await Future.delayed(Duration(milliseconds: 50));
259+
await testFixture.options.timeToDisplayTracker.reportFullyDisplayed();
260+
await future;
261+
262+
expect(spansWhenTrackCalled, isNotNull,
263+
reason: 'track() should have been called');
264+
expect(
265+
spansWhenTrackCalled!.any((s) => s.context.description == 'Cold Start'),
266+
isTrue,
267+
reason: 'Cold Start span must be attached before track() is called',
268+
);
269+
});
270+
225271
test('no transaction if app start takes more than 60s', () async {
226272
await fixture.call(
227273
appStartEnd: DateTime.fromMillisecondsSinceEpoch(60001),
@@ -536,3 +582,30 @@ class MockScope extends Mock implements Scope {
536582
_span = value;
537583
}
538584
}
585+
586+
/// Spy wrapper that intercepts track() calls to capture transaction state
587+
class _FakeTimeToDisplayTracker extends TimeToDisplayTracker {
588+
final TimeToDisplayTracker _delegate;
589+
final void Function(ISentrySpan transaction) onTrack;
590+
591+
_FakeTimeToDisplayTracker(this._delegate, {required this.onTrack})
592+
: super(options: _delegate.options);
593+
594+
@override
595+
SpanId? get transactionId => _delegate.transactionId;
596+
597+
@override
598+
set transactionId(SpanId? value) => _delegate.transactionId = value;
599+
600+
@override
601+
Future<void> track(ISentrySpan transaction,
602+
{DateTime? ttidEndTimestamp}) async {
603+
onTrack(transaction);
604+
return _delegate.track(transaction, ttidEndTimestamp: ttidEndTimestamp);
605+
}
606+
607+
@override
608+
Future<void> reportFullyDisplayed({SpanId? spanId, DateTime? endTimestamp}) =>
609+
_delegate.reportFullyDisplayed(
610+
spanId: spanId, endTimestamp: endTimestamp);
611+
}

0 commit comments

Comments
 (0)