Skip to content

Commit d4fca2d

Browse files
buenaflorclaudegetsentry-bot
authored
feat(extend-app-start): [3/4] Eagerly create the extended app start transaction (#5608)
* feat(extend-app-start): Add IAppStartExtender bridge and SentryOptions wiring Introduces the @ApiStatus.Internal IAppStartExtender contract (extendAppStart / finishAppStart / getExtendedAppStartSpan) and a NoOp default, wired into SentryOptions. This is the naming-stable core bridge for the app start extension API; it is inert (returns NoOpSpan / no-ops) until the Android implementation and public Sentry facade land later in the stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Annotate NoOpAppStartExtender internal and fix test name typo Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Rename finishAppStart to finishExtendedAppStart Mirrors sentry-cocoa's finishExtendedAppLaunch() and makes the API name explicit about finishing the *extended* app start. Renames IAppStartExtender.finishAppStart() and the NoOpAppStartExtender implementation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * refactor(extend-app-start): Inject logger into AppStartExtension instead of static scope lookup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Return ExtendedAppStart from the listener instead of a callback The listener now returns the created transaction+span (or null to decline) rather than calling back into onExtended() while extendAppStart() holds the lock. This removes the re-entrant lock acquisition and the A->B->A round trip, collapsing two public methods into one linear flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Remove redundant comments and rename reset to clear Drop comments that restate code or annotate omissions (region markers, getter javadoc, the reset call-site comment, the ExtendedAppStart/isActive docs). Rename AppStartExtension.reset() to clear() to match its owner AppStartMetrics.clear(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Inline the logger lookup instead of injecting it The extension logs only two warnings, both on rare guard paths in extendAppStart(). Inline Sentry.getCurrentScopes().getOptions().getLogger() at those sites instead of holding a logger field set at init, dropping the field, setLogger(), and the AndroidOptionsInitializer wiring. extendAppStart() runs post-init, so the lookup always yields the configured logger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Drop the class and listener javadocs on AppStartExtension Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Trim finishTransaction/getExtendedEndTime comments Drop finishTransaction's javadoc (trivial body; it described caller context and waitForChildren behavior configured elsewhere) and reduce getExtendedEndTime's javadoc to a single inline note on the only non-obvious branch (deadline suppression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Rename AppStartExtension.finishAppStart to finishExtendedAppStart Implements the renamed IAppStartExtender.finishExtendedAppStart(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(extend-app-start): Eagerly create the extended App Start transaction (standalone-only) Registers an extend-listener on AppStartExtension that eagerly creates the standalone app.start transaction + extended child span in Application.onCreate, held open via waitForChildren until Sentry.finishAppStart() or the deadline. The first activity continues the eager trace into ui.load (attaching the screen so it stays a foreground app.start) instead of creating a second app.start; headless finishes the eager txn. The app start vital is max(natural, extended) so an early finish never shortens it, and is suppressed on deadline. Standalone-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Drop the redundant foreground-check comment on extendAppStart Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Replace the no-op finish test with isExtended coverage Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Drop the no-value getAppStartExtension test and trim comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Drop the redundant foreground-check comment on extendAppStart Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Drop the no-value getAppStartExtension test and trim comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Drop the no-op finishExtendedAppStart test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Trim and de-duplicate comments in the eager app start path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(extend-app-start): Clarify the extensionActive comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Read the extended span finish date, not its finished flag getExtendedEndTime() gated on span.isFinished(), but finishing the extended span completes the waitForChildren transaction and runs the event processor re-entrantly within finishExtendedAppStart(), before the span's finished flag is set. The processor then saw an unfinished span and dropped the app start measurement whenever the extension finished after the first frame. Read getFinishDate() (set before the finish callback) instead, which also keeps the extended end controllable in tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): End the extended transaction at the extended span end When the extended span finished after the timestamp passed to finishTransaction() but before that call ran (e.g. a synchronous extension in a headless start, where finishTransaction runs later at main-thread idle), waitForChildren had nothing left to wait for and the transaction kept the earlier passed timestamp. The extended span then ended after the transaction, and the app start vital exceeded the transaction duration. Finish at max(endTimestamp, extended span finish date) so the span is contained and the duration matches the vital. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ref(extend-app-start): Rename extended app start span op to app.start.extended Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Guard extendAppStartListener with the lock The setter wrote the field without synchronization while extendAppStart() reads it under the lock, leaving no happens-before edge. Acquire the same lock in the setter, consistent with the rest of the class's mutable state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Fix headless app start end time and duplicate txn Two issues in onHeadlessAppStart for extended starts: - The extended branch finished the eager transaction but never persisted appStartEndTime, leaving the continuation window unbounded so a later activity would wrongly continue the stale trace. Persist it on all paths. - The branch only checked isActive(), so if the extension already finished (finishExtendedAppStart or the deadline) before the headless idle ran, a second, empty standalone app.start was created. Guard on shouldSendStartMeasurements to avoid the duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Trim low-value comments in the headless fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Drop redundant extend-listener comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Trim redundant comments in onActivityCreated Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Remove explanatory comments in app start paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Consume stored app start trace on the extension path The first activity continuing an extended app.start did not clear the stored trace headers (only the headless-follow path did), so a later activity saw them and wrongly reused the finished app start trace. Clear them on the extension path too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Only the launch activity attaches the app start screen While the extension was open, every activity's onActivityCreated re-attached app.vitals.start.screen to the eager app.start, so a second activity opened before finishExtendedAppStart() overwrote the launch screen. Gate the attach on isAppStart so only the launch activity sets it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Format code * style(extend-app-start): Trim verbose finishTransaction comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Trim verbose comments in app start paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Read finish date in getExtendedAppStartSpan Finishing the extended span runs the event processor re-entrantly (via the waitForChildren transaction) before the span's isFinished() flag is set, while the finish timestamp is already in place. Reading isFinished() could therefore hand out a span that is already finishing. Switch to getFinishDate() == null, matching getExtendedEndTime(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Trim duplicated reentrancy comment in getExtendedAppStartSpan Replace the repeated reentrancy explanation with a cross-reference to getExtendedEndTime(), which holds the canonical version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Keep trimmed reentrancy comment within line length Single-line form fits the 100-char limit so spotless leaves it unwrapped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Drop two line-narrating test comments Remove comments that restated the adjacent test code (the eager extendAppStart call and the second-activity open). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(extend-app-start): Reword getExtendedAppStartSpan reentrancy comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Rename isAppStartWindowOpen to canExtendAppStart The predicate has a single consumer — the extend gate in AppStartExtension — so name it for that intent. Also drop the self-evident max-end comment in finishTransaction. Regenerated apiDump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Drop redundant getExtendedAppStartSpan finished test After getExtendedAppStartSpan switched to getFinishDate(), the "after the span finished" case exercises the same branch as the reentrancy test (finishDate set -> NoOp); the isFinished stub was inert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Drop narrating comments from app start tests The test names describe the scenarios; the inline comments restated them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(extend-app-start): Add setData and isExtended to AppStartExtension Move these component accessors into the component-extraction PR (they were introduced later in the wiring PR). Keeps the full AppStartExtension surface and its unit tests together here; the wiring PR only consumes them. Inert on its own. Regenerated apiDump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(extend-app-start): Name app start branching booleans by intent Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extend-app-start): Make shouldSendStartMeasurements volatile It is written without a lock in onAppStartSpansSent() and read across threads in canExtendAppStart() (via extendAppStart()), with no happens-before edge. volatile guarantees visibility, matching the AtomicInteger/AtomicBoolean siblings in the same check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(extend-app-start): Explain why AppStartExtension holds span and transaction Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(extend-app-start): Return null from getExtendedAppStartSpan when inactive Mirrors Sentry.getSpan(): the extender reports null when there is no active extended span instead of a NoOpSpan, so callers can tell there is no span running. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(extend-app-start): Return null from AppStartExtension.getExtendedAppStartSpan when inactive Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Assert null extended span now that getExtendedAppStartSpan is nullable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(extend-app-start): Explain single-use app start sampling decision The get-then-clear of the app start sampling decision in onExtendAppStartRequested looks redundant without context. Document that the decision is pre-rolled on the previous run, forces the eager app.start transaction's sampling and profiler binding, and must be cleared so the first ui.load can't also claim it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(extend-app-start): Bound the trace continuation window after an eager extension finishes The eager extendAppStart path persists trace headers so a later ui.load can continue the app.start trace, but never recorded the app start end time - that was only set on the headless path. If the extended transaction finished (user finish or deadline) before any activity, the first ui.load treated the continuation window as unbounded and joined the completed app-start trace no matter how much later it started. Persist the transaction's finish date via the transaction finished callback, which covers every finish path (finishExtendedAppStart, first frame, deadline), so the existing continuation window check bounds the extend path the same way it bounds the headless one. Co-Authored-By: Claude <noreply@anthropic.com> * fix(extend-app-start): Skip warm-start reclassification while the extension is active A first activity arriving more than a minute after launch resets the app start span to the activity create time and flips the type to warm. While an app start extension is active this corrupts the extended vital: the eager app.start transaction stays anchored at process start while the measurement subtracts the reset span start, and a cold launch gets reported under the warm key. An active extension is the user explicitly saying the launch is still in progress, so let it pin the launch: skip the reclassification while the extension is active and apply the heuristic as before once it has finished. Co-Authored-By: Claude <noreply@anthropic.com> * test(extend-app-start): Remove NoOpAppStartExtenderTest per PR review Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent 31479ef commit d4fca2d

