From 7eccfdb1614d42ce9113e6233503ff89e761161d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 13 Dec 2023 13:14:51 +0100 Subject: [PATCH] [Starfish] Attach app-start spans (#3057) Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 4 + .../api/sentry-android-core.api | 101 +++++-- .../core/ActivityLifecycleIntegration.java | 62 +++-- .../io/sentry/android/core/AppStartState.java | 133 --------- .../io/sentry/android/core/ContextUtils.java | 3 +- .../core/DefaultAndroidEventProcessor.java | 8 +- .../android/core/InternalSentrySdk.java | 9 +- .../android/core/ManifestMetadataReader.java | 5 + .../PerformanceAndroidEventProcessor.java | 131 ++++++++- .../io/sentry/android/core/SentryAndroid.java | 29 +- .../android/core/SentryAndroidOptions.java | 24 ++ .../core/SentryPerformanceProvider.java | 245 ++++++++++------- .../core/cache/AndroidEnvelopeCache.java | 10 +- .../gestures/WindowCallbackAdapter.java | 4 +- .../internal/util/FirstDrawDoneListener.java | 32 +++ .../ActivityLifecycleCallbacksAdapter.java | 31 +++ .../ActivityLifecycleTimeSpan.java | 29 ++ .../core/performance/AppStartMetrics.java | 208 +++++++++++++++ .../android/core/performance/TimeSpan.java | 171 ++++++++++++ .../WindowContentChangedCallback.java | 22 ++ .../core/ActivityLifecycleIntegrationTest.kt | 100 ++++--- .../sentry/android/core/AppStartStateTest.kt | 107 -------- .../core/ManifestMetadataReaderTest.kt | 25 ++ .../PerformanceAndroidEventProcessorTest.kt | 252 ++++++++++++++++-- .../android/core/SentryAndroidOptionsTest.kt | 22 +- .../sentry/android/core/SentryAndroidTest.kt | 50 +++- .../android/core/SentryLogcatAdapterTest.kt | 3 +- .../core/SentryPerformanceProviderTest.kt | 150 +++++------ .../android/core/SentryShadowProcess.kt | 24 ++ .../core/cache/AndroidEnvelopeCacheTest.kt | 14 +- .../util/FirstDrawDoneListenerTest.kt | 50 ++++ .../ActivityLifecycleTimeSpanTest.kt | 54 ++++ .../core/performance/AppStartMetricsTest.kt | 99 +++++++ .../android/core/performance/TimeSpanTest.kt | 155 +++++++++++ .../android/mockservers/RelayAsserter.kt | 6 + .../src/main/AndroidManifest.xml | 2 + 36 files changed, 1809 insertions(+), 565 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/performance/TimeSpanTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index af69ed2b0a..64b861a977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Features - Support multiple debug-metadata.properties ([#3024](https://github.com/getsentry/sentry-java/pull/3024)) +- (Android) Experimental: Provide more detailed cold app start information ([#3057](https://github.com/getsentry/sentry-java/pull/3057)) + - Attaches spans for Application, ContentProvider, and Activities to app-start timings + - Uses Process.startUptimeMillis to calculate app-start timings + - To enable this feature set `options.isEnablePerformanceV2 = true` ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 42aca9b5cc..e6f49c5904 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -119,17 +119,6 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/AppStartState { - public fun getAppStartEndTime ()Lio/sentry/SentryDate; - public fun getAppStartInterval ()Ljava/lang/Long; - public fun getAppStartMillis ()Ljava/lang/Long; - public fun getAppStartTime ()Lio/sentry/SentryDate; - public static fun getInstance ()Lio/sentry/android/core/AppStartState; - public fun isColdStart ()Ljava/lang/Boolean; - public fun reset ()V - public fun setAppStartMillis (J)V -} - public final class io/sentry/android/core/AppState { public static fun getInstance ()Lio/sentry/android/core/AppState; public fun isInBackground ()Ljava/lang/Boolean; @@ -155,6 +144,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun isForegroundImportance ()Z } public class io/sentry/android/core/CurrentActivityHolder { @@ -274,6 +264,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z public fun isEnableNetworkEventBreadcrumbs ()Z + public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z public fun isEnableSystemEventBreadcrumbs ()Z @@ -296,6 +287,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V + public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V @@ -335,17 +327,10 @@ public final class io/sentry/android/core/SentryLogcatAdapter { public static fun wtf (Ljava/lang/String;Ljava/lang/Throwable;)I } -public final class io/sentry/android/core/SentryPerformanceProvider : android/app/Application$ActivityLifecycleCallbacks { +public final class io/sentry/android/core/SentryPerformanceProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V public fun getType (Landroid/net/Uri;)Ljava/lang/String; - public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityDestroyed (Landroid/app/Activity;)V - public fun onActivityPaused (Landroid/app/Activity;)V - public fun onActivityResumed (Landroid/app/Activity;)V - public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityStarted (Landroid/app/Activity;)V - public fun onActivityStopped (Landroid/app/Activity;)V public fun onCreate ()Z } @@ -397,3 +382,81 @@ public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } +public class io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter : android/app/Application$ActivityLifecycleCallbacks { + public fun ()V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V +} + +public class io/sentry/android/core/performance/ActivityLifecycleTimeSpan : java/lang/Comparable { + public fun ()V + public fun compareTo (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public final fun getOnCreate ()Lio/sentry/android/core/performance/TimeSpan; + public final fun getOnStart ()Lio/sentry/android/core/performance/TimeSpan; +} + +public class io/sentry/android/core/performance/AppStartMetrics { + public fun ()V + public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V + public fun clear ()V + public fun getActivityLifecycleTimeSpans ()Ljava/util/List; + public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getContentProviderOnCreateTimeSpans ()Ljava/util/List; + public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; + public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun isAppLaunchedInForeground ()Z + 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 setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V +} + +public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { + public static final field COLD Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static final field UNKNOWN Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static final field WARM Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; +} + +public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { + public fun ()V + public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun getDescription ()Ljava/lang/String; + public fun getDurationMs ()J + public fun getProjectedStopTimestamp ()Lio/sentry/SentryDate; + public fun getProjectedStopTimestampMs ()J + public fun getProjectedStopTimestampSecs ()D + public fun getStartTimestamp ()Lio/sentry/SentryDate; + public fun getStartTimestampMs ()J + public fun getStartTimestampSecs ()D + public fun getStartUptimeMs ()J + public fun hasNotStarted ()Z + public fun hasNotStopped ()Z + public fun hasStarted ()Z + public fun hasStopped ()Z + public fun reset ()V + public fun setDescription (Ljava/lang/String;)V + public fun setStartUnixTimeMs (J)V + public fun setStartedAt (J)V + public fun setStoppedAt (J)V + public fun start ()V + public fun stop ()V +} + +public class io/sentry/android/core/performance/WindowContentChangedCallback : io/sentry/android/core/internal/gestures/WindowCallbackAdapter { + public fun (Landroid/view/Window$Callback;Ljava/lang/Runnable;)V + public fun onContentChanged ()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 425d26ad32..87771da8e0 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 @@ -30,6 +30,8 @@ import io.sentry.TransactionOptions; import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.Objects; @@ -70,7 +72,6 @@ public final class ActivityLifecycleIntegration private boolean isAllActivityCallbacksAvailable; private boolean firstActivityCreated = false; - private final boolean foregroundImportance; private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; @@ -100,10 +101,6 @@ public ActivityLifecycleIntegration( if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.Q) { isAllActivityCallbacksAvailable = true; } - - // we only track app start for processes that will show an Activity (full launch). - // Here we check the process importance which will tell us that. - foregroundImportance = ContextUtils.isForegroundImportance(); } @Override @@ -182,15 +179,28 @@ private void startTracing(final @NotNull Activity activity) { if (!performanceEnabled) { activitiesWithOngoingTransactions.put(activity, NoOpTransaction.getInstance()); TracingUtils.startNewTrace(hub); - } else if (performanceEnabled) { + } else { // as we allow a single transaction running on the bound Scope, we finish the previous ones stopPreviousTransactions(); final String activityName = getActivityName(activity); - final SentryDate appStartTime = - foregroundImportance ? AppStartState.getInstance().getAppStartTime() : null; - final Boolean coldStart = AppStartState.getInstance().isColdStart(); + final @Nullable SentryDate appStartTime; + final @Nullable Boolean coldStart; + final TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + + // we only track app start for processes that will show an Activity (full launch). + // Here we check the process importance which will tell us that. + final boolean foregroundImportance = ContextUtils.isForegroundImportance(); + if (foregroundImportance && appStartTimeSpan.hasStarted()) { + appStartTime = appStartTimeSpan.getStartTimestamp(); + coldStart = + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD; + } else { + appStartTime = null; + coldStart = null; + } final TransactionOptions transactionOptions = new TransactionOptions(); transactionOptions.setDeadlineTimeout( @@ -407,6 +417,7 @@ public synchronized void onActivityStarted(final @NotNull Activity activity) { @Override public synchronized void onActivityResumed(final @NotNull Activity activity) { if (performanceEnabled) { + final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); final View rootView = activity.findViewById(android.R.id.content); @@ -536,13 +547,17 @@ private void cancelTtfdAutoClose() { private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable ISpan ttidSpan) { // app start span - @Nullable final SentryDate appStartStartTime = AppStartState.getInstance().getAppStartTime(); - @Nullable final SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); - // in case the SentryPerformanceProvider is disabled it does not set the app start times, - // and we need to set the end time manually here, - // the start time gets set manually in SentryAndroid.init() - if (appStartStartTime != null && appStartEndTime == null) { - AppStartState.getInstance().setAppStartEnd(); + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); + final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + + // in case the SentryPerformanceProvider is disabled it does not set the app start end times, + // and we need to set the end time manually here + if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { + appStartTimeSpan.stop(); + } + if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.stop(); } finishAppStartSpan(); @@ -626,7 +641,15 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { if (!firstActivityCreated) { // if Activity has savedInstanceState then its a warm start // https://developer.android.com/topic/performance/vitals/launch-time#warm - AppStartState.getInstance().setColdStart(savedInstanceState == null); + // SentryPerformanceProvider sets this already + // pre-performance-v2: back-fill with best guess + if (options != null && !options.isEnablePerformanceV2()) { + AppStartMetrics.getInstance() + .setAppStartType( + savedInstanceState == null + ? AppStartMetrics.AppStartType.COLD + : AppStartMetrics.AppStartType.WARM); + } } } @@ -662,7 +685,10 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { } private void finishAppStartSpan() { - final @Nullable SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); + final @Nullable SentryDate appStartEndTime = + AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java deleted file mode 100644 index 0c38d04d48..0000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.sentry.android.core; - -import android.os.SystemClock; -import io.sentry.DateUtils; -import io.sentry.SentryDate; -import io.sentry.SentryLongDate; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -/** AppStartState holds the state of the App Start metric and appStartTime */ -@ApiStatus.Internal -public final class AppStartState { - - private static @NotNull AppStartState instance = new AppStartState(); - - /** We filter out App starts more than 60s */ - private static final int MAX_APP_START_MILLIS = 60000; - - private @Nullable Long appStartMillis; - - private @Nullable Long appStartEndMillis; - - /** The type of App start coldStart=true -> Cold start, coldStart=false -> Warm start */ - private @Nullable Boolean coldStart = null; - - /** appStart as a Date used in the App's Context */ - private @Nullable SentryDate appStartTime; - - private AppStartState() {} - - public static @NotNull AppStartState getInstance() { - return instance; - } - - @TestOnly - void resetInstance() { - instance = new AppStartState(); - } - - synchronized void setAppStartEnd() { - setAppStartEnd(SystemClock.uptimeMillis()); - } - - @TestOnly - void setAppStartEnd(final long appStartEndMillis) { - if (this.appStartEndMillis != null) { - // only set app start end once - return; - } - this.appStartEndMillis = appStartEndMillis; - } - - @Nullable - public synchronized Long getAppStartInterval() { - if (appStartMillis == null || appStartEndMillis == null || coldStart == null) { - return null; - } - final long appStart = appStartEndMillis - appStartMillis; - - // We filter out app start more than 60s. - // This could be due to many different reasons. - // If you do the manual init and init the SDK too late and it does not compute the app start end - // in the very first Activity. - // If the process starts but the App isn't in the foreground. - // If the system forked the zygote earlier to accelerate the app start. - // And some unknown reasons that could not be reproduced. - // We've seen app starts with hours, days and even months. - if (appStart >= MAX_APP_START_MILLIS) { - return null; - } - - return appStart; - } - - public @Nullable Boolean isColdStart() { - return coldStart; - } - - synchronized void setColdStart(final boolean coldStart) { - if (this.coldStart != null) { - return; - } - this.coldStart = coldStart; - } - - @Nullable - public SentryDate getAppStartTime() { - return appStartTime; - } - - @Nullable - public SentryDate getAppStartEndTime() { - @Nullable final SentryDate start = getAppStartTime(); - if (start != null) { - @Nullable final Long durationMillis = getAppStartInterval(); - if (durationMillis != null) { - final long startNanos = start.nanoTimestamp(); - final long endNanos = startNanos + DateUtils.millisToNanos(durationMillis); - return new SentryLongDate(endNanos); - } - } - return null; - } - - @Nullable - public Long getAppStartMillis() { - return appStartMillis; - } - - synchronized void setAppStartTime( - final long appStartMillis, final @NotNull SentryDate appStartTime) { - // method is synchronized because the SDK may by init. on a background thread. - if (this.appStartTime != null && this.appStartMillis != null) { - return; - } - this.appStartTime = appStartTime; - this.appStartMillis = appStartMillis; - } - - @TestOnly - public synchronized void setAppStartMillis(final long appStartMillis) { - this.appStartMillis = appStartMillis; - } - - @TestOnly - public synchronized void reset() { - appStartTime = null; - appStartMillis = null; - appStartEndMillis = null; - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 4192468a4b..2e76de4d12 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -167,7 +167,8 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { * * @return true if IMPORTANCE_FOREGROUND and false otherwise */ - static boolean isForegroundImportance() { + @ApiStatus.Internal + public static boolean isForegroundImportance() { try { final ActivityManager.RunningAppProcessInfo appProcessInfo = new ActivityManager.RunningAppProcessInfo(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 0202bf2d86..8dcfe196c2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -11,6 +11,8 @@ import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.App; import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.SentryThread; @@ -199,7 +201,11 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); - app.setAppStartTime(DateUtils.toUtilDate(AppStartState.getInstance().getAppStartTime())); + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + if (appStartTimeSpan.hasStarted()) { + app.setAppStartTime(DateUtils.toUtilDate(appStartTimeSpan.getStartTimestamp())); + } // This should not be set by Hybrid SDKs since they have their own app's lifecycle if (!HintUtils.isFromHybridSdk(hint) && app.getInForeground() == null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 637247133b..9bdbe86a77 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -16,6 +16,8 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.Session; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.App; import io.sentry.protocol.Device; import io.sentry.protocol.SentryId; @@ -99,7 +101,12 @@ public static Map serializeScope( app = new App(); } app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); - app.setAppStartTime(DateUtils.toUtilDate(AppStartState.getInstance().getAppStartTime())); + + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + if (appStartTimeSpan.hasStarted()) { + app.setAppStartTime(DateUtils.toUtilDate(appStartTimeSpan.getStartTimestamp())); + } final @NotNull BuildInfoProvider buildInfoProvider = new BuildInfoProvider(options.getLogger()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 014268cff7..5551a54d3c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -96,6 +96,8 @@ final class ManifestMetadataReader { static final String SEND_MODULES = "io.sentry.send-modules"; + static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -360,6 +362,9 @@ static void applyMetadata( readBool(metadata, logger, ENABLE_ROOT_CHECK, options.isEnableRootCheck())); options.setSendModules(readBool(metadata, logger, SEND_MODULES, options.isSendModules())); + + options.setEnablePerformanceV2( + readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); } options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index eae0297614..ec7e3b74d9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -9,11 +9,17 @@ import io.sentry.MeasurementUnit; import io.sentry.SentryEvent; import io.sentry.SpanContext; +import io.sentry.SpanId; +import io.sentry.SpanStatus; +import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentrySpan; import io.sentry.protocol.SentryTransaction; import io.sentry.util.Objects; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -22,6 +28,12 @@ /** Event Processor responsible for adding Android metrics to transactions */ final class PerformanceAndroidEventProcessor implements EventProcessor { + private static final String APP_METRICS_ORIGIN = "auto.ui"; + + private static final String APP_METRICS_CONTENT_PROVIDER_OP = "contentprovider.load"; + private static final String APP_METRICS_ACTIVITIES_OP = "activity.load"; + private static final String APP_METRICS_APPLICATION_OP = "application.load"; + private boolean sentStartMeasurement = false; private final @NotNull ActivityFramesTracker activityFramesTracker; @@ -62,20 +74,25 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. - if (!sentStartMeasurement && hasAppStartSpan(transaction.getSpans())) { - final Long appStartUpInterval = AppStartState.getInstance().getAppStartInterval(); - // if appStartUpInterval is null, metrics are not ready to be sent - if (appStartUpInterval != null) { + if (!sentStartMeasurement && hasAppStartSpan(transaction)) { + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + final long appStartUpInterval = appStartTimeSpan.getDurationMs(); + + // if appStartUpInterval is 0, metrics are not ready to be sent + if (appStartUpInterval != 0) { final MeasurementValue value = new MeasurementValue( (float) appStartUpInterval, MeasurementUnit.Duration.MILLISECOND.apiName()); final String appStartKey = - AppStartState.getInstance().isColdStart() + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD ? MeasurementValue.KEY_APP_START_COLD : MeasurementValue.KEY_APP_START_WARM; transaction.getMeasurements().put(appStartKey, value); + + attachColdAppStartSpans(AppStartMetrics.getInstance(), transaction); sentStartMeasurement = true; } } @@ -99,13 +116,111 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { return transaction; } - private boolean hasAppStartSpan(final @NotNull List spans) { - for (final SentrySpan span : spans) { + private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { + final @NotNull List spans = txn.getSpans(); + for (final @NotNull SentrySpan span : spans) { if (span.getOp().contentEquals(APP_START_COLD) || span.getOp().contentEquals(APP_START_WARM)) { return true; } } - return false; + + final @Nullable SpanContext context = txn.getContexts().getTrace(); + return context != null + && (context.getOperation().equals(APP_START_COLD) + || context.getOperation().equals(APP_START_WARM)); + } + + private void attachColdAppStartSpans( + final @NotNull AppStartMetrics appStartMetrics, final @NotNull SentryTransaction txn) { + + // data will be filled only for cold app starts + if (appStartMetrics.getAppStartType() != AppStartMetrics.AppStartType.COLD) { + return; + } + + final @Nullable SpanContext traceContext = txn.getContexts().getTrace(); + if (traceContext == null) { + return; + } + final @NotNull SentryId traceId = traceContext.getTraceId(); + + // determine the app.start.cold span, where all other spans will be attached to + @Nullable SpanId parentSpanId = null; + final @NotNull List spans = txn.getSpans(); + for (final @NotNull SentrySpan span : spans) { + if (span.getOp().contentEquals(APP_START_COLD)) { + parentSpanId = span.getSpanId(); + break; + } + } + + // Content Providers + final @NotNull List contentProviderOnCreates = + appStartMetrics.getContentProviderOnCreateTimeSpans(); + if (!contentProviderOnCreates.isEmpty()) { + for (final @NotNull TimeSpan contentProvider : contentProviderOnCreates) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + } + } + + // Application.onCreate + final @NotNull TimeSpan appOnCreate = appStartMetrics.getApplicationOnCreateTimeSpan(); + if (appOnCreate.hasStopped()) { + txn.getSpans() + .add( + timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + } + + // Activities + final @NotNull List activityLifecycleTimeSpans = + appStartMetrics.getActivityLifecycleTimeSpans(); + if (!activityLifecycleTimeSpans.isEmpty()) { + for (ActivityLifecycleTimeSpan activityTimeSpan : activityLifecycleTimeSpans) { + if (activityTimeSpan.getOnCreate().hasStarted() + && activityTimeSpan.getOnCreate().hasStopped()) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + activityTimeSpan.getOnCreate(), + parentSpanId, + traceId, + APP_METRICS_ACTIVITIES_OP)); + } + if (activityTimeSpan.getOnStart().hasStarted() + && activityTimeSpan.getOnStart().hasStopped()) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + activityTimeSpan.getOnStart(), + parentSpanId, + traceId, + APP_METRICS_ACTIVITIES_OP)); + } + } + } + } + + @NotNull + private static SentrySpan timeSpanToSentrySpan( + final @NotNull TimeSpan span, + final @Nullable SpanId parentSpanId, + final @NotNull SentryId traceId, + final @NotNull String operation) { + return new SentrySpan( + span.getStartTimestampSecs(), + span.getProjectedStopTimestampSecs(), + traceId, + new SpanId(), + parentSpanId, + operation, + span.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGIN, + new HashMap<>(), + null); } } 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 f4e1539a58..af68a026fb 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 @@ -1,16 +1,19 @@ package io.sentry.android.core; +import android.annotation.SuppressLint; import android.content.Context; +import android.os.Process; import android.os.SystemClock; import io.sentry.IHub; import io.sentry.ILogger; import io.sentry.Integration; import io.sentry.OptionsContainer; import io.sentry.Sentry; -import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.BreadcrumbFactory; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; @@ -21,11 +24,8 @@ /** Sentry initialization class */ public final class SentryAndroid { - // static to rely on Class load init. - private static final @NotNull SentryDate appStartTime = - AndroidDateUtils.getCurrentSentryDateTime(); // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. - private static final long appStart = SystemClock.uptimeMillis(); + private static final long sdkInitMillis = SystemClock.uptimeMillis(); static final String SENTRY_FRAGMENT_INTEGRATION_CLASS_NAME = "io.sentry.android.fragment.FragmentLifecycleIntegration"; @@ -77,13 +77,11 @@ public static void init( * @param logger your custom logger that implements ILogger * @param configuration Sentry.OptionsConfiguration configuration handler */ + @SuppressLint("NewApi") public static synchronized void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { - // if SentryPerformanceProvider was disabled or removed, we set the App Start when - // the SDK is called. - AppStartState.getInstance().setAppStartTime(appStart, appStartTime); try { Sentry.init( @@ -124,6 +122,21 @@ public static synchronized void init( configuration.configure(options); + // if SentryPerformanceProvider was disabled or removed, + // we set the app start / sdk init time here instead + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + if (options.isEnablePerformanceV2() + && buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); + if (appStartTimeSpan.hasNotStarted()) { + appStartTimeSpan.setStartedAt(Process.getStartUptimeMillis()); + } + } + final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasNotStarted()) { + sdkInitTimeSpan.setStartedAt(sdkInitMillis); + } + AndroidOptionsInitializer.initializeIntegrationsAndProcessors( options, context, buildInfoProvider, loadClass, activityFramesTracker); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 8110ca442a..275dfa3d98 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -212,6 +212,8 @@ public interface BeforeCaptureCallback { */ private boolean attachAnrThreadDump = false; + private boolean enablePerformanceV2 = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -589,4 +591,26 @@ public boolean isAttachAnrThreadDump() { public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { this.attachAnrThreadDump = attachAnrThreadDump; } + + /** + * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for + * more details. + */ + @ApiStatus.Experimental + public boolean isEnablePerformanceV2() { + return enablePerformanceV2; + } + + /** + * Experimental: Enables or disables the Performance V2 SDK features. + * + *

With this change - Cold app start spans will provide more accurate timings - Cold app start + * spans will be enriched with detailed ContentProvider, Application and Activity startup times + * + * @param enablePerformanceV2 true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnablePerformanceV2(final boolean enablePerformanceV2) { + this.enablePerformanceV2 = enablePerformanceV2; + } } 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 91992b3c5e..0cdc076037 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 @@ -1,78 +1,40 @@ package io.sentry.android.core; -import android.annotation.SuppressLint; import android.app.Activity; import android.app.Application; import android.content.Context; import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Bundle; +import android.os.Process; import android.os.SystemClock; -import android.view.View; +import androidx.annotation.NonNull; import io.sentry.NoOpLogger; -import io.sentry.SentryDate; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.android.core.performance.ActivityLifecycleCallbacksAdapter; +import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -/** - * SentryPerformanceProvider is responsible for collecting data (eg appStart) as early as possible - * as ContentProvider is the only reliable hook for libraries that works across all the supported - * SDK versions. When minSDK is >= 24, we could use Process.getStartUptimeMillis() We could also use - * AppComponentFactory but it depends on androidx.core.app.AppComponentFactory - */ @ApiStatus.Internal -public final class SentryPerformanceProvider extends EmptySecureContentProvider - implements Application.ActivityLifecycleCallbacks { +public final class SentryPerformanceProvider extends EmptySecureContentProvider { // static to rely on Class load - private static @NotNull SentryDate appStartTime = AndroidDateUtils.getCurrentSentryDateTime(); // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. - private static long appStartMillis = SystemClock.uptimeMillis(); + private static final long sdkInitMillis = SystemClock.uptimeMillis(); - private boolean firstActivityCreated = false; - private boolean firstActivityResumed = false; - - private @Nullable Application application; - - private final @NotNull BuildInfoProvider buildInfoProvider; - - private final @NotNull MainLooperHandler mainHandler; - - public SentryPerformanceProvider() { - AppStartState.getInstance().setAppStartTime(appStartMillis, appStartTime); - buildInfoProvider = new BuildInfoProvider(NoOpLogger.getInstance()); - mainHandler = new MainLooperHandler(); - } - - SentryPerformanceProvider( - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull MainLooperHandler mainHandler) { - AppStartState.getInstance().setAppStartTime(appStartMillis, appStartTime); - this.buildInfoProvider = buildInfoProvider; - this.mainHandler = mainHandler; - } + private @Nullable Application app; + private @Nullable Application.ActivityLifecycleCallbacks activityCallback; @Override public boolean onCreate() { - Context context = getContext(); - - if (context == null) { - return false; - } - - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } - - if (context instanceof Application) { - application = ((Application) context); - application.registerActivityLifecycleCallbacks(this); - } - + onAppLaunched(getContext()); return true; } @@ -92,62 +54,143 @@ public String getType(@NotNull Uri uri) { return null; } - @TestOnly - static void setAppStartTime( - final long appStartMillisLong, final @NotNull SentryDate appStartTimeDate) { - appStartMillis = appStartMillisLong; - appStartTime = appStartTimeDate; - } + private void onAppLaunched(final @Nullable Context context) { + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - @Override - public void onActivityCreated(@NotNull Activity activity, @Nullable Bundle savedInstanceState) { - // Hybrid Apps like RN or Flutter init the Android SDK after the MainActivity of the App - // has been created, and some frameworks overwrites the behaviour of activity lifecycle - // or it's already too late to get the callback for the very first Activity, hence we - // register the ActivityLifecycleCallbacks here, since this Provider is always run first. - if (!firstActivityCreated) { - // if Activity has savedInstanceState then its a warm start - // https://developer.android.com/topic/performance/vitals/launch-time#warm - final boolean coldStart = savedInstanceState == null; - AppStartState.getInstance().setColdStart(coldStart); - - firstActivityCreated = true; - } - } + // sdk-init uses static field init as start time + final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + sdkInitTimeSpan.setStartedAt(sdkInitMillis); - @Override - public void onActivityStarted(@NotNull Activity activity) {} + // performance v2: Uses Process.getStartUptimeMillis() + // requires API level 24+ + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) { + return; + } - @SuppressLint("NewApi") - @Override - public void onActivityResumed(@NotNull Activity activity) { - if (!firstActivityResumed) { - // sets App start as finished when the very first activity calls onResume - firstActivityResumed = true; - final View rootView = activity.findViewById(android.R.id.content); - if (rootView != null) { - FirstDrawDoneListener.registerForNextDraw( - rootView, () -> AppStartState.getInstance().setAppStartEnd(), buildInfoProvider); - } else { - // Posting a task to the main thread's handler will make it executed after it finished - // its current job. That is, right after the activity draws the layout. - mainHandler.post(() -> AppStartState.getInstance().setAppStartEnd()); - } + if (context instanceof Application) { + app = (Application) context; } - if (application != null) { - application.unregisterActivityLifecycleCallbacks(this); + if (app == null) { + return; } - } - - @Override - public void onActivityPaused(@NotNull Activity activity) {} - @Override - public void onActivityStopped(@NotNull Activity activity) {} + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); + + final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + + activityCallback = + new ActivityLifecycleCallbacksAdapter() { + final WeakHashMap activityLifecycleMap = + new WeakHashMap<>(); + + @Override + public void onActivityPreCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + final long now = SystemClock.uptimeMillis(); + if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { + return; + } + + final ActivityLifecycleTimeSpan timeSpan = new ActivityLifecycleTimeSpan(); + timeSpan.getOnCreate().setStartedAt(now); + activityLifecycleMap.put(activity, timeSpan); + } + + @Override + public void onActivityCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + if (appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.UNKNOWN) { + appStartMetrics.setAppStartType( + savedInstanceState == null + ? AppStartMetrics.AppStartType.COLD + : AppStartMetrics.AppStartType.WARM); + } + } + + @Override + public void onActivityPostCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { + return; + } + + final @Nullable ActivityLifecycleTimeSpan timeSpan = activityLifecycleMap.get(activity); + if (timeSpan != null) { + timeSpan.getOnCreate().stop(); + timeSpan.getOnCreate().setDescription(activity.getClass().getName() + ".onCreate"); + } + } + + @Override + public void onActivityPreStarted(@NonNull Activity activity) { + final long now = SystemClock.uptimeMillis(); + if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { + return; + } + final @Nullable ActivityLifecycleTimeSpan timeSpan = activityLifecycleMap.get(activity); + if (timeSpan != null) { + timeSpan.getOnStart().setStartedAt(now); + } + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { + return; + } + FirstDrawDoneListener.registerForNextDraw( + activity, + () -> { + if (firstDrawDone.compareAndSet(false, true)) { + onAppStartDone(); + } + }, + // as the SDK isn't initialized yet, we don't have access to SentryOptions + new BuildInfoProvider(NoOpLogger.getInstance())); + } + + @Override + public void onActivityPostStarted(@NonNull Activity activity) { + final @Nullable ActivityLifecycleTimeSpan timeSpan = + activityLifecycleMap.remove(activity); + if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { + return; + } + if (timeSpan != null) { + timeSpan.getOnStart().stop(); + timeSpan.getOnStart().setDescription(activity.getClass().getName() + ".onStart"); + + appStartMetrics.addActivityLifecycleTimeSpans(timeSpan); + } + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + // safety net for activities which were created but never stopped + activityLifecycleMap.remove(activity); + } + }; + + app.registerActivityLifecycleCallbacks(activityCallback); + } - @Override - public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle outState) {} + @TestOnly + synchronized void onAppStartDone() { + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + + if (app != null) { + if (activityCallback != null) { + app.unregisterActivityLifecycleCallbacks(activityCallback); + } + } + } - @Override - public void onActivityDestroyed(@NotNull Activity activity) {} + @TestOnly + @Nullable + Application.ActivityLifecycleCallbacks getActivityCallback() { + return activityCallback; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index bd3e0809b5..28090ef6c7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -9,9 +9,10 @@ import io.sentry.SentryOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrV2Integration; -import io.sentry.android.core.AppStartState; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.cache.EnvelopeCache; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.FileUtils; @@ -51,11 +52,12 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { super.store(envelope, hint); final SentryAndroidOptions options = (SentryAndroidOptions) this.options; + final TimeSpan sdkInitTimeSpan = AppStartMetrics.getInstance().getSdkInitTimeSpan(); - final Long appStartTime = AppStartState.getInstance().getAppStartMillis(); if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class) - && appStartTime != null) { - long timeSinceSdkInit = currentDateProvider.getCurrentTimeMillis() - appStartTime; + && sdkInitTimeSpan.hasStarted()) { + long timeSinceSdkInit = + currentDateProvider.getCurrentTimeMillis() - sdkInitTimeSpan.getStartTimestampMs(); if (timeSinceSdkInit <= options.getStartupCrashDurationThresholdMillis()) { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java index a6568ad057..c6a3e62711 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java @@ -16,11 +16,11 @@ import org.jetbrains.annotations.Nullable; @Open -class WindowCallbackAdapter implements Window.Callback { +public class WindowCallbackAdapter implements Window.Callback { private final @NotNull Window.Callback delegate; - WindowCallbackAdapter(final Window.@NotNull Callback delegate) { + public WindowCallbackAdapter(final Window.@NotNull Callback delegate) { this.delegate = delegate; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index 280ef3b63e..f2612b4aa8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -18,12 +18,17 @@ package io.sentry.android.core.internal.util; +import android.app.Activity; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.view.View; import android.view.ViewTreeObserver; +import android.view.Window; +import androidx.annotation.Nullable; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.internal.gestures.NoOpWindowCallback; +import io.sentry.android.core.performance.WindowContentChangedCallback; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.NotNull; @@ -36,6 +41,33 @@ public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener { private final @NotNull AtomicReference viewReference; private final @NotNull Runnable callback; + public static void registerForNextDraw( + final @NotNull Activity activity, + final @NotNull Runnable drawDoneCallback, + final @NotNull BuildInfoProvider buildInfoProvider) { + + @Nullable Window window = activity.getWindow(); + if (window != null) { + @Nullable View decorView = window.peekDecorView(); + if (decorView != null) { + registerForNextDraw(decorView, drawDoneCallback, buildInfoProvider); + } else { + final @Nullable Window.Callback oldCallback = window.getCallback(); + window.setCallback( + new WindowContentChangedCallback( + oldCallback != null ? oldCallback : new NoOpWindowCallback(), + () -> { + @Nullable View newDecorView = window.peekDecorView(); + if (newDecorView != null) { + // let's set the old callback again, so we don't intercept anymore + window.setCallback(oldCallback); + registerForNextDraw(newDecorView, drawDoneCallback, buildInfoProvider); + } + })); + } + } + } + /** Registers a post-draw callback for the next draw of a view. */ public static void registerForNextDraw( final @NotNull View view, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java new file mode 100644 index 0000000000..c7a449d1ef --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java @@ -0,0 +1,31 @@ +package io.sentry.android.core.performance; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class ActivityLifecycleCallbacksAdapter implements Application.ActivityLifecycleCallbacks { + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(@NonNull Activity activity) {} + + @Override + public void onActivityResumed(@NonNull Activity activity) {} + + @Override + public void onActivityPaused(@NonNull Activity activity) {} + + @Override + public void onActivityStopped(@NonNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} + + @Override + public void onActivityDestroyed(@NonNull Activity activity) {} +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java new file mode 100644 index 0000000000..52688a2856 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java @@ -0,0 +1,29 @@ +package io.sentry.android.core.performance; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class ActivityLifecycleTimeSpan implements Comparable { + private final @NotNull TimeSpan onCreate = new TimeSpan(); + private final @NotNull TimeSpan onStart = new TimeSpan(); + + public final @NotNull TimeSpan getOnCreate() { + return onCreate; + } + + public final @NotNull TimeSpan getOnStart() { + return onStart; + } + + @Override + public int compareTo(ActivityLifecycleTimeSpan o) { + final int onCreateDiff = + Long.compare(onCreate.getStartUptimeMs(), o.onCreate.getStartUptimeMs()); + if (onCreateDiff == 0) { + return Long.compare(onStart.getStartUptimeMs(), o.onStart.getStartUptimeMs()); + } else { + return onCreateDiff; + } + } +} 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 new file mode 100644 index 0000000000..41f9feecd7 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -0,0 +1,208 @@ +package io.sentry.android.core.performance; + +import android.app.Application; +import android.content.ContentProvider; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import io.sentry.android.core.ContextUtils; +import io.sentry.android.core.SentryAndroidOptions; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * 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. + */ +@ApiStatus.Internal +public class AppStartMetrics { + + public enum AppStartType { + UNKNOWN, + COLD, + WARM + } + + private static volatile @Nullable AppStartMetrics instance; + + private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; + private boolean appLaunchedInForeground = false; + + private final @NotNull TimeSpan appStartSpan; + private final @NotNull TimeSpan sdkInitTimeSpan; + private final @NotNull TimeSpan applicationOnCreate; + private final @NotNull Map contentProviderOnCreates; + private final @NotNull List activityLifecycles; + + public static @NotNull AppStartMetrics getInstance() { + + if (instance == null) { + synchronized (AppStartMetrics.class) { + if (instance == null) { + instance = new AppStartMetrics(); + } + } + } + //noinspection DataFlowIssue + return instance; + } + + public AppStartMetrics() { + appStartSpan = new TimeSpan(); + sdkInitTimeSpan = new TimeSpan(); + applicationOnCreate = new TimeSpan(); + contentProviderOnCreates = new HashMap<>(); + activityLifecycles = new ArrayList<>(); + } + + /** + * @return the app start span Uses Process.getStartUptimeMillis() as start timestamp, which + * requires API level 24+ + */ + public @NotNull TimeSpan getAppStartTimeSpan() { + return appStartSpan; + } + + /** + * @return the SDK init time span, as measured pre-performance-v2 Uses ContentProvider/Sdk init + * time as start timestamp + *

Data is filled by either {@link io.sentry.android.core.SentryPerformanceProvider} with a + * fallback to {@link io.sentry.android.core.SentryAndroid}. At least the start timestamp + * should always be set. + */ + public @NotNull TimeSpan getSdkInitTimeSpan() { + return sdkInitTimeSpan; + } + + public @NotNull TimeSpan getApplicationOnCreateTimeSpan() { + return applicationOnCreate; + } + + public void setAppStartType(final @NotNull AppStartType appStartType) { + this.appStartType = appStartType; + } + + public @NotNull AppStartType getAppStartType() { + return appStartType; + } + + public boolean isAppLaunchedInForeground() { + return appLaunchedInForeground; + } + + /** + * Provides all collected content provider onCreate time spans + * + * @return A sorted list of all onCreate calls + */ + public @NotNull List getContentProviderOnCreateTimeSpans() { + final List measurements = new ArrayList<>(contentProviderOnCreates.values()); + Collections.sort(measurements); + return measurements; + } + + public @NotNull List getActivityLifecycleTimeSpans() { + final List measurements = new ArrayList<>(activityLifecycles); + Collections.sort(measurements); + return measurements; + } + + public void addActivityLifecycleTimeSpans(final @NotNull ActivityLifecycleTimeSpan timeSpan) { + activityLifecycles.add(timeSpan); + } + + /** + * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk + * init time span otherwise + */ + public @NotNull TimeSpan getAppStartTimeSpanWithFallback( + final @NotNull SentryAndroidOptions options) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted()) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + return getSdkInitTimeSpan(); + } + + public void clear() { + appStartType = AppStartType.UNKNOWN; + appStartSpan.reset(); + sdkInitTimeSpan.reset(); + applicationOnCreate.reset(); + contentProviderOnCreates.clear(); + activityLifecycles.clear(); + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStarted()) { + instance.applicationOnCreate.setStartedAt(now); + instance.appLaunchedInForeground = ContextUtils.isForegroundImportance(); + } + } + + /** + * 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); + } + } + + /** + * Called by instrumentation + * + * @param contentProvider The content provider where onCreate was called on + * @noinspection unused + */ + public static void onContentProviderCreate(final @NotNull ContentProvider contentProvider) { + final long now = SystemClock.uptimeMillis(); + + final TimeSpan measurement = new TimeSpan(); + measurement.setStartedAt(now); + getInstance().contentProviderOnCreates.put(contentProvider, measurement); + } + + /** + * Called by instrumentation + * + * @param contentProvider The content provider where onCreate was called on + * @noinspection unused + */ + public static void onContentProviderPostCreate(final @NotNull ContentProvider contentProvider) { + final long now = SystemClock.uptimeMillis(); + + final @Nullable TimeSpan measurement = + getInstance().contentProviderOnCreates.get(contentProvider); + if (measurement != null && measurement.hasNotStopped()) { + measurement.setDescription(contentProvider.getClass().getName() + ".onCreate"); + measurement.setStoppedAt(now); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java new file mode 100644 index 0000000000..5397790588 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java @@ -0,0 +1,171 @@ +package io.sentry.android.core.performance; + +import android.os.SystemClock; +import io.sentry.DateUtils; +import io.sentry.SentryDate; +import io.sentry.SentryLongDate; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * A measurement for time critical components on a macro (ms) level. Based on {@link + * SystemClock#uptimeMillis()} to ensure linear time progression (as opposed to a syncable clock). + * To provide real world unix time information, the start uptime time is stored alongside the unix + * time. The stop unix time is artificial, it gets projected based on the start time + duration of + * the time span. + */ +@ApiStatus.Internal +public class TimeSpan implements Comparable { + + private @Nullable String description; + + private long startUnixTimeMs; + private long startUptimeMs; + private long stopUptimeMs; + + /** Start the time span */ + public void start() { + startUptimeMs = SystemClock.uptimeMillis(); + startUnixTimeMs = System.currentTimeMillis(); + } + + /** + * @param uptimeMs the uptime in ms, provided by {@link SystemClock#uptimeMillis()} + */ + public void setStartedAt(final long uptimeMs) { + // TODO maybe sanity check? + this.startUptimeMs = uptimeMs; + + final long shiftMs = SystemClock.uptimeMillis() - startUptimeMs; + startUnixTimeMs = System.currentTimeMillis() - shiftMs; + } + + /** Stops the time span */ + public void stop() { + stopUptimeMs = SystemClock.uptimeMillis(); + } + + /** + * @param uptimeMs the uptime in ms, provided by {@link SystemClock#uptimeMillis()} + */ + public void setStoppedAt(final long uptimeMs) { + // TODO maybe sanity check? + stopUptimeMs = uptimeMs; + } + + public boolean hasStarted() { + return startUptimeMs != 0; + } + + public boolean hasNotStarted() { + return startUptimeMs == 0; + } + + public boolean hasStopped() { + return stopUptimeMs != 0; + } + + public boolean hasNotStopped() { + return stopUptimeMs == 0; + } + + /** + * @return the start timestamp of this measurement, as uptime, in ms + */ + public long getStartUptimeMs() { + return startUptimeMs; + } + + /** + * @return the start timestamp of this measurement, unix time, in ms + */ + public long getStartTimestampMs() { + return startUnixTimeMs; + } + + /** + * @return the start timestamp of this measurement, unix time + */ + public @Nullable SentryDate getStartTimestamp() { + if (hasStarted()) { + return new SentryLongDate(DateUtils.millisToNanos(getStartTimestampMs())); + } + return null; + } + + /** + * @return the start timestamp of this measurement, unix time, in seconds + */ + public double getStartTimestampSecs() { + return DateUtils.millisToSeconds(startUnixTimeMs); + } + + /** + * @return the projected stop timestamp of this measurement, based on the start timestamp and the + * duration. If the time span was not started 0 is returned, if the time span was not stopped + * the start timestamp is returned. + */ + public long getProjectedStopTimestampMs() { + if (hasStarted()) { + return startUnixTimeMs + getDurationMs(); + } + return 0; + } + + /** + * @return the projected stop timestamp + * @see #getProjectedStopTimestampMs() + */ + public double getProjectedStopTimestampSecs() { + return DateUtils.millisToSeconds(getProjectedStopTimestampMs()); + } + + /** + * @return the projected stop timestamp + * @see #getProjectedStopTimestampMs() + */ + public @Nullable SentryDate getProjectedStopTimestamp() { + if (hasStopped()) { + return new SentryLongDate(DateUtils.millisToNanos(getProjectedStopTimestampMs())); + } + return null; + } + + /** + * @return the duration of this measurement, in ms, or 0 if no end time is set + */ + public long getDurationMs() { + if (hasStopped()) { + return stopUptimeMs - startUptimeMs; + } else { + return 0; + } + } + + @TestOnly + public void setStartUnixTimeMs(long startUnixTimeMs) { + this.startUnixTimeMs = startUnixTimeMs; + } + + public @Nullable String getDescription() { + return description; + } + + public void setDescription(@Nullable final String description) { + this.description = description; + } + + public void reset() { + description = null; + startUptimeMs = 0; + stopUptimeMs = 0; + startUnixTimeMs = 0; + } + + @Override + public int compareTo(@NotNull final TimeSpan o) { + return Long.compare(startUnixTimeMs, o.startUnixTimeMs); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java new file mode 100644 index 0000000000..79180a7bd5 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java @@ -0,0 +1,22 @@ +package io.sentry.android.core.performance; + +import android.view.Window; +import io.sentry.android.core.internal.gestures.WindowCallbackAdapter; +import org.jetbrains.annotations.NotNull; + +public class WindowContentChangedCallback extends WindowCallbackAdapter { + + private final @NotNull Runnable callback; + + public WindowContentChangedCallback( + final @NotNull Window.Callback delegate, final @NotNull Runnable callback) { + super(delegate); + this.callback = callback; + } + + @Override + public void onContentChanged() { + super.onContentChanged(); + callback.run(); + } +} 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 1e280d173b..f5f482ad43 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 @@ -32,6 +32,8 @@ import io.sentry.TraceContext import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService @@ -76,7 +78,6 @@ class ActivityLifecycleIntegrationTest { dsn = "https://key@sentry.io/proj" } val bundle = mock() - val context = TransactionContext("name", "op") val activityFramesTracker = mock() val fullyDisplayedReporter = FullyDisplayedReporter.getInstance() val transactionFinishedCallback = mock() @@ -96,9 +97,10 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) // We let the ActivityLifecycleIntegration create the proper transaction here - val argumentCaptor = argumentCaptor() - whenever(hub.startTransaction(any(), argumentCaptor.capture())).thenAnswer { - val t = SentryTracer(context, hub, argumentCaptor.lastValue) + val optionCaptor = argumentCaptor() + val contextCaptor = argumentCaptor() + whenever(hub.startTransaction(contextCaptor.capture(), optionCaptor.capture())).thenAnswer { + val t = SentryTracer(contextCaptor.lastValue, hub, optionCaptor.lastValue) transaction = t return@thenAnswer t } @@ -130,7 +132,7 @@ class ActivityLifecycleIntegrationTest { @BeforeTest fun `reset instance`() { - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() context = ApplicationProvider.getApplicationContext() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? @@ -610,7 +612,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityDestroyed(activity) val span = fixture.transaction.children.first() - assertEquals(span.status, SpanStatus.CANCELLED) + assertEquals(SpanStatus.CANCELLED, span.status) assertTrue(span.isFinished) } @@ -795,7 +797,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() sut.onActivityCreated(activity, null) - assertTrue(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } @Test @@ -808,7 +810,7 @@ class ActivityLifecycleIntegrationTest { val bundle = Bundle() sut.onActivityCreated(activity, bundle) - assertFalse(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test @@ -822,7 +824,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, bundle) sut.onActivityCreated(activity, null) - assertFalse(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test @@ -831,14 +833,19 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) // call only once - verify(fixture.hub).startTransaction(any(), check { assertEquals(date, it.startTimestamp) }) + verify(fixture.hub).startTransaction( + any(), + check { + assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + } + ) } @Test @@ -848,9 +855,9 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.hub, fixture.options) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) - AppStartState.getInstance().setAppStartEnd(1) + AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(2) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -866,12 +873,13 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.hub, fixture.options) // usually set by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(0), 0) + val startDate = SentryNanotimeDate(Date(1), 0) setAppStartTime(startDate) - AppStartState.getInstance().setColdStart(false) - AppStartState.getInstance().setAppStartEnd(1) + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.WARM + appStartMetrics.sdkInitTimeSpan.setStoppedAt(2) - val endDate = AppStartState.getInstance().appStartEndTime!! + val endDate = appStartMetrics.sdkInitTimeSpan.projectedStopTimestamp val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -879,7 +887,7 @@ class ActivityLifecycleIntegrationTest { val appStartSpanCount = fixture.transaction.children.count { it.spanContext.operation.startsWith("app.start.warm") && it.startDate.nanoTimestamp() == startDate.nanoTimestamp() && - it.finishDate!!.nanoTimestamp() == endDate.nanoTimestamp() + it.finishDate!!.nanoTimestamp() == endDate!!.nanoTimestamp() } assertEquals(1, appStartSpanCount) } @@ -892,9 +900,9 @@ class ActivityLifecycleIntegrationTest { // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init - val startDate = SentryNanotimeDate(Date(0), 0) + val startDate = SentryNanotimeDate(Date(1), 0) setAppStartTime(startDate) - AppStartState.getInstance().setColdStart(false) + AppStartMetrics.getInstance().appStartType = AppStartType.WARM // when activity is created val view = fixture.createView() @@ -902,14 +910,14 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) sut.onActivityCreated(activity, fixture.bundle) // then app-start end time should still be null - assertNull(AppStartState.getInstance().appStartEndTime) + assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasNotStopped()) // when activity is resumed sut.onActivityResumed(activity) Thread.sleep(1) runFirstDraw(view) // end-time should be set - assertNotNull(AppStartState.getInstance().appStartEndTime) + assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasStopped()) } @Test @@ -919,10 +927,10 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.hub, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(0), 0) + val startDate = SentryNanotimeDate(Date(1), 0) setAppStartTime(startDate) - AppStartState.getInstance().setColdStart(false) - AppStartState.getInstance().setAppStartEnd(1234) + AppStartMetrics.getInstance().appStartType = AppStartType.WARM + AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(1234) // when activity is created and resumed val activity = mock() @@ -932,7 +940,7 @@ class ActivityLifecycleIntegrationTest { // then the end time should not be overwritten assertEquals( DateUtils.millisToNanos(1234), - AppStartState.getInstance().appStartEndTime!!.nanoTimestamp() + AppStartMetrics.getInstance().sdkInitTimeSpan.projectedStopTimestamp!!.nanoTimestamp() ) } @@ -943,9 +951,9 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.hub, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(0), 0) + val startDate = SentryNanotimeDate(Date(1), 0) setAppStartTime(startDate) - AppStartState.getInstance().setColdStart(false) + AppStartMetrics.getInstance().appStartType = AppStartType.WARM // when activity is created, started and resumed multiple times val view = fixture.createView() @@ -957,7 +965,7 @@ class ActivityLifecycleIntegrationTest { Thread.sleep(1) runFirstDraw(view) - val firstAppStartEndTime = AppStartState.getInstance().appStartEndTime + val firstAppStartEndTime = AppStartMetrics.getInstance().sdkInitTimeSpan.projectedStopTimestamp Thread.sleep(1) sut.onActivityPaused(activity) @@ -970,7 +978,7 @@ class ActivityLifecycleIntegrationTest { // then the end time should not be overwritten assertEquals( firstAppStartEndTime!!.nanoTimestamp(), - AppStartState.getInstance().appStartEndTime!!.nanoTimestamp() + AppStartMetrics.getInstance().sdkInitTimeSpan.projectedStopTimestamp!!.nanoTimestamp() ) } @@ -980,7 +988,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() @@ -988,7 +996,7 @@ class ActivityLifecycleIntegrationTest { val span = fixture.transaction.children.first() assertEquals(span.operation, "app.start.warm") - assertSame(span.startDate, date) + assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test @@ -997,7 +1005,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() @@ -1005,7 +1013,7 @@ class ActivityLifecycleIntegrationTest { val span = fixture.transaction.children.first() assertEquals(span.operation, "app.start.cold") - assertSame(span.startDate, date) + assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test @@ -1014,7 +1022,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() @@ -1022,7 +1030,7 @@ class ActivityLifecycleIntegrationTest { val span = fixture.transaction.children.first() assertEquals(span.description, "Warm Start") - assertSame(span.startDate, date) + assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test @@ -1031,7 +1039,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() @@ -1039,7 +1047,7 @@ class ActivityLifecycleIntegrationTest { val span = fixture.transaction.children.first() assertEquals(span.description, "Cold Start") - assertSame(span.startDate, date) + assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test @@ -1048,7 +1056,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(Date(1), 0) setAppStartTime() val activity = mock() @@ -1486,8 +1494,18 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } - private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(0), 0)) { + private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0)) { // set by SentryPerformanceProvider so forcing it here - AppStartState.getInstance().setAppStartTime(0, date) + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() + + sdkAppStartTimeSpan.setStartedAt(millis) + sdkAppStartTimeSpan.setStartUnixTimeMs(millis) + sdkAppStartTimeSpan.setStoppedAt(0) + + appStartTimeSpan.setStartedAt(millis) + appStartTimeSpan.setStartUnixTimeMs(millis) + appStartTimeSpan.setStoppedAt(0) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt deleted file mode 100644 index 29cb7c0d7e..0000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.sentry.android.core - -import io.sentry.SentryInstantDate -import io.sentry.SentryLongDate -import io.sentry.SentryNanotimeDate -import java.util.Date -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue - -class AppStartStateTest { - - @BeforeTest - fun `reset instance`() { - AppStartState.getInstance().resetInstance() - } - - @Test - fun `appStartInterval returns null if end time is not set`() { - val sut = AppStartState.getInstance() - - sut.setAppStartTime(0, SentryNanotimeDate(Date(0), 0)) - sut.setColdStart(true) - - assertNull(sut.appStartInterval) - } - - @Test - fun `appStartInterval returns null if start time is not set`() { - val sut = AppStartState.getInstance() - - sut.setAppStartEnd() - sut.setColdStart(true) - - assertNull(sut.appStartInterval) - } - - @Test - fun `appStartInterval returns null if coldStart is not set`() { - val sut = AppStartState.getInstance() - - sut.setAppStartTime(0, SentryNanotimeDate(Date(0), 0)) - sut.setAppStartEnd() - - assertNull(sut.appStartInterval) - } - - @Test - fun `do not overwrite app start values if already set`() { - val sut = AppStartState.getInstance() - - val date = SentryNanotimeDate() - sut.setAppStartTime(0, date) - sut.setAppStartTime(1, SentryInstantDate()) - - assertSame(date, sut.appStartTime) - } - - @Test - fun `do not overwrite app start end time if already set`() { - val sut = AppStartState.getInstance() - - sut.setColdStart(true) - sut.setAppStartTime(1, SentryLongDate(1000000)) - sut.setAppStartEnd(2) - sut.setAppStartEnd(3) - - assertEquals(0, SentryLongDate(2000000).compareTo(sut.appStartEndTime!!)) - } - - @Test - fun `do not overwrite cold start value if already set`() { - val sut = AppStartState.getInstance() - - sut.setColdStart(true) - sut.setColdStart(false) - - assertTrue(sut.isColdStart!!) - } - - @Test - fun `getAppStartInterval returns right calculation`() { - val sut = AppStartState.getInstance() - - val date = SentryNanotimeDate() - sut.setAppStartTime(100, date) - sut.setAppStartEnd(500) - sut.setColdStart(true) - - assertEquals(400, sut.appStartInterval) - } - - @Test - fun `getAppStartInterval returns null if more than 60s`() { - val sut = AppStartState.getInstance() - - val date = SentryNanotimeDate() - sut.setAppStartTime(100, date) - sut.setAppStartEnd(60100) - sut.setColdStart(true) - - assertNull(sut.appStartInterval) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 9b01348a59..acc62be070 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1318,4 +1318,29 @@ class ManifestMetadataReaderTest { // Assert assertTrue(fixture.options.isSendModules) } + + @Test + fun `applyMetadata reads performance-v2 flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_PERFORMANCE_V2 to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnablePerformanceV2) + } + + @Test + fun `applyMetadata reads performance-v2 flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnablePerformanceV2) + } } 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 5935748045..1d98aae539 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 @@ -1,14 +1,22 @@ package io.sentry.android.core +import android.content.ContentProvider import io.sentry.Hint import io.sentry.IHub import io.sentry.MeasurementUnit -import io.sentry.SentryNanotimeDate import io.sentry.SentryTracer +import io.sentry.SpanContext +import io.sentry.SpanId +import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP +import io.sentry.android.core.performance.ActivityLifecycleTimeSpan +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -16,6 +24,7 @@ import org.mockito.kotlin.whenever import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class PerformanceAndroidEventProcessorTest { @@ -28,8 +37,12 @@ class PerformanceAndroidEventProcessorTest { lateinit var tracer: SentryTracer val activityFramesTracker = mock() - fun getSut(tracesSampleRate: Double? = 1.0): PerformanceAndroidEventProcessor { + fun getSut( + tracesSampleRate: Double? = 1.0, + enablePerformanceV2: Boolean = false + ): PerformanceAndroidEventProcessor { options.tracesSampleRate = tracesSampleRate + options.isEnablePerformanceV2 = enablePerformanceV2 whenever(hub.options).thenReturn(options) tracer = SentryTracer(context, hub) return PerformanceAndroidEventProcessor(options, activityFramesTracker) @@ -40,15 +53,27 @@ class PerformanceAndroidEventProcessorTest { @BeforeTest fun `reset instance`() { - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() } @Test fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction() - setAppStart() + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `add cold start measurement for performance-v2`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -59,8 +84,8 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction("app.start.warm") - setAppStart(false) + var tr = getTransaction(AppStartType.WARM) + setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -71,8 +96,8 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction() - setAppStart() + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -84,12 +109,12 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction() - setAppStart(false) + var tr1 = getTransaction(AppStartType.COLD) + setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction() + var tr2 = getTransaction(AppStartType.UNKNOWN) tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) @@ -100,7 +125,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction() + var tr = getTransaction(AppStartType.UNKNOWN) tr = sut.process(tr, Hint()) @@ -111,7 +136,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction() + var tr = getTransaction(AppStartType.COLD) tr = sut.process(tr, Hint()) @@ -122,7 +147,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction("task") + var tr = getTransaction(AppStartType.UNKNOWN) tr = sut.process(tr, Hint()) @@ -132,7 +157,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction("task") + var tr = getTransaction(AppStartType.UNKNOWN) tr = sut.process(tr, Hint()) @@ -142,7 +167,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction("task") + var tr = getTransaction(AppStartType.UNKNOWN) tr = sut.process(tr, Hint()) @@ -156,7 +181,12 @@ class PerformanceAndroidEventProcessorTest { val tracer = SentryTracer(context, fixture.hub) var tr = SentryTransaction(tracer) - val metrics = mapOf(MeasurementValue.KEY_FRAMES_TOTAL to MeasurementValue(1f, MeasurementUnit.Duration.MILLISECOND.apiName())) + val metrics = mapOf( + MeasurementValue.KEY_FRAMES_TOTAL to MeasurementValue( + 1f, + MeasurementUnit.Duration.MILLISECOND.apiName() + ) + ) whenever(fixture.activityFramesTracker.takeMetrics(any())).thenReturn(metrics) tr = sut.process(tr, Hint()) @@ -164,14 +194,186 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_FRAMES_TOTAL)) } - private fun setAppStart(coldStart: Boolean = true) { - AppStartState.getInstance().setColdStart(coldStart) - AppStartState.getInstance().setAppStartTime(0, SentryNanotimeDate()) - AppStartState.getInstance().setAppStartEnd() + @Test + fun `adds app start metrics to app start txn`() { + // given some app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.COLD + appStartMetrics.appStartTimeSpan.setStartedAt(123) + appStartMetrics.appStartTimeSpan.setStoppedAt(456) + + val contentProvider = mock() + AppStartMetrics.onContentProviderCreate(contentProvider) + AppStartMetrics.onContentProviderPostCreate(contentProvider) + + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + + val activityTimeSpan = ActivityLifecycleTimeSpan() + activityTimeSpan.onCreate.description = "MainActivity.onCreate" + activityTimeSpan.onStart.description = "MainActivity.onStart" + + activityTimeSpan.onCreate.setStartedAt(200) + activityTimeSpan.onStart.setStartedAt(220) + activityTimeSpan.onStart.setStoppedAt(240) + activityTimeSpan.onCreate.setStoppedAt(260) + appStartMetrics.addActivityLifecycleTimeSpans(activityTimeSpan) + + // when an activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + + // and it contains an app.start.cold span + val appStartSpan = SentrySpan( + 0.0, + 1.0, + tr.contexts.trace!!.traceId, + SpanId(), + null, + APP_START_COLD, + "App Start", + SpanStatus.OK, + null, + emptyMap(), + null + ) + tr.spans.add(appStartSpan) + + // then the app start metrics should be attached + tr = sut.process(tr, Hint()) + + assertTrue( + tr.spans.any { + "contentprovider.load" == it.op && + appStartSpan.spanId == it.parentSpanId + } + ) + + assertTrue( + tr.spans.any { + "application.load" == it.op + } + ) + + assertTrue( + tr.spans.any { + "activity.load" == it.op && "MainActivity.onCreate" == it.description + } + ) + assertTrue( + tr.spans.any { + "activity.load" == it.op && "MainActivity.onStart" == it.description + } + ) } - private fun getTransaction(op: String = "app.start.cold"): SentryTransaction { - fixture.tracer.startChild(op) - return SentryTransaction(fixture.tracer) + @Test + fun `does not add app start metrics to app start txn when it is not a cold start`() { + // given some WARM app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.WARM + appStartMetrics.appStartTimeSpan.setStartedAt(123) + appStartMetrics.appStartTimeSpan.setStoppedAt(456) + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + // when an activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + + // then the app start metrics should not be attached + tr = sut.process(tr, Hint()) + + assertFalse( + tr.spans.any { + "application.load" == it.op + } + ) + } + + @Test + fun `does not add app start metrics more than once`() { + // given some WARM app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.COLD + appStartMetrics.appStartTimeSpan.setStartedAt(123) + appStartMetrics.appStartTimeSpan.setStoppedAt(456) + + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + + // when the first activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + val appStartSpan = SentrySpan( + 0.0, + 1.0, + tr.contexts.trace!!.traceId, + SpanId(), + null, + APP_START_COLD, + "App Start", + SpanStatus.OK, + null, + emptyMap(), + null + ) + tr.spans.add(appStartSpan) + + // then the app start metrics should not be attached + tr = sut.process(tr, Hint()) + + assertTrue( + tr.spans.any { + "application.load" == it.op + } + ) + + // but not on the second activity transaction + var tr2 = SentryTransaction(tracer) + tr2.spans.add(appStartSpan) + tr2 = sut.process(tr2, Hint()) + assertFalse( + tr2.spans.any { + "application.load" == it.op + } + ) + } + + private fun setAppStart(options: SentryAndroidOptions, coldStart: Boolean = true) { + AppStartMetrics.getInstance().apply { + appStartType = when (coldStart) { + true -> AppStartType.COLD + false -> AppStartType.WARM + } + val timeSpan = + if (options.isEnablePerformanceV2) appStartTimeSpan else sdkInitTimeSpan + timeSpan.apply { + setStartedAt(1) + setStoppedAt(2) + } + } + } + + private fun getTransaction(type: AppStartType): SentryTransaction { + val op = when (type) { + AppStartType.COLD -> "app.start.cold" + AppStartType.WARM -> "app.start.warm" + AppStartType.UNKNOWN -> "ui.load" + } + val txn = SentryTransaction(fixture.tracer) + txn.contexts.trace = SpanContext(op, TracesSamplingDecision(false)) + return txn } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 044380d6be..3ee8ad3fc5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -133,11 +133,6 @@ class SentryAndroidOptionsTest { assertNull(sentryOptions.nativeSdkName) } - @Test - fun `when options is initialized, enableScopeSync is enabled by default`() { - assertTrue(SentryAndroidOptions().isEnableScopeSync) - } - @Test fun `enableScopeSync can be properly disabled`() { val options = SentryAndroidOptions() @@ -146,6 +141,23 @@ class SentryAndroidOptionsTest { assertFalse(options.isEnableScopeSync) } + @Test + fun `performance v2 is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnablePerformanceV2) + } + + @Test + fun `performance v2 can be enabled`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.isEnablePerformanceV2 = true + assertTrue(sentryOptions.isEnablePerformanceV2) + } + + fun `when options is initialized, enableScopeSync is enabled by default`() { + assertTrue(SentryAndroidOptions().isEnableScopeSync) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null override fun clearDebugImages() {} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index b3f99f46c0..f48868ad72 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -4,7 +4,9 @@ import android.app.ActivityManager import android.app.Application import android.app.ApplicationExitInfo import android.content.Context +import android.os.Build import android.os.Bundle +import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb @@ -21,6 +23,7 @@ import io.sentry.Session import io.sentry.ShutdownHookIntegration import io.sentry.UncaughtExceptionHandlerIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache +import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache @@ -60,11 +63,16 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.N], + shadows = [SentryShadowProcess::class] +) class SentryAndroidTest { @get:Rule @@ -150,7 +158,7 @@ class SentryAndroidTest { @BeforeTest fun `set up`() { Sentry.close() - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() context = ApplicationProvider.getApplicationContext() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? fixture.shadowActivityManager = Shadow.extract(activityManager) @@ -202,10 +210,15 @@ class SentryAndroidTest { fixture.initSut(autoInit = true) // done by ActivityLifecycleIntegration so forcing it here - AppStartState.getInstance().setAppStartEnd() - AppStartState.getInstance().setColdStart(true) + AppStartMetrics.getInstance().apply { + appStartType = AppStartMetrics.AppStartType.COLD + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(1 + SystemClock.uptimeMillis()) + } + } - assertNotNull(AppStartState.getInstance().appStartInterval) + assertNotEquals(0, AppStartMetrics.getInstance().appStartTimeSpan.durationMs) } @Test @@ -420,6 +433,35 @@ class SentryAndroidTest { assertEquals(0, optionsRef.integrations.size) } + @Test + fun `init backfills sdk init and app start time`() { + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(42) + + fixture.initSut(context = mock()) { options -> + options.dsn = "https://key@sentry.io/123" + options.isEnablePerformanceV2 = true + } + assertEquals(42, AppStartMetrics.getInstance().appStartTimeSpan.startUptimeMs) + assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasStarted()) + } + + @Test + fun `init does not backfill sdk init and app start times if already set`() { + AppStartMetrics.getInstance().clear() + AppStartMetrics.getInstance().sdkInitTimeSpan.setStartedAt(99) + AppStartMetrics.getInstance().appStartTimeSpan.setStartedAt(99) + + SentryShadowProcess.setStartUptimeMillis(42) + fixture.initSut(context = mock()) { options -> + options.dsn = "https://key@sentry.io/123" + options.isEnablePerformanceV2 = true + } + + assertEquals(99, AppStartMetrics.getInstance().sdkInitTimeSpan.startUptimeMs) + assertEquals(99, AppStartMetrics.getInstance().appStartTimeSpan.startUptimeMs) + } + private fun prefillScopeCache(cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } File(scopeDir, BREADCRUMBS_FILENAME).writeText( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 96390c7ef9..1939e7ed80 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -6,6 +6,7 @@ import io.sentry.Breadcrumb import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.android.core.performance.AppStartMetrics import org.junit.runner.RunWith import java.lang.RuntimeException import kotlin.test.BeforeTest @@ -40,7 +41,7 @@ class SentryLogcatAdapterTest { @BeforeTest fun `set up`() { Sentry.close() - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() breadcrumbs.clear() fixture.initSut { 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 dec045af85..5ad892e454 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,136 +1,120 @@ package io.sentry.android.core -import android.app.Activity import android.app.Application +import android.content.Context import android.content.pm.ProviderInfo import android.os.Build import android.os.Bundle -import android.os.Looper -import android.view.View -import android.view.ViewTreeObserver -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryNanotimeDate +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.core.performance.AppStartMetrics.AppStartType import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.Shadows -import java.util.Date +import org.robolectric.annotation.Config import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.N], + shadows = [SentryShadowProcess::class] +) class SentryPerformanceProviderTest { @BeforeTest fun `set up`() { - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(1234) } @Test - fun `provider sets app start`() { - val providerInfo = ProviderInfo() - - val mockContext = ContextUtilsTestHelper.createMockContext() - providerInfo.authority = AUTHORITY - - val providerAppStartMillis = 10L - val providerAppStartTime = SentryNanotimeDate(Date(0), 0) - SentryPerformanceProvider.setAppStartTime(providerAppStartMillis, providerAppStartTime) + fun `provider starts appStartTimeSpan`() { + assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasNotStarted()) + assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasNotStarted()) + setupProvider() + assertTrue(AppStartMetrics.getInstance().sdkInitTimeSpan.hasStarted()) + assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasStarted()) + } - val provider = SentryPerformanceProvider() - provider.attachInfo(mockContext, providerInfo) + @Test + fun `provider sets cold start based on first activity`() { + val provider = setupProvider() - // done by ActivityLifecycleIntegration so forcing it here - val lifecycleAppEndMillis = 20L - AppStartState.getInstance().setAppStartEnd(lifecycleAppEndMillis) - AppStartState.getInstance().setColdStart(true) + // up until this point app start is not known + assertEquals(AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) - assertEquals(10L, AppStartState.getInstance().appStartInterval) + // when there's no saved state + provider.activityCallback!!.onActivityCreated(mock(), null) + // then app start should be cold + assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } @Test - fun `provider sets first activity as cold start`() { - val providerInfo = ProviderInfo() + fun `provider sets warm start based on first activity`() { + val provider = setupProvider() - val mockContext = ContextUtilsTestHelper.createMockContext() - providerInfo.authority = AUTHORITY - - val provider = SentryPerformanceProvider() - provider.attachInfo(mockContext, providerInfo) + // up until this point app start is not known + assertEquals(AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) - provider.onActivityCreated(mock(), null) + // when there's a saved state + provider.activityCallback!!.onActivityCreated(mock(), Bundle()) - assertTrue(AppStartState.getInstance().isColdStart!!) + // then app start should be warm + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test - fun `provider sets first activity as warm start`() { - val providerInfo = ProviderInfo() + fun `provider keeps startup state even if multiple activities are launched`() { + val provider = setupProvider() - val mockContext = ContextUtilsTestHelper.createMockContext() - providerInfo.authority = AUTHORITY + // when there's a saved state + provider.activityCallback!!.onActivityCreated(mock(), Bundle()) - val provider = SentryPerformanceProvider() - provider.attachInfo(mockContext, providerInfo) + // then app start should be warm + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - provider.onActivityCreated(mock(), Bundle()) + // when another activity is launched cold + provider.activityCallback!!.onActivityCreated(mock(), null) - assertFalse(AppStartState.getInstance().isColdStart!!) + // then app start should remain warm + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test - fun `provider sets app start end on first activity resume, and unregisters afterwards`() { - val providerInfo = ProviderInfo() + fun `provider sets both appstart and sdk init start + end times`() { + val provider = setupProvider() + provider.onAppStartDone() - val mockContext = ContextUtilsTestHelper.createMockContext(true) - providerInfo.authority = AUTHORITY + val metrics = AppStartMetrics.getInstance() + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertTrue(metrics.appStartTimeSpan.hasStopped()) - val provider = SentryPerformanceProvider( - mock { - whenever(mock.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) - }, - MainLooperHandler() - ) - provider.attachInfo(mockContext, providerInfo) - - val view = createView() - val activity = mock() - whenever(activity.findViewById(any())).thenReturn(view) - provider.onActivityCreated(activity, Bundle()) - provider.onActivityResumed(activity) - Thread.sleep(1) - runFirstDraw(view) - - assertNotNull(AppStartState.getInstance().appStartInterval) - assertNotNull(AppStartState.getInstance().appStartEndTime) - - verify((mockContext.applicationContext as Application)) - .unregisterActivityLifecycleCallbacks(any()) + assertTrue(metrics.sdkInitTimeSpan.hasStarted()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) } - private fun createView(): View { - val view = View(ApplicationProvider.getApplicationContext()) - - // Adding a listener forces ViewTreeObserver.mOnDrawListeners to be initialized and non-null. - val dummyListener = ViewTreeObserver.OnDrawListener {} - view.viewTreeObserver.addOnDrawListener(dummyListener) - view.viewTreeObserver.removeOnDrawListener(dummyListener) + @Test + fun `provider properly registers and unregisters ActivityLifecycleCallbacks`() { + val context = mock() + val provider = setupProvider(context) - return view + verify(context).registerActivityLifecycleCallbacks(any()) + provider.onAppStartDone() + verify(context).unregisterActivityLifecycleCallbacks(any()) } - private fun runFirstDraw(view: View) { - // Removes OnDrawListener in the next OnGlobalLayout after onDraw - view.viewTreeObserver.dispatchOnDraw() - view.viewTreeObserver.dispatchOnGlobalLayout() - Shadows.shadowOf(Looper.getMainLooper()).idle() + private fun setupProvider(context: Context = mock()): SentryPerformanceProvider { + val providerInfo = ProviderInfo() + providerInfo.authority = AUTHORITY + + // calls onCreate under the hood + val provider = SentryPerformanceProvider() + provider.attachInfo(context, providerInfo) + return provider } companion object { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt new file mode 100644 index 0000000000..2f3958817b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -0,0 +1,24 @@ +package io.sentry.android.core + +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(android.os.Process::class) +class SentryShadowProcess { + + companion object { + + private var startupTimeMillis: Long = 0 + + fun setStartUptimeMillis(value: Long) { + startupTimeMillis = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long { + return startupTimeMillis + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 095da5f32a..92b5d773fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -4,8 +4,8 @@ import io.sentry.NoOpLogger import io.sentry.SentryEnvelope import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.android.core.AnrV2Integration.AnrV2Hint -import io.sentry.android.core.AppStartState import io.sentry.android.core.SentryAndroidOptions +import io.sentry.android.core.performance.AppStartMetrics import io.sentry.cache.EnvelopeCache import io.sentry.transport.ICurrentDateProvider import io.sentry.util.HintUtils @@ -48,7 +48,15 @@ class AndroidEnvelopeCacheTest { lastReportedAnrFile = File(options.cacheDirPath!!, AndroidEnvelopeCache.LAST_ANR_REPORT) if (appStartMillis != null) { - AppStartState.getInstance().setAppStartMillis(appStartMillis) + AppStartMetrics.getInstance().apply { + if (options.isEnablePerformanceV2) { + appStartTimeSpan.setStartedAt(appStartMillis) + appStartTimeSpan.setStartUnixTimeMs(appStartMillis) + } else { + sdkInitTimeSpan.setStartedAt(appStartMillis) + sdkInitTimeSpan.setStartUnixTimeMs(appStartMillis) + } + } } if (currentTimeMillis != null) { whenever(dateProvider.currentTimeMillis).thenReturn(currentTimeMillis) @@ -62,7 +70,7 @@ class AndroidEnvelopeCacheTest { @BeforeTest fun `set up`() { - AppStartState.getInstance().reset() + AppStartMetrics.getInstance().clear() } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index a7b4cc3f8c..c26c09d191 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -1,16 +1,19 @@ package io.sentry.android.core.internal.util +import android.app.Activity import android.content.Context import android.os.Build import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver +import android.view.Window import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.BuildInfoProvider import io.sentry.test.getProperty import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -22,6 +25,7 @@ import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -136,4 +140,50 @@ class FirstDrawDoneListenerTest { Shadows.shadowOf(Looper.getMainLooper()).idle() verify(r).run() } + + @Test + fun `registerForNextDraw uses the activity decor view`() { + val view = fixture.getSut() + + val activity = mock() + val window = mock() + whenever(activity.window).thenReturn(window) + whenever(window.peekDecorView()).thenReturn(view) + + val r: Runnable = mock() + FirstDrawDoneListener.registerForNextDraw(activity, r, fixture.buildInfo) + + assertFalse(fixture.onDrawListeners.isEmpty()) + } + + @Test + fun `registerForNextDraw uses the activity decor view once it's available`() { + val view = fixture.getSut() + + val activity = mock() + val window = mock() + whenever(activity.window).thenReturn(window) + whenever(window.peekDecorView()).thenReturn(null) + val callbackCapture = argumentCaptor() + + // when registerForNextDraw is called, but the activity has no window yet + val r: Runnable = mock() + FirstDrawDoneListener.registerForNextDraw(activity, r, fixture.buildInfo) + + // then a window callback is installed + verify(window).callback = callbackCapture.capture() + + // once the window is available + whenever(window.peekDecorView()).thenReturn(view) + callbackCapture.firstValue.onContentChanged() + + // then a window callback should be set back to the original one + verify(window, times(2)).callback = callbackCapture.capture() + assertNull(callbackCapture.lastValue) + + // and the onDrawListener should be registered + assertFalse(fixture.onDrawListeners.isEmpty()) + + listOf(1, 2).isNotEmpty() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt new file mode 100644 index 0000000000..d22b376cdf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt @@ -0,0 +1,54 @@ +package io.sentry.android.core.performance + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ActivityLifecycleTimeSpanTest { + @Test + fun `init does not auto-start the spans`() { + val span = ActivityLifecycleTimeSpan() + + assertTrue(span.onCreate.hasNotStarted()) + assertTrue(span.onStart.hasNotStarted()) + } + + @Test + fun `spans are compareable`() { + // given some spans + val spanA = ActivityLifecycleTimeSpan() + spanA.onCreate.setStartedAt(1) + + val spanB = ActivityLifecycleTimeSpan() + spanB.onCreate.setStartedAt(2) + + val spanC = ActivityLifecycleTimeSpan() + spanC.onCreate.setStartedAt(3) + + // when put into an list out of order + // then sorted + val spans = listOf(spanB, spanC, spanA).sorted() + + // puts them back in order + assertEquals(spanA, spans[0]) + assertEquals(spanB, spans[1]) + assertEquals(spanC, spans[2]) + } + + @Test + fun `if two activity spans have same onCreate, they're sorted by onstart`() { + // given span A and B with same onCreate + val spanA = ActivityLifecycleTimeSpan() + spanA.onCreate.setStartedAt(1) + val spanB = ActivityLifecycleTimeSpan() + spanB.onCreate.setStartedAt(1) + + // when span A starts after span B + spanA.onStart.setStartedAt(20) + spanB.onStart.setStartedAt(10) + + // then they still should be properly sorted + val sortedSpans = listOf(spanA, spanB).sorted() + assertEquals(spanB, sortedSpans[0]) + } +} 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 new file mode 100644 index 0000000000..bec98f04fc --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -0,0 +1,99 @@ +package io.sentry.android.core.performance + +import android.app.Application +import android.content.ContentProvider +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.android.core.SentryShadowProcess +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.N], + shadows = [SentryShadowProcess::class] +) +class AppStartMetricsTest { + + @Before + fun setup() { + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(42) + } + + @Test + fun `getInstance returns a singleton`() { + assertSame(AppStartMetrics.getInstance(), AppStartMetrics.getInstance()) + } + + @Test + fun `metrics are properly cleared`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.applicationOnCreateTimeSpan.start() + metrics.addActivityLifecycleTimeSpans(ActivityLifecycleTimeSpan()) + AppStartMetrics.onApplicationCreate(mock()) + AppStartMetrics.onContentProviderCreate(mock()) + + metrics.clear() + + assertTrue(metrics.appStartTimeSpan.hasNotStarted()) + assertTrue(metrics.sdkInitTimeSpan.hasNotStarted()) + assertTrue(metrics.applicationOnCreateTimeSpan.hasNotStarted()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + assertTrue(metrics.applicationOnCreateTimeSpan.hasNotStarted()) + + assertTrue(metrics.activityLifecycleTimeSpans.isEmpty()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) + } + + @Test + fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertSame(appStartTimeSpan, timeSpan) + } + + @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() + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = false + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + val sdkInitSpan = AppStartMetrics.getInstance().sdkInitTimeSpan + assertSame(sdkInitSpan, timeSpan) + } + + @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()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + val sdkInitSpan = AppStartMetrics.getInstance().sdkInitTimeSpan + assertSame(sdkInitSpan, timeSpan) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/TimeSpanTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/TimeSpanTest.kt new file mode 100644 index 0000000000..7094835d10 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/TimeSpanTest.kt @@ -0,0 +1,155 @@ +package io.sentry.android.core.performance + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class TimeSpanTest { + @Test + fun `init default state`() { + val span = TimeSpan() + + assertTrue(span.hasNotStarted()) + assertTrue(span.hasNotStopped()) + + assertFalse(span.hasStarted()) + assertFalse(span.hasStopped()) + } + + @Test + fun `spans are compareable`() { + // given some spans + val spanA = TimeSpan() + spanA.setStartedAt(1) + + val spanB = TimeSpan() + spanA.setStartedAt(2) + + assertEquals(1, spanA.compareTo(spanB)) + } + + @Test + fun `spans reset`() { + val span = TimeSpan().apply { + description = "Hello World" + setStartedAt(1) + setStoppedAt(2) + } + span.reset() + + assertTrue(span.hasNotStarted()) + assertTrue(span.hasNotStopped()) + assertNull(span.description) + } + + @Test + fun `spans description`() { + val span = TimeSpan().apply { + description = "Hello World" + } + assertEquals("Hello World", span.description) + } + + @Test + fun `span duration`() { + val span = TimeSpan().apply { + setStartedAt(1) + setStoppedAt(10) + } + assertEquals(9, span.durationMs) + } + + @Test + fun `span has no duration if not started`() { + assertEquals(0, TimeSpan().durationMs) + } + + @Test + fun `span has no duration if not stopped`() { + val span = TimeSpan().apply { + setStartedAt(1) + } + assertEquals(0, span.durationMs) + } + + @Test + fun `span unix timestamp is correctly set`() { + val span = TimeSpan() + + span.setStartedAt(100) + span.setStoppedAt(200) + + assertEquals(100, span.projectedStopTimestampMs - span.startTimestampMs) + assertEquals(100, span.durationMs) + } + + @Test + fun `span stop time is 0 if not started`() { + val span = TimeSpan() + assertEquals(0, span.projectedStopTimestampMs) + assertEquals(0.0, span.projectedStopTimestampSecs) + } + + @Test + fun `span start and stop time is translated correctly into seconds`() { + val span = TimeSpan() + span.setStartedAt(1234) + span.setStoppedAt(1234) + + assertEquals(span.startTimestampMs / 1000.0, span.startTimestampSecs, 0.001) + assertEquals(span.projectedStopTimestampMs / 1000.0, span.projectedStopTimestampSecs, 0.001) + } + + @Test + fun `span start and stop time is translated correctly into SentryDate`() { + val span = TimeSpan() + assertNull(span.startTimestamp) + + span.setStartedAt(1234) + span.setStoppedAt(1234) + assertNotNull(span.startTimestamp) + + assertEquals( + span.startTimestampMs.toDouble(), + DateUtils.nanosToMillis(span.startTimestamp!!.nanoTimestamp().toDouble()), + 0.001 + ) + } + + @Test + fun `span start starts the timespan`() { + val span = TimeSpan() + span.start() + + assertTrue(span.hasStarted()) + assertFalse(span.hasNotStarted()) + } + + @Test + fun `span stop stops the timespan`() { + val span = TimeSpan() + span.start() + + assertFalse(span.hasStopped()) + + span.stop() + + assertTrue(span.hasStopped()) + assertFalse(span.hasNotStopped()) + } + + @Test + fun `span start uptime getter`() { + val span = TimeSpan() + span.setStartedAt(1234) + + assertEquals(1234, span.startUptimeMs) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt index a11f8566ba..47e8f09da4 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt @@ -5,6 +5,8 @@ import io.sentry.Sentry import io.sentry.SentryEnvelope import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest +import okio.GzipSource +import okio.buffer import java.io.IOException import java.util.zip.GZIPInputStream @@ -98,5 +100,9 @@ class RelayAsserter( assertion(EnvelopeAsserter(it, response)) } ?: throw AssertionError("Was unable to parse the request as an envelope: $request") } + + override fun toString(): String { + return "RelayResponse(request=${request.requestLine}\n${GzipSource(request.body).buffer().readUtf8()}\n, response=$response)" + } } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 8c6c9662a4..ad6889e253 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -151,5 +151,7 @@ + +