Skip to content

Commit 73237da

Browse files
authored
Check app start spans time and foreground state (#3550)
* App start now takes AppStartMetrics.appLaunchedInForeground variable to add spans to the transaction * App starts longer than 1 minute are dropped (same as Firebase) * added Activity lifecycle registration to check start launch time and foreground status * added AppStartMetrics.registerApplicationForegroundCheck in the SentryPerformanceProvider and SentryAndroid.init, other than AppStartMetrics.onApplicationCreate * ActivityLifecycleIntegration now reads first activity creation on create instead of class instantiation * AppStartMetrics stops app start profiler if no activity is being created
1 parent 7620eac commit 73237da

File tree

11 files changed

+485
-85
lines changed

11 files changed

+485
-85
lines changed

CHANGELOG.md

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

3+
## Unreleased
4+
5+
### Fixes
6+
7+
- Check app start spans time and ignore background app starts ([#3550](https://github.com/getsentry/sentry-java/pull/3550))
8+
- This should eliminate long-lasting App Start transactions
9+
310
## 7.12.0
411

512
### Features

sentry-android-core/api/sentry-android-core.api

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ public class io/sentry/android/core/performance/ActivityLifecycleTimeSpan : java
427427
public final fun getOnStart ()Lio/sentry/android/core/performance/TimeSpan;
428428
}
429429

430-
public class io/sentry/android/core/performance/AppStartMetrics {
430+
public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter {
431431
public fun <init> ()V
432432
public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V
433433
public fun clear ()V
@@ -443,10 +443,13 @@ public class io/sentry/android/core/performance/AppStartMetrics {
443443
public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics;
444444
public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
445445
public fun isAppLaunchedInForeground ()Z
446+
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
446447
public static fun onApplicationCreate (Landroid/app/Application;)V
447448
public static fun onApplicationPostCreate (Landroid/app/Application;)V
448449
public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V
449450
public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V
451+
public fun registerApplicationForegroundCheck (Landroid/app/Application;)V
452+
public fun setAppLaunchedInForeground (Z)V
450453
public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V
451454
public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V
452455
public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.sentry.NoOpTransaction;
2222
import io.sentry.SentryDate;
2323
import io.sentry.SentryLevel;
24+
import io.sentry.SentryNanotimeDate;
2425
import io.sentry.SentryOptions;
2526
import io.sentry.SpanStatus;
2627
import io.sentry.TracesSamplingDecision;
@@ -37,6 +38,7 @@
3738
import java.io.Closeable;
3839
import java.io.IOException;
3940
import java.lang.ref.WeakReference;
41+
import java.util.Date;
4042
import java.util.Map;
4143
import java.util.WeakHashMap;
4244
import java.util.concurrent.Future;
@@ -75,7 +77,7 @@ public final class ActivityLifecycleIntegration
7577
private @Nullable ISpan appStartSpan;
7678
private final @NotNull WeakHashMap<Activity, ISpan> ttidSpanMap = new WeakHashMap<>();
7779
private final @NotNull WeakHashMap<Activity, ISpan> ttfdSpanMap = new WeakHashMap<>();
78-
private @NotNull SentryDate lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime();
80+
private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0);
7981
private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper());
8082
private @Nullable Future<?> ttfdAutoCloseFuture = null;
8183

@@ -627,6 +629,14 @@ WeakHashMap<Activity, ISpan> getTtfdSpanMap() {
627629
}
628630

629631
private void setColdStart(final @Nullable Bundle savedInstanceState) {
632+
// The very first activity start timestamp cannot be set to the class instantiation time, as it
633+
// may happen before an activity is started (service, broadcast receiver, etc). So we set it
634+
// here.
635+
if (hub != null && lastPausedTime.nanoTimestamp() == 0) {
636+
lastPausedTime = hub.getOptions().getDateProvider().now();
637+
} else if (lastPausedTime.nanoTimestamp() == 0) {
638+
lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime();
639+
}
630640
if (!firstActivityCreated) {
631641
// if Activity has savedInstanceState then its a warm start
632642
// https://developer.android.com/topic/performance/vitals/launch-time#warm

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.sentry.android.core;
22

33
import android.annotation.SuppressLint;
4+
import android.app.Application;
45
import android.content.Context;
56
import android.os.Process;
67
import android.os.SystemClock;
@@ -141,6 +142,10 @@ public static synchronized void init(
141142
appStartTimeSpan.setStartedAt(Process.getStartUptimeMillis());
142143
}
143144
}
145+
if (context.getApplicationContext() instanceof Application) {
146+
appStartMetrics.registerApplicationForegroundCheck(
147+
(Application) context.getApplicationContext());
148+
}
144149
final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan();
145150
if (sdkInitTimeSpan.hasNotStarted()) {
146151
sdkInitTimeSpan.setStartedAt(sdkInitMillis);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ private void onAppLaunched(
201201

202202
final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan();
203203
appStartTimespan.setStartedAt(Process.getStartUptimeMillis());
204+
appStartMetrics.registerApplicationForegroundCheck(app);
204205

205206
final AtomicBoolean firstDrawDone = new AtomicBoolean(false);
206207

sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
package io.sentry.android.core.performance;
22

3+
import android.app.Activity;
34
import android.app.Application;
45
import android.content.ContentProvider;
6+
import android.os.Bundle;
7+
import android.os.Handler;
8+
import android.os.Looper;
59
import android.os.SystemClock;
10+
import androidx.annotation.NonNull;
611
import androidx.annotation.Nullable;
12+
import androidx.annotation.VisibleForTesting;
713
import io.sentry.ITransactionProfiler;
14+
import io.sentry.SentryDate;
15+
import io.sentry.SentryNanotimeDate;
816
import io.sentry.TracesSamplingDecision;
917
import io.sentry.android.core.ContextUtils;
1018
import io.sentry.android.core.SentryAndroidOptions;
@@ -13,6 +21,7 @@
1321
import java.util.HashMap;
1422
import java.util.List;
1523
import java.util.Map;
24+
import java.util.concurrent.TimeUnit;
1625
import org.jetbrains.annotations.ApiStatus;
1726
import org.jetbrains.annotations.NotNull;
1827
import org.jetbrains.annotations.TestOnly;
@@ -23,7 +32,7 @@
2332
* transformed into SDK specific txn/span data structures.
2433
*/
2534
@ApiStatus.Internal
26-
public class AppStartMetrics {
35+
public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter {
2736

2837
public enum AppStartType {
2938
UNKNOWN,
@@ -45,6 +54,9 @@ public enum AppStartType {
4554
private final @NotNull List<ActivityLifecycleTimeSpan> activityLifecycles;
4655
private @Nullable ITransactionProfiler appStartProfiler = null;
4756
private @Nullable TracesSamplingDecision appStartSamplingDecision = null;
57+
private @Nullable SentryDate onCreateTime = null;
58+
private boolean appLaunchTooLong = false;
59+
private boolean isCallbackRegistered = false;
4860

4961
public static @NotNull AppStartMetrics getInstance() {
5062

@@ -65,6 +77,7 @@ public AppStartMetrics() {
6577
applicationOnCreate = new TimeSpan();
6678
contentProviderOnCreates = new HashMap<>();
6779
activityLifecycles = new ArrayList<>();
80+
appLaunchedInForeground = ContextUtils.isForegroundImportance();
6881
}
6982

7083
/**
@@ -102,6 +115,11 @@ public boolean isAppLaunchedInForeground() {
102115
return appLaunchedInForeground;
103116
}
104117

118+
@VisibleForTesting
119+
public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) {
120+
this.appLaunchedInForeground = appLaunchedInForeground;
121+
}
122+
105123
/**
106124
* Provides all collected content provider onCreate time spans
107125
*
@@ -137,12 +155,20 @@ public long getClassLoadedUptimeMs() {
137155
// Only started when sdk version is >= N
138156
final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan();
139157
if (appStartSpan.hasStarted()) {
140-
return appStartSpan;
158+
return validateAppStartSpan(appStartSpan);
141159
}
142160
}
143161

144162
// fallback: use sdk init time span, as it will always have a start time set
145-
return getSdkInitTimeSpan();
163+
return validateAppStartSpan(getSdkInitTimeSpan());
164+
}
165+
166+
private @NotNull TimeSpan validateAppStartSpan(final @NotNull TimeSpan appStartSpan) {
167+
// If the app launch took too long or it was launched in the background we return an empty span
168+
if (appLaunchTooLong || !appLaunchedInForeground) {
169+
return new TimeSpan();
170+
}
171+
return appStartSpan;
146172
}
147173

148174
@TestOnly
@@ -158,6 +184,10 @@ public void clear() {
158184
}
159185
appStartProfiler = null;
160186
appStartSamplingDecision = null;
187+
appLaunchTooLong = false;
188+
appLaunchedInForeground = false;
189+
onCreateTime = null;
190+
isCallbackRegistered = false;
161191
}
162192

163193
public @Nullable ITransactionProfiler getAppStartProfiler() {
@@ -195,7 +225,55 @@ public static void onApplicationCreate(final @NotNull Application application) {
195225
final @NotNull AppStartMetrics instance = getInstance();
196226
if (instance.applicationOnCreate.hasNotStarted()) {
197227
instance.applicationOnCreate.setStartedAt(now);
198-
instance.appLaunchedInForeground = ContextUtils.isForegroundImportance();
228+
instance.registerApplicationForegroundCheck(application);
229+
}
230+
}
231+
232+
/**
233+
* Register a callback to check if an activity was started after the application was created
234+
*
235+
* @param application The application object to register the callback to
236+
*/
237+
public void registerApplicationForegroundCheck(final @NotNull Application application) {
238+
if (isCallbackRegistered) {
239+
return;
240+
}
241+
isCallbackRegistered = true;
242+
appLaunchedInForeground = appLaunchedInForeground || ContextUtils.isForegroundImportance();
243+
application.registerActivityLifecycleCallbacks(instance);
244+
new Handler(Looper.getMainLooper())
245+
.post(
246+
() -> {
247+
// if no activity has ever been created, app was launched in background
248+
if (onCreateTime == null) {
249+
appLaunchedInForeground = false;
250+
}
251+
application.unregisterActivityLifecycleCallbacks(instance);
252+
// we stop the app start profiler, as it's useless and likely to timeout
253+
if (appStartProfiler != null && appStartProfiler.isRunning()) {
254+
appStartProfiler.close();
255+
appStartProfiler = null;
256+
}
257+
});
258+
}
259+
260+
@Override
261+
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
262+
// An activity already called onCreate()
263+
if (!appLaunchedInForeground || onCreateTime != null) {
264+
return;
265+
}
266+
onCreateTime = new SentryNanotimeDate();
267+
268+
final long spanStartMillis = appStartSpan.getStartTimestampMs();
269+
final long spanEndMillis =
270+
appStartSpan.hasStopped()
271+
? appStartSpan.getProjectedStopTimestampMs()
272+
: System.currentTimeMillis();
273+
final long durationMillis = spanEndMillis - spanStartMillis;
274+
// If the app was launched more than 1 minute ago, it's likely wrong
275+
if (durationMillis > TimeUnit.MINUTES.toMillis(1)) {
276+
appLaunchTooLong = true;
199277
}
200278
}
201279

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

Lines changed: 77 additions & 3 deletions
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
@@ -94,6 +95,7 @@ class ActivityLifecycleIntegrationTest {
9495

9596
whenever(hub.options).thenReturn(options)
9697

98+
AppStartMetrics.getInstance().isAppLaunchedInForeground = true
9799
// We let the ActivityLifecycleIntegration create the proper transaction here
98100
val optionCaptor = argumentCaptor<TransactionOptions>()
99101
val contextCaptor = argumentCaptor<TransactionContext>()
@@ -709,15 +711,19 @@ class ActivityLifecycleIntegrationTest {
709711
sut.register(fixture.hub, fixture.options)
710712

711713
val date = SentryNanotimeDate(Date(1), 0)
714+
val date2 = SentryNanotimeDate(Date(2), 2)
712715
setAppStartTime(date)
713716

714717
val activity = mock<Activity>()
718+
// The activity onCreate date will be ignored
719+
fixture.options.dateProvider = SentryDateProvider { date2 }
715720
sut.onActivityCreated(activity, fixture.bundle)
716721

717722
verify(fixture.hub).startTransaction(
718723
any(),
719724
check<TransactionOptions> {
720725
assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp())
726+
assertNotEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp())
721727
assertFalse(it.isAppStartTransaction)
722728
}
723729
)
@@ -756,6 +762,30 @@ class ActivityLifecycleIntegrationTest {
756762
)
757763
}
758764

765+
@Test
766+
fun `When firstActivityCreated is true and no app start time is set, default to onActivityCreated time`() {
767+
val sut = fixture.getSut()
768+
fixture.options.tracesSampleRate = 1.0
769+
sut.register(fixture.hub, fixture.options)
770+
771+
// usually set by SentryPerformanceProvider
772+
val date = SentryNanotimeDate(Date(1), 0)
773+
val date2 = SentryNanotimeDate(Date(2), 2)
774+
775+
val activity = mock<Activity>()
776+
// Activity onCreate date will be used
777+
fixture.options.dateProvider = SentryDateProvider { date2 }
778+
sut.onActivityCreated(activity, fixture.bundle)
779+
780+
verify(fixture.hub).startTransaction(
781+
any(),
782+
check<TransactionOptions> {
783+
assertEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp())
784+
assertNotEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp())
785+
}
786+
)
787+
}
788+
759789
@Test
760790
fun `Create and finish app start span immediately in case SDK init is deferred`() {
761791
val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND)
@@ -940,6 +970,46 @@ class ActivityLifecycleIntegrationTest {
940970
assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp())
941971
}
942972

973+
@Test
974+
fun `When firstActivityCreated is true and app started more than 1 minute ago, app start spans are dropped`() {
975+
val sut = fixture.getSut()
976+
fixture.options.tracesSampleRate = 1.0
977+
sut.register(fixture.hub, fixture.options)
978+
979+
val date = SentryNanotimeDate(Date(1), 0)
980+
val duration = TimeUnit.MINUTES.toMillis(1) + 2
981+
val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration)
982+
val stopDate = SentryNanotimeDate(Date(duration), durationNanos)
983+
setAppStartTime(date, stopDate)
984+
985+
val activity = mock<Activity>()
986+
sut.onActivityCreated(activity, null)
987+
988+
val appStartSpan = fixture.transaction.children.firstOrNull {
989+
it.description == "Cold Start"
990+
}
991+
assertNull(appStartSpan)
992+
}
993+
994+
@Test
995+
fun `When firstActivityCreated is true and app started in background, app start spans are dropped`() {
996+
val sut = fixture.getSut()
997+
AppStartMetrics.getInstance().isAppLaunchedInForeground = false
998+
fixture.options.tracesSampleRate = 1.0
999+
sut.register(fixture.hub, fixture.options)
1000+
1001+
val date = SentryNanotimeDate(Date(1), 0)
1002+
setAppStartTime(date)
1003+
1004+
val activity = mock<Activity>()
1005+
sut.onActivityCreated(activity, null)
1006+
1007+
val appStartSpan = fixture.transaction.children.firstOrNull {
1008+
it.description == "Cold Start"
1009+
}
1010+
assertNull(appStartSpan)
1011+
}
1012+
9431013
@Test
9441014
fun `When firstActivityCreated is false, start transaction but not with given appStartTime`() {
9451015
val sut = fixture.getSut()
@@ -1412,18 +1482,22 @@ class ActivityLifecycleIntegrationTest {
14121482
shadowOf(Looper.getMainLooper()).idle()
14131483
}
14141484

1415-
private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0)) {
1485+
private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) {
14161486
// set by SentryPerformanceProvider so forcing it here
14171487
val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan
14181488
val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan
14191489
val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong()
1490+
val stopMillis = DateUtils.nanosToMillis(stopDate?.nanoTimestamp()?.toDouble() ?: 0.0).toLong()
14201491

14211492
sdkAppStartTimeSpan.setStartedAt(millis)
14221493
sdkAppStartTimeSpan.setStartUnixTimeMs(millis)
1423-
sdkAppStartTimeSpan.setStoppedAt(0)
1494+
sdkAppStartTimeSpan.setStoppedAt(stopMillis)
14241495

14251496
appStartTimeSpan.setStartedAt(millis)
14261497
appStartTimeSpan.setStartUnixTimeMs(millis)
1427-
appStartTimeSpan.setStoppedAt(0)
1498+
appStartTimeSpan.setStoppedAt(stopMillis)
1499+
if (stopDate != null) {
1500+
AppStartMetrics.getInstance().onActivityCreated(mock(), mock())
1501+
}
14281502
}
14291503
}

0 commit comments

Comments
 (0)