Skip to content

Commit 0c118e9

Browse files
buenaflorclaude
andauthored
feat(android): Report app start reason as app.vitals.start.reason on standalone app start transaction (#5552)
* feat(android): Report app start reason on standalone app start transaction Read ApplicationStartInfo.getReason() (API 35+) and attach it as app.start.reason trace data on the standalone app.start transaction in both the foreground and headless paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(android): Register StandaloneAppStart SDK integration marker Advertise that standalone app start tracing is active by adding a StandaloneAppStart marker to the SDK metadata integrations when the feature is enabled. Internal SDK metadata only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changelog): Note app.start.reason is searchable in Trace Explorer Address review feedback to mention that customers can search and group by the app.vitals.start.reason attribute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05aa61d commit 0c118e9

6 files changed

Lines changed: 171 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- Emits a transaction named `App Start` 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
2828
- The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view
2929
- Also covers non-activity starts (broadcast receivers, services, content providers)
30+
- On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552))
3031

3132
### Improvements
3233

‎sentry-android-core/api/sentry-android-core.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
746746
public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler;
747747
public fun getAppStartEndTime ()Lio/sentry/SentryDate;
748748
public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler;
749+
public fun getAppStartReason ()Ljava/lang/String;
749750
public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision;
750751
public fun getAppStartSentryTraceHeader ()Ljava/lang/String;
751752
public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
@@ -780,6 +781,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
780781
public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V
781782
public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V
782783
public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V
784+
public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V
783785
public fun setClassLoadedUptimeMs (J)V
784786
public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V
785787
public fun shouldSendStartMeasurements ()Z

‎sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public final class ActivityLifecycleIntegration
7171
static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1);
7272
private static final String TRACE_ORIGIN = "auto.ui.activity";
7373
static final String APP_START_SCREEN_DATA = "app.vitals.start.screen";
74+
static final String APP_START_REASON_DATA = "app.vitals.start.reason";
7475
static final String APP_START_TRACE_ORIGIN = "auto.app.start";
7576

7677
private final @NotNull Application application;
@@ -139,6 +140,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
139140

140141
if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) {
141142
AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart);
143+
addIntegrationToSdkVersion("StandaloneAppStart");
142144
}
143145

144146
this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed.");
@@ -285,6 +287,10 @@ private void startTracing(final @NotNull Activity activity) {
285287
appStartSamplingDecision),
286288
appStartTransactionOptions);
287289
appStartTransaction.setData(APP_START_SCREEN_DATA, activityName);
290+
final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason();
291+
if (appStartReason != null) {
292+
appStartTransaction.setData(APP_START_REASON_DATA, appStartReason);
293+
}
288294
}
289295

290296
// Continue either the foreground app.start above or an earlier headless app.start.
@@ -1001,6 +1007,10 @@ private void onHeadlessAppStart() {
10011007
null);
10021008

10031009
final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions);
1010+
final @Nullable String appStartReason = metrics.getAppStartReason();
1011+
if (appStartReason != null) {
1012+
transaction.setData(APP_START_REASON_DATA, appStartReason);
1013+
}
10041014
metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId());
10051015
// Persist trace headers so a later ui.load can share traceId and sampleRand.
10061016
metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue());

‎sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,45 @@ public void setAppStartType(final @NotNull AppStartType appStartType) {
166166
return appStartType;
167167
}
168168

169+
/**
170+
* The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}.
171+
* Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null}
172+
* otherwise or for an unmapped reason.
173+
*/
174+
public @Nullable String getAppStartReason() {
175+
if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
176+
return null;
177+
}
178+
switch (cachedStartInfo.getReason()) {
179+
case ApplicationStartInfo.START_REASON_ALARM:
180+
return "alarm";
181+
case ApplicationStartInfo.START_REASON_BACKUP:
182+
return "backup";
183+
case ApplicationStartInfo.START_REASON_BOOT_COMPLETE:
184+
return "boot_complete";
185+
case ApplicationStartInfo.START_REASON_BROADCAST:
186+
return "broadcast";
187+
case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER:
188+
return "content_provider";
189+
case ApplicationStartInfo.START_REASON_JOB:
190+
return "job";
191+
case ApplicationStartInfo.START_REASON_LAUNCHER:
192+
return "launcher";
193+
case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS:
194+
return "launcher_recents";
195+
case ApplicationStartInfo.START_REASON_PUSH:
196+
return "push";
197+
case ApplicationStartInfo.START_REASON_SERVICE:
198+
return "service";
199+
case ApplicationStartInfo.START_REASON_START_ACTIVITY:
200+
return "start_activity";
201+
case ApplicationStartInfo.START_REASON_OTHER:
202+
return "other";
203+
default:
204+
return null;
205+
}
206+
}
207+
169208
public boolean isAppLaunchedInForeground() {
170209
return appLaunchedInForeground.getValue();
171210
}
@@ -372,6 +411,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) {
372411
CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs;
373412
}
374413

