Skip to content

Commit 32564c8

Browse files
authored
Fix TTFD measurement when API called too early (#4297)
* Sentry.reportFullyDisplayed now sets a flag if the ttid is not finished. * When the TTID finishes, if the flag is set, the TTFD is finished as well
1 parent 484644c commit 32564c8

File tree

3 files changed

+118
-44
lines changed

3 files changed

+118
-44
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixes
6+
7+
- Fix TTFD measurement when API called too early ([#4297](https://github.com/getsentry/sentry-java/pull/4297))
8+
39
## 8.8.0
410

511
### Features

sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java

+57-27
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ public final class ActivityLifecycleIntegration
9191

9292
private final @NotNull ActivityFramesTracker activityFramesTracker;
9393
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
94+
private boolean fullyDisplayedCalled = false;
95+
private final @NotNull AutoClosableReentrantLock fullyDisplayedLock =
96+
new AutoClosableReentrantLock();
9497

9598
public ActivityLifecycleIntegration(
9699
final @NotNull Application application,
@@ -413,12 +416,17 @@ public void onActivityCreated(
413416
scopes.configureScope(scope -> scope.setScreen(activityClassName));
414417
}
415418
startTracing(activity);
419+
final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity);
416420
final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity);
417421

418422
firstActivityCreated = true;
419423

420-
if (performanceEnabled && ttfdSpan != null && fullyDisplayedReporter != null) {
421-
fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan));
424+
if (performanceEnabled
425+
&& ttidSpan != null
426+
&& ttfdSpan != null
427+
&& fullyDisplayedReporter != null) {
428+
fullyDisplayedReporter.registerFullyDrawnListener(
429+
() -> onFullFrameDrawn(ttidSpan, ttfdSpan));
422430
}
423431
}
424432
}
@@ -635,37 +643,59 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I
635643
}
636644
finishAppStartSpan();
637645

638-
if (options != null && ttidSpan != null) {
639-
final SentryDate endDate = options.getDateProvider().now();
640-
final long durationNanos = endDate.diff(ttidSpan.getStartDate());
641-
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos);
642-
ttidSpan.setMeasurement(
643-
MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND);
644-
645-
if (ttfdSpan != null && ttfdSpan.isFinished()) {
646-
ttfdSpan.updateEndDate(endDate);
647-
// If the ttfd span was finished before the first frame we adjust the measurement, too
646+
// Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization
647+
// with first frame drawn
648+
try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) {
649+
if (options != null && ttidSpan != null) {
650+
final SentryDate endDate = options.getDateProvider().now();
651+
final long durationNanos = endDate.diff(ttidSpan.getStartDate());
652+
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos);
648653
ttidSpan.setMeasurement(
649-
MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND);
654+
MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND);
655+
// If Sentry.reportFullyDisplayed was called before the first frame is drawn, we finish
656+
// the ttfd now
657+
if (ttfdSpan != null && fullyDisplayedCalled) {
658+
fullyDisplayedCalled = false;
659+
ttidSpan.setMeasurement(
660+
MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND);
661+
ttfdSpan.setMeasurement(
662+
MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND);
663+
finishSpan(ttfdSpan, endDate);
664+
}
665+
666+
finishSpan(ttidSpan, endDate);
667+
} else {
668+
finishSpan(ttidSpan);
669+
if (fullyDisplayedCalled) {
670+
finishSpan(ttfdSpan);
671+
}
650672
}
651-
finishSpan(ttidSpan, endDate);
652-
} else {
653-
finishSpan(ttidSpan);
654673
}
655674
}
656675

657-
private void onFullFrameDrawn(final @Nullable ISpan ttfdSpan) {
658-
if (options != null && ttfdSpan != null) {
659-
final SentryDate endDate = options.getDateProvider().now();
660-
final long durationNanos = endDate.diff(ttfdSpan.getStartDate());
661-
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos);
662-
ttfdSpan.setMeasurement(
663-
MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND);
664-
finishSpan(ttfdSpan, endDate);
665-
} else {
666-
finishSpan(ttfdSpan);
667-
}
676+
private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) {
668677
cancelTtfdAutoClose();
678+
// Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization
679+
// with first frame drawn
680+
try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) {
681+
// If the TTID span didn't finish, it means the first frame was not drawn yet, which means
682+
// Sentry.reportFullyDisplayed was called too early. We set a flag, so that whenever the TTID
683+
// will finish, we will finish the TTFD span as well.
684+
if (!ttidSpan.isFinished()) {
685+
fullyDisplayedCalled = true;
686+
return;
687+
}
688+
if (options != null) {
689+
final SentryDate endDate = options.getDateProvider().now();
690+
final long durationNanos = endDate.diff(ttfdSpan.getStartDate());
691+
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos);
692+
ttfdSpan.setMeasurement(
693+
MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND);
694+
finishSpan(ttfdSpan, endDate);
695+
} else {
696+
finishSpan(ttfdSpan);
697+
}
698+
}
669699
}
670700