6 files changed

Lines changed: 570 additions & 27 deletions

File tree

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

Lines changed: 126 additions & 14 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";
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.
@@ -139,7 +141,9 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
139141
application.registerActivityLifecycleCallbacks(this);
140142

141143
if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) {
142-
AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart);
144+
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
145+
metrics.setHeadlessAppStartListener(this::onHeadlessAppStart);
146+
metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested);
143147
addIntegrationToSdkVersion("StandaloneAppStart");
144148
}
145149

@@ -154,7 +158,9 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options
154158
@Override
155159
public void close() throws IOException {
156160
application.unregisterActivityLifecycleCallbacks(this);
157-
AppStartMetrics.getInstance().setHeadlessAppStartListener(null);
161+
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
162+
metrics.setHeadlessAppStartListener(null);
163+
metrics.getAppStartExtension().setExtendAppStartListener(null);
158164

159165
if (options != null) {
160166
options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed.");
@@ -259,17 +265,23 @@ private void startTracing(final @NotNull Activity activity) {
259265
transactionOptions.setAppStartTransaction(appStartSamplingDecision != null);
260266
setSpanOrigin(transactionOptions);
261267

268+
// Guards the headless-start check below with !isExtensionActive so the eager extension's
269+
// stored trace id isn't mistaken for a finished headless start.
270+
final boolean isExtensionActive =
271+
AppStartMetrics.getInstance().getAppStartExtension().isActive();
272+
262273
final @Nullable SentryId storedAppStartTraceId =
263274
AppStartMetrics.getInstance().getAppStartTraceId();
264-
final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null);
275+
final boolean isFollowingHeadlessAppStart =
276+
!isExtensionActive && (storedAppStartTraceId != null);
265277

266278
final boolean isAppStart =
267279
!(firstActivityCreated || appStartTime == null || coldStart == null);
268-
// Foreground starts create app.start first; ui.load then shares its trace.
269280
final boolean createStandaloneAppStart =
270281
isAppStart
271282
&& options.isEnableStandaloneAppStartTracing()
272-
&& !isFollowingHeadlessAppStart;
283+
&& !isFollowingHeadlessAppStart
284+
&& !isExtensionActive;
273285

274286
if (createStandaloneAppStart) {
275287
final TransactionOptions appStartTransactionOptions = new TransactionOptions();
@@ -300,15 +312,23 @@ private void startTracing(final @NotNull Activity activity) {
300312
continueSentryTrace = appStartTransaction.toSentryTrace().getValue();
301313
final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null);
302314
continueBaggage = baggageHeader == null ? null : baggageHeader.getValue();
303-
} else if (isFollowingHeadlessAppStart
304-
&& isWithinAppStartContinuationWindow(ttidStartTime)) {
315+
} else if (isExtensionActive
316+
|| (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) {
305317
continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader();
306318
continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader();
307319
} else {
308320
continueSentryTrace = null;
309321
continueBaggage = null;
310322
}
311323

324+
if (isExtensionActive && isAppStart) {
325+
// Only the launch activity sets the screen, so a later activity can't overwrite it. A
326+
// screen also keeps the processor from classifying the eager app.start as headless.
327+
AppStartMetrics.getInstance()
328+
.getAppStartExtension()
329+
.setData(APP_START_SCREEN_DATA, activityName);
330+
}
331+
312332
final @Nullable TransactionContext continuedContext =
313333
continueSentryTrace == null
314334
? null
@@ -328,8 +348,8 @@ && isWithinAppStartContinuationWindow(ttidStartTime)) {
328348
transactionOptions);
329349
}
330350

331-
if (isFollowingHeadlessAppStart) {
332-
// Consume the stored headless app-start trace so it isn't reused by another activity.
351+
if (isFollowingHeadlessAppStart || isExtensionActive) {
352+
// Consume the stored app-start trace so a later activity doesn't reuse it.
333353
AppStartMetrics.getInstance().setAppStartTraceId(null);
334354
AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null);
335355
AppStartMetrics.getInstance().setAppStartBaggageHeader(null);
@@ -967,6 +987,9 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) {
967987
if (appStartTransaction != null && !appStartTransaction.isFinished()) {
968988
appStartTransaction.finish(SpanStatus.OK, appStartEndTime);
969989
}
990+
// Finish the eager extended transaction at the natural first-frame end. waitForChildren keeps
991+
// it open until the extended span finishes; no-op if the app start was not extended.
992+
AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime);
970993
}
971994
}
972995

