Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ddd9bed
feat: Add standalone app start transaction (happy path)
buenaflor Apr 2, 2026
fdd26df
feat: Add non-activity app start path with end time resolution
buenaflor Apr 2, 2026
e5b7a18
feat: Support non-activity app start tracing without bytecode instrum…
buenaflor Apr 2, 2026
11898dc
refactor: Consolidate non-activity app start time-span resolution
buenaflor Apr 23, 2026
5efab1d
chore(samples): Register TestBroadcastReceiver in manifest
buenaflor Apr 23, 2026
f6c070d
Merge remote-tracking branch 'origin/main' into feat/standalone-app-s…
buenaflor Apr 23, 2026
215a549
fix(app-start): resolve standalone tracing misclassification and dupl…
buenaflor Apr 24, 2026
50a2f41
fix(android): refine standalone app start tracing
buenaflor Apr 28, 2026
26a83cc
chore: Update generated files
buenaflor Apr 28, 2026
a60d966
style(core): Apply spotless formatting
buenaflor Apr 28, 2026
09bac52
changelog
buenaflor Apr 28, 2026
5387e76
Merge branch 'main' into feat/standalone-app-start-tracing
buenaflor Apr 28, 2026
f7c9c62
fix(android): Use stable app start transaction name
buenaflor May 4, 2026
9adfa01
feat(android): Add standalone app start tracing
buenaflor May 7, 2026
87ff0d6
fix(android): Handle non-activity app starts below API 24
buenaflor May 7, 2026
706adad
fix(android): Guard app start timestamp clock base
buenaflor May 11, 2026
9bf80c4
Merge branch 'main' into feat/standalone-app-start-tracing
buenaflor May 11, 2026
5d6174e
ref(android): Remove app start reason plumbing
buenaflor May 11, 2026
dfb5aea
Merge remote-tracking branch 'origin/feat/standalone-app-start-tracin…
buenaflor May 11, 2026
e8be515
ref(android): Clarify no-activity app start handling
buenaflor May 11, 2026
b5a6336
docs(android): Clarify non-activity app start fallback
buenaflor May 11, 2026
f3ef6ce
fix(android): Preserve legacy no-activity app start guard
buenaflor May 11, 2026
9da8369
test(android): Opt into standalone no-activity API 35 tests
buenaflor May 11, 2026
72268a3
fix(android): Schedule no-activity idle check when standalone listene…
buenaflor May 11, 2026
cbaebe2
ref(android): Remove dead foregroundImportance check in standalone ap…
buenaflor May 11, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Features

- Add `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` 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)
- Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387))

## 8.41.0
Expand Down
10 changes: 10 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -742,7 +744,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
Expand All @@ -766,8 +770,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
}

Expand All @@ -779,6 +785,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 <init> ()V
public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,12 +56,14 @@ public final class ActivityLifecycleIntegration
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {

static final String UI_LOAD_OP = "ui.load";
static final String STANDALONE_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";
static final String TTFD_OP = "ui.load.full_display";
static final long TTFD_TIMEOUT_MILLIS = 25000;
private static final String TRACE_ORIGIN = "auto.ui.activity";
private static final String APP_START_SCREEN_DATA = "app.vitals.start.screen";

private final @NotNull Application application;
private final @NotNull BuildInfoProvider buildInfoProvider;
Expand All @@ -77,6 +80,7 @@ public final class ActivityLifecycleIntegration

private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null;
private @Nullable ISpan appStartSpan;
private @Nullable ITransaction appStartTransaction;
private final @NotNull WeakHashMap<Activity, ISpan> ttidSpanMap = new WeakHashMap<>();
private final @NotNull WeakHashMap<Activity, ISpan> ttfdSpanMap = new WeakHashMap<>();
private final @NotNull WeakHashMap<Activity, ActivityLifecycleSpanHelper> activitySpanHelpers =
Expand Down Expand Up @@ -124,6 +128,11 @@ 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");
}
Expand All @@ -135,6 +144,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.");
Expand Down Expand Up @@ -239,33 +249,70 @@ private void startTracing(final @NotNull Activity activity) {
transactionOptions.setAppStartTransaction(appStartSamplingDecision != null);
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);
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) {
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);

