From 2226b2db31b77cee219bdf457cd106f0c04ab23a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 23 Jan 2026 10:15:56 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 From b5eda0b9d99e9b36e73f4ef25e0675e7616618d6 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 10 Feb 2026 09:19:07 +0100 Subject: [PATCH 5/5] Split up tracing and metrics --- .../api/sentry-android-core.api | 12 + .../core/ApplicationStartInfoIntegration.java | 369 +++++------------- .../ApplicationStartInfoMetricsProcessor.java | 37 ++ .../ApplicationStartInfoTracingProcessor.java | 301 ++++++++++++++ .../core/IApplicationStartInfoProcessor.java | 32 ++ .../android/core/SentryAndroidOptions.java | 62 +++ .../core/cache/AndroidEnvelopeCache.java | 25 ++ .../core/performance/AppStartMetrics.java | 45 +++ 8 files changed, 617 insertions(+), 266 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index feb216e40e..3a23118900 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -360,6 +360,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableApplicationStartInfo ()Z + public fun isEnableApplicationStartInfoMetrics ()Z + public fun isEnableApplicationStartInfoTracing ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z @@ -389,6 +391,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAppComponentBreadcrumbs (Z)V public fun setEnableAppLifecycleBreadcrumbs (Z)V public fun setEnableApplicationStartInfo (Z)V + public fun setEnableApplicationStartInfoMetrics (Z)V + public fun setEnableApplicationStartInfoTracing (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V @@ -544,14 +548,18 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_APP_START_MARKER_LABEL Ljava/lang/String; + public static final field LAST_APP_START_REPORT Ljava/lang/String; public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedAppStart (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public static fun storeAppStartTimestamp (Lio/sentry/SentryOptions;J)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } @@ -602,6 +610,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getApplicationStartInfo ()Landroid/app/ApplicationStartInfo; + public fun getApplicationStartInfoTags ()Ljava/util/Map; + public fun getApplicationStartInfoUnixOffsetMs ()J public fun getClassLoadedUptimeMs ()J public fun getContentProviderOnCreateTimeSpans ()Ljava/util/List; public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; @@ -624,6 +635,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setApplicationStartInfo (Landroid/app/ApplicationStartInfo;Ljava/util/Map;J)V public fun setClassLoadedUptimeMs (J)V public fun shouldSendStartMeasurements ()Z } 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 bb27fc092e..cc2bc233d9 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 @@ -10,36 +10,20 @@ import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; 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.TracesSamplingDecision; -import io.sentry.android.core.internal.util.AndroidThreadChecker; -import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; +import io.sentry.android.core.cache.AndroidEnvelopeCache; 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; import io.sentry.util.Objects; 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.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -49,6 +33,8 @@ 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 final @NotNull List processors = + new CopyOnWriteArrayList<>(); private boolean isClosed; public ApplicationStartInfoIntegration( @@ -87,6 +73,13 @@ private void register( return; } + if (options.isEnableApplicationStartInfoTracing()) { + addProcessor(new ApplicationStartInfoTracingProcessor(options)); + } + if (options.isEnableApplicationStartInfoMetrics()) { + addProcessor(new ApplicationStartInfoMetricsProcessor(options)); + } + try { options .getExecutorService() @@ -94,7 +87,18 @@ private void register( () -> { try (final ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { - registerAppStartListener(scopes, options); + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + processHistoricalAppStarts(activityManager, scopes, options); + registerAppStartListener(activityManager, scopes, options); } } }); @@ -109,15 +113,9 @@ private void register( @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; - } - + final @NotNull ActivityManager activityManager, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { try { final Executor executor = new Executor() { @@ -150,231 +148,104 @@ public void execute(Runnable command) { } @RequiresApi(api = 35) - private void onApplicationStartInfoAvailable( - final @NotNull ApplicationStartInfo startInfo, + private void processHistoricalAppStarts( + final @NotNull ActivityManager activityManager, final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { - final long currentUnixMs = System.currentTimeMillis(); - final long currentRealtimeMs = SystemClock.elapsedRealtime(); - final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; - - final Map tags = extractTags(startInfo); - final long startRealtimeMs = getStartTimestampMs(startInfo); - final long ttidRealtimeMs = getFirstFrameTimestampMs(startInfo); - final long ttfdRealtimeMs = getFullyDrawnTimestampMs(startInfo); - final long bindApplicationRealtimeMs = getBindApplicationTimestampMs(startInfo); - - final SentryDate startDate = dateFromUnixTime(unixTimeOffsetMs + startRealtimeMs); - - final long endTimestamp = ttidRealtimeMs > 0 ? ttidRealtimeMs : ttfdRealtimeMs; - final SentryDate endDate = - endTimestamp > 0 - ? dateFromUnixTime(unixTimeOffsetMs + endTimestamp) - : options.getDateProvider().now(); - - final SentryId traceId = new SentryId(); - final SpanId spanId = new SpanId(); - final SpanContext traceContext = - new SpanContext(traceId, spanId, "app.start", null, new TracesSamplingDecision(true)); - traceContext.setStatus(SpanStatus.OK); - - final double startTimestampSecs = dateToSeconds(startDate); - final double endTimestampSecs = dateToSeconds(endDate); - - final SentryTransaction transaction = - new SentryTransaction( - "app.start", - startTimestampSecs, - endTimestampSecs, - new java.util.ArrayList<>(), - new HashMap<>(), - new TransactionInfo(TransactionNameSource.COMPONENT.apiName())); - - transaction.getContexts().setTrace(traceContext); - - for (Map.Entry entry : tags.entrySet()) { - transaction.setTag(entry.getKey(), entry.getValue()); - } + try { + final List historicalStarts = + activityManager.getHistoricalProcessStartReasons(0); - if (bindApplicationRealtimeMs > 0) { - transaction - .getSpans() - .add( - createSpan( - traceId, - spanId, - "bind_application", - null, - startDate, - dateFromUnixTime(unixTimeOffsetMs + bindApplicationRealtimeMs))); - } + if (historicalStarts.isEmpty()) { + options.getLogger().log(SentryLevel.DEBUG, "No historical app starts available."); + return; + } - if (startInfo.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { - attachColdStartInstrumentations(transaction, traceId, spanId); + final Long lastReportedTimestamp = AndroidEnvelopeCache.lastReportedAppStart(options); + + for (ApplicationStartInfo startInfo : historicalStarts) { + if (lastReportedTimestamp != null) { + final Long forkTime = + startInfo.getStartupTimestamps().get(ApplicationStartInfo.START_TIMESTAMP_FORK); + if (forkTime != null && forkTime <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Skipping already reported historical app start: %d", + forkTime); + continue; + } + } + + if (!isCompleted(startInfo)) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Historical app start not completed, skipping"); + continue; + } + + processAppStartInfo(startInfo, scopes, options); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to process historical ApplicationStartInfo.", e); } + } - attachActivitySpans(transaction, traceId, spanId); - - if (ttidRealtimeMs > 0) { - transaction - .getSpans() - .add( - createSpan( - traceId, - spanId, - "ttid", - null, - startDate, - dateFromUnixTime(unixTimeOffsetMs + ttidRealtimeMs))); - } + @RequiresApi(api = 35) + private void onApplicationStartInfoAvailable( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { - if (ttfdRealtimeMs > 0) { - transaction - .getSpans() - .add( - createSpan( - traceId, - spanId, - "ttfd", - null, - startDate, - dateFromUnixTime(unixTimeOffsetMs + ttfdRealtimeMs))); + if (!isCompleted(startInfo)) { + options.getLogger().log(SentryLevel.DEBUG, "App start not completed (no TTID), skipping"); + return; } - 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); + processAppStartInfo(startInfo, scopes, options); } - private static double dateToSeconds(final @NotNull SentryDate date) { - return date.nanoTimestamp() / 1e9; + @RequiresApi(api = 35) + private boolean isCompleted(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long ttid = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return ttid != null && ttid > 0; } @RequiresApi(api = 35) - private void attachColdStartInstrumentations( - final @NotNull SentryTransaction transaction, - final @NotNull SentryId traceId, - final @NotNull SpanId parentSpanId) { - - 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 = dateFromUnixTime(cpSpan.getStartTimestampMs()); - final SentryDate cpEndDate = dateFromUnixTime(cpSpan.getProjectedStopTimestampMs()); - - transaction - .getSpans() - .add( - createSpan( - traceId, - parentSpanId, - "contentprovider.on_create", - cpSpan.getDescription(), - cpStartDate, - cpEndDate)); + private void processAppStartInfo( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + + final Map tags = extractTags(startInfo); + + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + AppStartMetrics.getInstance().setApplicationStartInfo(startInfo, tags, unixTimeOffsetMs); + + for (IApplicationStartInfoProcessor processor : processors) { + try { + processor.process(startInfo, tags, scopes); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Processor failed", e); } } - final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); - final String appOnCreateDescription = - appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; - - if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { - final SentryDate appOnCreateStart = dateFromUnixTime(appOnCreateSpan.getStartTimestampMs()); - final SentryDate appOnCreateEnd = - dateFromUnixTime(appOnCreateSpan.getProjectedStopTimestampMs()); - - transaction - .getSpans() - .add( - createSpan( - traceId, - parentSpanId, - "application.on_create", - appOnCreateDescription, - appOnCreateStart, - appOnCreateEnd)); + final Long forkTime = + startInfo.getStartupTimestamps().get(ApplicationStartInfo.START_TIMESTAMP_FORK); + if (forkTime != null) { + AndroidEnvelopeCache.storeAppStartTimestamp(options, forkTime); } } - @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)); - } - } + private void addProcessor(final @NotNull IApplicationStartInfoProcessor processor) { + processors.add(processor); } @RequiresApi(api = 35) @@ -449,44 +320,10 @@ private void attachActivitySpans( } } - @RequiresApi(api = 35) - private long getStartTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { - final Map timestamps = startInfo.getStartupTimestamps(); - final Long forkTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FORK); - return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; - } - - @RequiresApi(api = 35) - private long getBindApplicationTimestampMs( - final @NotNull android.app.ApplicationStartInfo startInfo) { - final Map timestamps = startInfo.getStartupTimestamps(); - - final Long bindTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); - return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; - } - - @RequiresApi(api = 35) - private long getFirstFrameTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { - final Map timestamps = startInfo.getStartupTimestamps(); - final Long firstFrameTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); - return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; - } - - @RequiresApi(api = 35) - private long getFullyDrawnTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { - final Map timestamps = startInfo.getStartupTimestamps(); - final Long fullyDrawnTime = timestamps.get(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; } } - - private static @NotNull SentryDate dateFromUnixTime(final long timeMillis) { - return new SentryNanotimeDate(new Date(timeMillis), 0); - } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java new file mode 100644 index 0000000000..6fe63032e1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java @@ -0,0 +1,37 @@ +package io.sentry.android.core; + +import android.app.ApplicationStartInfo; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.metrics.SentryMetricsParameters; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +/** + * Processor that emits counter metrics from ApplicationStartInfo data. + * + *

This processor emits an app.launch counter metric with attributes for start reason, start + * type, and launch mode. + * + *

Requires API level 35 (Android 15) or higher. + */ +@RequiresApi(api = 35) +final class ApplicationStartInfoMetricsProcessor implements IApplicationStartInfoProcessor { + + private final @NotNull SentryAndroidOptions options; + + ApplicationStartInfoMetricsProcessor(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public void process( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull Map tags, + final @NotNull IScopes scopes) { + + @SuppressWarnings("unchecked") + final Map attributes = (Map) (Map) tags; + scopes.metrics().count("app.launch", 1.0, null, SentryMetricsParameters.create(attributes)); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java new file mode 100644 index 0000000000..5b7dcf08f5 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java @@ -0,0 +1,301 @@ +package io.sentry.android.core; + +import android.app.ApplicationStartInfo; +import android.os.SystemClock; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.SentryDate; +import io.sentry.SentryNanotimeDate; +import io.sentry.SpanContext; +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; +import io.sentry.protocol.SentrySpan; +import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.TransactionInfo; +import io.sentry.protocol.TransactionNameSource; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Processor that generates app.start transactions from ApplicationStartInfo data. + * + *

This processor creates a transaction with spans representing the app startup timeline, + * including bind_application, content provider initialization, application onCreate, activity + * lifecycle, TTID, and TTFD. + * + *

Requires API level 35 (Android 15) or higher. + */ +@RequiresApi(api = 35) +final class ApplicationStartInfoTracingProcessor implements IApplicationStartInfoProcessor { + + private final @NotNull SentryAndroidOptions options; + + ApplicationStartInfoTracingProcessor(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public void process( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull Map tags, + final @NotNull IScopes scopes) { + + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + + final long startRealtimeMs = getStartTimestampMs(startInfo); + final long ttidRealtimeMs = getFirstFrameTimestampMs(startInfo); + final long ttfdRealtimeMs = getFullyDrawnTimestampMs(startInfo); + final long bindApplicationRealtimeMs = getBindApplicationTimestampMs(startInfo); + + final SentryDate startDate = dateFromUnixTime(unixTimeOffsetMs + startRealtimeMs); + final long endTimestamp = ttidRealtimeMs > 0 ? ttidRealtimeMs : ttfdRealtimeMs; + final SentryDate endDate = + endTimestamp > 0 + ? dateFromUnixTime(unixTimeOffsetMs + endTimestamp) + : options.getDateProvider().now(); + + final SentryId traceId = new SentryId(); + final SpanId spanId = new SpanId(); + final SpanContext traceContext = + new SpanContext(traceId, spanId, "app.start", null, new TracesSamplingDecision(true)); + traceContext.setStatus(SpanStatus.OK); + + final double startTimestampSecs = dateToSeconds(startDate); + final double endTimestampSecs = dateToSeconds(endDate); + + final SentryTransaction transaction = + new SentryTransaction( + "app.start", + startTimestampSecs, + endTimestampSecs, + new ArrayList<>(), + new HashMap<>(), + new TransactionInfo(TransactionNameSource.COMPONENT.apiName())); + + transaction.getContexts().setTrace(traceContext); + + for (Map.Entry entry : tags.entrySet()) { + transaction.setTag(entry.getKey(), entry.getValue()); + } + + if (bindApplicationRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "bind_application", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + bindApplicationRealtimeMs))); + } + + if (startInfo.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + attachColdStartInstrumentations(transaction, traceId, spanId); + } + + attachActivitySpans(transaction, traceId, spanId); + + if (ttidRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttid", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttidRealtimeMs))); + } + if (ttfdRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttfd", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttfdRealtimeMs))); + } + + scopes.captureTransaction(transaction, null, null); + } + + private @NotNull SentrySpan createSpan( + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId, + final @NotNull String operation, + final @Nullable 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); + } + + private void attachColdStartInstrumentations( + final @NotNull SentryTransaction transaction, + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId) { + + 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 = dateFromUnixTime(cpSpan.getStartTimestampMs()); + final SentryDate cpEndDate = dateFromUnixTime(cpSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "contentprovider.on_create", + cpSpan.getDescription(), + cpStartDate, + cpEndDate)); + } + } + + final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); + final String appOnCreateDescription = + appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; + + if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { + final SentryDate appOnCreateStart = dateFromUnixTime(appOnCreateSpan.getStartTimestampMs()); + final SentryDate appOnCreateEnd = + dateFromUnixTime(appOnCreateSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "application.on_create", + appOnCreateDescription, + appOnCreateStart, + appOnCreateEnd)); + } + } + + 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)); + } + } + } + + private static double dateToSeconds(final @NotNull SentryDate date) { + return date.nanoTimestamp() / 1e9; + } + + private static @NotNull SentryDate dateFromUnixTime(final long timeMillis) { + return new SentryNanotimeDate(new Date(timeMillis), 0); + } + + private long getStartTimestampMs(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long forkTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FORK); + return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; + } + + private long getBindApplicationTimestampMs(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long bindTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); + return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; + } + + private long getFirstFrameTimestampMs(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long firstFrameTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; + } + + private long getFullyDrawnTimestampMs(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long fullyDrawnTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN); + return fullyDrawnTime != null ? TimeUnit.NANOSECONDS.toMillis(fullyDrawnTime) : 0; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java new file mode 100644 index 0000000000..3eb868914e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java @@ -0,0 +1,32 @@ +package io.sentry.android.core; + +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Interface for processing ApplicationStartInfo data from Android system. + * + *

