diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbc093d0f..abf287298b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) +- Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4250](https://github.com/getsentry/sentry-java/pull/4250)) ### Dependencies diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 85197f8038..6e42257b57 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -471,15 +471,15 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isColdStartValid ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityStarted (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V - public fun registerApplicationForegroundCheck (Landroid/app/Application;)V - public fun restartAppStart (J)V + public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 0bdfee71fd..34d20cf455 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,7 +9,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -83,7 +82,6 @@ public final class ActivityLifecycleIntegration private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - private long lastPausedUptimeMillis = 0; private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -400,7 +398,6 @@ public void onActivityPreCreated( scopes != null ? scopes.getOptions().getDateProvider().now() : AndroidDateUtils.getCurrentSentryDateTime(); - lastPausedUptimeMillis = SystemClock.uptimeMillis(); helper.setOnCreateStartTimestamp(lastPausedTime); } @@ -411,7 +408,6 @@ public void onActivityCreated( onActivityPreCreated(activity, savedInstanceState); } try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - setColdStart(savedInstanceState); if (scopes != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); scopes.configureScope(scope -> scope.setScreen(activityClassName)); @@ -516,7 +512,6 @@ public void onActivityPrePaused(@NotNull Activity activity) { scopes != null ? scopes.getOptions().getDateProvider().now() : AndroidDateUtils.getCurrentSentryDateTime(); - lastPausedUptimeMillis = SystemClock.uptimeMillis(); } @Override @@ -577,7 +572,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); - if (activitiesWithOngoingTransactions.isEmpty()) { + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { clear(); } } @@ -586,7 +581,6 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - lastPausedUptimeMillis = 0; activitySpanHelpers.clear(); } @@ -728,27 +722,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - final @NotNull TimeSpan appStartSpan = AppStartMetrics.getInstance().getAppStartTimeSpan(); - // If the app start span already started and stopped, it means the app restarted without - // killing the process, so we are in a warm start - // If the app has an invalid cold start, it means it was started in the background, like - // via BroadcastReceiver, so we consider it a warm start - if ((appStartSpan.hasStarted() && appStartSpan.hasStopped()) - || (!AppStartMetrics.getInstance().isColdStartValid())) { - AppStartMetrics.getInstance().restartAppStart(lastPausedUptimeMillis); - AppStartMetrics.getInstance().setAppStartType(AppStartMetrics.AppStartType.WARM); - } else { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index ea1f8ae875..d183d4c45b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -156,7 +156,7 @@ public static void init( } } if (context.getApplicationContext() instanceof Application) { - appStartMetrics.registerApplicationForegroundCheck( + appStartMetrics.registerLifecycleCallbacks( (Application) context.getApplicationContext()); } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 6658e14560..cdb5a13c27 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -174,8 +174,9 @@ private void onAppLaunched( // performance v2: Uses Process.getStartUptimeMillis() // requires API level 24+ - if (buildInfoProvider.getSdkInfoVersion() < android.os.Build.VERSION_CODES.N) { - return; + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); } if (context instanceof Application) { @@ -185,8 +186,6 @@ private void onAppLaunched( return; } - final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); - appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); - appStartMetrics.registerApplicationForegroundCheck(app); + appStartMetrics.registerLifecycleCallbacks(app); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 5ee32b6f7b..a2ca74b360 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -12,11 +12,12 @@ import androidx.annotation.VisibleForTesting; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; -import io.sentry.SentryDate; -import io.sentry.SentryNanotimeDate; +import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.Collections; @@ -24,6 +25,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -32,6 +35,9 @@ * An in-memory representation for app-metrics during app start. As the SDK can't be initialized * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { @@ -49,7 +55,7 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground = false; + private boolean appLaunchedInForeground; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -58,10 +64,10 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; - private @Nullable SentryDate onCreateTime = null; - private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; private boolean shouldSendStartMeasurements = true; + private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); + private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -135,10 +141,6 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } - public boolean isColdStartValid() { - return appLaunchedInForeground && !appLaunchTooLong; - } - @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -172,17 +174,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements; - } - - public void restartAppStart(final long uptimeMillis) { - shouldSendStartMeasurements = true; - appLaunchTooLong = false; - appLaunchedInForeground = true; - appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(uptimeMillis); - CLASS_LOADED_UPTIME_MS = appStartSpan.getStartUptimeMs(); + return shouldSendStartMeasurements && appLaunchedInForeground; } public long getClassLoadedUptimeMs() { @@ -195,20 +187,27 @@ public long getClassLoadedUptimeMs() { */ public @NotNull TimeSpan getAppStartTimeSpanWithFallback( final @NotNull SentryAndroidOptions options) { - // If the app launch took too long or it was launched in the background we return an empty span - if (!isColdStartValid()) { - return new TimeSpan(); - } - if (options.isEnablePerformanceV2()) { - // Only started when sdk version is >= N - final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); - if (appStartSpan.hasStarted()) { - return appStartSpan; + // If the app start type was never determined or app wasn't launched in foreground, + // the app start is considered invalid + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; } } - // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return new TimeSpan(); } @TestOnly @@ -224,11 +223,11 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; - appLaunchTooLong = false; appLaunchedInForeground = false; - onCreateTime = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; + firstDrawDone.set(false); + activeActivitiesCounter.set(0); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -266,7 +265,23 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.registerApplicationForegroundCheck(application); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); } } @@ -275,7 +290,7 @@ public static void onApplicationCreate(final @NotNull Application application) { * * @param application The application object to register the callback to */ - public void registerApplicationForegroundCheck(final @NotNull Application application) { + public void registerLifecycleCallbacks(final @NotNull Application application) { if (isCallbackRegistered) { return; } @@ -286,15 +301,15 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); } - private void checkCreateTimeOnMain(final @NotNull Application application) { + private void checkCreateTimeOnMain() { new Handler(Looper.getMainLooper()) .post( () -> { // if no activity has ever been created, app was launched in background - if (onCreateTime == null) { + if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground = false; // we stop the app start profiler, as it's useless and likely to timeout @@ -303,43 +318,56 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { appStartProfiler = null; } } - application.unregisterActivityLifecycleCallbacks(instance); }); } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - // An activity already called onCreate() - if (!appLaunchedInForeground || onCreateTime != null) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + + // the first activity determines the app start type + if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + // If the app (process) was launched more than 1 minute ago, it's likely wrong + final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); + if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + appStartType = AppStartType.WARM; + + shouldSendStartMeasurements = true; + appStartSpan.reset(); + appStartSpan.start(); + appStartSpan.setStartedAt(nowUptimeMs); + CLASS_LOADED_UPTIME_MS = nowUptimeMs; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); + } else { + appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } + } + appLaunchedInForeground = true; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { return; } - onCreateTime = new SentryNanotimeDate(); - - final long spanStartMillis = appStartSpan.getStartTimestampMs(); - final long spanEndMillis = - appStartSpan.hasStopped() - ? appStartSpan.getProjectedStopTimestampMs() - : System.currentTimeMillis(); - final long durationMillis = spanEndMillis - spanStartMillis; - // If the app was launched more than 1 minute ago, it's likely wrong - if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { - appLaunchTooLong = true; + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); } } - /** - * Called by instrumentation - * - * @param application The application object where onCreate was called on - * @noinspection unused - */ - public static void onApplicationPostCreate(final @NotNull Application application) { - final long now = SystemClock.uptimeMillis(); - - final @NotNull AppStartMetrics instance = getInstance(); - if (instance.applicationOnCreate.hasNotStopped()) { - instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); - instance.applicationOnCreate.setStoppedAt(now); + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + // if the app is moving into background + // as the next Activity is considered like a new app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appLaunchedInForeground = false; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); } } @@ -373,4 +401,12 @@ public static void onContentProviderPostCreate(final @NotNull ContentProvider co measurement.setStoppedAt(now); } } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 317dbc843c..a3d5d99bf6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,7 +54,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,7 +93,10 @@ class ActivityLifecycleIntegrationTest { whenever(scopes.options).thenReturn(options) - AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.start() + // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -626,45 +628,6 @@ class ActivityLifecycleIntegrationTest { verify(ttfdReporter, never()).registerFullyDrawnListener(any()) } - @Test - fun `App start is Cold when savedInstanceState is null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `App start is Warm when savedInstanceState is not null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `Do not overwrite App start type after set`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - @Test fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() @@ -920,129 +883,6 @@ class ActivityLifecycleIntegrationTest { ) } - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start warm span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityPreCreated(activity, fixture.bundle) - sut.onActivityCreated(activity, fixture.bundle) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start cold span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.cold") - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started more than 1 minute ago, start app with Warm start`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started in background, start app with Warm start`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is true and app started more than 1 minute ago, app start spans are dropped`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val appStartSpan = fixture.transaction.children.firstOrNull { - it.description == "Cold Start" - } - assertNull(appStartSpan) - } - - @Test - fun `When firstActivityCreated is true and app started in background, app start spans are dropped`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val appStartSpan = fixture.transaction.children.firstOrNull { - it.description == "Cold Start" - } - assertNull(appStartSpan) - } - @Test fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1568,7 +1408,6 @@ class ActivityLifecycleIntegrationTest { assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) sut.onActivityCreated(activity, null) - assertNotNull(sut.appStartSpan) sut.onActivityPostCreated(activity, null) assertTrue(helper.onCreateSpan!!.isFinished) @@ -1726,23 +1565,13 @@ class ActivityLifecycleIntegrationTest { appStartMetrics.appStartTimeSpan.stop() sut.register(fixture.scopes, fixture.options) - assertEquals(0, sut.getProperty("lastPausedUptimeMillis")) + assertEquals(0, sut.getProperty("lastPausedTime").nanoTimestamp()) // An Activity (the first) is created after app start has finished sut.onActivityPreCreated(activity, null) // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() - val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") - assertNotEquals(0, lastUptimeMillis) - - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - // AppStartMetrics app start time is set to Activity preCreated timestamp - assertEquals(lastUptimeMillis, appStartMetrics.appStartTimeSpan.startUptimeMs) - // AppStart type is considered warm - assertEquals(AppStartType.WARM, appStartMetrics.appStartType) - - // Activity appStart span timestamp is the same of AppStartMetrics.appStart timestamp - assertEquals(sut.appStartSpan!!.startDate.nanoTimestamp(), appStartMetrics.getAppStartTimeSpanWithFallback(fixture.options).startTimestamp!!.nanoTimestamp()) + val lastUptimeMillis = sut.getProperty("lastPausedTime") + assertNotEquals(0, lastUptimeMillis.nanoTimestamp()) } private fun SentryTracer.isFinishing() = getProperty("finishStatus").getProperty("isFinishing") @@ -1756,6 +1585,9 @@ class ActivityLifecycleIntegrationTest { private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here + AppStartMetrics.getInstance().appStartType = AppStartType.COLD + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index b491dcd088..510a1a429d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -74,7 +74,7 @@ class PerformanceAndroidEventProcessorTest { emptyMap(), null ).also { - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + AppStartMetrics.getInstance().onActivityCreated(mock(), if (coldStart) null else mock()) } @BeforeTest @@ -224,6 +224,7 @@ class PerformanceAndroidEventProcessorTest { fun `adds app start metrics to app start txn`() { // given some app start metrics val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.isAppLaunchedInForeground = true appStartMetrics.appStartType = AppStartType.COLD appStartMetrics.appStartTimeSpan.setStartedAt(123) appStartMetrics.appStartTimeSpan.setStoppedAt(456) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 1fb44774f1..4317751c59 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.pm.ProviderInfo import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,7 +17,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -48,6 +48,7 @@ class SentryPerformanceProviderTest { val providerInfo = ProviderInfo() val logger = mock() lateinit var configFile: File + var activityLifecycleCallbacks: MutableList = mutableListOf() fun getSut(sdkVersion: Int = Build.VERSION_CODES.S, authority: String = AUTHORITY, handleFile: ((config: File) -> Unit)? = null): SentryPerformanceProvider { val buildInfoProvider: BuildInfoProvider = mock() @@ -56,7 +57,14 @@ class SentryPerformanceProviderTest { whenever(mockContext.applicationContext).thenReturn(mockContext) configFile = File(sentryCache, Sentry.APP_START_PROFILING_CONFIG_FILE_NAME) handleFile?.invoke(configFile) - + whenever(mockContext.registerActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.add(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } + whenever(mockContext.unregisterActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.remove(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } providerInfo.authority = authority return SentryPerformanceProvider(logger, buildInfoProvider).apply { attachInfo(mockContext, providerInfo) @@ -101,6 +109,16 @@ class SentryPerformanceProviderTest { assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasStarted()) } + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val provider = fixture.getSut() + provider.onCreate() + + val metrics = AppStartMetrics.getInstance() + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertTrue(metrics.sdkInitTimeSpan.hasStarted()) + } + //region app start profiling @Test fun `when config file does not exists, nothing happens`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 86edd79b4f..81bd8b7945 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1,9 +1,12 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Bundle import android.os.Looper +import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.ITransactionProfiler @@ -78,6 +81,7 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.WARM appStartTimeSpan.start() val options = SentryAndroidOptions().apply { @@ -91,7 +95,12 @@ class AppStartMetricsTest { @Test fun `if perf-2 is disabled but app start time span has started, appStartTimeSpanWithFallback returns the sdk init span instead`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } + appStartTimeSpan.setStartedAt(123) val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false @@ -104,8 +113,11 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled but app start time span has not started, appStartTimeSpanWithFallback returns the sdk init span instead`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - assertTrue(appStartTimeSpan.hasNotStarted()) + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = true @@ -124,6 +136,8 @@ class AppStartMetricsTest { @Test fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) @@ -139,19 +153,65 @@ class AppStartMetricsTest { } @Test - fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() - assertTrue(appStartTimeSpan.hasStarted()) - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - - val options = SentryAndroidOptions().apply { - isEnablePerformanceV2 = true + fun `if app is launched in background, but an activity launches later, a new warm start is reported with correct timings`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + metrics.contentProviderOnCreateTimeSpans.add( + TimeSpan().apply { + description = "ExampleContentProvider" + setStartedAt(1) + setStoppedAt(2) + } + ) + + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(3) + setStoppedAt(4) } - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) - assertFalse(timeSpan.hasStarted()) + // when the looper runs + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // but no activity creation happened + // then the app wasn't launched in foreground and nothing should be sent + assertFalse(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + + val now = TimeUnit.MINUTES.toMillis(2) + 1234567 + SystemClock.setCurrentTimeMillis(now) + + // once an activity launches + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + + // then it should restart the timespan + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) + } + + @Test + fun `if app is launched in background, the first created activity assumes a warm start`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.registerLifecycleCallbacks(mock()) + + // when the handler callback is executed and no activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // isAppLaunchedInForeground should be false + assertFalse(metrics.isAppLaunchedInForeground) + + // but when the first activity launches + metrics.onActivityCreated(mock(), null) + + // then a warm start should be set + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test @@ -175,14 +235,15 @@ class AppStartMetricsTest { @Test fun `if activity is never started, returns an empty span`() { - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + val timeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) assertFalse(timeSpan.hasStarted()) } @@ -192,7 +253,7 @@ class AppStartMetricsTest { whenever(profiler.isRunning).thenReturn(true) AppStartMetrics.getInstance().appStartProfiler = profiler - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -206,7 +267,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().appStartProfiler = profiler AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -234,33 +295,26 @@ class AppStartMetricsTest { @Test fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + verify( + application, + times(1) + ).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } @Test fun `when registerApplicationForegroundCheck, a callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } - @Test - fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { - val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - } - @Test fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -271,7 +325,7 @@ class AppStartMetricsTest { fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) @@ -280,12 +334,6 @@ class AppStartMetricsTest { assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } - @Test - fun `isColdStartValid is false if app was launched in background`() { - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - assertFalse(AppStartMetrics.getInstance().isColdStartValid) - } - @Test fun `isColdStartValid is false if app launched in more than 1 minute`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -294,7 +342,6 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - assertFalse(AppStartMetrics.getInstance().isColdStartValid) } @Test @@ -310,20 +357,105 @@ class AppStartMetricsTest { } @Test - fun `restartAppStart set measurement flag and clear internal lists`() { + fun `a warm start gets reported after a cold start`() { val appStartMetrics = AppStartMetrics.getInstance() + + // when the first activity launches and gets destroyed + val activity0 = mock() + whenever(activity0.isChangingConfigurations).thenReturn(false) + appStartMetrics.onActivityCreated(activity0, null) + + // then the app start type should be cold and measurements should be sent + assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + + // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - appStartMetrics.isAppLaunchedInForeground = false assertFalse(appStartMetrics.shouldSendStartMeasurements()) - assertFalse(appStartMetrics.isColdStartValid) - appStartMetrics.restartAppStart(10) + appStartMetrics.onActivityDestroyed(activity0) + // then it should reset sending the measurements for the next warm activity + appStartMetrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) assertTrue(appStartMetrics.shouldSendStartMeasurements()) - assertTrue(appStartMetrics.isColdStartValid) - assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) - assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) - assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + + assertFalse(metrics.appStartTimeSpan.hasStopped()) + assertFalse(metrics.sdkInitTimeSpan.hasStopped()) + + metrics.onFirstFrameDrawn() + + assertTrue(metrics.appStartTimeSpan.hasStopped()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) + } + + @Test + fun `Sets app launch type to cold`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), mock()) + + // then the app start is still considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm if process init was too long ago`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + val app = mock() + metrics.registerLifecycleCallbacks(app) + + // when an activity is created later with a null bundle + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), mock()) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), null) + + // then the app start is still considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test