// 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() && !isFollowingNonActivityStart) {
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(),
TransactionNameSource.COMPONENT,
STANDALONE_APP_START_OP,
appStartSamplingDecision),
appStartTransactionOptions);
appStartTransaction.setData(APP_START_SCREEN_DATA, activityName);

finishAppStartSpan();
} else if (!options.isEnableStandaloneAppStartTracing()) {
appStartSpan =
transaction.startChild(
getAppStartOp(coldStart),
getAppStartDesc(coldStart),
appStartTime,
Instrumenter.SENTRY,
spanOptions);

finishAppStartSpan();
}
}
final @NotNull ISpan ttidSpan =
transaction.startChild(
Expand Down Expand Up @@ -440,8 +487,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));
}
}

Expand Down Expand Up @@ -479,8 +525,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();
}
Expand Down Expand Up @@ -559,6 +604,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);
Expand All @@ -575,6 +623,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);
}
Expand Down Expand Up @@ -637,21 +686,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I
final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance();
final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan();
final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan();
final @Nullable SentryDate firstFrameEndDate =
options != null && ttidSpan != null ? options.getDateProvider().now() : null;

// and we need to set the end time of the app start here, after the first frame is drawn.
if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) {
appStartTimeSpan.stop();
stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate);
}
if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) {
sdkInitTimeSpan.stop();
stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate);
}
finishAppStartSpan();
finishAppStartSpan(firstFrameEndDate);

// Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization
// with first frame drawn
try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) {
if (options != null && ttidSpan != null) {
final SentryDate endDate = options.getDateProvider().now();
if (options != null && ttidSpan != null && firstFrameEndDate != null) {
final @NotNull SentryDate endDate = firstFrameEndDate;
final long durationNanos = endDate.diff(ttidSpan.getStartDate());
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos);
ttidSpan.setMeasurement(
Expand All @@ -677,6 +728,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I
}
}

private void stopTimeSpanAtDate(
final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) {
final @Nullable SentryDate startDate = timeSpan.getStartTimestamp();
if (endDate != null && startDate != null) {
final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate));
timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis);
} else {
timeSpan.stop();
}
}

private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) {
cancelTtfdAutoClose();
// Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization
Expand Down Expand Up @@ -779,6 +841,20 @@ WeakHashMap<Activity, ISpan> 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() {
return "App Start";
}

private @NotNull String getAppStartOp(final boolean coldStart) {
if (coldStart) {
return APP_START_COLD;
Expand All @@ -788,12 +864,58 @@ WeakHashMap<Activity, ISpan> getTtfdSpanMap() {
}

private void finishAppStartSpan() {
finishAppStartSpan(null);
}

private void finishAppStartSpan(final @Nullable SentryDate endDate) {
final @Nullable SentryDate appStartEndTime =
AppStartMetrics.getInstance()
.getAppStartTimeSpanWithFallback(options)
.getProjectedStopTimestamp();
endDate != null
? endDate
: AppStartMetrics.getInstance()
.getAppStartTimeSpanWithFallback(options)
.getProjectedStopTimestamp();
if (performanceEnabled && appStartEndTime != null) {
finishSpan(appStartSpan, appStartEndTime);
if (appStartTransaction != null && !appStartTransaction.isFinished()) {
appStartTransaction.finish(SpanStatus.OK, appStartEndTime);
}
}
}

private void onNoActivityStarted() {
if (scopes == null || options == null || !performanceEnabled) {
return;
}

final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
// For non-activity starts, appLaunchedInForeground is false, so we can't use
// getAppStartTimeSpanWithFallback (which gates on foreground).
final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanDirect();

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 TransactionOptions txnOptions = new TransactionOptions();
txnOptions.setBindToScope(false);
txnOptions.setStartTimestamp(startTime);
setSpanOrigin(txnOptions);

final @NotNull TransactionContext txnContext =
new TransactionContext(
getAppStartTxnName(), TransactionNameSource.COMPONENT, STANDALONE_APP_START_OP, 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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";
Expand Down Expand Up @@ -499,6 +502,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()));
Expand Down
Loading
Loading