Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- 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
- 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 starts (broadcast receivers, services, content providers)
- On Android 15+ (API 35), the standalone 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()` ([#5552](https://github.com/getsentry/sentry-java/pull/5552))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth mentioning that customers can search/group by this property in trace viewer? (they can, right?)


### Improvements

Expand Down
2 changes: 2 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler;
public fun getAppStartEndTime ()Lio/sentry/SentryDate;
public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler;
public fun getAppStartReason ()Ljava/lang/String;
public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision;
public fun getAppStartSentryTraceHeader ()Ljava/lang/String;
public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
Expand Down Expand Up @@ -780,6 +781,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V
public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V
public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V
public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V
public fun setClassLoadedUptimeMs (J)V
public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V
public fun shouldSendStartMeasurements ()Z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public final class ActivityLifecycleIntegration
static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1);
private static final String TRACE_ORIGIN = "auto.ui.activity";
static final String APP_START_SCREEN_DATA = "app.vitals.start.screen";
static final String APP_START_REASON_DATA = "app.vitals.start.reason";
static final String APP_START_TRACE_ORIGIN = "auto.app.start";

private final @NotNull Application application;
Expand Down Expand Up @@ -140,6 +141,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions

if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) {
AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart);
addIntegrationToSdkVersion("StandaloneAppStart");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed for tracking in looker

}

this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed.");
Expand Down Expand Up @@ -286,6 +288,10 @@ private void startTracing(final @NotNull Activity activity) {
appStartSamplingDecision),
appStartTransactionOptions);
appStartTransaction.setData(APP_START_SCREEN_DATA, activityName);
final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason();
if (appStartReason != null) {
appStartTransaction.setData(APP_START_REASON_DATA, appStartReason);
}
}

// Continue either the foreground app.start above or an earlier headless app.start.
Expand Down Expand Up @@ -1002,6 +1008,10 @@ private void onHeadlessAppStart() {
null);

final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions);
final @Nullable String appStartReason = metrics.getAppStartReason();
if (appStartReason != null) {
transaction.setData(APP_START_REASON_DATA, appStartReason);
}
metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId());
// Persist trace headers so a later ui.load can share traceId and sampleRand.
metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,45 @@ public void setAppStartType(final @NotNull AppStartType appStartType) {
return appStartType;
}

/**
* The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}.
* Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null}
* otherwise or for an unmapped reason.
*/
public @Nullable String getAppStartReason() {
if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
return null;
}
switch (cachedStartInfo.getReason()) {
case ApplicationStartInfo.START_REASON_ALARM:
return "alarm";
case ApplicationStartInfo.START_REASON_BACKUP:
return "backup";
case ApplicationStartInfo.START_REASON_BOOT_COMPLETE:
return "boot_complete";
case ApplicationStartInfo.START_REASON_BROADCAST:
return "broadcast";
case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER:
return "content_provider";
case ApplicationStartInfo.START_REASON_JOB:
return "job";
case ApplicationStartInfo.START_REASON_LAUNCHER:
return "launcher";
case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS:
return "launcher_recents";
case ApplicationStartInfo.START_REASON_PUSH:
return "push";
case ApplicationStartInfo.START_REASON_SERVICE:
return "service";
case ApplicationStartInfo.START_REASON_START_ACTIVITY:
return "start_activity";
case ApplicationStartInfo.START_REASON_OTHER:
return "other";
default:
return null;
}
}

public boolean isAppLaunchedInForeground() {
return appLaunchedInForeground.getValue();
}
Expand Down Expand Up @@ -372,6 +411,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) {
CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs;
}

@TestOnly
@ApiStatus.Internal
public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) {
this.cachedStartInfo = cachedStartInfo;
}

/**
* Called by instrumentation
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application
import android.app.ApplicationStartInfo
import android.content.Context
import android.os.Build
import android.os.Bundle
Expand Down Expand Up @@ -275,6 +276,76 @@ class ActivityLifecycleIntegrationTest {
)
}

@Test
@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
fun `Standalone app start transaction carries app start reason when available`() {
val sut =
fixture.getSut {
it.tracesSampleRate = 1.0
it.isEnableStandaloneAppStartTracing = true
}
sut.register(fixture.scopes, fixture.options)

setAppStartTime()
val startInfo =
mock<ApplicationStartInfo>().apply {
whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER)
}
AppStartMetrics.getInstance().setCachedStartInfo(startInfo)

val activity = mock<Activity>()
sut.onActivityCreated(activity, fixture.bundle)

val appStartTransaction =
fixture.createdTransactions.single {
it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP
}
assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason"))
}

@Test
fun `Standalone app start transaction has no app start reason when unavailable`() {
val sut =
fixture.getSut {
it.tracesSampleRate = 1.0
it.isEnableStandaloneAppStartTracing = true
}
sut.register(fixture.scopes, fixture.options)

setAppStartTime()

val activity = mock<Activity>()
sut.onActivityCreated(activity, fixture.bundle)

val appStartTransaction =
fixture.createdTransactions.single {
it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP
}
assertNull(appStartTransaction.getData("app.vitals.start.reason"))
}

@Test
@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
fun `Headless standalone app start transaction carries app start reason when available`() {
val sut =
fixture.getSut {
it.tracesSampleRate = 1.0
it.isEnableStandaloneAppStartTracing = true
}
sut.register(fixture.scopes, fixture.options)
prepareHeadlessAppStart(appStartType = AppStartType.COLD)
val startInfo =
mock<ApplicationStartInfo>().apply {
whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST)
}
AppStartMetrics.getInstance().setCachedStartInfo(startInfo)

driveHeadlessAppStart()

val transaction = fixture.createdTransactions.single()
assertEquals("broadcast", transaction.getData("app.vitals.start.reason"))
}

@Test
fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() {
val sut =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
Expand Down Expand Up @@ -207,6 +208,47 @@ class AppStartMetricsTestApi35 {
assertEquals(1, listenerCalls.get())
}

@Test
fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() {
val mockStartInfo = mock<ApplicationStartInfo>()
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST)
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
val metrics = AppStartMetrics.getInstance()

val app = ApplicationProvider.getApplicationContext<Application>()
metrics.registerLifecycleCallbacks(app)

assertEquals("broadcast", metrics.appStartReason)
}

@Test
fun `getAppStartReason returns null when no ApplicationStartInfo is available`() {
SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList())
val metrics = AppStartMetrics.getInstance()

val app = ApplicationProvider.getApplicationContext<Application>()
metrics.registerLifecycleCallbacks(app)

assertNull(metrics.appStartReason)
}

@Test
fun `getAppStartReason returns null for an unmapped reason`() {
val mockStartInfo = mock<ApplicationStartInfo>()
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE)
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
val metrics = AppStartMetrics.getInstance()

val app = ApplicationProvider.getApplicationContext<Application>()
metrics.registerLifecycleCallbacks(app)

assertNull(metrics.appStartReason)
}

private fun waitForMainLooperIdle() {
Handler(Looper.getMainLooper()).post {}
Shadows.shadowOf(Looper.getMainLooper()).idle()
Expand Down
Loading