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
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 @@ -193,6 +193,8 @@ public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStar
public fun getExtendedAppStartSpan ()Lio/sentry/ISpan;
public fun getExtendedEndTime ()Lio/sentry/SentryDate;
public fun isActive ()Z
public fun isExtended ()Z
public fun setData (Ljava/lang/String;Ljava/lang/Object;)V
public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public final class ActivityLifecycleIntegration
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 String APP_START_EXTENDED_OP = "app.start.extended";
static final String APP_START_EXTENDED_DESC = "Extended App Start";
static final long TTFD_TIMEOUT_MILLIS = 25000;
// If a headless app start and the following activity's ui.load are more than this far apart, they
// are treated as unrelated and not connected into the same trace.
Expand Down Expand Up @@ -139,7 +141,10 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
application.registerActivityLifecycleCallbacks(this);

if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) {
AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart);
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
metrics.setHeadlessAppStartListener(this::onHeadlessAppStart);
// Enables Sentry.extendAppStart(). Standalone-only, since it is only registered here.
metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested);
addIntegrationToSdkVersion("StandaloneAppStart");
}

Expand All @@ -154,7 +159,9 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options
@Override
public void close() throws IOException {
application.unregisterActivityLifecycleCallbacks(this);
AppStartMetrics.getInstance().setHeadlessAppStartListener(null);
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
metrics.setHeadlessAppStartListener(null);
metrics.getAppStartExtension().setExtendAppStartListener(null);

if (options != null) {
options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed.");
Expand Down Expand Up @@ -259,17 +266,27 @@ private void startTracing(final @NotNull Activity activity) {
transactionOptions.setAppStartTransaction(appStartSamplingDecision != null);
setSpanOrigin(transactionOptions);

// An extend-app-start transaction (Sentry.extendAppStart) is already open. Reuse its trace
// for this ui.load instead of creating a second app.start. It also stores an app-start
// trace id, so the headless-start check below is guarded with !extensionActive to avoid
// mistaking it for a finished headless start.
final boolean extensionActive =
AppStartMetrics.getInstance().getAppStartExtension().isActive();

final @Nullable SentryId storedAppStartTraceId =
AppStartMetrics.getInstance().getAppStartTraceId();
final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null);
final boolean isFollowingHeadlessAppStart =
!extensionActive && (storedAppStartTraceId != null);

final boolean isAppStart =
!(firstActivityCreated || appStartTime == null || coldStart == null);
// Foreground starts create app.start first; ui.load then shares its trace.
// Foreground starts create app.start first; ui.load then shares its trace. When the app
// start is being extended, the eager app.start txn already exists, so we continue it.
final boolean createStandaloneAppStart =
isAppStart
&& options.isEnableStandaloneAppStartTracing()
&& !isFollowingHeadlessAppStart;
&& !isFollowingHeadlessAppStart
&& !extensionActive;

if (createStandaloneAppStart) {
final TransactionOptions appStartTransactionOptions = new TransactionOptions();
Expand Down Expand Up @@ -300,15 +317,25 @@ private void startTracing(final @NotNull Activity activity) {
continueSentryTrace = appStartTransaction.toSentryTrace().getValue();
final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null);
continueBaggage = baggageHeader == null ? null : baggageHeader.getValue();
} else if (isFollowingHeadlessAppStart
&& isWithinAppStartContinuationWindow(ttidStartTime)) {
} else if (extensionActive
|| (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) {
// Continue the eager extension's app.start trace, or an earlier headless app.start.
continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader();
continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader();
} else {
continueSentryTrace = null;
continueBaggage = null;
}

if (extensionActive) {
// Attach the screen (this first activity) so the eager app.start matches the foreground
// standalone app.start and the event processor treats it as a foreground (not headless)
// start.
AppStartMetrics.getInstance()
.getAppStartExtension()
.setData(APP_START_SCREEN_DATA, activityName);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Extended headless vitals dropped after screen

Medium Severity

Attaching app.vitals.start.screen to the eager extended app.start transaction makes the processor classify it as non-headless, while shouldSendStartMeasurements still requires appLaunchedInForeground. Headless extended starts that later open an activity can skip cold/warm app-start measurements entirely.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit 571416e. Configure here.


final @Nullable TransactionContext continuedContext =
continueSentryTrace == null
? null
Expand Down Expand Up @@ -967,6 +994,9 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) {
if (appStartTransaction != null && !appStartTransaction.isFinished()) {
appStartTransaction.finish(SpanStatus.OK, appStartEndTime);
}
// Finish the eager extended transaction at the natural first-frame end. waitForChildren keeps
// it open until the extended span finishes; no-op if the app start was not extended.
AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime);
}
}

Expand Down Expand Up @@ -994,17 +1024,51 @@ private void onHeadlessAppStart() {
return;
}

// Extended headless start: finish the existing eager txn at the headless end instead of
// creating a second one.
if (metrics.getAppStartExtension().isActive()) {
metrics.getAppStartExtension().finishTransaction(endTime);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Extended headless omits end time

Low Severity

When headless startup reuses an active extended eager transaction, onHeadlessAppStart calls finishTransaction and returns without calling setAppStartEndTime. The non-extended headless path persists that end time so a later ui.load can use the one-minute continuation window; extended headless always treats the gap as unknown and may continue the trace incorrectly.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit 66cd87d. Configure here.

}
Comment on lines +1029 to +1032

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: An extended headless app start fails to set appStartEndTime, causing all subsequent activities to be incorrectly attached to the initial app start trace.
Severity: MEDIUM

Suggested Fix

After the call to metrics.getAppStartExtension().finishTransaction(endTime) in the extended headless start code path, add a call to metrics.setAppStartEndTime(endTime) to properly close the app start time window.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java#L1029-L1032

Potential issue: In the case of an extended headless app start, the code finishes the
transaction but fails to call `metrics.setAppStartEndTime(endTime)`. Consequently,
`AppStartMetrics.getInstance().getAppStartEndTime()` returns `null`. This causes the
`isWithinAppStartContinuationWindow` check to always return `true`, effectively creating
an infinite time window for trace continuation. As a result, all subsequent activities,
even those occurring much later, are incorrectly attached to the initial app start
trace, leading to inaccurate performance metrics.

Did we get this right? ๐Ÿ‘ / ๐Ÿ‘Ž to inform future reviews.


final @NotNull ITransaction transaction =
createStandaloneAppStartTransaction(startTime, null, false);
// Persist the end time so a later activity can decide whether its ui.load is close enough in
// time to continue this trace.
metrics.setAppStartEndTime(endTime);

transaction.finish(SpanStatus.OK, endTime);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Headless path duplicates finished extension

Medium Severity

onHeadlessAppStart only skips creating a new standalone app.start when AppStartExtension.isActive() is true. If Sentry.finishAppStart() completes the eager extended transaction before the main-looper headless idle runs, isActive() is false and a second standalone app.start is created and finished, duplicating headless app-start tracing for one launch.

Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit 66cd87d. Configure here.

}