414+
@TestOnly
415+
@ApiStatus.Internal
416+
public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) {
417+
this.cachedStartInfo = cachedStartInfo;
418+
}
419+
375420
/**
376421
* Called by instrumentation
377422
*

‎sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Activity
44
import android.app.ActivityManager
55
import android.app.ActivityManager.RunningAppProcessInfo
66
import android.app.Application
7+
import android.app.ApplicationStartInfo
78
import android.content.Context
89
import android.os.Build
910
import android.os.Bundle
@@ -274,6 +275,76 @@ class ActivityLifecycleIntegrationTest {
274275
)
275276
}
276277

278+
@Test
279+
@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
280+
fun `Standalone app start transaction carries app start reason when available`() {
281+
val sut =
282+
fixture.getSut {
283+
it.tracesSampleRate = 1.0
284+
it.isEnableStandaloneAppStartTracing = true
285+
}
286+
sut.register(fixture.scopes, fixture.options)
287+
288+
setAppStartTime()
289+
val startInfo =
290+
mock<ApplicationStartInfo>().apply {
291+
whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER)
292+
}
293+
AppStartMetrics.getInstance().setCachedStartInfo(startInfo)
294+
295+
val activity = mock<Activity>()
296+
sut.onActivityCreated(activity, fixture.bundle)
297+
298+
val appStartTransaction =
299+
fixture.createdTransactions.single {
300+
it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP
301+
}
302+
assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason"))
303+
}
304+
305+
@Test
306+
fun `Standalone app start transaction has no app start reason when unavailable`() {
307+
val sut =
308+
fixture.getSut {
309+
it.tracesSampleRate = 1.0
310+
it.isEnableStandaloneAppStartTracing = true
311+
}
312+
sut.register(fixture.scopes, fixture.options)
313+
314+
setAppStartTime()
315+
316+
val activity = mock<Activity>()
317+
sut.onActivityCreated(activity, fixture.bundle)
318+
319+
val appStartTransaction =
320+
fixture.createdTransactions.single {
321+
it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP
322+
}
323+
assertNull(appStartTransaction.getData("app.vitals.start.reason"))
324+
}
325+
326+
@Test
327+
@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
328+
fun `Headless standalone app start transaction carries app start reason when available`() {
329+
val sut =
330+
fixture.getSut {
331+
it.tracesSampleRate = 1.0
332+
it.isEnableStandaloneAppStartTracing = true
333+
}
334+
sut.register(fixture.scopes, fixture.options)
335+
prepareHeadlessAppStart(appStartType = AppStartType.COLD)
336+
val startInfo =
337+
mock<ApplicationStartInfo>().apply {
338+
whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST)
339+
}
340+
AppStartMetrics.getInstance().setCachedStartInfo(startInfo)
341+
342+
driveHeadlessAppStart()
343+
344+
val transaction = fixture.createdTransactions.single()
345+
assertEquals("broadcast", transaction.getData("app.vitals.start.reason"))
346+
}
347+
277348
@Test
278349
fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() {
279350
val sut =

‎sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger
1515
import kotlin.test.Test
1616
import kotlin.test.assertEquals
1717
import kotlin.test.assertFalse
18+
import kotlin.test.assertNull
1819
import org.junit.Before
1920
import org.junit.runner.RunWith
2021
import org.mockito.kotlin.mock
@@ -207,6 +208,47 @@ class AppStartMetricsTestApi35 {
207208
assertEquals(1, listenerCalls.get())
208209
}
209210

211+
@Test
212+
fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() {
213+
val mockStartInfo = mock<ApplicationStartInfo>()
214+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
215+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
216+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST)
217+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
218+
val metrics = AppStartMetrics.getInstance()
219+
220+
val app = ApplicationProvider.getApplicationContext<Application>()
221+
metrics.registerLifecycleCallbacks(app)
222+
223+
assertEquals("broadcast", metrics.appStartReason)
224+
}
225+
226+
@Test
227+
fun `getAppStartReason returns null when no ApplicationStartInfo is available`() {
228+
SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList())
229+
val metrics = AppStartMetrics.getInstance()
230+
231+
val app = ApplicationProvider.getApplicationContext<Application>()
232+
metrics.registerLifecycleCallbacks(app)
233+
234+
assertNull(metrics.appStartReason)
235+
}
236+
237+
@Test
238+
fun `getAppStartReason returns null for an unmapped reason`() {
239+
val mockStartInfo = mock<ApplicationStartInfo>()
240+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
241+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
242+
whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE)
243+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
244+
val metrics = AppStartMetrics.getInstance()
245+
246+
val app = ApplicationProvider.getApplicationContext<Application>()
247+
metrics.registerLifecycleCallbacks(app)
248+
249+
assertNull(metrics.appStartReason)
250+
}
251+
210252
private fun waitForMainLooperIdle() {
211253
Handler(Looper.getMainLooper()).post {}
212254
Shadows.shadowOf(Looper.getMainLooper()).idle()

0 commit comments

Comments
 (0)