@@ -994,17 +1017,60 @@ private void onHeadlessAppStart() {
9941017
return;
9951018
}
9961019

1020+
// Persist the end time so a later ui.load can tell whether it is close enough to continue this
1021+
// trace; without it the continuation window is unbounded.
1022+
metrics.setAppStartEndTime(endTime);
1023+
1024+
final @NotNull AppStartExtension extension = metrics.getAppStartExtension();
1025+
if (extension.isActive()) {
1026+
extension.finishTransaction(endTime);
1027+
return;
1028+
}
1029+
if (!metrics.shouldSendStartMeasurements(true)) {
1030+
return;
1031+
}
1032+
1033+
final @NotNull ITransaction transaction =
1034+
createStandaloneAppStartTransaction(startTime, null, false);
1035+
transaction.finish(SpanStatus.OK, endTime);
1036+
}
1037+
1038+
/**
1039+
* Creates the standalone {@code app.start} transaction (not bound to the scope) and persists its
1040+
* trace headers so a later {@code ui.load} can share the same trace. Shared by the headless path
1041+
* and the eager extension path. When {@code holdOpenForExtension} is true, the transaction waits
1042+
* for its children and gets a deadline so it stays open until the extended span finishes.
1043+
*/
1044+
private @NotNull ITransaction createStandaloneAppStartTransaction(
1045+
final @NotNull SentryDate startTime,
1046+
final @Nullable TracesSamplingDecision samplingDecision,
1047+
final boolean holdOpenForExtension) {
1048+
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
1049+
9971050
final TransactionOptions txnOptions = new TransactionOptions();
9981051
txnOptions.setBindToScope(false);
9991052
txnOptions.setStartTimestamp(startTime);
10001053
txnOptions.setOrigin(APP_START_TRACE_ORIGIN);
1054+
txnOptions.setAppStartTransaction(samplingDecision != null);
1055+
if (holdOpenForExtension) {
1056+
txnOptions.setWaitForChildren(true);
1057+
final long deadlineTimeoutMillis = options.getDeadlineTimeout();
1058+
txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis);
1059+
// Persist the end time (covering every finish path: user finish, first frame, deadline) so a
1060+
// later ui.load can tell whether it is close enough to continue this trace; without it the
1061+
// continuation window is unbounded.
1062+
txnOptions.setTransactionFinishedCallback(
1063+
finishedTransaction ->
1064+
AppStartMetrics.getInstance()
1065+
.setAppStartEndTime(finishedTransaction.getFinishDate()));
1066+
}
10011067