/**
* Creates the standalone {@code app.start} transaction (not bound to the scope) and persists its
* trace headers so a later {@code ui.load} can share the same trace. Shared by the headless path
* and the eager extension path. When {@code holdOpenForExtension} is true, the transaction waits
* for its children and gets a deadline so it stays open until the extended span finishes.
*/
private @NotNull ITransaction createStandaloneAppStartTransaction(
final @NotNull SentryDate startTime,
final @Nullable TracesSamplingDecision samplingDecision,
final boolean holdOpenForExtension) {
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();

final TransactionOptions txnOptions = new TransactionOptions();
txnOptions.setBindToScope(false);
txnOptions.setStartTimestamp(startTime);
txnOptions.setOrigin(APP_START_TRACE_ORIGIN);
txnOptions.setAppStartTransaction(samplingDecision != null);
if (holdOpenForExtension) {
txnOptions.setWaitForChildren(true);
final long deadlineTimeoutMillis = options.getDeadlineTimeout();
txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis);
}

final @NotNull TransactionContext txnContext =
new TransactionContext(
STANDALONE_APP_START_NAME,
TransactionNameSource.COMPONENT,
STANDALONE_APP_START_OP,
null);
samplingDecision);

final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions);
final @Nullable String appStartReason = metrics.getAppStartReason();
Expand All @@ -1016,10 +1080,55 @@ private void onHeadlessAppStart() {
metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue());
final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null);
metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue());
// Persist the end time so a later activity can decide whether its ui.load is close enough in
// time to continue this trace.
metrics.setAppStartEndTime(endTime);
return transaction;
}

