Skip to content

Commit 0f8ee4d

Browse files
buenaflorclaude
andcommitted
feat(extend-app-start): Materialize extended app start span and extend the vital
Wires the deferred extended span into all three app start transaction paths (standalone, non-standalone ui.load, headless) via a materializeExtendedAppStart helper, holding each transaction open with waitForChildren + deadline until the extension finishes. The app start vital now measures process start to the extended end on a user finish, and is suppressed entirely when the extension hits the deadline (never emitting an inflated ~30s value). The extend guard ignores the foreground check so headless app starts can also be extended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 54be119 commit 0f8ee4d

7 files changed

Lines changed: 247 additions & 12 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
808808
public fun getPendingExtendedAppStartSpan ()Lio/sentry/android/core/ExtendedAppStartSpan;
809809
public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
810810
public fun isAppLaunchedInForeground ()Z
811+
public fun isAppStartExtended ()Z
811812
public fun isExtendedAppStartPending ()Z
812813
public fun markExtendedAppStartMaterialized ()V
813814
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public final class ActivityLifecycleIntegration
6565
static final String APP_START_COLD = "app.start.cold";
6666
static final String TTID_OP = "ui.load.initial_display";
6767
static final String TTFD_OP = "ui.load.full_display";
68+
static final String APP_START_EXTENDED_OP = "app.start.extended_app_start";
69+
static final String APP_START_EXTENDED_DESC = "Extended App Start";
6870
static final long TTFD_TIMEOUT_MILLIS = 25000;
6971
// If a headless app start and the following activity's ui.load are more than this far apart, they
7072
// are treated as unrelated and not connected into the same trace.
@@ -277,6 +279,13 @@ private void startTracing(final @NotNull Activity activity) {
277279
appStartTransactionOptions.setStartTimestamp(appStartTime);
278280
appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null);
279281
appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN);
282+
// When the app start is being extended, hold the transaction open until the extended span
283+
// finishes (or the deadline forces it).
284+
if (AppStartMetrics.getInstance().isExtendedAppStartPending()) {
285+
appStartTransactionOptions.setWaitForChildren(true);
286+
appStartTransactionOptions.setDeadlineTimeout(
287+
deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis);
288+
}
280289

281290
appStartTransaction =
282291
scopes.startTransaction(
@@ -291,6 +300,7 @@ private void startTracing(final @NotNull Activity activity) {
291300
if (appStartReason != null) {
292301
appStartTransaction.setData(APP_START_REASON_DATA, appStartReason);
293302
}
303+
materializeExtendedAppStart(appStartTransaction);
294304
}
295305

296306
// Continue either the foreground app.start above or an earlier headless app.start.
@@ -349,6 +359,9 @@ && isWithinAppStartContinuationWindow(ttidStartTime)) {
349359
spanOptions);
350360

351361
finishAppStartSpan();
362+
// The ui.load transaction already waits for children + has a deadline, so parenting the
363+
// extended span here keeps it open until the extension finishes.
364+
materializeExtendedAppStart(appStartSpan);
352365
}
353366
}
354367
final @NotNull ISpan ttidSpan =
@@ -400,6 +413,30 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) {
400413
spanOptions.setOrigin(TRACE_ORIGIN);
401414
}
402415

