From 2226b2db31b77cee219bdf457cd106f0c04ab23a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 23 Jan 2026 10:15:56 +0100 Subject: [PATCH 1/4] feat(android): Add ApplicationStartInfo API support for Android 15+ --- CHANGELOG.md | 5 + .../api/sentry-android-core.api | 8 + .../core/AndroidOptionsInitializer.java | 2 + .../core/ApplicationStartInfoIntegration.java | 404 ++++++++++++++++++ .../android/core/SentryAndroidOptions.java | 32 ++ .../core/AndroidOptionsInitializerTest.kt | 8 + .../ApplicationStartInfoIntegrationTest.kt | 330 ++++++++++++++ 7 files changed, 789 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c945a7d78e..9763c70e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Features +- Add ApplicationStartInfo API support for Android 15+ ([#5055](https://github.com/getsentry/sentry-java/pull/5055)) + - Captures detailed app startup timing data from Android system + - Creates transactions with milestone spans (bind_application, application_oncreate, ttid, ttfd) + - Enriches with AppStartMetrics data (content provider spans, class names) + - Opt-in via `SentryAndroidOptions.setEnableApplicationStartInfo(boolean)` (disabled by default) - Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) ### Internal diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff9a0c7597..ee37ceb032 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -215,6 +215,12 @@ public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } +public final class io/sentry/android/core/ApplicationStartInfoIntegration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V + public fun close ()V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -353,6 +359,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableActivityLifecycleTracingAutoFinish ()Z public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z + public fun isEnableApplicationStartInfo ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z @@ -381,6 +388,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableActivityLifecycleTracingAutoFinish (Z)V public fun setEnableAppComponentBreadcrumbs (Z)V public fun setEnableAppLifecycleBreadcrumbs (Z)V + public fun setEnableApplicationStartInfo (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V 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 b7bb5bf21a..7734d7ce9a 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 @@ -380,6 +380,8 @@ static void installDefaultIntegrations( options.addIntegration(new TombstoneIntegration(context)); } + options.addIntegration(new ApplicationStartInfoIntegration(context, buildInfoProvider)); + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java new file mode 100644 index 0000000000..89244ccc70 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -0,0 +1,404 @@ +package io.sentry.android.core; + +import android.app.ActivityManager; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.Integration; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.IntegrationUtils; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class ApplicationStartInfoIntegration implements Integration, Closeable { + + private final @NotNull Context context; + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); + private @Nullable SentryAndroidOptions options; + private @Nullable IScopes scopes; + private boolean isClosed = false; + + public ApplicationStartInfoIntegration( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.buildInfoProvider = + java.util.Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + } + + @Override + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + register(scopes, (SentryAndroidOptions) options); + } + + private void register( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + this.scopes = java.util.Objects.requireNonNull(scopes, "Scopes are required"); + this.options = java.util.Objects.requireNonNull(options, "SentryAndroidOptions is required"); + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "ApplicationStartInfoIntegration enabled: %s", + options.isEnableApplicationStartInfo()); + + if (!options.isEnableApplicationStartInfo()) { + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + options + .getLogger() + .log( + SentryLevel.INFO, + "ApplicationStartInfo requires API level 35+. Current: %d", + buildInfoProvider.getSdkInfoVersion()); + return; + } + + try { + options + .getExecutorService() + .submit( + () -> { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + if (!isClosed) { + registerAppStartListener(scopes, options); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Failed to start ApplicationStartInfoIntegration.", e); + } + + IntegrationUtils.addIntegrationToSdkVersion("ApplicationStartInfo"); + } + + @RequiresApi(api = 35) + private void registerAppStartListener( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + try { + // Wrap ISentryExecutorService as Executor for Android API + final java.util.concurrent.Executor executor = options.getExecutorService()::submit; + + activityManager.addApplicationStartInfoCompletionListener( + executor, + startInfo -> { + try { + onApplicationStartInfoAvailable(startInfo, scopes, options); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error reporting ApplicationStartInfo.", e); + } + }); + + options + .getLogger() + .log(SentryLevel.DEBUG, "ApplicationStartInfo completion listener registered."); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to register ApplicationStartInfo listener.", e); + } + } + + @RequiresApi(api = 35) + private void onApplicationStartInfoAvailable( + final @NotNull android.app.ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + // Extract tags + final Map tags = extractTags(startInfo); + + // Create transaction name based on reason + final String transactionName = "app.start." + getReasonLabel(startInfo.getReason()); + + // Create timestamp + final SentryDate startTimestamp = dateFromMillis(getStartTimestamp(startInfo)); + + // Calculate duration (use first frame or fully drawn as end) + long endTimestamp = + getFirstFrameTimestamp(startInfo) > 0 + ? getFirstFrameTimestamp(startInfo) + : getFullyDrawnTimestamp(startInfo); + + final SentryDate endDate = + endTimestamp > 0 ? dateFromMillis(endTimestamp) : options.getDateProvider().now(); + + // Create transaction + final TransactionContext transactionContext = + new TransactionContext(transactionName, TransactionNameSource.COMPONENT, "app.start.info"); + + final TransactionOptions transactionOptions = new TransactionOptions(); + transactionOptions.setStartTimestamp(startTimestamp); + + final ITransaction transaction = + scopes.startTransaction(transactionContext, transactionOptions); + + // Add tags + for (Map.Entry entry : tags.entrySet()) { + transaction.setTag(entry.getKey(), entry.getValue()); + } + + // Create child spans for startup milestones (all start from app launch timestamp) + attachAppStartMetricData(transaction, startInfo, startTimestamp); + + // Finish transaction + transaction.finish(SpanStatus.OK, endDate); + } + + @RequiresApi(api = 35) + private void attachAppStartMetricData( + final @NotNull ITransaction transaction, + final @NotNull android.app.ApplicationStartInfo startInfo, + final @NotNull SentryDate startTimestamp) { + final long startMs = getStartTimestamp(startInfo); + + // Span 1: app.start.bind_application (from fork to bind application) + if (getBindApplicationTimestamp(startInfo) > 0) { + final io.sentry.ISpan bindSpan = + transaction.startChild( + "app.start.bind_application", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + bindSpan.finish(SpanStatus.OK, dateFromMillis(getBindApplicationTimestamp(startInfo))); + } + + // Add content provider onCreate spans from AppStartMetrics + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull List contentProviderSpans = + appStartMetrics.getContentProviderOnCreateTimeSpans(); + for (final TimeSpan cpSpan : contentProviderSpans) { + if (cpSpan.hasStarted() && cpSpan.hasStopped()) { + final SentryDate cpStartDate = dateFromMillis(cpSpan.getStartTimestampMs()); + final SentryDate cpEndDate = dateFromMillis(cpSpan.getProjectedStopTimestampMs()); + + final io.sentry.ISpan contentProviderSpan = + transaction.startChild( + "contentprovider.load", + cpSpan.getDescription(), + cpStartDate, + io.sentry.Instrumenter.SENTRY); + contentProviderSpan.finish(SpanStatus.OK, cpEndDate); + } + } + + // Span 2: app.start.application_oncreate (from fork to Application.onCreate) + // Use ApplicationStartInfo timestamp if available, enriched with AppStartMetrics description + final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); + final String appOnCreateDescription = + appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; + + if (getApplicationOnCreateTimestamp(startInfo) > 0) { + // Use precise timestamp from ApplicationStartInfo + final io.sentry.ISpan onCreateSpan = + transaction.startChild( + "app.start.application_oncreate", + appOnCreateDescription, + startTimestamp, + io.sentry.Instrumenter.SENTRY); + onCreateSpan.finish( + SpanStatus.OK, dateFromMillis(getApplicationOnCreateTimestamp(startInfo))); + } else if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { + // Fallback to AppStartMetrics timing + final SentryDate appOnCreateStart = dateFromMillis(appOnCreateSpan.getStartTimestampMs()); + final SentryDate appOnCreateEnd = + dateFromMillis(appOnCreateSpan.getProjectedStopTimestampMs()); + + final io.sentry.ISpan onCreateSpan = + transaction.startChild( + "app.start.application_oncreate", + appOnCreateDescription, + appOnCreateStart, + io.sentry.Instrumenter.SENTRY); + onCreateSpan.finish(SpanStatus.OK, appOnCreateEnd); + } + + // Span 3: app.start.ttid (from fork to first frame - time to initial display) + if (getFirstFrameTimestamp(startInfo) > 0) { + final io.sentry.ISpan ttidSpan = + transaction.startChild( + "app.start.ttid", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + ttidSpan.finish(SpanStatus.OK, dateFromMillis(getFirstFrameTimestamp(startInfo))); + } + + // Span 4: app.start.ttfd (from fork to fully drawn - time to full display) + if (getFullyDrawnTimestamp(startInfo) > 0) { + final io.sentry.ISpan ttfdSpan = + transaction.startChild( + "app.start.ttfd", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + ttfdSpan.finish(SpanStatus.OK, dateFromMillis(getFullyDrawnTimestamp(startInfo))); + } + } + + @RequiresApi(api = 35) + private @NotNull Map extractTags( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map tags = new HashMap<>(); + + // Add reason + tags.put("start.reason", getReasonLabel(startInfo.getReason())); + + // Add startup type from ApplicationStartInfo + tags.put("start.type", getStartupTypeLabel(startInfo.getStartType())); + + // Add launch mode from ApplicationStartInfo + tags.put("start.launch_mode", getLaunchModeLabel(startInfo.getLaunchMode())); + + // Note: Additional properties like component type, importance, etc. may be added + // when they become available in future Android API levels + + return tags; + } + + @RequiresApi(api = 35) + private @NotNull String getStartupTypeLabel(final int startType) { + switch (startType) { + case android.app.ApplicationStartInfo.START_TYPE_COLD: + return "cold"; + case android.app.ApplicationStartInfo.START_TYPE_WARM: + return "warm"; + case android.app.ApplicationStartInfo.START_TYPE_HOT: + return "hot"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private @NotNull String getLaunchModeLabel(final int launchMode) { + switch (launchMode) { + case android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD: + return "standard"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP: + return "single_top"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE: + return "single_instance"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TASK: + return "single_task"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE_PER_TASK: + return "single_instance_per_task"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private @NotNull String getReasonLabel(final int reason) { + switch (reason) { + case android.app.ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case android.app.ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case android.app.ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case android.app.ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case android.app.ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case android.app.ApplicationStartInfo.START_REASON_JOB: + return "job"; + case android.app.ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case android.app.ApplicationStartInfo.START_REASON_OTHER: + return "other"; + case android.app.ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case android.app.ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case android.app.ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + default: + return "unknown"; + } + } + + // Helper methods to access timestamps from the startupTimestamps map + @RequiresApi(api = 35) + private long getStartTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long forkTime = timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FORK); + return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; + } + + @RequiresApi(api = 35) + private long getBindApplicationTimestamp( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long bindTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); + return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; + } + + @RequiresApi(api = 35) + private long getApplicationOnCreateTimestamp( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long onCreateTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + return onCreateTime != null ? TimeUnit.NANOSECONDS.toMillis(onCreateTime) : 0; + } + + @RequiresApi(api = 35) + private long getFirstFrameTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long firstFrameTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; + } + + @RequiresApi(api = 35) + private long getFullyDrawnTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long fullyDrawnTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN); + return fullyDrawnTime != null ? TimeUnit.NANOSECONDS.toMillis(fullyDrawnTime) : 0; + } + + @Override + public void close() throws IOException { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + isClosed = true; + } + } + + /** + * Creates a SentryDate from milliseconds timestamp. Uses SentryNanotimeDate for compatibility + * with older Android versions. + */ + private static @NotNull SentryDate dateFromMillis(final long millis) { + return new SentryNanotimeDate(new Date(millis), 0); + } +} 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 12917ed4b7..adad161743 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 @@ -243,6 +243,17 @@ public interface BeforeCaptureCallback { private boolean enableTombstone = false; + /** + * Controls whether to collect and report application startup information from the {@link + * android.app.ApplicationStartInfo} system API (Android 15+). When enabled, creates transactions + * and metrics for each application start event. + * + *

Requires API level 35 (Android 15) or higher. + * + *

Default is false (opt-in). + */ + private boolean enableApplicationStartInfo = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -337,6 +348,27 @@ public boolean isTombstoneEnabled() { return enableTombstone; } + /** + * Sets ApplicationStartInfo collection to enabled or disabled. Requires API level 35 (Android 15) + * or higher. + * + * @param enableApplicationStartInfo true for enabled and false for disabled + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfo(final boolean enableApplicationStartInfo) { + this.enableApplicationStartInfo = enableApplicationStartInfo; + } + + /** + * Checks if ApplicationStartInfo collection is enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfo() { + return enableApplicationStartInfo; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } 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 348075ff90..9d6a77b9cc 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 @@ -936,4 +936,12 @@ class AndroidOptionsInitializerTest { fixture.initSut() assertIs(fixture.sentryOptions.runtimeManager) } + + @Test + fun `ApplicationStartInfoIntegration is added to integration list`() { + fixture.initSut() + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ApplicationStartInfoIntegration } + assertNotNull(actual) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt new file mode 100644 index 0000000000..6a521764d3 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt @@ -0,0 +1,330 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +import io.sentry.ISentryExecutorService +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.TransactionContext +import java.util.concurrent.Callable +import java.util.function.Consumer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class ApplicationStartInfoIntegrationTest { + + private lateinit var context: Context + private lateinit var options: SentryAndroidOptions + private lateinit var scopes: IScopes + private lateinit var activityManager: ActivityManager + private lateinit var executor: ISentryExecutorService + private lateinit var buildInfoProvider: BuildInfoProvider + + @Before + fun setup() { + context = mock() + options = SentryAndroidOptions() + scopes = mock() + activityManager = mock() + executor = mock() + buildInfoProvider = mock() + + // Setup default options + options.isEnableApplicationStartInfo = true + options.executorService = executor + options.setLogger(mock()) + options.dateProvider = mock() + + // Mock BuildInfoProvider to return API 35+ + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.VANILLA_ICE_CREAM) + + // Execute tasks immediately for testing + whenever(executor.submit(any>())).thenAnswer { + val callable = it.arguments[0] as Callable<*> + callable.call() + mock>() + } + whenever(executor.submit(any())).thenAnswer { + val runnable = it.arguments[0] as Runnable + runnable.run() + mock>() + } + + // Mock ActivityManager as system service + whenever(context.getSystemService(Context.ACTIVITY_SERVICE)).thenReturn(activityManager) + } + + @Test + fun `integration does not register when disabled`() { + options.isEnableApplicationStartInfo = false + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + + integration.register(scopes, options) + + verify(executor, never()).submit(any()) + } + + @Test + fun `integration registers completion listener on API 35+`() { + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager).addApplicationStartInfoCompletionListener(any(), any()) + } + + @Test + fun `transaction includes correct tags from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag(eq("start.reason"), any()) + } + + @Test + fun `transaction includes start type from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.startType) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_TYPE_COLD else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag("start.type", "cold") + } + + @Test + fun `transaction includes launch mode from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.launchMode) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD + else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag("start.launch_mode", "standard") + } + + @Test + fun `creates bind_application span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever( + mockTransaction.startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) + ) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, bindApplicationTime = 1100000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates application_oncreate span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever( + mockTransaction.startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) + ) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, applicationOnCreateTime = 1200000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction) + .startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates ttid span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever(mockTransaction.startChild(eq("app.start.ttid"), anyOrNull(), any(), any())) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, firstFrameTime = 1500000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.ttid"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates ttfd span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever(mockTransaction.startChild(eq("app.start.ttfd"), anyOrNull(), any(), any())) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, fullyDrawnTime = 2000000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.ttfd"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `closes integration without errors`() { + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + integration.close() + // Should not throw exception + } + + @Test + fun `transaction name includes reason label`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + var capturedContext: TransactionContext? = null + whenever(scopes.startTransaction(any(), any())).thenAnswer { + capturedContext = it.arguments[0] as TransactionContext + mock() + } + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.reason) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_REASON_LAUNCHER + else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + assertNotNull(capturedContext) + assertEquals("app.start.launcher", capturedContext!!.name) + } + + // Helper methods + private fun createMockApplicationStartInfo( + forkTime: Long = 1000000000L, // nanoseconds + bindApplicationTime: Long = 0L, + applicationOnCreateTime: Long = 0L, + firstFrameTime: Long = 0L, + fullyDrawnTime: Long = 0L, + ): android.app.ApplicationStartInfo { + val startInfo = mock() + + val timestamps = mutableMapOf() + if (Build.VERSION.SDK_INT >= 35) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FORK] = forkTime + if (bindApplicationTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION] = + bindApplicationTime + } + if (applicationOnCreateTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE] = + applicationOnCreateTime + } + if (firstFrameTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME] = firstFrameTime + } + if (fullyDrawnTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN] = fullyDrawnTime + } + + whenever(startInfo.reason).thenReturn(android.app.ApplicationStartInfo.START_REASON_LAUNCHER) + } + + whenever(startInfo.startupTimestamps).thenReturn(timestamps) + + return startInfo + } +} From 867aaed5adbcd128a273c1aedb5a0bd0a2453356 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 26 Jan 2026 09:02:08 +0100 Subject: [PATCH 2/4] Switch to using protocol objects --- .../core/ApplicationStartInfoIntegration.java | 379 +++++++++++------- 1 file changed, 224 insertions(+), 155 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java index 89244ccc70..cfc2e4e8d0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -1,22 +1,30 @@ package io.sentry.android.core; +import android.annotation.SuppressLint; import android.app.ActivityManager; +import android.app.ApplicationStartInfo; import android.content.Context; import android.os.Build; +import android.os.SystemClock; import androidx.annotation.RequiresApi; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; -import io.sentry.ITransaction; import io.sentry.Integration; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; import io.sentry.SentryOptions; +import io.sentry.SpanContext; +import io.sentry.SpanDataConvention; +import io.sentry.SpanId; import io.sentry.SpanStatus; -import io.sentry.TransactionContext; -import io.sentry.TransactionOptions; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.SentrySpan; +import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.TransactionInfo; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.IntegrationUtils; @@ -26,10 +34,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class ApplicationStartInfoIntegration implements Integration, Closeable { @@ -37,9 +46,7 @@ public final class ApplicationStartInfoIntegration implements Integration, Close private final @NotNull Context context; private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); - private @Nullable SentryAndroidOptions options; - private @Nullable IScopes scopes; - private boolean isClosed = false; + private boolean isClosed; public ApplicationStartInfoIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { @@ -49,15 +56,13 @@ public ApplicationStartInfoIntegration( } @Override - public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { register(scopes, (SentryAndroidOptions) options); } + @SuppressLint("NewApi") private void register( final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { - this.scopes = java.util.Objects.requireNonNull(scopes, "Scopes are required"); - this.options = java.util.Objects.requireNonNull(options, "SentryAndroidOptions is required"); - options .getLogger() .log( @@ -65,9 +70,9 @@ private void register( "ApplicationStartInfoIntegration enabled: %s", options.isEnableApplicationStartInfo()); - if (!options.isEnableApplicationStartInfo()) { - return; - } + // if (!options.isEnableApplicationStartInfo()) { + // return; + // } if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.VANILLA_ICE_CREAM) { options @@ -111,8 +116,13 @@ private void registerAppStartListener( } try { - // Wrap ISentryExecutorService as Executor for Android API - final java.util.concurrent.Executor executor = options.getExecutorService()::submit; + final Executor executor = + new Executor() { + @Override + public void execute(Runnable command) { + options.getExecutorService().submit(command); + } + }; activityManager.addApplicationStartInfoCompletionListener( executor, @@ -138,128 +148,204 @@ private void registerAppStartListener( @RequiresApi(api = 35) private void onApplicationStartInfoAvailable( - final @NotNull android.app.ApplicationStartInfo startInfo, + final @NotNull ApplicationStartInfo startInfo, final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { - // Extract tags - final Map tags = extractTags(startInfo); - // Create transaction name based on reason + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + + final Map tags = extractTags(startInfo); final String transactionName = "app.start." + getReasonLabel(startInfo.getReason()); + final long startRealtimeMs = getStartTimestampMs(startInfo); - // Create timestamp - final SentryDate startTimestamp = dateFromMillis(getStartTimestamp(startInfo)); + final long ttidRealtimeMs = getFirstFrameTimestampMs(startInfo); + final long ttfdRealtimeMs = getFullyDrawnTimestampMs(startInfo); + final long bindApplicationRealtimeMs = getBindApplicationTimestampMs(startInfo); - // Calculate duration (use first frame or fully drawn as end) - long endTimestamp = - getFirstFrameTimestamp(startInfo) > 0 - ? getFirstFrameTimestamp(startInfo) - : getFullyDrawnTimestamp(startInfo); + final SentryDate startDate = dateFromUnixTime(unixTimeOffsetMs + startRealtimeMs); + final long endTimestamp = ttidRealtimeMs > 0 ? ttidRealtimeMs : ttfdRealtimeMs; final SentryDate endDate = - endTimestamp > 0 ? dateFromMillis(endTimestamp) : options.getDateProvider().now(); + endTimestamp > 0 + ? dateFromUnixTime(unixTimeOffsetMs + endTimestamp) + : options.getDateProvider().now(); + + // Create trace context + final SentryId traceId = new SentryId(); + final SpanId spanId = new SpanId(); + final SpanContext traceContext = + new SpanContext(traceId, spanId, "app.startDate.info", null, null); + traceContext.setStatus(SpanStatus.OK); + + // Convert timestamps to seconds + final double startTimestampSecs = dateToSeconds(startDate); + final double endTimestampSecs = dateToSeconds(endDate); + + // Create transaction directly + final SentryTransaction transaction = + new SentryTransaction( + transactionName, + startTimestampSecs, + endTimestampSecs, + new java.util.ArrayList<>(), + new HashMap<>(), + new TransactionInfo(TransactionNameSource.COMPONENT.apiName())); + + // Set trace context + transaction.getContexts().setTrace(traceContext); + + // Set tags + for (Map.Entry entry : tags.entrySet()) { + transaction.setTag(entry.getKey(), entry.getValue()); + } - // Create transaction - final TransactionContext transactionContext = - new TransactionContext(transactionName, TransactionNameSource.COMPONENT, "app.start.info"); + // Add spans + if (bindApplicationRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "bind_application", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + bindApplicationRealtimeMs))); + } - final TransactionOptions transactionOptions = new TransactionOptions(); - transactionOptions.setStartTimestamp(startTimestamp); + if (ttidRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttid", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttidRealtimeMs))); + } - final ITransaction transaction = - scopes.startTransaction(transactionContext, transactionOptions); + if (ttfdRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttfd", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttfdRealtimeMs))); + } - // Add tags - for (Map.Entry entry : tags.entrySet()) { - transaction.setTag(entry.getKey(), entry.getValue()); + attachAppStartMetrics(transaction, traceId, spanId, unixTimeOffsetMs); + + // if application instrumentation was disabled, report app start info data + final TimeSpan appOnCreateSpan = AppStartMetrics.getInstance().getApplicationOnCreateTimeSpan(); + if (!appOnCreateSpan.hasStarted() || appOnCreateSpan.hasStopped()) { + final long applicationOnCreateRealtimeMs = getApplicationOnCreateTimestampMs(startInfo); + if (applicationOnCreateRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "application.onCreate", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + applicationOnCreateRealtimeMs))); + } } - // Create child spans for startup milestones (all start from app launch timestamp) - attachAppStartMetricData(transaction, startInfo, startTimestamp); + scopes.captureTransaction(transaction, null, null); + } + + private @NotNull SentrySpan createSpan( + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId, + final @NotNull String operation, + final String description, + final @NotNull SentryDate startDate, + final @NotNull SentryDate endDate) { + + final Map spanData = new HashMap<>(); + spanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); + spanData.put(SpanDataConvention.THREAD_NAME, "main"); + + final double startTimestampSecs = dateToSeconds(startDate); + final double endTimestampSecs = dateToSeconds(endDate); + + return new SentrySpan( + startTimestampSecs, + endTimestampSecs, + traceId, + new SpanId(), + parentSpanId, + operation, + description, + SpanStatus.OK, + "manual", + new ConcurrentHashMap<>(), + new ConcurrentHashMap<>(), + spanData); + } - // Finish transaction - transaction.finish(SpanStatus.OK, endDate); + private static double dateToSeconds(final @NotNull SentryDate date) { + return date.nanoTimestamp() / 1e9; } @RequiresApi(api = 35) - private void attachAppStartMetricData( - final @NotNull ITransaction transaction, - final @NotNull android.app.ApplicationStartInfo startInfo, - final @NotNull SentryDate startTimestamp) { - final long startMs = getStartTimestamp(startInfo); - - // Span 1: app.start.bind_application (from fork to bind application) - if (getBindApplicationTimestamp(startInfo) > 0) { - final io.sentry.ISpan bindSpan = - transaction.startChild( - "app.start.bind_application", null, startTimestamp, io.sentry.Instrumenter.SENTRY); - bindSpan.finish(SpanStatus.OK, dateFromMillis(getBindApplicationTimestamp(startInfo))); - } + private void attachAppStartMetrics( + final @NotNull SentryTransaction transaction, + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId, + final long unixTimeOffsetMs) { - // Add content provider onCreate spans from AppStartMetrics final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull List contentProviderSpans = appStartMetrics.getContentProviderOnCreateTimeSpans(); + for (final TimeSpan cpSpan : contentProviderSpans) { if (cpSpan.hasStarted() && cpSpan.hasStopped()) { - final SentryDate cpStartDate = dateFromMillis(cpSpan.getStartTimestampMs()); - final SentryDate cpEndDate = dateFromMillis(cpSpan.getProjectedStopTimestampMs()); - - final io.sentry.ISpan contentProviderSpan = - transaction.startChild( - "contentprovider.load", - cpSpan.getDescription(), - cpStartDate, - io.sentry.Instrumenter.SENTRY); - contentProviderSpan.finish(SpanStatus.OK, cpEndDate); + final SentryDate cpStartDate = dateFromUnixTime(cpSpan.getStartTimestampMs()); + final SentryDate cpEndDate = dateFromUnixTime(cpSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "contentprovider.load", + cpSpan.getDescription(), + cpStartDate, + cpEndDate)); } } - // Span 2: app.start.application_oncreate (from fork to Application.onCreate) - // Use ApplicationStartInfo timestamp if available, enriched with AppStartMetrics description final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); final String appOnCreateDescription = appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; - if (getApplicationOnCreateTimestamp(startInfo) > 0) { - // Use precise timestamp from ApplicationStartInfo - final io.sentry.ISpan onCreateSpan = - transaction.startChild( - "app.start.application_oncreate", - appOnCreateDescription, - startTimestamp, - io.sentry.Instrumenter.SENTRY); - onCreateSpan.finish( - SpanStatus.OK, dateFromMillis(getApplicationOnCreateTimestamp(startInfo))); - } else if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { - // Fallback to AppStartMetrics timing - final SentryDate appOnCreateStart = dateFromMillis(appOnCreateSpan.getStartTimestampMs()); + if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { + final SentryDate appOnCreateStart = dateFromUnixTime(appOnCreateSpan.getStartTimestampMs()); final SentryDate appOnCreateEnd = - dateFromMillis(appOnCreateSpan.getProjectedStopTimestampMs()); - - final io.sentry.ISpan onCreateSpan = - transaction.startChild( - "app.start.application_oncreate", - appOnCreateDescription, - appOnCreateStart, - io.sentry.Instrumenter.SENTRY); - onCreateSpan.finish(SpanStatus.OK, appOnCreateEnd); - } - - // Span 3: app.start.ttid (from fork to first frame - time to initial display) - if (getFirstFrameTimestamp(startInfo) > 0) { - final io.sentry.ISpan ttidSpan = - transaction.startChild( - "app.start.ttid", null, startTimestamp, io.sentry.Instrumenter.SENTRY); - ttidSpan.finish(SpanStatus.OK, dateFromMillis(getFirstFrameTimestamp(startInfo))); - } - - // Span 4: app.start.ttfd (from fork to fully drawn - time to full display) - if (getFullyDrawnTimestamp(startInfo) > 0) { - final io.sentry.ISpan ttfdSpan = - transaction.startChild( - "app.start.ttfd", null, startTimestamp, io.sentry.Instrumenter.SENTRY); - ttfdSpan.finish(SpanStatus.OK, dateFromMillis(getFullyDrawnTimestamp(startInfo))); + dateFromUnixTime(appOnCreateSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "application.onCreate", + appOnCreateDescription, + appOnCreateStart, + appOnCreateEnd)); } } @@ -267,30 +353,20 @@ private void attachAppStartMetricData( private @NotNull Map extractTags( final @NotNull android.app.ApplicationStartInfo startInfo) { final Map tags = new HashMap<>(); - - // Add reason tags.put("start.reason", getReasonLabel(startInfo.getReason())); - - // Add startup type from ApplicationStartInfo tags.put("start.type", getStartupTypeLabel(startInfo.getStartType())); - - // Add launch mode from ApplicationStartInfo tags.put("start.launch_mode", getLaunchModeLabel(startInfo.getLaunchMode())); - - // Note: Additional properties like component type, importance, etc. may be added - // when they become available in future Android API levels - return tags; } @RequiresApi(api = 35) private @NotNull String getStartupTypeLabel(final int startType) { switch (startType) { - case android.app.ApplicationStartInfo.START_TYPE_COLD: + case ApplicationStartInfo.START_TYPE_COLD: return "cold"; - case android.app.ApplicationStartInfo.START_TYPE_WARM: + case ApplicationStartInfo.START_TYPE_WARM: return "warm"; - case android.app.ApplicationStartInfo.START_TYPE_HOT: + case ApplicationStartInfo.START_TYPE_HOT: return "hot"; default: return "unknown"; @@ -300,15 +376,15 @@ private void attachAppStartMetricData( @RequiresApi(api = 35) private @NotNull String getLaunchModeLabel(final int launchMode) { switch (launchMode) { - case android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD: + case ApplicationStartInfo.LAUNCH_MODE_STANDARD: return "standard"; - case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP: + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP: return "single_top"; - case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE: + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE: return "single_instance"; - case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TASK: + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_TASK: return "single_task"; - case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE_PER_TASK: + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE_PER_TASK: return "single_instance_per_task"; default: return "unknown"; @@ -318,72 +394,69 @@ private void attachAppStartMetricData( @RequiresApi(api = 35) private @NotNull String getReasonLabel(final int reason) { switch (reason) { - case android.app.ApplicationStartInfo.START_REASON_ALARM: + case ApplicationStartInfo.START_REASON_ALARM: return "alarm"; - case android.app.ApplicationStartInfo.START_REASON_BACKUP: + case ApplicationStartInfo.START_REASON_BACKUP: return "backup"; - case android.app.ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: return "boot_complete"; - case android.app.ApplicationStartInfo.START_REASON_BROADCAST: + case ApplicationStartInfo.START_REASON_BROADCAST: return "broadcast"; - case android.app.ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: return "content_provider"; - case android.app.ApplicationStartInfo.START_REASON_JOB: + case ApplicationStartInfo.START_REASON_JOB: return "job"; - case android.app.ApplicationStartInfo.START_REASON_LAUNCHER: + case ApplicationStartInfo.START_REASON_LAUNCHER: return "launcher"; - case android.app.ApplicationStartInfo.START_REASON_OTHER: + case ApplicationStartInfo.START_REASON_OTHER: return "other"; - case android.app.ApplicationStartInfo.START_REASON_PUSH: + case ApplicationStartInfo.START_REASON_PUSH: return "push"; - case android.app.ApplicationStartInfo.START_REASON_SERVICE: + case ApplicationStartInfo.START_REASON_SERVICE: return "service"; - case android.app.ApplicationStartInfo.START_REASON_START_ACTIVITY: + case ApplicationStartInfo.START_REASON_START_ACTIVITY: return "start_activity"; default: return "unknown"; } } - // Helper methods to access timestamps from the startupTimestamps map @RequiresApi(api = 35) - private long getStartTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + private long getStartTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); - final Long forkTime = timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FORK); + final Long forkTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FORK); return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; } @RequiresApi(api = 35) - private long getBindApplicationTimestamp( + private long getBindApplicationTimestampMs( final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); - final Long bindTime = - timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); + + final Long bindTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; } @RequiresApi(api = 35) - private long getApplicationOnCreateTimestamp( + private long getApplicationOnCreateTimestampMs( final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); final Long onCreateTime = - timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); return onCreateTime != null ? TimeUnit.NANOSECONDS.toMillis(onCreateTime) : 0; } @RequiresApi(api = 35) - private long getFirstFrameTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + private long getFirstFrameTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); - final Long firstFrameTime = - timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + final Long firstFrameTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; } @RequiresApi(api = 35) - private long getFullyDrawnTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + private long getFullyDrawnTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); - final Long fullyDrawnTime = - timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN); + final Long fullyDrawnTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN); return fullyDrawnTime != null ? TimeUnit.NANOSECONDS.toMillis(fullyDrawnTime) : 0; } @@ -394,11 +467,7 @@ public void close() throws IOException { } } - /** - * Creates a SentryDate from milliseconds timestamp. Uses SentryNanotimeDate for compatibility - * with older Android versions. - */ - private static @NotNull SentryDate dateFromMillis(final long millis) { - return new SentryNanotimeDate(new Date(millis), 0); + private static @NotNull SentryDate dateFromUnixTime(final long timeMillis) { + return new SentryNanotimeDate(new Date(timeMillis), 0); } } From aa86a9fb54aee043d4a3eeaadcd7a379c494ea5e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 2 Feb 2026 14:35:40 +0100 Subject: [PATCH 3/4] Report all AppStartMetrics spans --- .../api/sentry-android-core.api | 2 +- .../core/ApplicationStartInfoIntegration.java | 115 ++++++++++-------- 2 files changed, 68 insertions(+), 49 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ee37ceb032..feb216e40e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -218,7 +218,7 @@ public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io public final class io/sentry/android/core/ApplicationStartInfoIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V - public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/BuildConfig { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java index cfc2e4e8d0..bb27fc092e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -18,7 +18,9 @@ import io.sentry.SpanDataConvention; import io.sentry.SpanId; import io.sentry.SpanStatus; +import io.sentry.TracesSamplingDecision; import io.sentry.android.core.internal.util.AndroidThreadChecker; +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.SentryId; @@ -28,6 +30,7 @@ import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.IntegrationUtils; +import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; import java.util.Date; @@ -52,7 +55,7 @@ public ApplicationStartInfoIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { this.context = ContextUtils.getApplicationContext(context); this.buildInfoProvider = - java.util.Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); } @Override @@ -70,9 +73,9 @@ private void register( "ApplicationStartInfoIntegration enabled: %s", options.isEnableApplicationStartInfo()); - // if (!options.isEnableApplicationStartInfo()) { - // return; - // } + if (!options.isEnableApplicationStartInfo()) { + return; + } if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.VANILLA_ICE_CREAM) { options @@ -157,9 +160,7 @@ private void onApplicationStartInfoAvailable( final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; final Map tags = extractTags(startInfo); - final String transactionName = "app.start." + getReasonLabel(startInfo.getReason()); final long startRealtimeMs = getStartTimestampMs(startInfo); - final long ttidRealtimeMs = getFirstFrameTimestampMs(startInfo); final long ttfdRealtimeMs = getFullyDrawnTimestampMs(startInfo); final long bindApplicationRealtimeMs = getBindApplicationTimestampMs(startInfo); @@ -172,36 +173,30 @@ private void onApplicationStartInfoAvailable( ? dateFromUnixTime(unixTimeOffsetMs + endTimestamp) : options.getDateProvider().now(); - // Create trace context final SentryId traceId = new SentryId(); final SpanId spanId = new SpanId(); final SpanContext traceContext = - new SpanContext(traceId, spanId, "app.startDate.info", null, null); + new SpanContext(traceId, spanId, "app.start", null, new TracesSamplingDecision(true)); traceContext.setStatus(SpanStatus.OK); - // Convert timestamps to seconds final double startTimestampSecs = dateToSeconds(startDate); final double endTimestampSecs = dateToSeconds(endDate); - // Create transaction directly final SentryTransaction transaction = new SentryTransaction( - transactionName, + "app.start", startTimestampSecs, endTimestampSecs, new java.util.ArrayList<>(), new HashMap<>(), new TransactionInfo(TransactionNameSource.COMPONENT.apiName())); - // Set trace context transaction.getContexts().setTrace(traceContext); - // Set tags for (Map.Entry entry : tags.entrySet()) { transaction.setTag(entry.getKey(), entry.getValue()); } - // Add spans if (bindApplicationRealtimeMs > 0) { transaction .getSpans() @@ -215,6 +210,12 @@ private void onApplicationStartInfoAvailable( dateFromUnixTime(unixTimeOffsetMs + bindApplicationRealtimeMs))); } + if (startInfo.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + attachColdStartInstrumentations(transaction, traceId, spanId); + } + + attachActivitySpans(transaction, traceId, spanId); + if (ttidRealtimeMs > 0) { transaction .getSpans() @@ -241,26 +242,6 @@ private void onApplicationStartInfoAvailable( dateFromUnixTime(unixTimeOffsetMs + ttfdRealtimeMs))); } - attachAppStartMetrics(transaction, traceId, spanId, unixTimeOffsetMs); - - // if application instrumentation was disabled, report app start info data - final TimeSpan appOnCreateSpan = AppStartMetrics.getInstance().getApplicationOnCreateTimeSpan(); - if (!appOnCreateSpan.hasStarted() || appOnCreateSpan.hasStopped()) { - final long applicationOnCreateRealtimeMs = getApplicationOnCreateTimestampMs(startInfo); - if (applicationOnCreateRealtimeMs > 0) { - transaction - .getSpans() - .add( - createSpan( - traceId, - spanId, - "application.onCreate", - null, - startDate, - dateFromUnixTime(unixTimeOffsetMs + applicationOnCreateRealtimeMs))); - } - } - scopes.captureTransaction(transaction, null, null); } @@ -299,11 +280,10 @@ private static double dateToSeconds(final @NotNull SentryDate date) { } @RequiresApi(api = 35) - private void attachAppStartMetrics( + private void attachColdStartInstrumentations( final @NotNull SentryTransaction transaction, final @NotNull SentryId traceId, - final @NotNull SpanId parentSpanId, - final long unixTimeOffsetMs) { + final @NotNull SpanId parentSpanId) { final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull List contentProviderSpans = @@ -320,7 +300,7 @@ private void attachAppStartMetrics( createSpan( traceId, parentSpanId, - "contentprovider.load", + "contentprovider.on_create", cpSpan.getDescription(), cpStartDate, cpEndDate)); @@ -342,13 +322,61 @@ private void attachAppStartMetrics( createSpan( traceId, parentSpanId, - "application.onCreate", + "application.on_create", appOnCreateDescription, appOnCreateStart, appOnCreateEnd)); } } + @RequiresApi(api = 35) + private void attachActivitySpans( + final @NotNull SentryTransaction transaction, + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId) { + + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull List activityLifecycleTimeSpans = + appStartMetrics.getActivityLifecycleTimeSpans(); + + for (final ActivityLifecycleTimeSpan span : activityLifecycleTimeSpans) { + final TimeSpan onCreate = span.getOnCreate(); + final TimeSpan onStart = span.getOnStart(); + + if (onCreate.hasStarted() && onCreate.hasStopped()) { + final SentryDate start = dateFromUnixTime(onCreate.getStartTimestampMs()); + final SentryDate end = dateFromUnixTime(onCreate.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "activity.on_create", + onCreate.getDescription(), + start, + end)); + } + + if (onStart.hasStarted() && onStart.hasStopped()) { + final SentryDate start = dateFromUnixTime(onStart.getStartTimestampMs()); + final SentryDate end = dateFromUnixTime(onStart.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "activity.on_start", + onStart.getDescription(), + start, + end)); + } + } + } + @RequiresApi(api = 35) private @NotNull Map extractTags( final @NotNull android.app.ApplicationStartInfo startInfo) { @@ -437,15 +465,6 @@ private long getBindApplicationTimestampMs( return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; } - @RequiresApi(api = 35) - private long getApplicationOnCreateTimestampMs( - final @NotNull android.app.ApplicationStartInfo startInfo) { - final Map timestamps = startInfo.getStartupTimestamps(); - final Long onCreateTime = - timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); - return onCreateTime != null ? TimeUnit.NANOSECONDS.toMillis(onCreateTime) : 0; - } - @RequiresApi(api = 35) private long getFirstFrameTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { final Map timestamps = startInfo.getStartupTimestamps(); From 0c847e6091bc194a7365926ba337dd93c64495ec Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 2 Feb 2026 14:57:38 +0100 Subject: [PATCH 4/4] Update tests --- .../ApplicationStartInfoIntegrationTest.kt | 187 +++++++----------- 1 file changed, 68 insertions(+), 119 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt index 6a521764d3..262039b56b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt @@ -6,20 +6,18 @@ import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IScopes import io.sentry.ISentryExecutorService -import io.sentry.ISpan -import io.sentry.ITransaction -import io.sentry.TransactionContext +import io.sentry.protocol.SentryTransaction import java.util.concurrent.Callable import java.util.function.Consumer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -50,7 +48,12 @@ class ApplicationStartInfoIntegrationTest { options.isEnableApplicationStartInfo = true options.executorService = executor options.setLogger(mock()) - options.dateProvider = mock() + + val mockDateProvider = mock() + val mockDate = mock() + whenever(mockDate.nanoTimestamp()).thenReturn(System.currentTimeMillis() * 1_000_000L) + whenever(mockDateProvider.now()).thenReturn(mockDate) + options.dateProvider = mockDateProvider // Mock BuildInfoProvider to return API 35+ whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.VANILLA_ICE_CREAM) @@ -92,168 +95,117 @@ class ApplicationStartInfoIntegrationTest { @Test fun `transaction includes correct tags from ApplicationStartInfo`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - val startInfo = createMockApplicationStartInfo() listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).setTag(eq("start.reason"), any()) + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val transaction = transactionCaptor.firstValue + assertNotNull(transaction.tags) + assertTrue(transaction.tags!!.containsKey("start.reason")) + assertTrue(transaction.tags!!.containsKey("start.type")) + assertTrue(transaction.tags!!.containsKey("start.launch_mode")) } @Test fun `transaction includes start type from ApplicationStartInfo`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - - val startInfo = createMockApplicationStartInfo() - whenever(startInfo.startType) - .thenReturn( - if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_TYPE_COLD else 0 - ) + val startInfo = + createMockApplicationStartInfo(startType = android.app.ApplicationStartInfo.START_TYPE_COLD) listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).setTag("start.type", "cold") + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("cold", transactionCaptor.firstValue.tags!!["start.type"]) } @Test fun `transaction includes launch mode from ApplicationStartInfo`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - - val startInfo = createMockApplicationStartInfo() - whenever(startInfo.launchMode) - .thenReturn( - if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD - else 0 + val startInfo = + createMockApplicationStartInfo( + launchMode = android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD ) listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).setTag("start.launch_mode", "standard") + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("standard", transactionCaptor.firstValue.tags!!["start.launch_mode"]) } @Test fun `creates bind_application span when timestamp available`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - val mockSpan = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - whenever( - mockTransaction.startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) - ) - .thenReturn(mockSpan) - val startInfo = createMockApplicationStartInfo(forkTime = 1000000000L, bindApplicationTime = 1100000000L) listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) - verify(mockSpan).finish(any(), any()) - } - - @Test - fun `creates application_oncreate span when timestamp available`() { - val listenerCaptor = argumentCaptor>() - val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) - integration.register(scopes, options) - - verify(activityManager) - .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - - val mockTransaction = mock() - val mockSpan = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - whenever( - mockTransaction.startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) - ) - .thenReturn(mockSpan) - - val startInfo = - createMockApplicationStartInfo(forkTime = 1000000000L, applicationOnCreateTime = 1200000000L) - listenerCaptor.firstValue.accept(startInfo) - - verify(mockTransaction) - .startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) - verify(mockSpan).finish(any(), any()) + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "bind_application" }) } @Test fun `creates ttid span when timestamp available`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - val mockSpan = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - whenever(mockTransaction.startChild(eq("app.start.ttid"), anyOrNull(), any(), any())) - .thenReturn(mockSpan) - val startInfo = createMockApplicationStartInfo(forkTime = 1000000000L, firstFrameTime = 1500000000L) listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).startChild(eq("app.start.ttid"), anyOrNull(), any(), any()) - verify(mockSpan).finish(any(), any()) + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "ttid" }) } @Test fun `creates ttfd span when timestamp available`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - val mockTransaction = mock() - val mockSpan = mock() - whenever(scopes.startTransaction(any(), any())) - .thenReturn(mockTransaction) - whenever(mockTransaction.startChild(eq("app.start.ttfd"), anyOrNull(), any(), any())) - .thenReturn(mockSpan) - val startInfo = createMockApplicationStartInfo(forkTime = 1000000000L, fullyDrawnTime = 2000000000L) listenerCaptor.firstValue.accept(startInfo) - verify(mockTransaction).startChild(eq("app.start.ttfd"), anyOrNull(), any(), any()) - verify(mockSpan).finish(any(), any()) + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "ttfd" }) } @Test @@ -266,63 +218,60 @@ class ApplicationStartInfoIntegrationTest { } @Test - fun `transaction name includes reason label`() { + fun `transaction name is app_start`() { val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) integration.register(scopes, options) verify(activityManager) .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) - var capturedContext: TransactionContext? = null - whenever(scopes.startTransaction(any(), any())).thenAnswer { - capturedContext = it.arguments[0] as TransactionContext - mock() - } - val startInfo = createMockApplicationStartInfo() - whenever(startInfo.reason) - .thenReturn( - if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_REASON_LAUNCHER - else 0 - ) listenerCaptor.firstValue.accept(startInfo) - assertNotNull(capturedContext) - assertEquals("app.start.launcher", capturedContext!!.name) + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("app.start", transactionCaptor.firstValue.transaction) + } + + @Test + fun `does not register on API lower than 35`() { + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(34) + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + + integration.register(scopes, options) + + verify(activityManager, never()).addApplicationStartInfoCompletionListener(any(), any()) } // Helper methods private fun createMockApplicationStartInfo( forkTime: Long = 1000000000L, // nanoseconds bindApplicationTime: Long = 0L, - applicationOnCreateTime: Long = 0L, firstFrameTime: Long = 0L, fullyDrawnTime: Long = 0L, + reason: Int = android.app.ApplicationStartInfo.START_REASON_LAUNCHER, + startType: Int = android.app.ApplicationStartInfo.START_TYPE_COLD, + launchMode: Int = android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD, ): android.app.ApplicationStartInfo { val startInfo = mock() val timestamps = mutableMapOf() - if (Build.VERSION.SDK_INT >= 35) { - timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FORK] = forkTime - if (bindApplicationTime > 0) { - timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION] = - bindApplicationTime - } - if (applicationOnCreateTime > 0) { - timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE] = - applicationOnCreateTime - } - if (firstFrameTime > 0) { - timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME] = firstFrameTime - } - if (fullyDrawnTime > 0) { - timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN] = fullyDrawnTime - } - - whenever(startInfo.reason).thenReturn(android.app.ApplicationStartInfo.START_REASON_LAUNCHER) + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FORK] = forkTime + if (bindApplicationTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION] = + bindApplicationTime + } + if (firstFrameTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME] = firstFrameTime + } + if (fullyDrawnTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN] = fullyDrawnTime } + whenever(startInfo.reason).thenReturn(reason) + whenever(startInfo.startType).thenReturn(startType) + whenever(startInfo.launchMode).thenReturn(launchMode) whenever(startInfo.startupTimestamps).thenReturn(timestamps) return startInfo