Processors are registered with {@link ApplicationStartInfoIntegration} and are called when app + * start data becomes available, either from historical data or the current app start. + * + *

Requires API level 35 (Android 15) or higher. + */ +@ApiStatus.Internal +interface IApplicationStartInfoProcessor { + + /** + * Process the ApplicationStartInfo data. + * + * @param startInfo The ApplicationStartInfo from Android system + * @param tags Extracted tags (start.reason, start.type, start.launch_mode) + * @param scopes The Sentry scopes for capturing events + */ + @RequiresApi(api = 35) + void process( + @NotNull android.app.ApplicationStartInfo startInfo, + @NotNull Map tags, + @NotNull IScopes scopes); +} 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 adad161743..85cd612d7b 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 @@ -254,6 +254,26 @@ public interface BeforeCaptureCallback { */ private boolean enableApplicationStartInfo = false; + /** + * Controls whether to generate transactions from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + *

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

Default is false (opt-in). + */ + @ApiStatus.Experimental private boolean enableApplicationStartInfoTracing = false; + + /** + * Controls whether to emit metrics from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + *

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

Default is false (opt-in). + */ + @ApiStatus.Experimental private boolean enableApplicationStartInfoMetrics = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -369,6 +389,48 @@ public boolean isEnableApplicationStartInfo() { return enableApplicationStartInfo; } + /** + * Controls whether to generate transactions from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + * @param enable true to enable transaction generation, false to disable + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfoTracing(final boolean enable) { + this.enableApplicationStartInfoTracing = enable; + } + + /** + * Checks if ApplicationStartInfo tracing is enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfoTracing() { + return enableApplicationStartInfoTracing; + } + + /** + * Controls whether to emit metrics from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + * @param enable true to enable metrics emission, false to disable + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfoMetrics(final boolean enable) { + this.enableApplicationStartInfoMetrics = enable; + } + + /** + * Checks if ApplicationStartInfo metrics are enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfoMetrics() { + return enableApplicationStartInfoMetrics; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 5aad7ef1b2..74bba7c25f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -35,6 +35,7 @@ public final class AndroidEnvelopeCache extends EnvelopeCache { public static final String LAST_ANR_REPORT = "last_anr_report"; public static final String LAST_TOMBSTONE_REPORT = "last_tombstone_report"; + public static final String LAST_APP_START_REPORT = "last_app_start_report"; private final @NotNull ICurrentDateProvider currentDateProvider; @@ -209,6 +210,29 @@ private void writeLastReportedMarker( return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); } + public static @Nullable Long lastReportedAppStart(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_APP_START_REPORT, LAST_APP_START_MARKER_LABEL); + } + + public static void storeAppStartTimestamp( + final @NotNull SentryOptions options, final long timestamp) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options + .getLogger() + .log(DEBUG, "Cache dir path is null, the App Start marker will not be written"); + return; + } + + final File marker = new File(cacheDirPath, LAST_APP_START_REPORT); + try (final OutputStream outputStream = new FileOutputStream(marker)) { + outputStream.write(String.valueOf(timestamp).getBytes(UTF_8)); + outputStream.flush(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Error writing the App Start marker to the disk", e); + } + } + private static final class TimestampMarkerHandler { interface TimestampExtractor { @NotNull @@ -254,6 +278,7 @@ void handle( public static final String LAST_TOMBSTONE_MARKER_LABEL = "Tombstone"; public static final String LAST_ANR_MARKER_LABEL = "ANR"; + public static final String LAST_APP_START_MARKER_LABEL = "App Start"; private static final List> TIMESTAMP_MARKER_HANDLERS = Arrays.asList( new TimestampMarkerHandler<>( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 1bb95b9061..a9587f57c9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -85,6 +85,11 @@ public enum AppStartType { private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + // ApplicationStartInfo data (API 35+) + private volatile @Nullable ApplicationStartInfo applicationStartInfo = null; + private volatile @Nullable Map applicationStartInfoTags = null; + private volatile long applicationStartInfoUnixOffsetMs = 0; + public static @NotNull AppStartMetrics getInstance() { if (instance == null) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { @@ -235,6 +240,43 @@ long getFirstIdle() { return firstIdle; } + /** + * Store ApplicationStartInfo data for later access. + * + * @param info The ApplicationStartInfo from Android system + * @param tags Extracted tags (start.reason, start.type, start.launch_mode) + * @param unixOffsetMs Offset to convert realtime to unix time + */ + public void setApplicationStartInfo( + final @Nullable ApplicationStartInfo info, + final @Nullable Map tags, + final long unixOffsetMs) { + this.applicationStartInfo = info; + this.applicationStartInfoTags = tags; + this.applicationStartInfoUnixOffsetMs = unixOffsetMs; + } + + /** + * @return The stored ApplicationStartInfo, or null if not available + */ + public @Nullable ApplicationStartInfo getApplicationStartInfo() { + return applicationStartInfo; + } + + /** + * @return The extracted tags from ApplicationStartInfo, or null if not available + */ + public @Nullable Map getApplicationStartInfoTags() { + return applicationStartInfoTags; + } + + /** + * @return The unix time offset for ApplicationStartInfo timestamps + */ + public long getApplicationStartInfoUnixOffsetMs() { + return applicationStartInfoUnixOffsetMs; + } + @TestOnly public void clear() { appStartType = AppStartType.UNKNOWN; @@ -258,6 +300,9 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + applicationStartInfo = null; + applicationStartInfoTags = null; + applicationStartInfoUnixOffsetMs = 0; } public @Nullable ITransactionProfiler getAppStartProfiler() {