diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f8f969fb9..a6250561b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- (Internal, Experimental) Attach spans for Application, ContentProvider, and Activities to app-start ([#3057](https://github.com/getsentry/sentry-java/pull/3057)) + ## 6.34.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index d6253b3f025..f5511b996c7 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -115,17 +115,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; @@ -151,6 +140,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun isForegroundImportance (Landroid/content/Context;)Z } public class io/sentry/android/core/CurrentActivityHolder { @@ -269,6 +259,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableFramesTracking ()Z public fun isEnableNetworkEventBreadcrumbs ()Z public fun isEnableRootCheck ()Z + public fun isEnableStarfish ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isReportHistoricalAnrs ()Z public fun setAnrEnabled (Z)V @@ -289,6 +280,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableFramesTracking (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V public fun setEnableRootCheck (Z)V + public fun setEnableStarfish (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setNativeSdkName (Ljava/lang/String;)V public fun setProfilingTracesHz (I)V @@ -326,17 +318,12 @@ 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 getActivityCallback ()Landroid/app/Application$ActivityLifecycleCallbacks; 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 onAppLaunched ()V public fun onCreate ()Z } @@ -387,3 +374,89 @@ 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 final field onCreate Lio/sentry/android/core/performance/TimeSpan; + public final field onStart Lio/sentry/android/core/performance/TimeSpan; + public fun ()V + public fun compareTo (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)I + public synthetic fun compareTo (Ljava/lang/Object;)I +} + +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 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 getLegacyAppStartTimeSpan ()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/NextDrawListener : android/view/View$OnAttachStateChangeListener, android/view/ViewTreeObserver$OnDrawListener { + protected fun (Landroid/os/Handler;Ljava/lang/Runnable;)V + public static fun forActivity (Landroid/app/Activity;Ljava/lang/Runnable;)Lio/sentry/android/core/performance/NextDrawListener; + public fun onDraw ()V + public fun onViewAttachedToWindow (Landroid/view/View;)V + public fun onViewDetachedFromWindow (Landroid/view/View;)V + public fun unregister ()V +} + +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 getProjectedStopTimestampS ()D + public fun getStartTimestamp ()Lio/sentry/SentryDate; + public fun getStartTimestampMs ()J + public fun getStartTimestampS ()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 12eea7922a2..c7ed4ee9b75 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(this.application); } @Override @@ -182,15 +179,27 @@ 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 = getAppStartTimeSpan(); + + // 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(this.application); + if (foregroundImportance && appStartTimeSpan.hasStarted()) { + appStartTime = appStartTimeSpan.getStartTimestamp(); + coldStart = + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD; + } else { + appStartTime = null; + coldStart = null; + } final TransactionOptions transactionOptions = new TransactionOptions(); if (options.isEnableActivityLifecycleTracingAutoFinish()) { @@ -406,13 +415,13 @@ public synchronized void onActivityStarted(final @NotNull Activity activity) { public synchronized void onActivityResumed(final @NotNull Activity activity) { if (performanceEnabled) { // app start span - @Nullable final SentryDate appStartStartTime = AppStartState.getInstance().getAppStartTime(); - @Nullable final SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); + final @NotNull TimeSpan appStartTimeSpan = getAppStartTimeSpan(); + // 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(); + if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { + appStartTimeSpan.stop(); } finishAppStartSpan(); @@ -625,7 +634,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-starfish: back-fill with best guess + if (options != null && !options.isEnableStarfish()) { + AppStartMetrics.getInstance() + .setAppStartType( + savedInstanceState == null + ? AppStartMetrics.AppStartType.COLD + : AppStartMetrics.AppStartType.WARM); + } } } @@ -661,9 +678,18 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { } private void finishAppStartSpan() { - final @Nullable SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); + final @NotNull TimeSpan appStartTimeSpan = getAppStartTimeSpan(); + final @Nullable SentryDate appStartEndTime = appStartTimeSpan.getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); } } + + private @NotNull TimeSpan getAppStartTimeSpan() { + final @NotNull TimeSpan appStartTimeSpan = + options.isEnableStarfish() + ? AppStartMetrics.getInstance().getAppStartTimeSpan() + : AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + return appStartTimeSpan; + } } 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 de690aa6683..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java +++ /dev/null @@ -1,129 +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) { - 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 25d4cb05369..f4d83594657 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 @@ -169,7 +169,7 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { * * @return true if IMPORTANCE_FOREGROUND and false otherwise */ - static boolean isForegroundImportance(final @NotNull Context context) { + public static boolean isForegroundImportance(final @NotNull Context context) { try { final Object service = context.getSystemService(Context.ACTIVITY_SERVICE); if (service instanceof ActivityManager) { 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 75cc4821367..834ac250904 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 @@ -10,6 +10,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; @@ -192,7 +194,13 @@ 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 = + options.isEnableStarfish() + ? AppStartMetrics.getInstance().getAppStartTimeSpan() + : AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + 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 aa25a4e7458..79e93a0837e 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,14 @@ 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 = + options.isEnableStarfish() + ? AppStartMetrics.getInstance().getAppStartTimeSpan() + : AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + 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 014268cff7d..533757a29c6 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_STARFISH = "io.sentry.starfish.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.setEnableStarfish( + readBool(metadata, logger, ENABLE_STARFISH, options.isEnableStarfish())); } 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 eae02976140..34e4ae5ea0f 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,7 @@ /** Event Processor responsible for adding Android metrics to transactions */ final class PerformanceAndroidEventProcessor implements EventProcessor { + private static final String APP_METRICS_ORIGN = "auto.ui"; private boolean sentStartMeasurement = false; private final @NotNull ActivityFramesTracker activityFramesTracker; @@ -62,20 +69,27 @@ 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 = + options.isEnableStarfish() + ? AppStartMetrics.getInstance().getAppStartTimeSpan() + : AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + 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 +113,148 @@ 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 anyway 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(); + + // Application.onCreate + final @NotNull TimeSpan appOnCreate = appStartMetrics.getApplicationOnCreateTimeSpan(); + if (appOnCreate.hasStopped()) { + final SentrySpan span = + new SentrySpan( + appOnCreate.getStartTimestampS(), + appOnCreate.getProjectedStopTimestampS(), + traceId, + new SpanId(), + null, + UI_LOAD_OP, + appOnCreate.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(span); + } + + // Content Provider + final @NotNull List contentProviderOnCreates = + appStartMetrics.getContentProviderOnCreateTimeSpans(); + if (!contentProviderOnCreates.isEmpty()) { + final @NotNull SentrySpan contentProviderRootSpan = + new SentrySpan( + contentProviderOnCreates.get(0).getStartTimestampS(), + contentProviderOnCreates + .get(contentProviderOnCreates.size() - 1) + .getProjectedStopTimestampS(), + traceId, + new SpanId(), + null, + UI_LOAD_OP, + "ContentProvider", + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(contentProviderRootSpan); + for (final @NotNull TimeSpan contentProvider : contentProviderOnCreates) { + final SentrySpan contentProviderSpan = + new SentrySpan( + contentProvider.getStartTimestampS(), + contentProvider.getProjectedStopTimestampS(), + traceId, + new SpanId(), + contentProviderRootSpan.getSpanId(), + UI_LOAD_OP, + contentProvider.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(contentProviderSpan); + } + } + + // Activities + final @NotNull List activityLifecycleTimeSpans = + appStartMetrics.getActivityLifecycleTimeSpans(); + if (!activityLifecycleTimeSpans.isEmpty()) { + final SentrySpan activityRootSpan = + new SentrySpan( + activityLifecycleTimeSpans.get(0).onCreate.getStartTimestampS(), + activityLifecycleTimeSpans + .get(activityLifecycleTimeSpans.size() - 1) + .onStart + .getProjectedStopTimestampS(), + traceId, + new SpanId(), + null, + UI_LOAD_OP, + "Activity", + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(activityRootSpan); + + for (ActivityLifecycleTimeSpan activityTimeSpan : activityLifecycleTimeSpans) { + if (activityTimeSpan.onCreate.hasStarted() && activityTimeSpan.onCreate.hasStopped()) { + final SentrySpan onCreateSpan = + new SentrySpan( + activityTimeSpan.onCreate.getStartTimestampS(), + activityTimeSpan.onCreate.getProjectedStopTimestampS(), + traceId, + new SpanId(), + activityRootSpan.getSpanId(), + UI_LOAD_OP, + activityTimeSpan.onCreate.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(onCreateSpan); + } + if (activityTimeSpan.onStart.hasStarted() && activityTimeSpan.onStart.hasStopped()) { + final SentrySpan onStartSpan = + new SentrySpan( + activityTimeSpan.onStart.getStartTimestampS(), + activityTimeSpan.onStart.getProjectedStopTimestampS(), + traceId, + new SpanId(), + activityRootSpan.getSpanId(), + UI_LOAD_OP, + activityTimeSpan.onStart.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGN, + new HashMap<>(), + null); + txn.getSpans().add(onStartSpan); + } + } + } } } 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 044e727a5c1..0642516f655 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,18 @@ package io.sentry.android.core; 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,9 +23,6 @@ /** 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(); @@ -81,9 +80,6 @@ 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 +120,24 @@ public static synchronized void init( configuration.configure(options); + // if SentryPerformanceProvider was disabled or removed, we set the App Start when + // the SDK is called. + // pre-starfish: fill-back the app start time to the SDK init time + if (options.isEnableStarfish()) { + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpan(); + if (appStartTimeSpan.hasNotStarted() + && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + appStartTimeSpan.setStartedAt(Process.getStartUptimeMillis()); + } + } else { + final @NotNull TimeSpan appStartTime = + AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + if (appStartTime.hasNotStarted()) { + appStartTime.setStartedAt(appStart); + } + } + 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 62437a0425f..a5e55c654e4 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 @@ -203,6 +203,8 @@ public interface BeforeCaptureCallback { */ private boolean attachAnrThreadDump = false; + private boolean enableStarfish; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -547,4 +549,14 @@ public boolean isAttachAnrThreadDump() { public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { this.attachAnrThreadDump = attachAnrThreadDump; } + + @ApiStatus.Internal + public boolean isEnableStarfish() { + return enableStarfish; + } + + @ApiStatus.Internal + public void setEnableStarfish(final boolean enableStarfish) { + this.enableStarfish = enableStarfish; + } } 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 7ba270351b3..77859067c64 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 @@ -6,55 +6,36 @@ import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; import android.os.SystemClock; -import io.sentry.SentryDate; +import androidx.annotation.NonNull; +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.NextDrawListener; +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 legacyAppStartMillis = SystemClock.uptimeMillis(); - private boolean firstActivityCreated = false; - private boolean firstActivityResumed = false; - - private @Nullable Application application; - - public SentryPerformanceProvider() { - AppStartState.getInstance().setAppStartTime(appStartMillis, appStartTime); - } + 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(); return true; } @@ -74,53 +55,145 @@ public String getType(@NotNull Uri uri) { return null; } - @TestOnly - static void setAppStartTime( - final long appStartMillisLong, final @NotNull SentryDate appStartTimeDate) { - appStartMillis = appStartMillisLong; - appStartTime = appStartTimeDate; - } - - @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; + @ApiStatus.Internal + public void onAppLaunched() { + // pre-starfish: use static field init as app start time + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull TimeSpan legacyAppStartSpan = appStartMetrics.getLegacyAppStartTimeSpan(); + legacyAppStartSpan.setStartedAt(legacyAppStartMillis); + + // starfish: Use Process.getStartUptimeMillis() + // Process.getStartUptimeMillis() requires API level 24+ + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) { + return; } - } - @Override - public void onActivityStarted(@NotNull Activity activity) {} - - @Override - public void onActivityResumed(@NotNull Activity activity) { - if (!firstActivityResumed) { - // sets App start as finished when the very first activity calls onResume - firstActivityResumed = true; - AppStartState.getInstance().setAppStartEnd(); + @Nullable Context context = getContext(); + if (context != null) { + context = context.getApplicationContext(); } - if (application != null) { - application.unregisterActivityLifecycleCallbacks(this); + if (context instanceof Application) { + app = (Application) context; + } + if (app == null) { + return; } - } - @Override - public void onActivityPaused(@NotNull Activity activity) {} + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); + + final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + final Handler handler = new Handler(Looper.getMainLooper()); + + 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.onCreate.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.onCreate.stop(); + timeSpan.onCreate.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.onStart.setStartedAt(now); + } + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { + return; + } + NextDrawListener.forActivity( + activity, + () -> { + handler.postAtFrontOfQueue( + () -> { + if (firstDrawDone.compareAndSet(false, true)) { + onAppStartDone(); + } + }); + }); + } + + @Override + public void onActivityPostStarted(@NonNull Activity activity) { + final @Nullable ActivityLifecycleTimeSpan timeSpan = + activityLifecycleMap.remove(activity); + if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { + return; + } + if (timeSpan != null) { + timeSpan.onStart.stop(); + timeSpan.onStart.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 onActivityStopped(@NotNull Activity activity) {} + private synchronized void onAppStartDone() { + AppStartMetrics.getInstance().getAppStartTimeSpan().stop(); - @Override - public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle outState) {} + if (app != null) { + if (activityCallback != null) { + app.unregisterActivityLifecycleCallbacks(activityCallback); + } + } + } - @Override - public void onActivityDestroyed(@NotNull Activity activity) {} + @TestOnly + public @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 bd3e0809b5d..bf83bbb1168 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; @@ -52,10 +53,15 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { final SentryAndroidOptions options = (SentryAndroidOptions) this.options; - final Long appStartTime = AppStartState.getInstance().getAppStartMillis(); + final TimeSpan appStartTimeSpan = + options.isEnableStarfish() + ? AppStartMetrics.getInstance().getAppStartTimeSpan() + : AppStartMetrics.getInstance().getLegacyAppStartTimeSpan(); + if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class) - && appStartTime != null) { - long timeSinceSdkInit = currentDateProvider.getCurrentTimeMillis() - appStartTime; + && appStartTimeSpan.hasStarted()) { + long timeSinceSdkInit = + currentDateProvider.getCurrentTimeMillis() - appStartTimeSpan.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 a6568ad057e..c6a3e627115 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/performance/ActivityLifecycleCallbacksAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java new file mode 100644 index 00000000000..c7a449d1ef8 --- /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 00000000000..1d7f4eb2aac --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java @@ -0,0 +1,15 @@ +package io.sentry.android.core.performance; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class ActivityLifecycleTimeSpan implements Comparable { + public final @NotNull TimeSpan onCreate = new TimeSpan(); + public final @NotNull TimeSpan onStart = new TimeSpan(); + + @Override + public int compareTo(ActivityLifecycleTimeSpan o) { + return Long.compare(onCreate.getStartUptimeMs(), o.onCreate.getStartUptimeMs()); + } +} 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 00000000000..71219aa2bc1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -0,0 +1,183 @@ +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 java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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. + */ +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 legacyAppStartSpan; + 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(); + legacyAppStartSpan = 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 app start time span, as measured pre-starfish Uses ContentProvider/Sdk init time as + * start timestamp + */ + public @NotNull TimeSpan getLegacyAppStartTimeSpan() { + return legacyAppStartSpan; + } + + 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); + } + + public void clear() { + appStartType = AppStartType.UNKNOWN; + appStartSpan.reset(); + legacyAppStartSpan.reset(); + applicationOnCreate.reset(); + contentProviderOnCreates.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(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); + } + } + + /** + * 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/NextDrawListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/NextDrawListener.java new file mode 100644 index 00000000000..48b669f1c78 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/NextDrawListener.java @@ -0,0 +1,138 @@ +package io.sentry.android.core.performance; + +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.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import io.sentry.android.core.internal.gestures.NoOpWindowCallback; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Inspired by https://blog.p-y.wtf/tracking-android-app-launch-in-production Adapted from: + * https://github.com/square/papa/blob/31eebb3d70908bcb1209d82f066ec4d4377183ee/papa/src/main/java/papa/internal/ViewTreeObservers.kt + * + *

Copyright 2021 Square Inc. + * + *

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +@ApiStatus.Internal +public class NextDrawListener + implements ViewTreeObserver.OnDrawListener, View.OnAttachStateChangeListener { + + private @NotNull final Runnable onDrawCallback; + private @NotNull final Handler mainHandler; + private boolean invoked; + + private @Nullable View view; + + protected NextDrawListener( + final @NotNull Handler handler, final @NotNull Runnable onDrawCallback) { + this.mainHandler = handler; + this.onDrawCallback = onDrawCallback; + } + + public static NextDrawListener forActivity( + final @NotNull Activity activity, final @NotNull Runnable onDrawCallback) { + final NextDrawListener listener = + new NextDrawListener(new Handler(Looper.getMainLooper()), onDrawCallback); + + @Nullable Window window = activity.getWindow(); + if (window != null) { + @Nullable View decorView = window.peekDecorView(); + if (decorView != null) { + listener.safelyRegisterForNextDraw(decorView); + } else { + @Nullable Window.Callback oldCallback = window.getCallback(); + if (oldCallback == null) { + oldCallback = new NoOpWindowCallback(); + } + window.setCallback( + new WindowContentChangedCallback( + oldCallback, + () -> { + @Nullable View newDecorView = window.peekDecorView(); + if (newDecorView != null) { + listener.safelyRegisterForNextDraw(newDecorView); + } + })); + } + } + return listener; + } + + @Override + public void onDraw() { + if (invoked) { + return; + } + invoked = true; + // ViewTreeObserver.removeOnDrawListener() throws if called from the onDraw() callback + mainHandler.post( + () -> { + final ViewTreeObserver observer = view.getViewTreeObserver(); + if (observer != null && observer.isAlive()) { + observer.removeOnDrawListener(NextDrawListener.this); + } + }); + onDrawCallback.run(); + } + + private void safelyRegisterForNextDraw(final @NotNull View view) { + this.view = view; + // Prior to API 26, OnDrawListener wasn't merged back from the floating ViewTreeObserver into + // the real ViewTreeObserver. + // https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3 + final @Nullable ViewTreeObserver viewTreeObserver = view.getViewTreeObserver(); + if (Build.VERSION.SDK_INT >= 26 + && viewTreeObserver != null + && (viewTreeObserver.isAlive() && ViewCompat.isAttachedToWindow(view))) { + viewTreeObserver.addOnDrawListener(this); + } else { + view.addOnAttachStateChangeListener(this); + } + } + + @Override + public void onViewAttachedToWindow(@NonNull View v) { + if (view != null) { + // Backed by CopyOnWriteArrayList, ok to self remove from onViewDetachedFromWindow() + view.removeOnAttachStateChangeListener(this); + + final @Nullable ViewTreeObserver viewTreeObserver = view.getViewTreeObserver(); + if (viewTreeObserver != null && viewTreeObserver.isAlive()) { + viewTreeObserver.addOnDrawListener(this); + } + } + } + + @Override + public void onViewDetachedFromWindow(@NonNull View v) { + unregister(); + } + + public void unregister() { + if (view != null) { + view.removeOnAttachStateChangeListener(this); + + final @Nullable ViewTreeObserver viewTreeObserver = view.getViewTreeObserver(); + if (viewTreeObserver != null && viewTreeObserver.isAlive()) { + viewTreeObserver.removeOnDrawListener(this); + } + } + } +} 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 00000000000..946cdc11791 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java @@ -0,0 +1,164 @@ +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.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. + */ +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 ms + */ + public double getStartTimestampS() { + return (double) startUnixTimeMs / 1000.0d; + } + + /** + * @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; + } + + public double getProjectedStopTimestampS() { + return (double) getProjectedStopTimestampMs() / 1000.0d; + } + + /** + * @return the start timestamp of this measurement, unix time + */ + 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 00000000000..79180a7bd5a --- /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 26bd42e5abf..69113a85b80 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 @@ -30,6 +30,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.getProperty @@ -129,7 +131,7 @@ class ActivityLifecycleIntegrationTest { @BeforeTest fun `reset instance`() { - AppStartState.getInstance().resetInstance() + AppStartMetrics.getInstance().clear() } @AfterTest @@ -602,7 +604,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) } @@ -787,7 +789,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() sut.onActivityCreated(activity, null) - assertTrue(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } @Test @@ -800,7 +802,7 @@ class ActivityLifecycleIntegrationTest { val bundle = Bundle() sut.onActivityCreated(activity, bundle) - assertFalse(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test @@ -814,7 +816,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, bundle) sut.onActivityCreated(activity, null) - assertFalse(AppStartState.getInstance().isColdStart!!) + assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test @@ -823,14 +825,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 @@ -840,9 +847,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().legacyAppStartTimeSpan.setStoppedAt(2) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -858,12 +865,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.legacyAppStartTimeSpan.setStoppedAt(2) - val endDate = AppStartState.getInstance().appStartEndTime!! + val endDate = appStartMetrics.legacyAppStartTimeSpan.projectedStopTimestamp val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -871,7 +879,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) } @@ -884,20 +892,20 @@ 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 activity = mock() sut.onActivityCreated(activity, fixture.bundle) // then app-start end time should still be null - assertNull(AppStartState.getInstance().appStartEndTime) + assertTrue(AppStartMetrics.getInstance().legacyAppStartTimeSpan.hasNotStopped()) // when activity is resumed sut.onActivityResumed(activity) // end-time should be set - assertNotNull(AppStartState.getInstance().appStartEndTime) + assertTrue(AppStartMetrics.getInstance().legacyAppStartTimeSpan.hasStopped()) } @Test @@ -907,10 +915,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().legacyAppStartTimeSpan.setStoppedAt(1234) // when activity is created and resumed val activity = mock() @@ -920,7 +928,7 @@ class ActivityLifecycleIntegrationTest { // then the end time should not be overwritten assertEquals( DateUtils.millisToNanos(1234), - AppStartState.getInstance().appStartEndTime!!.nanoTimestamp() + AppStartMetrics.getInstance().legacyAppStartTimeSpan.projectedStopTimestamp!!.nanoTimestamp() ) } @@ -931,9 +939,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 activity = mock() @@ -941,7 +949,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityStarted(activity) sut.onActivityResumed(activity) - val firstAppStartEndTime = AppStartState.getInstance().appStartEndTime + val firstAppStartEndTime = AppStartMetrics.getInstance().legacyAppStartTimeSpan.projectedStopTimestamp Thread.sleep(1) sut.onActivityPaused(activity) @@ -952,7 +960,7 @@ class ActivityLifecycleIntegrationTest { // then the end time should not be overwritten assertEquals( firstAppStartEndTime!!.nanoTimestamp(), - AppStartState.getInstance().appStartEndTime!!.nanoTimestamp() + AppStartMetrics.getInstance().legacyAppStartTimeSpan.projectedStopTimestamp!!.nanoTimestamp() ) } @@ -962,7 +970,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() @@ -970,7 +978,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 @@ -979,7 +987,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() @@ -987,7 +995,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 @@ -996,7 +1004,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() @@ -1004,7 +1012,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 @@ -1013,7 +1021,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() @@ -1021,7 +1029,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 @@ -1030,7 +1038,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() @@ -1488,8 +1496,13 @@ 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 appStartTimeSpan = AppStartMetrics.getInstance().legacyAppStartTimeSpan + val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() + + 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 421274b42a6..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.sentry.android.core - -import io.sentry.SentryInstantDate -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 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 62d829403b0..fe9ecabdb68 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 starfish flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STARFISH to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableStarfish) + } + + @Test + fun `applyMetadata reads starfish 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.isEnableStarfish) + } } 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 5935748045f..0c76fa59f92 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 @@ -3,12 +3,19 @@ package io.sentry.android.core 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 @@ -28,8 +35,12 @@ class PerformanceAndroidEventProcessorTest { lateinit var tracer: SentryTracer val activityFramesTracker = mock() - fun getSut(tracesSampleRate: Double? = 1.0): PerformanceAndroidEventProcessor { + fun getSut( + tracesSampleRate: Double? = 1.0, + enableStarfish: Boolean = false + ): PerformanceAndroidEventProcessor { options.tracesSampleRate = tracesSampleRate + options.isEnableStarfish = enableStarfish whenever(hub.options).thenReturn(options) tracer = SentryTracer(context, hub) return PerformanceAndroidEventProcessor(options, activityFramesTracker) @@ -40,15 +51,28 @@ 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 starfish`() { + val sut = fixture.getSut() + fixture.options.isEnableStarfish = true + + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -59,8 +83,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 +95,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 +108,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 +124,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 +135,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 +146,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 +156,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 +166,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 +180,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 +193,90 @@ 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 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(enableStarfish = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + + // and it contains an app start signal + tr.spans.add( + SentrySpan( + 0.0, + 1.0, + tr.contexts.trace!!.traceId, + SpanId(), + null, + APP_START_COLD, + "App Start", + SpanStatus.OK, + null, + emptyMap(), + null + ) + ) + + // then the app start metrics should be attached + tr = sut.process(tr, Hint()) + + assertTrue( + tr.spans.any { + UI_LOAD_OP == it.op && "Activity" == it.description + } + ) + assertTrue( + tr.spans.any { + UI_LOAD_OP == it.op && "MainActivity.onCreate" == it.description + } + ) + assertTrue( + tr.spans.any { + UI_LOAD_OP == it.op && "MainActivity.onStart" == it.description + } + ) + } + + private fun setAppStart(options: SentryAndroidOptions, coldStart: Boolean = true) { + AppStartMetrics.getInstance().apply { + appStartType = when (coldStart) { + true -> AppStartType.COLD + false -> AppStartType.WARM + } + val timeSpan = + if (options.isEnableStarfish) appStartTimeSpan else legacyAppStartTimeSpan + timeSpan.apply { + setStartedAt(1) + setStoppedAt(2) + } + } } - private fun getTransaction(op: String = "app.start.cold"): SentryTransaction { - fixture.tracer.startChild(op) - return SentryTransaction(fixture.tracer) + 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 07e16af252a..540e667e5d1 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,6 +133,19 @@ class SentryAndroidOptionsTest { assertNull(sentryOptions.nativeSdkName) } + @Test + fun `starfish is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStarfish) + } + + @Test + fun `starfish can be enabled`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.isEnableStarfish = true + assertTrue(sentryOptions.isEnableStarfish) + } + 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 b441c7789a5..2a9e20be299 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 @@ -5,6 +5,7 @@ import android.app.Application import android.app.ApplicationExitInfo import android.content.Context 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 +22,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,6 +62,7 @@ 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 @@ -150,7 +153,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 +205,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 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 89bc9d6037e..2543285e23c 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 cf3ca7c2eab..256b15957fc 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,100 +1,115 @@ package io.sentry.android.core -import android.app.Application import android.content.pm.ProviderInfo +import android.os.Build import android.os.Bundle 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 java.util.Date +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue +@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 + } + } +} + @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 = ContextUtilsTest.createMockContext() - providerInfo.authority = AUTHORITY - - val providerAppStartMillis = 10L - val providerAppStartTime = SentryNanotimeDate(Date(0), 0) - SentryPerformanceProvider.setAppStartTime(providerAppStartMillis, providerAppStartTime) + fun `provider starts appStartTimeSpan`() { + assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasNotStarted()) + setupProvider() + 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 = ContextUtilsTest.createMockContext() - providerInfo.authority = AUTHORITY + // up until this point app start is not known + assertEquals(AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) - val provider = SentryPerformanceProvider() - provider.attachInfo(mockContext, providerInfo) - - 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 sets keeps startup state even if multiple activities are launched`() { + val provider = setupProvider() - val mockContext = ContextUtilsTest.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`() { + private fun setupProvider(): SentryPerformanceProvider { val providerInfo = ProviderInfo() val mockContext = ContextUtilsTest.createMockContext(true) providerInfo.authority = AUTHORITY + // calls onCreate val provider = SentryPerformanceProvider() provider.attachInfo(mockContext, providerInfo) - - provider.onActivityCreated(mock(), Bundle()) - provider.onActivityResumed(mock()) - - assertNotNull(AppStartState.getInstance().appStartInterval) - assertNotNull(AppStartState.getInstance().appStartEndTime) - - verify((mockContext.applicationContext as Application)) - .unregisterActivityLifecycleCallbacks(any()) + return provider } companion object { 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 095da5f32ad..7448c215c7d 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.isEnableStarfish) { + appStartTimeSpan.setStartedAt(appStartMillis) + appStartTimeSpan.setStartUnixTimeMs(appStartMillis) + } else { + legacyAppStartTimeSpan.setStartedAt(appStartMillis) + legacyAppStartTimeSpan.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/performance/ActivityLifecycleTimeSpanTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt new file mode 100644 index 00000000000..beeed5e9f13 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpanTest.kt @@ -0,0 +1,37 @@ +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]) + } +} 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 00000000000..adf4b1bd178 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/TimeSpanTest.kt @@ -0,0 +1,154 @@ +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 { + 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.projectedStopTimestampS) + } + + @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.startTimestampS, 0.001) + assertEquals(span.projectedStopTimestampMs / 1000.0, span.projectedStopTimestampS, 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-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 8c6c9662a4a..e98065be6d5 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 @@ + +