671701
private void finishExceededTtfdSpan(

sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt

+55-17
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import org.robolectric.shadow.api.Shadow
5454
import org.robolectric.shadows.ShadowActivityManager
5555
import java.util.Date
5656
import java.util.concurrent.Future
57+
import java.util.concurrent.TimeUnit
5758
import kotlin.test.AfterTest
5859
import kotlin.test.BeforeTest
5960
import kotlin.test.Test
@@ -300,7 +301,7 @@ class ActivityLifecycleIntegrationTest {
300301
val sut = fixture.getSut(initializer = {
301302
it.tracesSampleRate = 1.0
302303
it.isEnableTimeToFullDisplayTracing = true
303-
it.idleTimeout = 200
304+
it.idleTimeout = 100
304305
})
305306
sut.register(fixture.scopes, fixture.options)
306307
sut.onActivityCreated(activity, fixture.bundle)
@@ -318,7 +319,7 @@ class ActivityLifecycleIntegrationTest {
318319
)
319320

320321
// but when idle timeout has passed
321-
Thread.sleep(400)
322+
Thread.sleep(200)
322323

323324
// then the transaction should be finished
324325
verify(fixture.scopes).captureTransaction(
@@ -815,7 +816,6 @@ class ActivityLifecycleIntegrationTest {
815816

816817
// when activity is resumed
817818
sut.onActivityResumed(activity)
818-
Thread.sleep(1)
819819
runFirstDraw(view)
820820
// end-time should be set
821821
assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasStopped())
@@ -863,17 +863,14 @@ class ActivityLifecycleIntegrationTest {
863863
sut.onActivityCreated(activity, fixture.bundle)
864864
sut.onActivityStarted(activity)
865865
sut.onActivityResumed(activity)
866-
Thread.sleep(1)
867866
runFirstDraw(view)
868867

869868
val firstAppStartEndTime = AppStartMetrics.getInstance().sdkInitTimeSpan.projectedStopTimestamp
870869

871-
Thread.sleep(1)
872870
sut.onActivityPaused(activity)
873871
sut.onActivityStopped(activity)
874872
sut.onActivityStarted(activity)
875873
sut.onActivityResumed(activity)
876-
Thread.sleep(1)
877874
runFirstDraw(view)
878875

879876
// then the end time should not be overwritten
@@ -983,6 +980,36 @@ class ActivityLifecycleIntegrationTest {
983980
)
984981
}
985982

983+
@Test
984+
fun `When isEnableTimeToFullDisplayTracing is true and reportFullyDrawn is called, ttfd is finished on first frame if ttid is running`() {
985+
val sut = fixture.getSut()
986+
val view = fixture.createView()
987+
val activity = mock<Activity>()
988+
whenever(activity.findViewById<View>(any())).thenReturn(view)
989+
fixture.options.tracesSampleRate = 1.0
990+
fixture.options.isEnableTimeToFullDisplayTracing = true
991+
sut.register(fixture.scopes, fixture.options)
992+
sut.onActivityCreated(activity, fixture.bundle)
993+
sut.onActivityResumed(activity)
994+
val ttidSpan = sut.ttidSpanMap[activity]
995+
val ttfdSpan = sut.ttfdSpanMap[activity]
996+
997+
// Assert the ttfd span is running and a timeout autoCancel future has been scheduled
998+
assertNotNull(ttidSpan)
999+
assertNotNull(ttfdSpan)
1000+
assertFalse(ttidSpan.isFinished)
1001+
assertFalse(ttfdSpan.isFinished)
1002+
1003+
// ReportFullyDrawn should not finish the ttfd span, as the ttid is still running
1004+
fixture.options.fullyDisplayedReporter.reportFullyDrawn()
1005+
assertFalse(ttfdSpan.isFinished)
1006+
1007+
// But when ReportFullyDrawn should not finish the ttfd span, as the ttid is still running
1008+
runFirstDraw(view)
1009+
assertTrue(ttidSpan.isFinished)
1010+
assertTrue(ttfdSpan.isFinished)
1011+
}
1012+
9861013
@Test
9871014
fun `When isEnableTimeToFullDisplayTracing is true and reportFullyDrawn is called, ttfd autoClose future is cancelled`() {
9881015
val sut = fixture.getSut()
@@ -991,16 +1018,17 @@ class ActivityLifecycleIntegrationTest {
9911018
sut.register(fixture.scopes, fixture.options)
9921019
val activity = mock<Activity>()
9931020
sut.onActivityCreated(activity, fixture.bundle)
1021+
val ttidSpan = sut.ttidSpanMap[activity]
9941022
val ttfdSpan = sut.ttfdSpanMap[activity]
9951023
var autoCloseFuture = sut.getProperty<Future<*>?>("ttfdAutoCloseFuture")
1024+
ttidSpan?.finish()
9961025

9971026
// Assert the ttfd span is running and a timeout autoCancel future has been scheduled
9981027
assertNotNull(ttfdSpan)
9991028
assertFalse(ttfdSpan.isFinished)
10001029
assertNotNull(autoCloseFuture)
10011030

10021031
// ReportFullyDrawn should finish the ttfd span and cancel the future
1003-
Thread.sleep(1)
10041032
fixture.options.fullyDisplayedReporter.reportFullyDrawn()
10051033
assertTrue(ttfdSpan.isFinished)
10061034
assertNotEquals(SpanStatus.DEADLINE_EXCEEDED, ttfdSpan.status)
@@ -1077,7 +1105,6 @@ class ActivityLifecycleIntegrationTest {
10771105
assertFalse(ttidSpan.isFinished)
10781106

10791107
// Mock the draw of the view. The ttid span should finish now
1080-
Thread.sleep(1)
10811108
runFirstDraw(view)
10821109
assertTrue(ttidSpan.isFinished)
10831110

@@ -1097,7 +1124,9 @@ class ActivityLifecycleIntegrationTest {
10971124

10981125
@Test
10991126
fun `When isEnableTimeToFullDisplayTracing is true and reportFullyDrawn is called too early, ttfd is adjusted to equal ttid`() {
1100-
val sut = fixture.getSut()
1127+
val sut = fixture.getSut() {
1128+
// it.fullyDisplayedReporter = mock()
1129+
}
11011130
val view = fixture.createView()
11021131
val activity = mock<Activity>()
11031132
fixture.options.tracesSampleRate = 1.0
@@ -1116,18 +1145,28 @@ class ActivityLifecycleIntegrationTest {
11161145
assertFalse(ttfdSpan.isFinished)
11171146

11181147
// Let's finish the ttfd span too early (before the first view is drawn)
1119-
ttfdSpan.finish()
1120-
assertTrue(ttfdSpan.isFinished)
1121-
val oldEndDate = ttfdSpan.finishDate
1148+
fixture.options.fullyDisplayedReporter.reportFullyDrawn()
11221149

1123-
// Mock the draw of the view. The ttid span should finish now and the ttfd end date should be adjusted
1150+
// The TTFD shouldn't be finished yet
1151+
assertFalse(ttfdSpan.isFinished)
1152+
1153+
// Mock the draw of the view. The ttid span should finish now and the ttfd, too
11241154
runFirstDraw(view)
11251155
assertTrue(ttidSpan.isFinished)
1126-
val newEndDate = ttfdSpan.finishDate
1127-
assertNotEquals(newEndDate, oldEndDate)
1128-
assertEquals(newEndDate, ttidSpan.finishDate)
1156+
assertTrue(ttfdSpan.isFinished)
1157+
assertEquals(ttfdSpan.finishDate, ttidSpan.finishDate)
11291158

11301159
sut.onActivityDestroyed(activity)
1160+
1161+
// The measurements should be set to the same value for ttid and ttfd
1162+
val ttidDuration = TimeUnit.NANOSECONDS.toMillis(ttidSpan.finishDate!!.diff(ttidSpan.startDate))
1163+
val ttfdDuration = TimeUnit.NANOSECONDS.toMillis(ttfdSpan.finishDate!!.diff(ttfdSpan.startDate))
1164+
assertEquals(ttidDuration, ttfdDuration)
1165+
// TTID also has initial display measurement, but TTFD has not
1166+
assertEquals(ttidDuration, ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value)
1167+
assertEquals(ttidDuration, ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_FULL_DISPLAY]!!.value)
1168+
assertEquals(ttidDuration, ttfdSpan.measurements[MeasurementValue.KEY_TIME_TO_FULL_DISPLAY]!!.value)
1169+
11311170
verify(fixture.scopes).captureTransaction(
11321171
check {
11331172
// ttid and ttfd measurements should be the same
@@ -1189,7 +1228,6 @@ class ActivityLifecycleIntegrationTest {
11891228
assertFalse(ttfdSpan.isFinished)
11901229

11911230
// Run the autoClose task 1 ms after finishing the ttid span and assert the ttfd span is finished
1192-
Thread.sleep(1)
11931231
deferredExecutorService.runAll()
11941232
assertTrue(ttfdSpan.isFinished)
11951233

0 commit comments

Comments
 (0)