Skip to content

Commit d2b96ad

Browse files
buenaflorclaude
andcommitted
feat(extend-app-start): Extract AppStartExtension component for the Android extender
Replaces the AppStartMetrics IAppStartExtender implementation and the deferred ExtendedAppStartSpan with a focused, lock-guarded AppStartExtension that owns the eager App Start transaction and extended span. AppStartMetrics now only holds the component and exposes isAppStartWindowOpen(). Inert until 3/4 registers the listener. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7a7d63f commit d2b96ad

6 files changed

Lines changed: 490 additions & 0 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In
184184
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
185185
}
186186

187+
public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStartExtender {
188+
public fun <init> (Lio/sentry/android/core/performance/AppStartMetrics;)V
189+
public fun extendAppStart ()V
190+
public fun finishAppStart ()V
191+
public fun finishTransaction (Lio/sentry/SentryDate;)V
192+
public fun getExtendedAppStartSpan ()Lio/sentry/ISpan;
193+
public fun getExtendedEndTime ()Lio/sentry/SentryDate;
194+
public fun isActive ()Z
195+
public fun onExtended (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V
196+
public fun reset ()V
197+
public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V
198+
}
199+
200+
public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener {
201+
public abstract fun onExtendAppStartRequested ()V
202+
}
203+
187204
public final class io/sentry/android/core/AppState : java/io/Closeable {
188205
public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V
189206
public fun close ()V
@@ -745,6 +762,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
745762
public fun getAppStartBaggageHeader ()Ljava/lang/String;
746763
public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler;
747764
public fun getAppStartEndTime ()Lio/sentry/SentryDate;
765+
public fun getAppStartExtension ()Lio/sentry/android/core/AppStartExtension;
748766
public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler;
749767
public fun getAppStartReason ()Ljava/lang/String;
750768
public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision;
@@ -760,6 +778,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
760778
public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics;
761779
public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
762780
public fun isAppLaunchedInForeground ()Z
781+
public fun isAppStartWindowOpen ()Z
763782
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
764783
public fun onActivityDestroyed (Landroid/app/Activity;)V
765784
public fun onActivityPaused (Landroid/app/Activity;)V

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ static void initializeIntegrationsAndProcessors(
198198
}
199199

200200
final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance();
201+
options.setAppStartExtender(appStartMetrics.getAppStartExtension());
201202

202203
if (options.getModulesLoader() instanceof NoOpModulesLoader) {
203204
options.setModulesLoader(new AssetsModulesLoader(context, options));
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package io.sentry.android.core;
2+
3+
import io.sentry.IAppStartExtender;
4+
import io.sentry.ILogger;
5+
import io.sentry.ISentryLifecycleToken;
6+
import io.sentry.ISpan;
7+
import io.sentry.ITransaction;
8+
import io.sentry.NoOpSpan;
9+
import io.sentry.Sentry;
10+
import io.sentry.SentryDate;
11+
import io.sentry.SentryLevel;
12+
import io.sentry.SpanStatus;
13+
import io.sentry.android.core.performance.AppStartMetrics;
14+
import io.sentry.util.AutoClosableReentrantLock;
15+
import org.jetbrains.annotations.ApiStatus;
16+
import org.jetbrains.annotations.NotNull;
17+
import org.jetbrains.annotations.Nullable;
18+
19+
/**
20+
* Owns the lifecycle of an extended app start. Created and held by {@link AppStartMetrics}, it
21+
* keeps the new "extend app start" concern out of that already-large class.
22+
*
23+
* <p>Both the eager standalone App Start {@link ITransaction} and its extended child {@link ISpan}
24+
* are created by the integration (which has access to scopes) and handed back here via {@link
25+
* #onExtended(ITransaction, ISpan)}. This component owns them from then on: it never stores them in
26+
* the integration's shared transaction field, so the per-activity cleanup can never cancel an
27+
* eagerly-created extension.
28+
*/
29+
@ApiStatus.Internal
30+
public final class AppStartExtension implements IAppStartExtender {
31+
32+
/**
33+
* Notifies the integration that an extension was requested. The integration creates the
34+
* standalone App Start transaction + extended child span (it has scopes) and hands them back via
35+
* {@link #onExtended(ITransaction, ISpan)}. When no listener is registered (e.g. standalone
36+
* tracing is disabled), {@link #extendAppStart()} is inert and the whole API stays a no-op.
37+
*/
38+
public interface ExtendAppStartListener {
39+
void onExtendAppStartRequested();
40+
}
41+
42+
private final @NotNull AppStartMetrics metrics;
43+
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
44+
45+
private @Nullable ExtendAppStartListener extendAppStartListener;
46+
private @Nullable ISpan extendedSpan;
47+
private @Nullable ITransaction extendedTransaction;
48+
49+
public AppStartExtension(final @NotNull AppStartMetrics metrics) {
50+
this.metrics = metrics;
51+
}
52+
53+
public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) {
54+
this.extendAppStartListener = listener;
55+
}
56+
57+
@Override
58+
public void extendAppStart() {
59+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
60+
if (extendedSpan != null) {
61+
getLogger().log(SentryLevel.WARNING, "App start is already being extended.");
62+
return;
63+
}
64+
// Ignore the foreground check: headless app starts (broadcast/service) run in a
65+
// non-foreground process but can still be extended. The window gate still rejects an
66+
// extension once an activity was created, the first frame was drawn, or measurements were
67+
// already sent.
68+
if (!metrics.isAppStartWindowOpen()) {
69+
getLogger()
70+
.log(
71+
SentryLevel.WARNING,
72+
"Cannot extend app start: the app start window has already passed.");
73+
return;
74+
}
75+
final @Nullable ExtendAppStartListener listener = extendAppStartListener;
76+
if (listener != null) {
77+
listener.onExtendAppStartRequested();
78+
}
79+
}
80+
}
81+
82+
/**
83+
* Hands the eagerly-created standalone App Start transaction and its extended child span over to
84+
* this component, which owns them from now on. Called synchronously by the integration while
85+
* handling {@link ExtendAppStartListener#onExtendAppStartRequested()}.
86+
*/
87+
public void onExtended(
88+
final @NotNull ITransaction transaction, final @NotNull ISpan extendedSpan) {
89+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
90+
this.extendedTransaction = transaction;
91+
this.extendedSpan = extendedSpan;
92+
}
93+
}
94+
95+
@Override
96+
public void finishAppStart() {
97+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
98+
final @Nullable ISpan span = extendedSpan;
99+
if (span != null && !span.isFinished()) {
100+
span.finish(SpanStatus.OK);
101+
}
102+
}
103+
}
104+
105+
@Override
106+
public @NotNull ISpan getExtendedAppStartSpan() {
107+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
108+
final @Nullable ISpan span = extendedSpan;
109+
if (span != null && !span.isFinished()) {
110+
return span;
111+
}
112+
return NoOpSpan.getInstance();
113+
}
114+
}
115+
116+
/** Whether an eagerly-created extension transaction exists and has not finished yet. */
117+
public boolean isActive() {
118+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
119+
return extendedTransaction != null && !extendedTransaction.isFinished();
120+
}
121+
}
122+
123+
/**
124+
* Finishes the owned transaction at the natural app start end (first frame, or the headless stop
125+
* time). {@code waitForChildren} holds the transaction open until the extended span finishes, so
126+
* the app start vital is never captured before this point. Idempotent.
127+
*/
128+
public void finishTransaction(final @NotNull SentryDate endTimestamp) {
129+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
130+
final @Nullable ITransaction transaction = extendedTransaction;
131+
if (transaction != null && !transaction.isFinished()) {
132+
transaction.finish(SpanStatus.OK, endTimestamp);
133+
}
134+
}
135+
}
136+
137+
/**
138+
* The effective end of the extended app start, used to extend the app start vital. Returns {@code
139+
* null} when no extension finished, or when it finished via the deadline timeout - in the latter
140+
* case the vital is suppressed instead of reporting an artificially inflated duration.
141+
*/
142+
public @Nullable SentryDate getExtendedEndTime() {
143+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
144+
final @Nullable ISpan span = extendedSpan;
145+
if (span == null || !span.isFinished()) {
146+
return null;
147+
}
148+
if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) {
149+
return null;
150+
}
151+
return span.getFinishDate();
152+
}
153+
}
154+
155+
/**
156+
* Resets the per-start state so a stale extension can't affect a later (e.g. warm) app start. The
157+
* registered listener is intentionally kept: it is registered once at SDK init and must survive
158+
* across app starts.
159+
*/
160+
public void reset() {
161+
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
162+
extendedSpan = null;
163+
extendedTransaction = null;
164+
}
165+
}
166+
167+
private static @NotNull ILogger getLogger() {
168+
return Sentry.getCurrentScopes().getOptions().getLogger();
169+
}
170+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.sentry.NoOpLogger;
2121
import io.sentry.SentryDate;
2222
import io.sentry.TracesSamplingDecision;
23+
import io.sentry.android.core.AppStartExtension;
2324
import io.sentry.android.core.BuildInfoProvider;
2425
import io.sentry.android.core.ContextUtils;
2526
import io.sentry.android.core.CurrentActivityHolder;
@@ -98,6 +99,7 @@ public enum AppStartType {
9899
private @Nullable String appStartBaggageHeader;
99100
private @Nullable SentryDate appStartEndTime;
100101
private @Nullable ApplicationStartInfo cachedStartInfo;
102+
private final @NotNull AppStartExtension appStartExtension = new AppStartExtension(this);
101103

102104
public static @NotNull AppStartMetrics getInstance() {
103105
if (instance == null) {
@@ -281,6 +283,9 @@ public void onAppStartSpansSent() {
281283
shouldSendStartMeasurements = false;
282284
contentProviderOnCreates.clear();
283285
activityLifecycles.clear();
286+
// Reset extension state so a stale extended span/txn can't affect a later (e.g. warm) app
287+
// start.
288+
appStartExtension.reset();
284289
}
285290

286291
public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) {
@@ -336,6 +341,26 @@ public long getClassLoadedUptimeMs() {
336341
return new TimeSpan();
337342
}
338343

344+
// region app start extension
345+
346+
/** The focused component that owns the "extend app start" lifecycle. */
347+
public @NotNull AppStartExtension getAppStartExtension() {
348+
return appStartExtension;
349+
}
350+
351+
/**
352+
* Whether the app start window is still open, i.e. an app start can be extended: measurements
353+
* haven't been sent yet, no activity has been created, and the first frame hasn't been drawn. The
354+
* foreground check is ignored so headless app starts (broadcast/service) can also be extended.
355+
*/
356+
public boolean isAppStartWindowOpen() {
357+
return shouldSendStartMeasurements(true)
358+
&& activeActivitiesCounter.get() == 0
359+
&& !firstDrawDone.get();
360+
}
361+
362+
// endregion
363+
339364
@TestOnly
340365
void setFirstIdle(final long firstIdle) {
341366
this.firstIdle = firstIdle;
@@ -377,6 +402,7 @@ public void clear() {
377402
appStartBaggageHeader = null;
378403
appStartEndTime = null;
379404
cachedStartInfo = null;
405+
appStartExtension.reset();
380406
}
381407

382408
public @Nullable ITransactionProfiler getAppStartProfiler() {

0 commit comments

Comments
 (0)