From ddd9bed03e3b471240846fdec1988bde84d9b0f2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Apr 2026 14:43:55 +0200 Subject: [PATCH 01/10] feat: Add standalone app start transaction (happy path) Introduce experimental `enableStandaloneAppStartTracing` option that creates a separate app start transaction instead of attaching app start as a child span of the first activity transaction. This is the happy path only (foreground importance, activity launch, first frame drawn as end time). The standalone transaction shares the same trace ID as the activity transaction but is not bound to the scope. App start measurements and child spans (process init, content providers, application.onCreate) are attached to the standalone transaction instead of the activity transaction. Includes foreground importance check branching to prepare for the non-activity launch path (next PR). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/ActivityLifecycleIntegration.java | 81 +++++++++++++++---- .../android/core/ManifestMetadataReader.java | 10 +++ .../PerformanceAndroidEventProcessor.java | 8 ++ .../android/core/SentryAndroidOptions.java | 23 ++++++ .../src/main/AndroidManifest.xml | 4 + .../android/TestBroadcastReceiver.java | 26 ++++++ .../java/io/sentry/TransactionContext.java | 19 +++++ 7 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..9a34893b928 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -77,6 +77,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -254,18 +255,43 @@ private void startTracing(final @NotNull Activity activity) { // in case appStartTime isn't available, we don't create a span for it. if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (options.isEnableStandaloneAppStartTracing() && foregroundImportance) { + // Happy path: activity will launch, create standalone app start transaction + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction( + appStartSamplingDecision != null); + setSpanOrigin(appStartTransactionOptions); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + transaction.getSpanContext().getTraceId(), + getAppStartTxnName(coldStart), + TransactionNameSource.COMPONENT, + getAppStartOp(coldStart), + appStartSamplingDecision), + appStartTransactionOptions); + + // in case there's already an end time (e.g. due to deferred SDK init) + // we can finish the app start transaction + finishAppStartSpan(); + } else if (!options.isEnableStandaloneAppStartTracing()) { + // Legacy behavior: app start span as child of activity transaction + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + // in case there's already an end time (e.g. due to deferred SDK init) + // we can finish the app-start span + finishAppStartSpan(); + } + // else: flag ON but not foreground — non-activity launch path (TODO: next PR) } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -440,8 +466,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,8 +504,7 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } @@ -559,6 +583,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +602,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -779,6 +807,24 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + + private @NotNull String getAppStartTxnName(final boolean coldStart) { + if (coldStart) { + return "App Start Cold"; + } else { + return "App Start Warm"; + } + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -794,6 +840,9 @@ private void finishAppStartSpan() { .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 6d90bb5ca8e..8a252c0fb46 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -106,6 +106,9 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; @@ -493,6 +496,13 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..adf86f8e75b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -245,6 +245,14 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (APP_START_COLD.equals(txnOp) || APP_START_WARM.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() 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 054e43322a2..1f683e01799 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 @@ -240,6 +240,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -663,6 +665,27 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. When enabled, app start metrics are sent as a + * standalone transaction instead of being attached as a child span of the first activity + * transaction. + * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 548e5e8ac0d..417325694ef 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -225,6 +225,10 @@ android:name="io.sentry.performance-v2.enable" android:value="true" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..baf6727778f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,26 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing app start importance. + * + *

When this receiver triggers a cold start (process was dead), Application.onCreate() runs + * first. We can then check if importance == IMPORTANCE_FOREGROUND even though no activity will + * launch. + * + *

Test with: adb shell am force-stop io.sentry.samples.android adb shell am broadcast -a + * io.sentry.samples.android.TEST_BROADCAST -n + * io.sentry.samples.android/.TestBroadcastReceiver + */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} diff --git a/sentry/src/main/java/io/sentry/TransactionContext.java b/sentry/src/main/java/io/sentry/TransactionContext.java index 5785917add4..6fcfd33bd7d 100644 --- a/sentry/src/main/java/io/sentry/TransactionContext.java +++ b/sentry/src/main/java/io/sentry/TransactionContext.java @@ -84,6 +84,25 @@ public TransactionContext( this.baggage = TracingUtils.ensureBaggage(null, samplingDecision); } + /** + * Creates {@link TransactionContext} that shares a trace ID with another transaction. Used for + * standalone app start transactions that need to belong to the same trace as the activity + * transaction. + */ + @ApiStatus.Internal + public TransactionContext( + final @NotNull SentryId traceId, + final @NotNull String name, + final @NotNull TransactionNameSource transactionNameSource, + final @NotNull String operation, + final @Nullable TracesSamplingDecision samplingDecision) { + super(traceId, new SpanId(), operation, null, samplingDecision); + this.name = Objects.requireNonNull(name, "name is required"); + this.transactionNameSource = transactionNameSource; + this.setSamplingDecision(samplingDecision); + this.baggage = TracingUtils.ensureBaggage(null, samplingDecision); + } + @ApiStatus.Internal public TransactionContext( final @NotNull SentryId traceId, From fdd26df9b8eb8e8e728e9f4f1d9ef8c83ac121b1 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Apr 2026 15:29:34 +0200 Subject: [PATCH 02/10] feat: Add non-activity app start path with end time resolution When the app starts without launching an activity (service, broadcast receiver, content provider), create a standalone app start transaction with the end time determined by priority: 1. onApplicationPostCreate (Gradle plugin bytecode instrumentation) 2. ApplicationStartInfo timestamps (API 35+) 3. firstIdle - main thread idle handler (pre-API 35 fallback) The non-activity app start transaction stores its trace ID so that if an activity is later launched, the activity transaction reuses the same trace ID to keep both in the same trace. Adds OnNoActivityStartedListener callback from AppStartMetrics to ActivityLifecycleIntegration, triggered by checkCreateTimeOnMain() when no activity was created after Application.onCreate(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/ActivityLifecycleIntegration.java | 87 +++++++++++++++++-- .../core/performance/AppStartMetrics.java | 81 +++++++++++++++++ 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9a34893b928..eb17849c811 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -33,6 +33,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -125,6 +126,12 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance() + .setOnNoActivityStartedListener(this::onNoActivityStarted); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -136,6 +143,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setOnNoActivityStartedListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -241,14 +249,32 @@ private void startTracing(final @NotNull Activity activity) { setSpanOrigin(transactionOptions); // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + // If a non-activity app start transaction was created earlier, reuse its trace ID + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + + final ITransaction transaction; + if (storedAppStartTraceId != null) { + transaction = + scopes.startTransaction( + new TransactionContext( + storedAppStartTraceId, + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + AppStartMetrics.getInstance().setAppStartTraceId(null); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); @@ -291,7 +317,8 @@ private void startTracing(final @NotNull Activity activity) { // we can finish the app-start span finishAppStartSpan(); } - // else: flag ON but not foreground — non-activity launch path (TODO: next PR) + // else: flag ON but not foreground — non-activity launch path is handled + // via OnNoActivityStartedListener callback in checkCreateTimeOnMain() } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -845,4 +872,46 @@ private void finishAppStartSpan() { } } } + + private void onNoActivityStarted() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + final @NotNull TimeSpan appStartTimeSpan = + metrics.getAppStartTimeSpanWithFallback(options); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + final boolean coldStart = + metrics.getAppStartType() == AppStartMetrics.AppStartType.COLD; + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + setSpanOrigin(txnOptions); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + getAppStartTxnName(coldStart), + TransactionNameSource.COMPONENT, + getAppStartOp(coldStart), + null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + + // Store trace ID so future activity transactions can share it + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + + transaction.finish(SpanStatus.OK, endTime); + } } 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 1bb95b9061a..47c6d0cf2a5 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 @@ -18,6 +18,7 @@ import io.sentry.IContinuousProfiler; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; +import io.sentry.protocol.SentryId; import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface OnNoActivityStartedListener { + void onNoActivityStarted(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,9 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private @Nullable OnNoActivityStartedListener noActivityStartedListener; + private @Nullable SentryId appStartTraceId; + private @Nullable Application applicationContext; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -161,6 +169,20 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setOnNoActivityStartedListener( + final @Nullable OnNoActivityStartedListener listener) { + this.noActivityStartedListener = listener; + } + + /** Trace ID from a non-activity app start transaction, to be reused by a later activity. */ + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + /** * Provides all collected content provider onCreate time spans * @@ -258,6 +280,9 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + noActivityStartedListener = null; + appStartTraceId = null; + applicationContext = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -336,6 +361,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } isCallbackRegistered = true; appLaunchedInForeground.resetValue(); + applicationContext = application; application.registerActivityLifecycleCallbacks(instance); final @Nullable ActivityManager activityManager = @@ -400,6 +426,61 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + resolveNonActivityAppStartEndTime(); + + if (noActivityStartedListener != null) { + noActivityStartedListener.onNoActivityStarted(); + } + } + } + + /** + * Resolves the end time for a non-activity app start. Priority: 1. onApplicationPostCreate + * (Gradle plugin) 2. ApplicationStartInfo (API 35+) 3. firstIdle (main thread idle) + */ + private void resolveNonActivityAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + appStartSpan.setStoppedAt(stopUptimeMs); + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM + && applicationContext != null) { + try { + final @Nullable ActivityManager activityManager = + (ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager != null) { + final List startInfos = + activityManager.getHistoricalProcessStartReasons(1); + if (!startInfos.isEmpty()) { + final @NotNull Map timestamps = + startInfos.get(0).getStartupTimestamps(); + // ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE = 6 + // Timestamps are in nanoseconds (monotonic clock) + final @Nullable Long onCreateNanos = timestamps.get(6); + if (onCreateNanos != null && onCreateNanos > 0) { + final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); + appStartSpan.setStoppedAt(onCreateUptimeMs); + sdkInitTimeSpan.setStoppedAt(onCreateUptimeMs); + return; + } + } + } + } catch (Throwable ignored) { + // best effort + } + } + + // Priority 3: firstIdle + if (firstIdle != -1) { + appStartSpan.setStoppedAt(firstIdle); + sdkInitTimeSpan.setStoppedAt(firstIdle); } } From e5b7a1844a72276ba4fb6431fa7de198c4424de3 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Apr 2026 16:51:38 +0200 Subject: [PATCH 03/10] feat: Support non-activity app start tracing without bytecode instrumentation When an app is launched via broadcast receiver, service, or content provider (no activity), detect this via Handler.post() and create a standalone app start transaction. Resolves app start end time with priority: Gradle plugin > ApplicationStartInfo (API 35+) > process init time. Also attaches child spans (process init, content providers, Application.onCreate) to standalone transactions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/ActivityLifecycleIntegration.java | 8 +++-- .../PerformanceAndroidEventProcessor.java | 28 ++++++++++++++++-- .../core/performance/AppStartMetrics.java | 29 ++++++++++--------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index eb17849c811..9e914e256aa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -879,8 +879,12 @@ private void onNoActivityStarted() { } final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); - final @NotNull TimeSpan appStartTimeSpan = - metrics.getAppStartTimeSpanWithFallback(options); + // For non-activity starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). Use the spans directly. + @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpan(); + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + appStartTimeSpan = metrics.getSdkInitTimeSpan(); + } if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { return; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index adf86f8e75b..338aa813844 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -84,9 +84,20 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // Check if this is a standalone app start transaction (op is app.start.cold/warm). + // For non-activity starts, appLaunchedInForeground is false, so + // shouldSendStartMeasurements() would return false. We still want to attach child spans. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null + && (APP_START_COLD.equals(traceContext.getOperation()) + || APP_START_WARM.equals(traceContext.getOperation())); + + if (appStartMetrics.shouldSendStartMeasurements() || isStandaloneAppStartTxn) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isStandaloneAppStartTxn + ? getAppStartTimeSpanForStandalone(appStartMetrics) + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -221,6 +232,19 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { || context.getOperation().equals(APP_START_WARM)); } + /** + * For standalone app start transactions (non-activity starts), appLaunchedInForeground is false, + * so getAppStartTimeSpanWithFallback won't return a valid span. We get the spans directly. + */ + private static @NotNull TimeSpan getAppStartTimeSpanForStandalone( + final @NotNull AppStartMetrics metrics) { + final @NotNull TimeSpan appStartSpan = metrics.getAppStartTimeSpan(); + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return metrics.getSdkInitTimeSpan(); + } + private void attachAppStartSpans( final @NotNull AppStartMetrics appStartMetrics, final @NotNull SentryTransaction txn) { 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 47c6d0cf2a5..327294b3281 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 @@ -382,7 +382,11 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType != AppStartType.UNKNOWN) { + // App start type is already known (e.g. from ApplicationStartInfo on API 35+). + // We still need to detect non-activity starts, so post a check on the main thread. + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( @@ -394,7 +398,7 @@ public boolean queueIdle() { return false; } }); - } else if (appStartType == AppStartType.UNKNOWN) { + } else { // We post on the main thread a task to post a check on the main thread. On Pixel devices // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate @@ -436,8 +440,7 @@ private void checkCreateTimeOnMain() { } /** - * Resolves the end time for a non-activity app start. Priority: 1. onApplicationPostCreate - * (Gradle plugin) 2. ApplicationStartInfo (API 35+) 3. firstIdle (main thread idle) + * Resolves the end time for a non-activity app start. */ private void resolveNonActivityAppStartEndTime() { // Priority 1: Gradle plugin instrumented onApplicationPostCreate @@ -445,7 +448,6 @@ private void resolveNonActivityAppStartEndTime() { final long stopUptimeMs = applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); appStartSpan.setStoppedAt(stopUptimeMs); - sdkInitTimeSpan.setStoppedAt(stopUptimeMs); return; } @@ -461,13 +463,17 @@ private void resolveNonActivityAppStartEndTime() { if (!startInfos.isEmpty()) { final @NotNull Map timestamps = startInfos.get(0).getStartupTimestamps(); - // ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE = 6 // Timestamps are in nanoseconds (monotonic clock) - final @Nullable Long onCreateNanos = timestamps.get(6); + final @Nullable Long onCreateNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); if (onCreateNanos != null && onCreateNanos > 0) { final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); appStartSpan.setStoppedAt(onCreateUptimeMs); - sdkInitTimeSpan.setStoppedAt(onCreateUptimeMs); + + // Also fill applicationOnCreate stop time if not already set by Gradle plugin + if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { + applicationOnCreate.setStoppedAt(onCreateUptimeMs); + } return; } } @@ -477,11 +483,8 @@ private void resolveNonActivityAppStartEndTime() { } } - // Priority 3: firstIdle - if (firstIdle != -1) { - appStartSpan.setStoppedAt(firstIdle); - sdkInitTimeSpan.setStoppedAt(firstIdle); - } + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) — always available + appStartSpan.setStoppedAt(CLASS_LOADED_UPTIME_MS); } @Override From 11898dc6379520f160df537665e546e6f80ca7a8 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 23 Apr 2026 15:12:42 +0200 Subject: [PATCH 04/10] refactor: Consolidate non-activity app start time-span resolution Extract the "try appStartSpan, fall back to sdkInitTimeSpan" logic used for standalone (non-activity) app start transactions into a new AppStartMetrics.getAppStartTimeSpanDirect() helper, removing the duplicated inline fallback in ActivityLifecycleIntegration and the private helper in PerformanceAndroidEventProcessor. Also cache the API 35+ ApplicationStartInfo on registerLifecycleCallbacks so onAppStartSpansSent no longer re-queries ActivityManager, and simplify the non-activity detection path to always use the main-thread IdleHandler. Regenerates the sentry-android-core API to include method additions missed in prior commits on this branch (standalone-app-start options, trace id accessors, OnNoActivityStartedListener). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/sentry-android-core.api | 10 +++ .../core/ActivityLifecycleIntegration.java | 16 ++--- .../PerformanceAndroidEventProcessor.java | 15 +--- .../core/performance/AppStartMetrics.java | 68 +++++++++---------- 4 files changed, 49 insertions(+), 60 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..182db58b23b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -391,6 +391,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z @@ -421,6 +422,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V @@ -715,7 +717,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanDirect ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -739,8 +743,10 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setOnNoActivityStartedListener (Lio/sentry/android/core/performance/AppStartMetrics$OnNoActivityStartedListener;)V public fun shouldSendStartMeasurements ()Z } @@ -752,6 +758,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$OnNoActivityStartedListener { + public abstract fun onNoActivityStarted ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9e914e256aa..4780abed6a6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -128,8 +128,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions application.registerActivityLifecycleCallbacks(this); if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { - AppStartMetrics.getInstance() - .setOnNoActivityStartedListener(this::onNoActivityStarted); + AppStartMetrics.getInstance().setOnNoActivityStartedListener(this::onNoActivityStarted); } this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); @@ -286,8 +285,7 @@ private void startTracing(final @NotNull Activity activity) { final TransactionOptions appStartTransactionOptions = new TransactionOptions(); appStartTransactionOptions.setBindToScope(false); appStartTransactionOptions.setStartTimestamp(appStartTime); - appStartTransactionOptions.setAppStartTransaction( - appStartSamplingDecision != null); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(appStartTransactionOptions); appStartTransaction = @@ -880,11 +878,8 @@ private void onNoActivityStarted() { final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); // For non-activity starts, appLaunchedInForeground is false, so we can't use - // getAppStartTimeSpanWithFallback (which gates on foreground). Use the spans directly. - @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpan(); - if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { - appStartTimeSpan = metrics.getSdkInitTimeSpan(); - } + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanDirect(); if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { return; @@ -896,8 +891,7 @@ private void onNoActivityStarted() { return; } - final boolean coldStart = - metrics.getAppStartType() == AppStartMetrics.AppStartType.COLD; + final boolean coldStart = metrics.getAppStartType() == AppStartMetrics.AppStartType.COLD; final TransactionOptions txnOptions = new TransactionOptions(); txnOptions.setBindToScope(false); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 338aa813844..bda9c8a3e0c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -96,7 +96,7 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (appStartMetrics.shouldSendStartMeasurements() || isStandaloneAppStartTxn) { final @NotNull TimeSpan appStartTimeSpan = isStandaloneAppStartTxn - ? getAppStartTimeSpanForStandalone(appStartMetrics) + ? appStartMetrics.getAppStartTimeSpanDirect() : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); @@ -232,19 +232,6 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { || context.getOperation().equals(APP_START_WARM)); } - /** - * For standalone app start transactions (non-activity starts), appLaunchedInForeground is false, - * so getAppStartTimeSpanWithFallback won't return a valid span. We get the spans directly. - */ - private static @NotNull TimeSpan getAppStartTimeSpanForStandalone( - final @NotNull AppStartMetrics metrics) { - final @NotNull TimeSpan appStartSpan = metrics.getAppStartTimeSpan(); - if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { - return appStartSpan; - } - return metrics.getSdkInitTimeSpan(); - } - private void attachAppStartSpans( final @NotNull AppStartMetrics appStartMetrics, final @NotNull SentryTransaction txn) { 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 327294b3281..ddea1fe6c3c 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 @@ -18,7 +18,6 @@ import io.sentry.IContinuousProfiler; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; -import io.sentry.protocol.SentryId; import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; @@ -26,6 +25,7 @@ import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -92,6 +92,7 @@ public enum AppStartType { private @Nullable OnNoActivityStartedListener noActivityStartedListener; private @Nullable SentryId appStartTraceId; private @Nullable Application applicationContext; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -169,8 +170,7 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } - public void setOnNoActivityStartedListener( - final @Nullable OnNoActivityStartedListener listener) { + public void setOnNoActivityStartedListener(final @Nullable OnNoActivityStartedListener listener) { this.noActivityStartedListener = listener; } @@ -183,6 +183,18 @@ public void setAppStartTraceId(final @Nullable SentryId traceId) { this.appStartTraceId = traceId; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for non-activity starts where appLaunchedInForeground is + * false. + */ + public @NotNull TimeSpan getAppStartTimeSpanDirect() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * Provides all collected content provider onCreate time spans * @@ -283,6 +295,7 @@ public void clear() { noActivityStartedListener = null; appStartTraceId = null; applicationContext = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -372,6 +385,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { activityManager.getHistoricalProcessStartReasons(1); if (!historicalProcessStartReasons.isEmpty()) { final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { appStartType = AppStartType.COLD; @@ -382,11 +396,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType != AppStartType.UNKNOWN) { - // App start type is already known (e.g. from ApplicationStartInfo on API 35+). - // We still need to detect non-activity starts, so post a check on the main thread. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( @@ -439,9 +449,7 @@ private void checkCreateTimeOnMain() { } } - /** - * Resolves the end time for a non-activity app start. - */ + /** Resolves the end time for a non-activity app start. */ private void resolveNonActivityAppStartEndTime() { // Priority 1: Gradle plugin instrumented onApplicationPostCreate if (applicationOnCreate.hasStopped()) { @@ -451,32 +459,22 @@ private void resolveNonActivityAppStartEndTime() { return; } - // Priority 2: API 35+ ApplicationStartInfo - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM - && applicationContext != null) { + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { try { - final @Nullable ActivityManager activityManager = - (ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE); - if (activityManager != null) { - final List startInfos = - activityManager.getHistoricalProcessStartReasons(1); - if (!startInfos.isEmpty()) { - final @NotNull Map timestamps = - startInfos.get(0).getStartupTimestamps(); - // Timestamps are in nanoseconds (monotonic clock) - final @Nullable Long onCreateNanos = - timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); - if (onCreateNanos != null && onCreateNanos > 0) { - final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); - appStartSpan.setStoppedAt(onCreateUptimeMs); - - // Also fill applicationOnCreate stop time if not already set by Gradle plugin - if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { - applicationOnCreate.setStoppedAt(onCreateUptimeMs); - } - return; - } + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + // Timestamps are in nanoseconds (monotonic clock) + final @Nullable Long onCreateNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateNanos != null && onCreateNanos > 0) { + final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); + appStartSpan.setStoppedAt(onCreateUptimeMs); + + // Also fill applicationOnCreate stop time if not already set by Gradle plugin + if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { + applicationOnCreate.setStoppedAt(onCreateUptimeMs); } + return; } } catch (Throwable ignored) { // best effort From 5efab1d9899f7ff632b6073947089a2539cac542 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 23 Apr 2026 15:12:46 +0200 Subject: [PATCH 05/10] chore(samples): Register TestBroadcastReceiver in manifest Wires up the TestBroadcastReceiver added earlier so the sample app can trigger a non-activity cold start via `adb shell am broadcast`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sentry-samples-android/src/main/AndroidManifest.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 417325694ef..32bea127d97 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ android:exported="true" android:foregroundServiceType="remoteMessaging" /> + + + + + + Date: Fri, 24 Apr 2026 16:38:44 +0200 Subject: [PATCH 06/10] fix(app-start): resolve standalone tracing misclassification and duplicate emission Two pre-merge fixes for the standalone app-start tracing path introduced on this branch (issue #5046): - AppStartMetrics.checkCreateTimeOnMain() now defaults appStartType to COLD when UNKNOWN with no active activities. On API < 35 (where ApplicationStartInfo is unavailable) non-activity cold starts were stuck at UNKNOWN, which both misclassified the standalone transaction as App Start Warm and caused PerformanceAndroidEventProcessor.attachAppStartSpans to early-return (dropping process.load / application.load / contentprovider.load phase spans). - ActivityLifecycleIntegration.onActivityPreCreated() now skips emitting a second standalone App Start transaction when the non-activity path has already reported the process's app start (detected via the stashed appStartTraceId). Previously a broadcast followed by an activity launch produced two standalone transactions (a spurious App Start Warm in addition to the broadcast's App Start Cold), violating one-per-process semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/core/ActivityLifecycleIntegration.java | 8 +++++++- .../sentry/android/core/performance/AppStartMetrics.java | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 4780abed6a6..d4a571cde47 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -251,6 +251,10 @@ private void startTracing(final @NotNull Activity activity) { // If a non-activity app start transaction was created earlier, reuse its trace ID final @Nullable SentryId storedAppStartTraceId = AppStartMetrics.getInstance().getAppStartTraceId(); + // When we reuse a stashed traceId, it means the process's app start has already been + // accounted for by a standalone transaction from the non-activity path — don't emit + // a second standalone here just because an activity subsequently showed up. + final boolean isFollowingNonActivityStart = (storedAppStartTraceId != null); final ITransaction transaction; if (storedAppStartTraceId != null) { @@ -280,7 +284,9 @@ private void startTracing(final @NotNull Activity activity) { // in case appStartTime isn't available, we don't create a span for it. if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - if (options.isEnableStandaloneAppStartTracing() && foregroundImportance) { + if (options.isEnableStandaloneAppStartTracing() + && foregroundImportance + && !isFollowingNonActivityStart) { // Happy path: activity will launch, create standalone app start transaction final TransactionOptions appStartTransactionOptions = new TransactionOptions(); appStartTransactionOptions.setBindToScope(false); 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 74997ffd07a..35e449c3519 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 @@ -432,6 +432,15 @@ private void checkCreateTimeOnMain() { if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground.setValue(false); + // Reaching this callback means Application.onCreate() finished with no Activity created, + // which is definitionally a cold start for this process. On API < 35 we can't resolve the + // start type via ApplicationStartInfo, so appStartType is still UNKNOWN at this point — + // default it to COLD so the standalone transaction (and PerformanceAndroidEventProcessor) + // classify it correctly. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); From 50a2f417ef667f3a6e062597cba5a7d1a9d093d2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 28 Apr 2026 16:23:36 +0200 Subject: [PATCH 07/10] fix(android): refine standalone app start tracing --- CHANGELOG.md | 11 + .../core/ActivityLifecycleIntegration.java | 9 +- .../PerformanceAndroidEventProcessor.java | 12 +- .../android/core/SentryAndroidOptions.java | 31 +- .../core/performance/AppStartMetrics.java | 1 - .../core/ActivityLifecycleIntegrationTest.kt | 301 +++++++++++++++++- .../core/ManifestMetadataReaderTest.kt | 30 ++ .../PerformanceAndroidEventProcessorTest.kt | 83 ++++- .../android/core/SentryAndroidOptionsTest.kt | 6 + .../core/performance/AppStartMetricsTest.kt | 93 +++++- .../performance/AppStartMetricsTestApi35.kt | 58 ++++ .../java/io/sentry/TransactionContextTest.kt | 44 +++ 12 files changed, 656 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dabfab7294..bc365e163f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Features + +- Add experimental `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) + - Disabled by default; opt in via `SentryAndroidOptions.setEnableStandaloneAppStartTracing(true)` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start Cold` / `App Start Warm` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity cold starts (broadcast receivers, services, content providers) — the transaction is created when `Application.onCreate()` completes without an activity being launched + - App start data is only sent once: when enabled, it is not also attached to the first activity `ui.load` transaction + ## 8.40.0 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index d4a571cde47..3f2b90bf493 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -56,6 +56,7 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String APP_START_OP = "app.start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; @@ -287,7 +288,6 @@ private void startTracing(final @NotNull Activity activity) { if (options.isEnableStandaloneAppStartTracing() && foregroundImportance && !isFollowingNonActivityStart) { - // Happy path: activity will launch, create standalone app start transaction final TransactionOptions appStartTransactionOptions = new TransactionOptions(); appStartTransactionOptions.setBindToScope(false); appStartTransactionOptions.setStartTimestamp(appStartTime); @@ -300,7 +300,7 @@ private void startTracing(final @NotNull Activity activity) { transaction.getSpanContext().getTraceId(), getAppStartTxnName(coldStart), TransactionNameSource.COMPONENT, - getAppStartOp(coldStart), + APP_START_OP, appStartSamplingDecision), appStartTransactionOptions); @@ -906,10 +906,7 @@ private void onNoActivityStarted() { final @NotNull TransactionContext txnContext = new TransactionContext( - getAppStartTxnName(coldStart), - TransactionNameSource.COMPONENT, - getAppStartOp(coldStart), - null); + getAppStartTxnName(coldStart), TransactionNameSource.COMPONENT, APP_START_OP, null); final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index bda9c8a3e0c..f3f6b29fe64 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,6 +1,7 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; @@ -84,14 +85,11 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - // Check if this is a standalone app start transaction (op is app.start.cold/warm). // For non-activity starts, appLaunchedInForeground is false, so // shouldSendStartMeasurements() would return false. We still want to attach child spans. final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); final boolean isStandaloneAppStartTxn = - traceContext != null - && (APP_START_COLD.equals(traceContext.getOperation()) - || APP_START_WARM.equals(traceContext.getOperation())); + traceContext != null && APP_START_OP.equals(traceContext.getOperation()); if (appStartMetrics.shouldSendStartMeasurements() || isStandaloneAppStartTxn) { final @NotNull TimeSpan appStartTimeSpan = @@ -227,9 +225,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(APP_START_OP); } private void attachAppStartSpans( @@ -259,7 +255,7 @@ private void attachAppStartSpans( // For standalone app start transactions, the transaction root IS the app start span if (parentSpanId == null) { final @NotNull String txnOp = traceContext.getOperation(); - if (APP_START_COLD.equals(txnOp) || APP_START_WARM.equals(txnOp)) { + if (APP_START_OP.equals(txnOp)) { parentSpanId = traceContext.getSpanId(); } } 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 1f683e01799..2b0a6ef37c6 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 @@ -675,9 +675,34 @@ public boolean isEnableStandaloneAppStartTracing() { } /** - * Enables or disables standalone app start tracing. When enabled, app start metrics are sent as a - * standalone transaction instead of being attached as a child span of the first activity - * transaction. + * Enables or disables standalone app start tracing. + * + *

When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

The SDK reports app start through these paths: + * + *

    + *
  • With an Activity: the SDK sends an {@code App Start Cold/Warm} transaction with operation + * {@code app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Without an Activity: for launches started by something like a broadcast receiver, + * service, or content provider, the SDK sends only the standalone app-start transaction. + *
      + *
    • On Android API 35 and newer, the SDK can use {@code ApplicationStartInfo} to + * classify cold versus warm starts and find the {@code Application.onCreate} end + * time. + *
    • Before Android API 35, no-Activity launches are treated as cold once {@code + * Application.onCreate} finishes without an Activity. The end time falls back to the + * best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a no-Activity start, its {@code ui.load} transaction reuses + * the app-start trace ID. + *
* * @param enableStandaloneAppStartTracing true if enabled or false otherwise */ 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 35e449c3519..48314891fb1 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 @@ -459,7 +459,6 @@ private void checkCreateTimeOnMain() { } } - /** Resolves the end time for a non-activity app start. */ private void resolveNonActivityAppStartEndTime() { // Priority 1: Gradle plugin instrumented onApplicationPostCreate if (applicationOnCreate.hasStopped()) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 9e94d7b9905..9212b3963ad 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -7,6 +7,7 @@ import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -33,6 +34,7 @@ import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty @@ -83,6 +85,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +107,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +235,158 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.APP_START_OP } + assertEquals("App Start Cold", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `OnNoActivityStartedListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.UNKNOWN) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start Cold", fixture.capturedContexts.single().name) + } + + @Test + fun `OnNoActivityStartedListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `OnNoActivityStartedListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears OnNoActivityStartedListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onNoActivityStarted creates standalone App Start Cold transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.COLD) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.APP_START_OP, context.operation) + assertEquals("App Start Cold", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onNoActivityStarted creates standalone App Start Warm transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.WARM) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.APP_START_OP, context.operation) + assertEquals("App Start Warm", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onNoActivityStarted does nothing when appStartTimeSpan is incomplete`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +690,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1066,86 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start Cold sharing trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = transactionIndexForOperation(ActivityLifecycleIntegration.APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + } + + @Test + fun `activity following a non-activity start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2001,41 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveNoActivityStarted() { + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareNonActivityAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 81b73d5dea7..dab600aad6c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1442,6 +1442,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..8ad07a200a6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -13,6 +13,7 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan @@ -95,6 +96,63 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) @@ -867,12 +925,31 @@ class PerformanceAndroidEventProcessorTest { } } + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } + } + } + } + private fun getTransaction(type: AppStartType): SentryTransaction { val op = when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + AppStartType.COLD -> APP_START_OP + AppStartType.WARM -> APP_START_OP + AppStartType.UNKNOWN -> UI_LOAD_OP } val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..0eec9502702 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..f1f8be8730c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -16,8 +16,10 @@ import io.sentry.SentryNanotimeDate import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -44,6 +46,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +68,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +82,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -208,6 +213,93 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `checkCreateTimeOnMain defaults appStartType to COLD when UNKNOWN and no activity started`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `checkCreateTimeOnMain does not overwrite appStartType when already set`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `checkCreateTimeOnMain fires onNoActivityStartedListener when no activity started`() { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setOnNoActivityStartedListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `checkCreateTimeOnMain does not fire onNoActivityStartedListener when an activity has started`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setOnNoActivityStartedListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveNonActivityAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveNonActivityAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `getAppStartTimeSpanDirect falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanDirect()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -585,7 +677,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..12a9316eec1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -3,16 +3,22 @@ package io.sentry.android.core.performance import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -26,6 +32,7 @@ class AppStartMetricsTestApi35 { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -81,4 +88,55 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `checkCreateTimeOnMain keeps appStartType COLD when ApplicationStartInfo reports cold start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setOnNoActivityStartedListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `resolveNonActivityAppStartEndTime uses ApplicationStartInfo application onCreate timestamp`() { + val onCreateUptimeMs = 250L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(150, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry/src/test/java/io/sentry/TransactionContextTest.kt b/sentry/src/test/java/io/sentry/TransactionContextTest.kt index 55603853a66..51dedf77496 100644 --- a/sentry/src/test/java/io/sentry/TransactionContextTest.kt +++ b/sentry/src/test/java/io/sentry/TransactionContextTest.kt @@ -5,6 +5,7 @@ import io.sentry.protocol.TransactionNameSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -136,4 +137,47 @@ class TransactionContextTest { assertNotNull(context.baggage) assertEquals(0.2, context.baggage?.sampleRand!!, 0.0001) } + + @Test + fun `traceId constructor reuses provided trace id and operation`() { + val traceId = SentryId() + val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2, true, 0.3) + + val context = + TransactionContext( + traceId, + "name", + TransactionNameSource.COMPONENT, + "op", + samplingDecision, + ) + + assertEquals(traceId, context.traceId) + assertEquals("name", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("op", context.operation) + assertTrue(context.sampled!!) + assertTrue(context.profileSampled!!) + } + + @Test + fun `traceId constructor creates a fresh span id and baggage`() { + val traceId = SentryId() + val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2) + + val context = + TransactionContext( + traceId, + "name", + TransactionNameSource.COMPONENT, + "op", + samplingDecision, + ) + + assertNotNull(context.spanId) + assertNotEquals(SpanId.EMPTY_ID, context.spanId) + assertNull(context.parentSpanId) + assertNotNull(context.baggage) + assertEquals(0.2, context.baggage?.sampleRand!!, 0.0001) + } } From 26a83cc4f85122f5ed6cd72e7ce061d4cb4c6bcb Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 28 Apr 2026 16:42:48 +0200 Subject: [PATCH 08/10] chore: Update generated files --- .../java/io/sentry/samples/android/TestBroadcastReceiver.java | 3 +-- sentry/api/sentry.api | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java index baf6727778f..424494455cc 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -13,8 +13,7 @@ * launch. * *

Test with: adb shell am force-stop io.sentry.samples.android adb shell am broadcast -a - * io.sentry.samples.android.TEST_BROADCAST -n - * io.sentry.samples.android/.TestBroadcastReceiver + * io.sentry.samples.android.TEST_BROADCAST -n io.sentry.samples.android/.TestBroadcastReceiver */ public class TestBroadcastReceiver extends BroadcastReceiver { private static final String TAG = "SentryAppStart"; diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b9cbb2ae1b2..98f46414705 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4539,6 +4539,7 @@ public final class io/sentry/TracesSamplingDecision { public final class io/sentry/TransactionContext : io/sentry/SpanContext { public static final field DEFAULT_TRANSACTION_NAME Ljava/lang/String; public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/TracesSamplingDecision;Lio/sentry/Baggage;)V + public fun (Lio/sentry/protocol/SentryId;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;Ljava/lang/String;)V From a60d9668b1e0a381a96bfb6892158d7a01f18d99 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 28 Apr 2026 16:49:45 +0200 Subject: [PATCH 09/10] style(core): Apply spotless formatting --- .../java/io/sentry/TransactionContextTest.kt | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/sentry/src/test/java/io/sentry/TransactionContextTest.kt b/sentry/src/test/java/io/sentry/TransactionContextTest.kt index 51dedf77496..dc301f627e0 100644 --- a/sentry/src/test/java/io/sentry/TransactionContextTest.kt +++ b/sentry/src/test/java/io/sentry/TransactionContextTest.kt @@ -144,13 +144,7 @@ class TransactionContextTest { val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2, true, 0.3) val context = - TransactionContext( - traceId, - "name", - TransactionNameSource.COMPONENT, - "op", - samplingDecision, - ) + TransactionContext(traceId, "name", TransactionNameSource.COMPONENT, "op", samplingDecision) assertEquals(traceId, context.traceId) assertEquals("name", context.name) @@ -166,13 +160,7 @@ class TransactionContextTest { val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2) val context = - TransactionContext( - traceId, - "name", - TransactionNameSource.COMPONENT, - "op", - samplingDecision, - ) + TransactionContext(traceId, "name", TransactionNameSource.COMPONENT, "op", samplingDecision) assertNotNull(context.spanId) assertNotEquals(SpanId.EMPTY_ID, context.spanId) From 09bac522678525cbcad9ee052ba4829f23a068db Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 28 Apr 2026 16:52:08 +0200 Subject: [PATCH 10/10] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc365e163f4..905f42c57d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add experimental `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) +- Add experimental `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) - Disabled by default; opt in via `SentryAndroidOptions.setEnableStandaloneAppStartTracing(true)` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` - Emits a transaction named `App Start Cold` / `App Start Warm` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view