416+
/**
417+
* Materializes a pending extended app start span as a child of {@code parent}. The parent's
418+
* transaction must have {@code waitForChildren} + a deadline set so it stays open until the
419+
* extension finishes (or the deadline forces it).
420+
*/
421+
private void materializeExtendedAppStart(final @NotNull ISpan parent) {
422+
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
423+
final @Nullable ExtendedAppStartSpan ext = metrics.getPendingExtendedAppStartSpan();
424+
if (ext == null) {
425+
return;
426+
}
427+
final SpanOptions spanOptions = new SpanOptions();
428+
setSpanOrigin(spanOptions);
429+
final @NotNull ISpan child =
430+
parent.startChild(
431+
APP_START_EXTENDED_OP,
432+
APP_START_EXTENDED_DESC,
433+
ext.getStartDate(),
434+
Instrumenter.SENTRY,
435+
spanOptions);
436+
ext.materialize(child);
437+
metrics.markExtendedAppStartMaterialized();
438+
}
439+
403440
/**
404441
* Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless
405442
* app start to belong to the same trace. If they are more than {@link
@@ -998,6 +1035,13 @@ private void onHeadlessAppStart() {
9981035
txnOptions.setBindToScope(false);
9991036
txnOptions.setStartTimestamp(startTime);
10001037
txnOptions.setOrigin(APP_START_TRACE_ORIGIN);
1038+
// When the app start is being extended, hold the headless transaction open until the extended
1039+
// span finishes (or the deadline forces it).
1040+
if (metrics.isExtendedAppStartPending()) {
1041+
txnOptions.setWaitForChildren(true);
1042+
final long deadlineTimeoutMillis = options.getDeadlineTimeout();
1043+
txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis);
1044+
}
10011045

10021046
final @NotNull TransactionContext txnContext =
10031047
new TransactionContext(
@@ -1020,6 +1064,7 @@ private void onHeadlessAppStart() {
10201064
// time to continue this trace.
10211065
metrics.setAppStartEndTime(endTime);
10221066

1067+
materializeExtendedAppStart(transaction);
10231068
transaction.finish(SpanStatus.OK, endTime);
10241069
}
10251070
}

sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import io.sentry.Hint;
1111
import io.sentry.ISentryLifecycleToken;
1212
import io.sentry.MeasurementUnit;
13+
import io.sentry.SentryDate;
1314
import io.sentry.SentryEvent;
1415
import io.sentry.SpanContext;
1516
import io.sentry.SpanDataConvention;
@@ -29,6 +30,7 @@
2930
import java.util.List;
3031
import java.util.Map;
3132
import java.util.concurrent.ConcurrentHashMap;
33+
import java.util.concurrent.TimeUnit;
3234
import org.jetbrains.annotations.NotNull;
3335
import org.jetbrains.annotations.Nullable;
3436

@@ -101,20 +103,45 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) {
101103
isHeadlessStandaloneAppStartTxn
102104
? appStartMetrics.getAppStartTimeSpanForHeadless()
103105
: appStartMetrics.getAppStartTimeSpanWithFallback(options);
104-
final long appStartUpDurationMs = appStartTimeSpan.getDurationMs();
105106

106-
// if appStartUpDurationMs is 0, metrics are not ready to be sent
107-
if (appStartUpDurationMs != 0) {
108-
final MeasurementValue value =
109-
new MeasurementValue(
110-
(float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName());
107+
final long appStartUpDurationMs;
108+
// Whether the app start is ready to be finalized (spans attached, marked sent). When not
109+
// ready (duration 0), we leave it for a later transaction to retry.
110+
final boolean appStartReady;
111+
if (appStartMetrics.isAppStartExtended()) {
112+
final @Nullable SentryDate extendedEnd = appStartMetrics.getExtendedAppStartEndTime();
113+
if (extendedEnd != null && appStartTimeSpan.hasStarted()) {
114+
// The user finished the extension: measure from process start to the extended end.
115+
// The end stays within the 1-minute cap thanks to the 30s deadline timeout.
116+
appStartUpDurationMs =
117+
TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp())
118+
- appStartTimeSpan.getStartTimestampMs();
119+
appStartReady = appStartUpDurationMs != 0;
120+
} else {
121+
// The extension hit the deadline (or no valid start): suppress the measurement so we
122+
// never emit an artificially inflated value, but still finalize the app start spans.
123+
appStartUpDurationMs = 0;
124+
appStartReady = appStartTimeSpan.hasStarted();
125+
}
126+
} else {
127+
appStartUpDurationMs = appStartTimeSpan.getDurationMs();
128+
// if appStartUpDurationMs is 0, metrics are not ready to be sent
129+
appStartReady = appStartUpDurationMs != 0;
130+
}
131+
132+
if (appStartReady) {
133+
if (appStartUpDurationMs != 0) {
134+
final MeasurementValue value =
135+
new MeasurementValue(
136+
(float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName());
111137

112-
final String appStartKey =
113-
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
114-
? MeasurementValue.KEY_APP_START_COLD
115-
: MeasurementValue.KEY_APP_START_WARM;
138+
final String appStartKey =
139+
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
140+
? MeasurementValue.KEY_APP_START_COLD
141+
: MeasurementValue.KEY_APP_START_WARM;
116142

117-
transaction.getMeasurements().put(appStartKey, value);
143+
transaction.getMeasurements().put(appStartKey, value);
144+
}
118145

119146
attachAppStartSpans(appStartMetrics, transaction);
120147
appStartMetrics.onAppStartSpansSent();

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ public void onAppStartSpansSent() {
292292
shouldSendStartMeasurements = false;
293293
contentProviderOnCreates.clear();
294294
activityLifecycles.clear();
295+
// Reset extension state so a stale extended span can't affect a later (e.g. warm) app start.
296+
extendedAppStartSpan = null;
297+
extendedAppStartMaterialized = false;
295298
}
296299

297300
public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) {
@@ -359,7 +362,11 @@ public void extendAppStart() {
359362
.log(SentryLevel.WARNING, "App start is already being extended.");
360363
return;
361364
}
362-
if (!shouldSendStartMeasurements()
365+
// Ignore the foreground check: headless app starts (broadcast/service) run in a
366+
// non-foreground process but can still be extended. The window guards below still reject an
367+
// extension once an activity was created, the first frame was drawn, or measurements were
368+
// already sent.
369+
if (!shouldSendStartMeasurements(true)
363370
|| activeActivitiesCounter.get() > 0
364371
|| firstDrawDone.get()) {
365372
Sentry.getCurrentScopes()
@@ -391,6 +398,11 @@ public void finishAppStart() {
391398
return NoOpSpan.getInstance();
392399
}
393400

401+
/** Whether the app start was extended (regardless of materialization or finish state). */
402+
public boolean isAppStartExtended() {
403+
return extendedAppStartSpan != null;
404+
}
405+
394406
/** Whether an extension has been requested but not yet materialized into a real child span. */
395407
public boolean isExtendedAppStartPending() {
396408
return extendedAppStartSpan != null && !extendedAppStartMaterialized;

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,93 @@ class ActivityLifecycleIntegrationTest {
323323
assertNull(appStartTransaction.getData("app.vitals.start.reason"))
324324
}
325325

326+
// region extended app start
327+
328+
@Test
329+
fun `extended standalone app start span keeps the transaction open until finished`() {
330+
val sut =
331+
fixture.getSut {
332+
it.tracesSampleRate = 1.0
333+
it.isEnableStandaloneAppStartTracing = true
334+
}
335+
sut.register(fixture.scopes, fixture.options)
336+
337+
setAppStartTime()
338+
AppStartMetrics.getInstance().extendAppStart()
339+
340+
val activity = mock<Activity>()
341+
sut.onActivityCreated(activity, fixture.bundle)
342+
343+
val appStartTransaction =
344+
fixture.createdTransactions.single {
345+
it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP
346+
}
347+
assertTrue(
348+
appStartTransaction.children.any {
349+
it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP
350+
}
351+
)
352+
353+
// waitForChildren keeps the app start transaction open until the extension finishes
354+
appStartTransaction.finish(SpanStatus.OK)
355+
assertFalse(appStartTransaction.isFinished)
356+
357+
AppStartMetrics.getInstance().finishAppStart()
358+
assertTrue(appStartTransaction.isFinished)
359+
}
360+
361+
@Test
362+
fun `extended non-standalone app start span is attached under the ui load transaction`() {
363+
val sut = fixture.getSut { it.tracesSampleRate = 1.0 }
364+
sut.register(fixture.scopes, fixture.options)
365+
366+
setAppStartTime()
367+
AppStartMetrics.getInstance().extendAppStart()
368+
369+
val activity = mock<Activity>()
370+
sut.onActivityCreated(activity, fixture.bundle)
371+
372+
val uiLoadTransaction =
373+
fixture.createdTransactions.single {
374+
it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP
375+
}
376+
assertTrue(
377+
uiLoadTransaction.children.any {
378+
it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP
379+
}
380+
)
381+
}
382+
383+
@Test
384+
fun `extended headless app start span keeps the transaction open until finished`() {
385+
val sut =
386+
fixture.getSut {
387+
it.tracesSampleRate = 1.0
388+
it.isEnableStandaloneAppStartTracing = true
389+
}
390+
sut.register(fixture.scopes, fixture.options)
391+
392+
prepareHeadlessAppStart(appStartType = AppStartType.COLD)
393+
AppStartMetrics.getInstance().extendAppStart()
394+
395+
driveHeadlessAppStart()
396+
397+
val transaction = fixture.createdTransactions.single()
398+
assertTrue(
399+
transaction.children.any {
400+
it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP
401+
}
402+
)
403+
404+
// headless finishes immediately, but waitForChildren keeps it open until the extension finishes
405+
assertFalse(transaction.isFinished)
406+
407+
AppStartMetrics.getInstance().finishAppStart()
408+
assertTrue(transaction.isFinished)
409+
}
410+
411+
// endregion
412+
326413
@Test
327414
@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
328415
fun `Headless standalone app start transaction carries app start reason when available`() {

sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.content.ContentProvider
44
import androidx.test.ext.junit.runners.AndroidJUnit4
55
import io.sentry.Hint
66
import io.sentry.IScopes
7+
import io.sentry.ISpan
78
import io.sentry.MeasurementUnit
9+
import io.sentry.SentryLongDate
810
import io.sentry.SentryTracer
911
import io.sentry.SpanContext
1012
import io.sentry.SpanDataConvention
@@ -192,6 +194,59 @@ class PerformanceAndroidEventProcessorTest {
192194
assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value)
193195
}
194196

197+
// region extended app start
198+
199+
private fun extendAppStartFinishedWith(status: SpanStatus, endMs: Long) {
200+
val metrics = AppStartMetrics.getInstance()
201+
metrics.extendAppStart()
202+
val child = mock<ISpan>()
203+
whenever(child.isFinished).thenReturn(true)
204+
whenever(child.status).thenReturn(status)
205+
whenever(child.finishDate).thenReturn(SentryLongDate(endMs * 1_000_000L))
206+
metrics.pendingExtendedAppStartSpan!!.materialize(child)
207+
}
208+
209+
@Test
210+
fun `extended app start uses the extended end for the cold start measurement`() {
211+
val sut = fixture.getSut(enablePerformanceV2 = true)
212+
val metrics = AppStartMetrics.getInstance()
213+
metrics.appStartType = AppStartType.COLD
214+
metrics.isAppLaunchedInForeground = true
215+
metrics.appStartTimeSpan.apply {
216+
setStartedAt(1)
217+
setStoppedAt(100)
218+
}
219+
val startMs = metrics.appStartTimeSpan.startTimestampMs
220+
extendAppStartFinishedWith(SpanStatus.OK, startMs + 500)
221+
222+
var tr = createUiLoadTransactionWithAppStartChildSpan()
223+
tr = sut.process(tr, Hint())
224+
225+
assertEquals(500f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value)
226+
}
227+
228+
@Test
229+
fun `extended app start that hit the deadline suppresses the measurement`() {
230+
val sut = fixture.getSut(enablePerformanceV2 = true)
231+
val metrics = AppStartMetrics.getInstance()
232+
metrics.appStartType = AppStartType.COLD
233+
metrics.isAppLaunchedInForeground = true
234+
metrics.appStartTimeSpan.apply {
235+
setStartedAt(1)
236+
setStoppedAt(100)
237+
}
238+
val startMs = metrics.appStartTimeSpan.startTimestampMs
239+
extendAppStartFinishedWith(SpanStatus.DEADLINE_EXCEEDED, startMs + 30_000)
240+
241+
var tr = createUiLoadTransactionWithAppStartChildSpan()
242+
tr = sut.process(tr, Hint())
243+
244+
assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD))
245+
assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM))
246+
}
247+
248+
// endregion
249+
195250
@Test
196251
fun `add cold start measurement for performance-v2`() {
197252
val sut = fixture.getSut(enablePerformanceV2 = true)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,14 @@ class AppStartMetricsTest {
10591059
assertSame(first, metrics.pendingExtendedAppStartSpan)
10601060
}
10611061

1062+
@Test
1063+
fun `extendAppStart is allowed when not launched in foreground (headless)`() {
1064+
val metrics = AppStartMetrics.getInstance()
1065+
metrics.isAppLaunchedInForeground = false
1066+
metrics.extendAppStart()
1067+
assertTrue(metrics.isExtendedAppStartPending)
1068+
}
1069+
10621070
@Test
10631071
fun `extendAppStart is ignored after start measurements were sent`() {
10641072
val metrics = AppStartMetrics.getInstance()

0 commit comments

Comments
 (0)