10021068
final @NotNull TransactionContext txnContext =
10031069
new TransactionContext(
10041070
STANDALONE_APP_START_NAME,
10051071
TransactionNameSource.COMPONENT,
10061072
STANDALONE_APP_START_OP,
1007-
null);
1073+
samplingDecision);
10081074

10091075
final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions);
10101076
final @Nullable String appStartReason = metrics.getAppStartReason();
@@ -1016,10 +1082,56 @@ private void onHeadlessAppStart() {
10161082
metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue());
10171083
final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null);
10181084
metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue());
1019-
// Persist the end time so a later activity can decide whether its ui.load is close enough in
1020-
// time to continue this trace.
1021-
metrics.setAppStartEndTime(endTime);
1085+
return transaction;
1086+
}
10221087

1023-
transaction.finish(SpanStatus.OK, endTime);
1088+
/**
1089+
* Handles {@code Sentry.extendAppStart()}: eagerly creates the standalone app.start transaction
1090+
* and the extended child span (we have scopes here), then hands both to the {@link
1091+
* AppStartExtension}, which owns them. The transaction is held open ({@code waitForChildren})
1092+
* until the user calls {@code Sentry.finishExtendedAppStart()} or the deadline forces it.
1093+
* Standalone-only: this is only registered as a listener when standalone app start tracing is
1094+
* enabled.
1095+
*/
1096+
private @Nullable AppStartExtension.ExtendedAppStart onExtendAppStartRequested() {
1097+
if (scopes == null
1098+
|| options == null
1099+
|| !performanceEnabled
1100+
|| !options.isEnableStandaloneAppStartTracing()) {
1101+
return null;
1102+
}
1103+
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
1104+
1105+
final @NotNull TimeSpan appStartTimeSpan =
1106+
metrics.getAppStartTimeSpan().hasStarted()
1107+
? metrics.getAppStartTimeSpan()
1108+
: metrics.getSdkInitTimeSpan();
1109+
final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp();
1110+
if (startTime == null) {
1111+
return null;
1112+
}
1113+
1114+
// The app start sampling decision was pre-rolled on the previous run so the app start
1115+
// profiler could start before Sentry.init. It forces the trace sampling of the eager
1116+
// app.start transaction created below (no re-roll, staying consistent with whether the
1117+
// profiler actually started) and lets it bind the app start profiler. It's single-use:
1118+
// we clear it so the first ui.load can't also claim it.
1119+
final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision();
1120+
metrics.setAppStartSamplingDecision(null);
1121+
1122+
final @NotNull ITransaction transaction =
1123+
createStandaloneAppStartTransaction(startTime, samplingDecision, true);
1124+
1125+
final SpanOptions spanOptions = new SpanOptions();
1126+
setSpanOrigin(spanOptions);
1127+
final @NotNull ISpan extendedSpan =
1128+
transaction.startChild(
1129+
APP_START_EXTENDED_OP,
1130+
APP_START_EXTENDED_DESC,
1131+
AndroidDateUtils.getCurrentSentryDateTime(),
1132+
Instrumenter.SENTRY,
1133+
spanOptions);
1134+
1135+
return new AppStartExtension.ExtendedAppStart(transaction, extendedSpan);
10241136
}
10251137
}

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