transaction.finish(SpanStatus.OK, endTime);
/**
* Handles {@code Sentry.extendAppStart()}: eagerly creates the standalone app.start transaction
* and the extended child span (we have scopes here), then hands both to the {@link
* AppStartExtension}, which owns them. The transaction is held open ({@code waitForChildren})
* until the user calls {@code Sentry.finishExtendedAppStart()} or the deadline forces it.
* Standalone-only: this is only registered as a listener when standalone app start tracing is
* enabled.
*/
private @Nullable AppStartExtension.ExtendedAppStart onExtendAppStartRequested() {
if (scopes == null
|| options == null
|| !performanceEnabled
|| !options.isEnableStandaloneAppStartTracing()) {
return null;
}
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();

// The earliest known start of this app start (process start when perf-v2 is available, else SDK
// init). It is available before the first activity because SentryPerformanceProvider sets it.
final @NotNull TimeSpan appStartTimeSpan =
metrics.getAppStartTimeSpan().hasStarted()
? metrics.getAppStartTimeSpan()
: metrics.getSdkInitTimeSpan();
final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp();
if (startTime == null) {
return null;
}

// The app start txn inherits the sampling decision from app start profiling, then clears it so
// it doesn't leak to the later ui.load.
final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision();
metrics.setAppStartSamplingDecision(null);

final @NotNull ITransaction transaction =
createStandaloneAppStartTransaction(startTime, samplingDecision, true);

final SpanOptions spanOptions = new SpanOptions();
setSpanOrigin(spanOptions);
final @NotNull ISpan extendedSpan =
transaction.startChild(
APP_START_EXTENDED_OP,
APP_START_EXTENDED_DESC,
AndroidDateUtils.getCurrentSentryDateTime(),
Instrumenter.SENTRY,
spanOptions);

return new AppStartExtension.ExtendedAppStart(transaction, extendedSpan);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ public void extendAppStart() {
}
}

/**
* Sets data on the owned (eager) transaction if it is still open. Used to attach the screen name
* once the first activity is known, since the transaction is created in {@code onCreate} before
* any activity exists.
*/
public void setData(final @NotNull String key, final @Nullable Object value) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
if (extendedTransaction != null && !extendedTransaction.isFinished()) {
extendedTransaction.setData(key, value);
}
}
}

@Override
public void finishExtendedAppStart() {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
Expand Down Expand Up @@ -105,6 +118,16 @@ public boolean isActive() {
}
}

/**
* Whether this app start was extended at all, regardless of finish or deadline state. Used by the
* event processor to decide whether to apply the never-shorten vital logic.
*/
public boolean isExtended() {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
return extendedSpan != null;
}
}

public void finishTransaction(final @NotNull SentryDate endTimestamp) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
final @Nullable ITransaction transaction = extendedTransaction;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.sentry.Hint;
import io.sentry.ISentryLifecycleToken;
import io.sentry.MeasurementUnit;
import io.sentry.SentryDate;
import io.sentry.SentryEvent;
import io.sentry.SpanContext;
import io.sentry.SpanDataConvention;
Expand All @@ -29,6 +30,7 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

Expand Down Expand Up @@ -101,20 +103,49 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) {
isHeadlessStandaloneAppStartTxn
? appStartMetrics.getAppStartTimeSpanForHeadless()
: appStartMetrics.getAppStartTimeSpanWithFallback(options);
final long appStartUpDurationMs = appStartTimeSpan.getDurationMs();
final long naturalDurationMs = appStartTimeSpan.getDurationMs();

final long appStartUpDurationMs;
// Whether the app start is ready to be finalized (spans attached, marked sent). When not
// ready (duration 0 on a non-extended start), we leave it for a later transaction to
// retry.
final boolean appStartReady;
final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension();
if (extension.isExtended()) {
final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime();
if (extendedEnd != null && appStartTimeSpan.hasStarted()) {
// The user finished the extension: measure from process start to the extended end,
// but never report shorter than the natural first-frame duration.
final long extendedDurationMs =
TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp())
- appStartTimeSpan.getStartTimestampMs();
appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs);
appStartReady = appStartUpDurationMs != 0;
} else {
// The extension hit the deadline (DEADLINE_EXCEEDED -> null) or there is no valid
// start: suppress the measurement so we never emit an artificially inflated value,
// but still finalize the app start spans.
appStartUpDurationMs = 0;
appStartReady = appStartTimeSpan.hasStarted();
}
} else {
appStartUpDurationMs = naturalDurationMs;
appStartReady = appStartUpDurationMs != 0;
}

// if appStartUpDurationMs is 0, metrics are not ready to be sent
if (appStartUpDurationMs != 0) {
final MeasurementValue value =
new MeasurementValue(
(float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName());
if (appStartReady) {
if (appStartUpDurationMs != 0) {
final MeasurementValue value =
new MeasurementValue(
(float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName());

final String appStartKey =
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
? MeasurementValue.KEY_APP_START_COLD
: MeasurementValue.KEY_APP_START_WARM;
final String appStartKey =
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
? MeasurementValue.KEY_APP_START_COLD
: MeasurementValue.KEY_APP_START_WARM;

transaction.getMeasurements().put(appStartKey, value);
transaction.getMeasurements().put(appStartKey, value);
}

attachAppStartSpans(appStartMetrics, transaction);
appStartMetrics.onAppStartSpansSent();
Expand Down
Loading
Loading