diff --git a/CHANGELOG.md b/CHANGELOG.md index eb04bc1ad6..49c6bff068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +* Feat: Measure app start time (#1487) + ## 5.0.1 * Fix: Sources and Javadoc artifacts were mixed up (#1515) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 12d0d12306..a2fcab865b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -123,6 +123,17 @@ public final class io/sentry/android/core/SentryInitProvider : android/content/C public fun update (Landroid/net/Uri;Landroid/content/ContentValues;Ljava/lang/String;[Ljava/lang/String;)I } +public final class io/sentry/android/core/SentryPerformanceProvider : android/content/ContentProvider { + public fun ()V + public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V + public fun delete (Landroid/net/Uri;Ljava/lang/String;[Ljava/lang/String;)I + public fun getType (Landroid/net/Uri;)Ljava/lang/String; + public fun insert (Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri; + public fun onCreate ()Z + public fun query (Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor; + public fun update (Landroid/net/Uri;Landroid/content/ContentValues;Ljava/lang/String;[Ljava/lang/String;)I +} + public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V diff --git a/sentry-android-core/src/main/AndroidManifest.xml b/sentry-android-core/src/main/AndroidManifest.xml index facbc0d012..d0aa1fbe4d 100644 --- a/sentry-android-core/src/main/AndroidManifest.xml +++ b/sentry-android-core/src/main/AndroidManifest.xml @@ -8,5 +8,11 @@ android:name=".SentryInitProvider" android:authorities="${applicationId}.SentryInitProvider" android:exported="false"/> + + 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 1fe07871d0..85ef0153dd 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 @@ -8,6 +8,7 @@ import androidx.annotation.Nullable; import io.sentry.Breadcrumb; import io.sentry.IHub; +import io.sentry.ISpan; import io.sentry.ITransaction; import io.sentry.Integration; import io.sentry.Scope; @@ -17,6 +18,7 @@ import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; +import java.util.Date; import java.util.Map; import java.util.WeakHashMap; import org.jetbrains.annotations.NotNull; @@ -26,6 +28,10 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + private static final String UI_LOAD_OP = "ui.load"; + static final String APP_START_WARM = "app.start.warm"; + static final String APP_START_COLD = "app.start.cold"; + private final @NotNull Application application; private @Nullable IHub hub; private @Nullable SentryAndroidOptions options; @@ -34,6 +40,11 @@ public final class ActivityLifecycleIntegration private boolean isAllActivityCallbacksAvailable; + private boolean firstActivityCreated = false; + private boolean firstActivityResumed = false; + + private @Nullable ISpan appStartSpan; + // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the // main-thread private final @NotNull WeakHashMap activitiesWithOngoingTransactions = @@ -116,8 +127,21 @@ private void startTracing(final @NonNull Activity activity) { stopPreviousTransactions(); // we can only bind to the scope if there's no running transaction - final ITransaction transaction = - hub.startTransaction(getActivityName(activity), "navigation"); + ITransaction transaction; + final String activityName = getActivityName(activity); + + final Date appStartTime = AppStartState.getInstance().getAppStartTime(); + + // in case appStartTime isn't available, we don't create a span for it. + if (firstActivityCreated || appStartTime == null) { + transaction = hub.startTransaction(activityName, UI_LOAD_OP); + } else { + // start transaction with app start timestamp + transaction = hub.startTransaction(activityName, UI_LOAD_OP, appStartTime); + // start specific span for app start + + appStartSpan = transaction.startChild(getAppStartOp(), getAppStartDesc(), appStartTime); + } // lets bind to the scope so other integrations can pick it up hub.configureScope( @@ -176,6 +200,8 @@ public synchronized void onActivityPreCreated( // only executed if API >= 29 otherwise it happens on onActivityCreated if (isAllActivityCallbacksAvailable) { + setColdStart(savedInstanceState); + // if activity has global fields being init. and // they are slow, this won't count the whole fields/ctor initialization time, but only // when onCreate is actually called. @@ -186,12 +212,17 @@ public synchronized void onActivityPreCreated( @Override public synchronized void onActivityCreated( final @NonNull Activity activity, final @Nullable Bundle savedInstanceState) { + if (!isAllActivityCallbacksAvailable) { + setColdStart(savedInstanceState); + } + addBreadcrumb(activity, "created"); // fallback call for API < 29 compatibility, otherwise it happens on onActivityPreCreated if (!isAllActivityCallbacksAvailable) { startTracing(activity); } + firstActivityCreated = true; } @Override @@ -201,6 +232,17 @@ public synchronized void onActivityStarted(final @NonNull Activity activity) { @Override public synchronized void onActivityResumed(final @NonNull Activity activity) { + if (!firstActivityResumed && performanceEnabled) { + // sets App start as finished when the very first activity calls onResume + AppStartState.getInstance().setAppStartEnd(); + + // finishes app start span + if (appStartSpan != null) { + appStartSpan.finish(); + } + firstActivityResumed = true; + } + addBreadcrumb(activity, "resumed"); // fallback call for API < 29 compatibility, otherwise it happens on onActivityPostResumed @@ -256,4 +298,28 @@ public synchronized void onActivityDestroyed(final @NonNull Activity activity) { WeakHashMap getActivitiesWithOngoingTransactions() { return activitiesWithOngoingTransactions; } + + private void setColdStart(final @Nullable Bundle savedInstanceState) { + if (!firstActivityCreated && performanceEnabled) { + // if Activity has savedInstanceState then its a warm start + // https://developer.android.com/topic/performance/vitals/launch-time#warm + AppStartState.getInstance().setColdStart(savedInstanceState == null); + } + } + + private @NotNull String getAppStartDesc() { + if (AppStartState.getInstance().isColdStart()) { + return "Cold Start"; + } else { + return "Warm Start"; + } + } + + private @NotNull String getAppStartOp() { + if (AppStartState.getInstance().isColdStart()) { + return APP_START_COLD; + } else { + return APP_START_WARM; + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 052bd91572..de740b57f6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -105,6 +105,7 @@ static void init( readDefaultOptionValues(options, context); options.addEventProcessor(new DefaultAndroidEventProcessor(context, logger, buildInfoProvider)); + options.addEventProcessor(new PerformanceAndroidEventProcessor(options)); options.setTransportGate(new AndroidTransportGate(context, options.getLogger())); } 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 new file mode 100644 index 0000000000..49d00e2ee3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java @@ -0,0 +1,73 @@ +package io.sentry.android.core; + +import android.os.SystemClock; +import java.util.Date; +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 */ +final class AppStartState { + + private static @NotNull AppStartState instance = new AppStartState(); + + private @Nullable Long appStartMillis; + + private @Nullable Long appStartEndMillis; + + /** The type of App start coldStart=true -> Cold start, coldStart=false -> Warm start */ + private boolean coldStart; + + /** appStart as a Date used in the App's Context */ + private @Nullable Date appStartTime; + + private AppStartState() {} + + 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 + synchronized Long getAppStartInterval() { + if (appStartMillis == null || appStartEndMillis == null) { + return null; + } + return appStartEndMillis - appStartMillis; + } + + boolean isColdStart() { + return coldStart; + } + + synchronized void setColdStart(final boolean coldStart) { + this.coldStart = coldStart; + } + + @Nullable + Date getAppStartTime() { + return appStartTime; + } + + synchronized void setAppStartTime(final long appStartMillis, final @NotNull Date 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; + } +} 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 2b02a7dbeb..7b8f83798d 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 @@ -70,9 +70,6 @@ final class DefaultAndroidEventProcessor implements EventProcessor { @TestOnly static final String EMULATOR = "emulator"; @TestOnly static final String SIDE_LOADED = "sideLoaded"; - // it could also be a parameter and get from Sentry.init(...) - private static final @Nullable Date appStartTime = DateUtils.getCurrentDateTime(); - @TestOnly final Context context; @TestOnly final Future> contextData; @@ -299,7 +296,7 @@ private void mergeDebugImages(final @NotNull SentryEvent event) { private void setAppExtras(final @NotNull App app) { app.setAppName(getApplicationName()); - app.setAppStartTime(appStartTime); + app.setAppStartTime(AppStartState.getInstance().getAppStartTime()); } @SuppressWarnings("deprecation") 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 new file mode 100644 index 0000000000..861691d3af --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -0,0 +1,56 @@ +package io.sentry.android.core; + +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; + +import io.sentry.EventProcessor; +import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentrySpan; +import io.sentry.protocol.SentryTransaction; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Event Processor responsible for adding Android metrics to transactions */ +final class PerformanceAndroidEventProcessor implements EventProcessor { + + private final boolean tracingEnabled; + + private boolean sentStartMeasurement = false; + + PerformanceAndroidEventProcessor(final @NotNull SentryAndroidOptions options) { + tracingEnabled = options.isTracingEnabled(); + } + + @Override + public synchronized @NotNull SentryTransaction process( + @NotNull SentryTransaction transaction, @Nullable Object 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 && tracingEnabled && hasAppStartSpan(transaction.getSpans())) { + final Long appStartUpInterval = AppStartState.getInstance().getAppStartInterval(); + // if appStartUpInterval is null, metrics are not ready to be sent + if (appStartUpInterval != null) { + final MeasurementValue value = new MeasurementValue((float) appStartUpInterval); + + final String appStartKey = + AppStartState.getInstance().isColdStart() ? "app_start_cold" : "app_start_warm"; + + transaction.getMeasurements().put(appStartKey, value); + sentStartMeasurement = true; + } + } + + return transaction; + } + + private boolean hasAppStartSpan(final @NotNull List spans) { + for (final SentrySpan span : spans) { + if (span.getOp().contentEquals(APP_START_COLD) + || span.getOp().contentEquals(APP_START_WARM)) { + return true; + } + } + return false; + } +} 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 0bf9fcb99b..a90229e370 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,24 @@ package io.sentry.android.core; import android.content.Context; +import android.os.SystemClock; +import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import java.lang.reflect.InvocationTargetException; +import java.util.Date; import org.jetbrains.annotations.NotNull; /** Sentry initialization class */ public final class SentryAndroid { + // static to rely on Class load init. + private static final @NotNull Date appStartTime = DateUtils.getCurrentDateTime(); + // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. + private static final long appStart = SystemClock.uptimeMillis(); + private SentryAndroid() {} /** @@ -51,10 +59,14 @@ public static void init( * @param logger your custom logger that implements ILogger * @param configuration Sentry.OptionsConfiguration configuration handler */ - public static void init( + 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( OptionsContainer.create(SentryAndroidOptions.class), 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 new file mode 100644 index 0000000000..4fb7b32c8e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -0,0 +1,93 @@ +package io.sentry.android.core; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.net.Uri; +import android.os.SystemClock; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.sentry.DateUtils; +import java.util.Date; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +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() + */ +@ApiStatus.Internal +public final class SentryPerformanceProvider extends ContentProvider { + + // static to rely on Class load + private static @NotNull Date appStartTime = DateUtils.getCurrentDateTime(); + // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. + private static long appStartMillis = SystemClock.uptimeMillis(); + + public SentryPerformanceProvider() { + AppStartState.getInstance().setAppStartTime(appStartMillis, appStartTime); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public void attachInfo(Context context, ProviderInfo info) { + // applicationId is expected to be prepended. See AndroidManifest.xml + if (SentryPerformanceProvider.class.getName().equals(info.authority)) { + throw new IllegalStateException( + "An applicationId is required to fulfill the manifest placeholder."); + } + super.attachInfo(context, info); + } + + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + return null; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return null; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + return 0; + } + + @TestOnly + static void setAppStartTime(final long appStartMillisLong, final @NotNull Date appStartTimeDate) { + appStartMillis = appStartMillisLong; + appStartTime = appStartTimeDate; + } +} 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 e0e2258afe..013830bc72 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 @@ -5,6 +5,7 @@ import android.app.Application import android.os.Bundle import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify @@ -17,10 +18,13 @@ import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus import io.sentry.TransactionContext +import java.util.Date +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertSame import kotlin.test.assertTrue class ActivityLifecycleIntegrationTest { @@ -29,13 +33,15 @@ class ActivityLifecycleIntegrationTest { val application = mock() val hub = mock() val options = SentryAndroidOptions() - val activity = mock() val bundle = mock() - val transaction = SentryTracer(TransactionContext("name", "op"), hub) + val context = TransactionContext("name", "op") + val transaction = SentryTracer(context, hub) val buildInfo = mock() fun getSut(apiVersion: Int = 29): ActivityLifecycleIntegration { whenever(hub.startTransaction(any(), any())).thenReturn(transaction) + whenever(hub.options).thenReturn(options) + whenever(hub.startTransaction(any(), any(), any())).thenReturn(transaction) whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) return ActivityLifecycleIntegration(application, buildInfo) } @@ -43,6 +49,11 @@ class ActivityLifecycleIntegrationTest { private val fixture = Fixture() + @BeforeTest + fun `reset instance`() { + AppStartState.getInstance().resetInstance() + } + @Test fun `When activity lifecycle breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() @@ -100,6 +111,7 @@ class ActivityLifecycleIntegrationTest { fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) + sut.close() verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) @@ -110,7 +122,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + verify(fixture.hub).addBreadcrumb(check { assertEquals("ui.lifecycle", it.category) assertEquals("navigation", it.type) @@ -123,7 +137,9 @@ class ActivityLifecycleIntegrationTest { fun `When activity is created, it should add a breadcrumb`() { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityCreated(fixture.activity, fixture.bundle) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) verify(fixture.hub).addBreadcrumb(any()) } @@ -132,7 +148,9 @@ class ActivityLifecycleIntegrationTest { fun `When activity is started, it should add a breadcrumb`() { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityStarted(fixture.activity) + + val activity = mock() + sut.onActivityStarted(activity) verify(fixture.hub).addBreadcrumb(any()) } @@ -142,7 +160,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityResumed(fixture.activity) + val activity = mock() + sut.onActivityResumed(activity) + verify(fixture.hub).addBreadcrumb(any()) } @@ -151,7 +171,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityPaused(fixture.activity) + val activity = mock() + sut.onActivityPaused(activity) + verify(fixture.hub).addBreadcrumb(any()) } @@ -160,7 +182,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityStopped(fixture.activity) + val activity = mock() + sut.onActivityStopped(activity) + verify(fixture.hub).addBreadcrumb(any()) } @@ -169,7 +193,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivitySaveInstanceState(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivitySaveInstanceState(activity, fixture.bundle) + verify(fixture.hub).addBreadcrumb(any()) } @@ -178,7 +204,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityDestroyed(fixture.activity) + val activity = mock() + sut.onActivityDestroyed(activity) + verify(fixture.hub).addBreadcrumb(any()) } @@ -187,9 +215,11 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) verify(fixture.hub, never()).startTransaction(any(), any()) + verify(fixture.hub, never()).startTransaction(any(), any(), any()) } @Test @@ -198,24 +228,29 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityPreCreated(activity, fixture.bundle) // call only once verify(fixture.hub).startTransaction(any(), any()) + verify(fixture.hub, never()).startTransaction(any(), any(), any()) } @Test - fun `Transaction op is navigation`() { + fun `Transaction op is ui_load`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + setAppStartTime() - verify(fixture.hub).startTransaction(any(), check { - assertEquals("navigation", it) - }) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + + verify(fixture.hub).startTransaction(any(), check { + assertEquals("ui.load", it) + }, any()) } @Test @@ -224,11 +259,14 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + setAppStartTime() + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction(check { + verify(fixture.hub).startTransaction(check { assertEquals("Activity", it) - }, any()) + }, any(), any()) } @Test @@ -246,7 +284,8 @@ class ActivityLifecycleIntegrationTest { assertNotNull(scope.transaction) } - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) } @Test @@ -259,14 +298,15 @@ class ActivityLifecycleIntegrationTest { whenever(fixture.hub.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) val previousTransaction = SentryTracer(TransactionContext("name", "op"), fixture.hub) - scope.setTransaction(previousTransaction) + scope.transaction = previousTransaction sut.applyScope(scope, fixture.transaction) assertEquals(previousTransaction, scope.transaction) } - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) } @Test @@ -275,8 +315,9 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) - sut.onActivityPostResumed(fixture.activity) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityPostResumed(activity) verify(fixture.hub).captureTransaction(check { assertEquals(SpanStatus.OK, it.status) @@ -289,11 +330,12 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) fixture.transaction.status = SpanStatus.UNKNOWN_ERROR - sut.onActivityPostResumed(fixture.activity) + sut.onActivityPostResumed(activity) verify(fixture.hub).captureTransaction(check { assertEquals(SpanStatus.UNKNOWN_ERROR, it.status) @@ -307,8 +349,9 @@ class ActivityLifecycleIntegrationTest { fixture.options.isEnableActivityLifecycleTracingAutoFinish = false sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) - sut.onActivityPostResumed(fixture.activity) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityPostResumed(activity) verify(fixture.hub, never()).captureTransaction(any()) } @@ -318,7 +361,8 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) - sut.onActivityPostResumed(fixture.activity) + val activity = mock() + sut.onActivityPostResumed(activity) verify(fixture.hub, never()).captureTransaction(any()) } @@ -329,8 +373,9 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) - sut.onActivityDestroyed(fixture.activity) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) verify(fixture.hub).captureTransaction(any()) } @@ -341,7 +386,8 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) assertFalse(sut.activitiesWithOngoingTransactions.isEmpty()) } @@ -352,8 +398,9 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) - sut.onActivityDestroyed(fixture.activity) + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) assertTrue(sut.activitiesWithOngoingTransactions.isEmpty()) } @@ -364,10 +411,9 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) - val activity = mock() - sut.onActivityPreCreated(activity, mock()) + sut.onActivityPreCreated(mock(), mock()) - sut.onActivityPreCreated(fixture.activity, fixture.bundle) + sut.onActivityPreCreated(mock(), fixture.bundle) verify(fixture.hub).captureTransaction(any()) } @@ -381,6 +427,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, mock()) verify(fixture.hub, never()).startTransaction(any(), any()) + verify(fixture.hub, never()).startTransaction(any(), any(), any()) } @Test @@ -402,10 +449,12 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) + setAppStartTime() + val activity = mock() sut.onActivityCreated(activity, mock()) - verify(fixture.hub).startTransaction(any(), any()) + verify(fixture.hub).startTransaction(any(), any(), any()) } @Test @@ -420,4 +469,169 @@ class ActivityLifecycleIntegrationTest { verify(fixture.hub).captureTransaction(any()) } + + @Test + fun `App start is Cold when savedInstanceState is null`() { + val sut = fixture.getSut(14) + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val activity = mock() + sut.onActivityCreated(activity, null) + + assertTrue(AppStartState.getInstance().isColdStart) + } + + @Test + fun `App start is Warm when savedInstanceState is not null`() { + val sut = fixture.getSut(14) + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val activity = mock() + val bundle = Bundle() + sut.onActivityCreated(activity, bundle) + + assertFalse(AppStartState.getInstance().isColdStart) + } + + @Test + fun `Do not overwrite App start type after set`() { + val sut = fixture.getSut(14) + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val activity = mock() + val bundle = Bundle() + sut.onActivityCreated(activity, bundle) + sut.onActivityCreated(activity, null) + + assertFalse(AppStartState.getInstance().isColdStart) + } + + @Test + fun `App start end time is set`() { + val sut = fixture.getSut(14) + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, null) + sut.onActivityResumed(activity) + + // SystemClock.uptimeMillis() always returns 0, can't assert real values + assertNotNull(AppStartState.getInstance().appStartInterval) + } + + @Test + fun `When firstActivityCreated is true, start transaction with given appStartTime`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = Date(0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + + // call only once + verify(fixture.hub).startTransaction(any(), any(), eq(date)) + } + + @Test + fun `When firstActivityCreated is true, start app start warm span with given appStartTime`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = Date(0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + + val span = fixture.transaction.children.first() + assertEquals(span.operation, "app.start.warm") + assertSame(span.startTimestamp, date) + } + + @Test + fun `When firstActivityCreated is true, start app start cold span with given appStartTime`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = Date(0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityPreCreated(activity, null) + + val span = fixture.transaction.children.first() + assertEquals(span.operation, "app.start.cold") + assertSame(span.startTimestamp, date) + } + + @Test + fun `When firstActivityCreated is true, start app start span with Warm description`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = Date(0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + + val span = fixture.transaction.children.first() + assertEquals(span.description, "Warm Start") + assertSame(span.startTimestamp, date) + } + + @Test + fun `When firstActivityCreated is true, start app start span with Cold description`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = Date(0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityPreCreated(activity, null) + + val span = fixture.transaction.children.first() + assertEquals(span.description, "Cold Start") + assertSame(span.startTimestamp, date) + } + + @Test + fun `When firstActivityCreated is false, start transaction but not with given appStartTime`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + + verify(fixture.hub).startTransaction(any(), any(), any()) + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityPostResumed(activity) + + val newActivity = mock() + sut.onActivityPreCreated(newActivity, fixture.bundle) + + verify(fixture.hub).startTransaction(any(), any()) + } + + private fun setAppStartTime(date: Date = Date(0)) { + // set by SentryPerformanceProvider so forcing it here + AppStartState.getInstance().setAppStartTime(0, date) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 68c94d2a51..9622c2d602 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -62,6 +62,16 @@ class AndroidOptionsInitializerTest { assertNotNull(actual) } + @Test + fun `PerformanceAndroidEventProcessor added to processors list`() { + val sentryOptions = SentryAndroidOptions() + val mockContext = createMockContext() + + AndroidOptionsInitializer.init(sentryOptions, mockContext) + val actual = sentryOptions.eventProcessors.any { it is PerformanceAndroidEventProcessor } + assertNotNull(actual) + } + @Test fun `MainEventProcessor added to processors list and its the 1st`() { val sentryOptions = SentryAndroidOptions() 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 new file mode 100644 index 0000000000..aa6e077ddc --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartStateTest.kt @@ -0,0 +1,56 @@ +package io.sentry.android.core + +import java.util.Date +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +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, Date(0)) + + assertNull(sut.appStartInterval) + } + + @Test + fun `appStartInterval returns null if start time is not set`() { + val sut = AppStartState.getInstance() + + sut.setAppStartEnd() + + assertNull(sut.appStartInterval) + } + + @Test + fun `do not overwrite app start values if already set`() { + val sut = AppStartState.getInstance() + + val date = Date() + sut.setAppStartTime(0, date) + sut.setAppStartTime(1, Date()) + + assertSame(date, sut.appStartTime) + } + + @Test + fun `getAppStartInterval returns right calculation`() { + val sut = AppStartState.getInstance() + + val date = Date() + sut.setAppStartTime(100, date) + sut.setAppStartEnd(500) + + assertEquals(400, sut.appStartInterval) + } +} 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 new file mode 100644 index 0000000000..891d53d7b7 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.core + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.IHub +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.protocol.SentryTransaction +import java.util.Date +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class PerformanceAndroidEventProcessorTest { + + private class Fixture { + val options = SentryAndroidOptions() + + val hub = mock() + val context = TransactionContext("name", "op") + val tracer = SentryTracer(context, hub) + + fun getSut(tracesSampleRate: Double? = 1.0): PerformanceAndroidEventProcessor { + options.tracesSampleRate = tracesSampleRate + whenever(hub.options).thenReturn(options) + return PerformanceAndroidEventProcessor(options) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `reset instance`() { + AppStartState.getInstance().resetInstance() + } + + @Test + fun `add cold start measurement`() { + val sut = fixture.getSut() + + var tr = getTransaction() + setAppStart() + + tr = sut.process(tr, null) + + assertTrue(tr.measurements.containsKey("app_start_cold")) + } + + @Test + fun `add warm start measurement`() { + val sut = fixture.getSut() + + var tr = getTransaction("app.start.warm") + setAppStart(false) + + tr = sut.process(tr, null) + + assertTrue(tr.measurements.containsKey("app_start_warm")) + } + + @Test + fun `do not add app start metric twice`() { + val sut = fixture.getSut() + + var tr1 = getTransaction() + setAppStart(false) + + tr1 = sut.process(tr1, null) + + var tr2 = getTransaction() + tr2 = sut.process(tr2, null) + + assertTrue(tr1.measurements.containsKey("app_start_warm")) + assertTrue(tr2.measurements.isEmpty()) + } + + @Test + fun `do not add app start metric if its not ready`() { + val sut = fixture.getSut() + + var tr = getTransaction() + + tr = sut.process(tr, null) + + assertTrue(tr.measurements.isEmpty()) + } + + @Test + fun `do not add app start metric if performance is disabled`() { + val sut = fixture.getSut(tracesSampleRate = null) + + var tr = getTransaction() + + tr = sut.process(tr, null) + + assertTrue(tr.measurements.isEmpty()) + } + + @Test + fun `do not add app start metric if no app_start span`() { + val sut = fixture.getSut(tracesSampleRate = null) + + var tr = getTransaction("task") + + tr = sut.process(tr, null) + + assertTrue(tr.measurements.isEmpty()) + } + + private fun setAppStart(coldStart: Boolean = true) { + AppStartState.getInstance().isColdStart = coldStart + AppStartState.getInstance().setAppStartTime(0, Date()) + AppStartState.getInstance().setAppStartEnd() + } + + private fun getTransaction(op: String = "app.start.cold"): SentryTransaction { + fixture.tracer.startChild(op) + return SentryTransaction(fixture.tracer) + } +} 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 ecf41c5385..9907c4fc8e 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 @@ -14,6 +14,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.runner.RunWith @@ -23,6 +24,7 @@ class SentryAndroidTest { @BeforeTest fun `set up`() { Sentry.close() + AppStartState.getInstance().resetInstance() } @Test @@ -88,4 +90,18 @@ class SentryAndroidTest { SentryAndroid.init(mockContext, logger) verify(logger, never()).log(eq(SentryLevel.FATAL), any(), any()) } + + @Test + fun `set app start if provider is disabled`() { + val metaData = Bundle() + val mockContext = ContextUtilsTest.mockMetaData(metaData = metaData) + metaData.putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") + + SentryAndroid.init(mockContext, mock()) + + // done by ActivityLifecycleIntegration so forcing it here + AppStartState.getInstance().setAppStartEnd() + + assertNotNull(AppStartState.getInstance().appStartInterval) + } } 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 new file mode 100644 index 0000000000..5e7916e4f7 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -0,0 +1,43 @@ +package io.sentry.android.core + +import android.content.pm.ProviderInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.util.Date +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SentryPerformanceProviderTest { + + @BeforeTest + fun `set up`() { + AppStartState.getInstance().resetInstance() + } + + @Test + fun `provider sets app start`() { + val providerInfo = ProviderInfo() + + val mockContext = ContextUtilsTest.createMockContext() + providerInfo.authority = AUTHORITY + + val providerAppStartMillis = 10L + val providerAppStartTime = Date(0) + SentryPerformanceProvider.setAppStartTime(providerAppStartMillis, providerAppStartTime) + + val provider = SentryPerformanceProvider() + provider.attachInfo(mockContext, providerInfo) + + // done by ActivityLifecycleIntegration so forcing it here + val lifecycleAppEndMillis = 20L + AppStartState.getInstance().setAppStartEnd(lifecycleAppEndMillis) + + assertEquals(10L, AppStartState.getInstance().appStartInterval) + } + + companion object { + private const val AUTHORITY = "io.sentry.sample.SentryPerformanceProvider" + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 1e3eaabc87..5a168ec1b5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -134,6 +134,7 @@ public final class io/sentry/Hub : io/sentry/IHub { public fun setUser (Lio/sentry/protocol/User;)V public fun startSession ()V public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;ZLjava/util/Date;)Lio/sentry/ITransaction; public fun traceHeaders ()Lio/sentry/SentryTraceHeader; public fun withScope (Lio/sentry/ScopeCallback;)V } @@ -173,6 +174,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun startSession ()V public fun startTransaction (Lio/sentry/TransactionContext;)Lio/sentry/ITransaction; public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;ZLjava/util/Date;)Lio/sentry/ITransaction; public fun traceHeaders ()Lio/sentry/SentryTraceHeader; public fun withScope (Lio/sentry/ScopeCallback;)V } @@ -227,10 +229,12 @@ public abstract interface class io/sentry/IHub { public fun startTransaction (Lio/sentry/TransactionContext;)Lio/sentry/ITransaction; public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;)Lio/sentry/ITransaction; public abstract fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public abstract fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;ZLjava/util/Date;)Lio/sentry/ITransaction; public fun startTransaction (Lio/sentry/TransactionContext;Z)Lio/sentry/ITransaction; public fun startTransaction (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ITransaction; public fun startTransaction (Ljava/lang/String;Ljava/lang/String;Lio/sentry/CustomSamplingContext;)Lio/sentry/ITransaction; public fun startTransaction (Ljava/lang/String;Ljava/lang/String;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public fun startTransaction (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ITransaction; public fun startTransaction (Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/ITransaction; public abstract fun traceHeaders ()Lio/sentry/SentryTraceHeader; public abstract fun withScope (Lio/sentry/ScopeCallback;)V @@ -300,6 +304,7 @@ public abstract interface class io/sentry/ISpan { public abstract fun setThrowable (Ljava/lang/Throwable;)V public abstract fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ISpan; public abstract fun toSentryTrace ()Lio/sentry/SentryTraceHeader; } @@ -376,6 +381,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun startSession ()V public fun startTransaction (Lio/sentry/TransactionContext;)Lio/sentry/ITransaction; public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;ZLjava/util/Date;)Lio/sentry/ITransaction; public fun traceHeaders ()Lio/sentry/SentryTraceHeader; public fun withScope (Lio/sentry/ScopeCallback;)V } @@ -406,6 +412,7 @@ public final class io/sentry/NoOpSpan : io/sentry/ISpan { public fun setThrowable (Ljava/lang/Throwable;)V public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ISpan; public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; } @@ -436,6 +443,7 @@ public final class io/sentry/NoOpTransaction : io/sentry/ITransaction { public fun setThrowable (Ljava/lang/Throwable;)V public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ISpan; public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; } @@ -583,6 +591,7 @@ public final class io/sentry/Sentry { public static fun startTransaction (Lio/sentry/TransactionContext;)Lio/sentry/ITransaction; public static fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;)Lio/sentry/ITransaction; public static fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Z)Lio/sentry/ITransaction; + public static fun startTransaction (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;ZLjava/util/Date;)Lio/sentry/ITransaction; public static fun startTransaction (Lio/sentry/TransactionContext;Z)Lio/sentry/ITransaction; public static fun startTransaction (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ITransaction; public static fun startTransaction (Ljava/lang/String;Ljava/lang/String;Lio/sentry/CustomSamplingContext;)Lio/sentry/ITransaction; @@ -942,6 +951,7 @@ public final class io/sentry/SentryTracer : io/sentry/ITransaction { public fun setThrowable (Ljava/lang/Throwable;)V public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ISpan; public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; } @@ -994,6 +1004,7 @@ public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, ja } public final class io/sentry/Span : io/sentry/ISpan { + public fun (Lio/sentry/TransactionContext;Lio/sentry/SentryTracer;Lio/sentry/IHub;Ljava/util/Date;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun getDescription ()Ljava/lang/String; @@ -1017,6 +1028,7 @@ public final class io/sentry/Span : io/sentry/ISpan { public fun setThrowable (Ljava/lang/Throwable;)V public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;)Lio/sentry/ISpan; public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; } @@ -1492,6 +1504,10 @@ public final class io/sentry/protocol/Gpu : io/sentry/IUnknownPropertiesConsumer public fun setVersion (Ljava/lang/String;)V } +public final class io/sentry/protocol/MeasurementValue { + public fun (F)V +} + public final class io/sentry/protocol/Mechanism : io/sentry/IUnknownPropertiesConsumer { public fun ()V public fun (Ljava/lang/Thread;)V @@ -1732,6 +1748,7 @@ public final class io/sentry/protocol/SentryThread : io/sentry/IUnknownPropertie public final class io/sentry/protocol/SentryTransaction : io/sentry/SentryBaseEvent { public fun (Lio/sentry/SentryTracer;)V + public fun getMeasurements ()Ljava/util/Map; public fun getSpans ()Ljava/util/List; public fun getStartTimestamp ()Ljava/util/Date; public fun getStatus ()Lio/sentry/SpanStatus; diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index ca4273bb11..18f3cd1dd0 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -11,6 +11,7 @@ import io.sentry.util.Pair; import java.io.Closeable; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.WeakHashMap; @@ -580,6 +581,25 @@ public void flush(long timeoutMillis) { final @NotNull TransactionContext transactionContext, final @Nullable CustomSamplingContext customSamplingContext, final boolean bindToScope) { + return createTransaction(transactionContext, customSamplingContext, bindToScope, null); + } + + @ApiStatus.Internal + @Override + public @NotNull ITransaction startTransaction( + @NotNull TransactionContext transactionContext, + @Nullable CustomSamplingContext customSamplingContext, + boolean bindToScope, + @Nullable Date startTimestamp) { + return createTransaction( + transactionContext, customSamplingContext, bindToScope, startTimestamp); + } + + private @NotNull ITransaction createTransaction( + final @NotNull TransactionContext transactionContext, + final @Nullable CustomSamplingContext customSamplingContext, + final boolean bindToScope, + final @Nullable Date startTimestamp) { Objects.requireNonNull(transactionContext, "transactionContext is required"); ITransaction transaction; @@ -602,7 +622,7 @@ public void flush(long timeoutMillis) { boolean samplingDecision = tracesSampler.sample(samplingContext); transactionContext.setSampled(samplingDecision); - transaction = new SentryTracer(transactionContext, this); + transaction = new SentryTracer(transactionContext, this, startTimestamp); } if (bindToScope) { configureScope(scope -> scope.setTransaction(transaction)); diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index a6e03ae7ef..c29f6f31eb 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -3,6 +3,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; +import java.util.Date; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -174,6 +175,17 @@ public void flush(long timeoutMillis) { return Sentry.startTransaction(transactionContexts, customSamplingContext, bindToScope); } + @ApiStatus.Internal + @Override + public @NotNull ITransaction startTransaction( + @NotNull TransactionContext transactionContexts, + @Nullable CustomSamplingContext customSamplingContext, + boolean bindToScope, + @Nullable Date startTimestamp) { + return Sentry.startTransaction( + transactionContexts, customSamplingContext, bindToScope, startTimestamp); + } + @Override public @Nullable SentryTraceHeader traceHeaders() { return Sentry.traceHeaders(); diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 0f88661eba..1a5523c3df 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -3,6 +3,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; +import java.util.Date; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -373,6 +374,14 @@ ITransaction startTransaction( @Nullable CustomSamplingContext customSamplingContext, boolean bindToScope); + @ApiStatus.Internal + @NotNull + ITransaction startTransaction( + @NotNull TransactionContext transactionContexts, + @Nullable CustomSamplingContext customSamplingContext, + boolean bindToScope, + @Nullable Date startTimestamp); + /** * Creates a Transaction and returns the instance. Based on the {@link * SentryOptions#getTracesSampleRate()} the decision if transaction is sampled will be taken by @@ -384,7 +393,13 @@ ITransaction startTransaction( */ default @NotNull ITransaction startTransaction( final @NotNull String name, final @NotNull String operation) { - return startTransaction(name, operation, null); + return startTransaction(name, operation, (CustomSamplingContext) null); + } + + @ApiStatus.Internal + default @NotNull ITransaction startTransaction( + final @NotNull String name, final @NotNull String operation, @Nullable Date startTimestamp) { + return startTransaction(new TransactionContext(name, operation), null, false, startTimestamp); } /** diff --git a/sentry/src/main/java/io/sentry/ISpan.java b/sentry/src/main/java/io/sentry/ISpan.java index 145423a85a..07b8a180fe 100644 --- a/sentry/src/main/java/io/sentry/ISpan.java +++ b/sentry/src/main/java/io/sentry/ISpan.java @@ -1,5 +1,7 @@ package io.sentry; +import java.util.Date; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -14,6 +16,11 @@ public interface ISpan { @NotNull ISpan startChild(@NotNull String operation); + @ApiStatus.Internal + @NotNull + ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable Date timestamp); + /** * Starts a child Span. * diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 2ef71d60d7..11139f82aa 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -3,6 +3,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; +import java.util.Date; import java.util.List; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -134,6 +135,15 @@ public void flush(long timeoutMillis) {} return NoOpTransaction.getInstance(); } + @Override + public @NotNull ITransaction startTransaction( + @NotNull TransactionContext transactionContexts, + @Nullable CustomSamplingContext customSamplingContext, + boolean bindToScope, + @Nullable Date startTimestamp) { + return NoOpTransaction.getInstance(); + } + @Override public @NotNull SentryTraceHeader traceHeaders() { return new SentryTraceHeader(SentryId.EMPTY_ID, SpanId.EMPTY_ID, true); diff --git a/sentry/src/main/java/io/sentry/NoOpSpan.java b/sentry/src/main/java/io/sentry/NoOpSpan.java index 75f44ff085..ea58bc8ca7 100644 --- a/sentry/src/main/java/io/sentry/NoOpSpan.java +++ b/sentry/src/main/java/io/sentry/NoOpSpan.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.protocol.SentryId; +import java.util.Date; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,6 +20,12 @@ public static NoOpSpan getInstance() { return NoOpSpan.getInstance(); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable Date timestamp) { + return NoOpSpan.getInstance(); + } + @Override public @NotNull ISpan startChild( final @NotNull String operation, final @Nullable String description) { diff --git a/sentry/src/main/java/io/sentry/NoOpTransaction.java b/sentry/src/main/java/io/sentry/NoOpTransaction.java index 6d2ba8d8b5..3fff45748d 100644 --- a/sentry/src/main/java/io/sentry/NoOpTransaction.java +++ b/sentry/src/main/java/io/sentry/NoOpTransaction.java @@ -4,6 +4,7 @@ import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import java.util.Collections; +import java.util.Date; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -32,6 +33,12 @@ public void setName(@NotNull String name) {} return NoOpSpan.getInstance(); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable Date timestamp) { + return NoOpSpan.getInstance(); + } + @Override public @NotNull ISpan startChild( final @NotNull String operation, final @Nullable String description) { diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 635bf86113..773e292d75 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -6,6 +6,7 @@ import io.sentry.protocol.User; import java.io.File; import java.lang.reflect.InvocationTargetException; +import java.util.Date; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -652,6 +653,16 @@ public static void endSession() { .startTransaction(transactionContexts, customSamplingContext, bindToScope); } + @ApiStatus.Internal + public static @NotNull ITransaction startTransaction( + final @NotNull TransactionContext transactionContexts, + final @Nullable CustomSamplingContext customSamplingContext, + final boolean bindToScope, + final @Nullable Date startTimestamp) { + return getCurrentHub() + .startTransaction(transactionContexts, customSamplingContext, bindToScope, startTimestamp); + } + /** * Returns trace header of active transaction or {@code null} if no transaction is active. * diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8e1a1bb879..ea7ba8e336 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -24,9 +24,16 @@ public final class SentryTracer implements ITransaction { private @NotNull String name; public SentryTracer(final @NotNull TransactionContext context, final @NotNull IHub hub) { + this(context, hub, null); + } + + SentryTracer( + final @NotNull TransactionContext context, + final @NotNull IHub hub, + final @Nullable Date startTimestamp) { Objects.requireNonNull(context, "context is required"); Objects.requireNonNull(hub, "hub is required"); - this.root = new Span(context, this, hub); + this.root = new Span(context, this, hub, startTimestamp); this.name = context.getName(); this.hub = hub; } @@ -56,11 +63,20 @@ ISpan startChild( final @NotNull SpanId parentSpanId, final @NotNull String operation, final @Nullable String description) { - final ISpan span = startChild(parentSpanId, operation); + final ISpan span = createChild(parentSpanId, operation); span.setDescription(description); return span; } + @NotNull + ISpan startChild( + final @NotNull SpanId parentSpanId, + final @NotNull String operation, + final @Nullable String description, + final @Nullable Date timestamp) { + return createChild(parentSpanId, operation, description, timestamp); + } + /** * Starts a child Span with given trace id and parent span id. * @@ -68,24 +84,48 @@ ISpan startChild( * @return a new transaction span */ @NotNull - private ISpan startChild(final @NotNull SpanId parentSpanId, final @NotNull String operation) { + private ISpan createChild(final @NotNull SpanId parentSpanId, final @NotNull String operation) { + return createChild(parentSpanId, operation, null, null); + } + + @NotNull + private ISpan createChild( + final @NotNull SpanId parentSpanId, + final @NotNull String operation, + final @Nullable String description, + @Nullable Date timestamp) { Objects.requireNonNull(parentSpanId, "parentSpanId is required"); Objects.requireNonNull(operation, "operation is required"); - final Span span = new Span(root.getTraceId(), parentSpanId, this, operation, this.hub); + final Span span = + new Span(root.getTraceId(), parentSpanId, this, operation, this.hub, timestamp); + span.setDescription(description); this.children.add(span); return span; } @Override public @NotNull ISpan startChild(final @NotNull String operation) { - return this.startChild(operation, null); + return this.startChild(operation, (String) null); + } + + @Override + public @NotNull ISpan startChild( + final @NotNull String operation, @Nullable String description, @Nullable Date timestamp) { + return createChild(operation, description, timestamp); } @Override public @NotNull ISpan startChild( final @NotNull String operation, final @Nullable String description) { + return createChild(operation, description, null); + } + + private @NotNull ISpan createChild( + final @NotNull String operation, + final @Nullable String description, + @Nullable Date timestamp) { if (children.size() < hub.getOptions().getMaxSpans()) { - return root.startChild(operation, description); + return root.startChild(operation, description, timestamp); } else { hub.getOptions() .getLogger() diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index 18f1a202f9..a917116823 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -8,6 +8,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; @ApiStatus.Internal public final class Span implements ISpan { @@ -38,21 +39,33 @@ public final class Span implements ISpan { final @NotNull SentryTracer transaction, final @NotNull String operation, final @NotNull IHub hub) { + this(traceId, parentSpanId, transaction, operation, hub, null); + } + + Span( + final @NotNull SentryId traceId, + final @Nullable SpanId parentSpanId, + final @NotNull SentryTracer transaction, + final @NotNull String operation, + final @NotNull IHub hub, + final @Nullable Date startTimestamp) { this.context = new SpanContext(traceId, new SpanId(), operation, parentSpanId, transaction.isSampled()); this.transaction = Objects.requireNonNull(transaction, "transaction is required"); - this.startTimestamp = DateUtils.getCurrentDateTime(); + this.startTimestamp = startTimestamp != null ? startTimestamp : DateUtils.getCurrentDateTime(); this.hub = Objects.requireNonNull(hub, "hub is required"); } - Span( + @VisibleForTesting + public Span( final @NotNull TransactionContext context, final @NotNull SentryTracer sentryTracer, - final @NotNull IHub hub) { + final @NotNull IHub hub, + final @Nullable Date startTimestamp) { this.context = Objects.requireNonNull(context, "context is required"); this.transaction = Objects.requireNonNull(sentryTracer, "sentryTracer is required"); this.hub = Objects.requireNonNull(hub, "hub is required"); - this.startTimestamp = DateUtils.getCurrentDateTime(); + this.startTimestamp = startTimestamp != null ? startTimestamp : DateUtils.getCurrentDateTime(); } public @NotNull Date getStartTimestamp() { @@ -65,7 +78,15 @@ public final class Span implements ISpan { @Override public @NotNull ISpan startChild(final @NotNull String operation) { - return this.startChild(operation, null); + return this.startChild(operation, (String) null); + } + + @Override + public @NotNull ISpan startChild( + final @NotNull String operation, + final @Nullable String description, + final @Nullable Date timestamp) { + return transaction.startChild(context.getSpanId(), operation, description, timestamp); } @Override diff --git a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java new file mode 100644 index 0000000000..36ddc15f6c --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java @@ -0,0 +1,13 @@ +package io.sentry.protocol; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class MeasurementValue { + @SuppressWarnings("UnusedVariable") + private final float value; + + public MeasurementValue(final float value) { + this.value = value; + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 0004ee899f..4abc9f7f91 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -9,6 +9,7 @@ import io.sentry.util.Objects; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -34,6 +35,8 @@ public final class SentryTransaction extends SentryBaseEvent { @SuppressWarnings("UnusedVariable") private @NotNull final String type = "transaction"; + private @NotNull final Map measurements = new HashMap<>(); + @SuppressWarnings("deprecation") public SentryTransaction(final @NotNull SentryTracer sentryTracer) { super(sentryTracer.getEventId()); @@ -85,4 +88,8 @@ public boolean isSampled() { final SpanContext trace = this.getContexts().getTrace(); return trace != null && Boolean.TRUE.equals(trace.getSampled()); } + + public @NotNull Map getMeasurements() { + return measurements; + } } diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 56a6f70b2d..e01c4544b9 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -6,12 +6,14 @@ import com.nhaarman.mockitokotlin2.spy import com.nhaarman.mockitokotlin2.verify import io.sentry.protocol.App import io.sentry.protocol.Request +import java.util.Date 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.assertSame import kotlin.test.assertTrue class SentryTracerTest { @@ -26,9 +28,12 @@ class SentryTracerTest { hub.bindClient(mock()) } - fun getSut(optionsConfiguration: Sentry.OptionsConfiguration = Sentry.OptionsConfiguration {}): SentryTracer { + fun getSut( + optionsConfiguration: Sentry.OptionsConfiguration = Sentry.OptionsConfiguration {}, + startTimestamp: Date? = null + ): SentryTracer { optionsConfiguration.configure(options) - return SentryTracer(TransactionContext("name", "op"), hub) + return SentryTracer(TransactionContext("name", "op"), hub, startTimestamp) } } @@ -307,4 +312,19 @@ class SentryTracerTest { assertTrue(transaction.isFinished) } + + @Test + fun `when startTimestamp is given, use it as startTimestamp`() { + val date = Date(0) + val transaction = fixture.getSut(startTimestamp = date) + + assertSame(date, transaction.startTimestamp) + } + + @Test + fun `when startTimestamp is nullable, set it automatically`() { + val transaction = fixture.getSut(startTimestamp = null) + + assertNotNull(transaction.startTimestamp) + } }