Lines changed: 42 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,49 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) {
101103
isHeadlessStandaloneAppStartTxn
102104
? appStartMetrics.getAppStartTimeSpanForHeadless()
103105
: appStartMetrics.getAppStartTimeSpanWithFallback(options);
104-
final long appStartUpDurationMs = appStartTimeSpan.getDurationMs();
106+
final long naturalDurationMs = appStartTimeSpan.getDurationMs();
107+
108+
final long appStartUpDurationMs;
109+
final boolean shouldAttachAppStartSpans;
110+
final boolean reportAppStartMeasurement;
111+
final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension();
112+
if (extension.isExtended()) {
113+
final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime();
114+
if (extendedEnd != null && appStartTimeSpan.hasStarted()) {
115+
// Measure to the extended end, but never shorter than the natural first-frame
116+
// duration.
117+
final long extendedDurationMs =
118+
TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp())
119+
- appStartTimeSpan.getStartTimestampMs();
120+
appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs);
121+
shouldAttachAppStartSpans = appStartUpDurationMs != 0;
122+
reportAppStartMeasurement = shouldAttachAppStartSpans;
123+
} else {
124+
// Deadline (null) or no valid start: attach the spans but suppress the measurement so
125+
// it isn't inflated.
126+
appStartUpDurationMs = 0;
127+
shouldAttachAppStartSpans = appStartTimeSpan.hasStarted();
128+
reportAppStartMeasurement = false;
129+
}
130+
} else {
131+
appStartUpDurationMs = naturalDurationMs;
132+
shouldAttachAppStartSpans = appStartUpDurationMs != 0;
133+
reportAppStartMeasurement = shouldAttachAppStartSpans;
134+
}
105135

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());
136+
if (shouldAttachAppStartSpans) {
137+
if (reportAppStartMeasurement) {
138+
final MeasurementValue value =
139+
new MeasurementValue(
140+
(float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName());
111141

112-
final String appStartKey =
113-
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
114-
? MeasurementValue.KEY_APP_START_COLD
115-
: MeasurementValue.KEY_APP_START_WARM;
142+
final String appStartKey =
143+
appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD
144+
? MeasurementValue.KEY_APP_START_COLD
145+
: MeasurementValue.KEY_APP_START_WARM;
116146

117-
transaction.getMeasurements().put(appStartKey, value);
147+
transaction.getMeasurements().put(appStartKey, value);
148+
}
118149

119150
attachAppStartSpans(appStartMetrics, transaction);
120151
appStartMetrics.onAppStartSpansSent();

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -643,8 +643,12 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved
643643
// NOTE: meaningless in standalone app start mode, where a headless start is already its own
644644
// standalone transaction and therefore cannot be re-classified as warm.
645645
final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs();
646-
if (!appLaunchedInForeground.getValue()
647-
|| durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) {
646+
// An active extension explicitly keeps the launch alive: resetting the span here would make
647+
// the extended vital measure from the activity while the eager app.start transaction stays
648+
// anchored at process start.
649+
if ((!appLaunchedInForeground.getValue()
650+
|| durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1))
651+
&& !appStartExtension.isActive()) {
648652
appStartType = AppStartType.WARM;
649653
shouldSendStartMeasurements = true;
650654
appStartSpan.reset();

0 commit comments

Comments
 (0)