Skip to content

Commit cc59f48

Browse files
buenaflorclaudegetsentry-bot
authored
feat(extend-app-start): App start extension API (#5604)
* collection: extend app start * changelog * feat(extend-app-start): [1/4] Add IAppStartExtender bridge (#5605) * 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): 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> * test(extend-app-start): Remove NoOpAppStartExtenderTest per PR review Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(extend-app-start): Guard setAppStartExtender against null Match the NoOp-fallback convention used by other SentryOptions setters: accept a @nullable argument and coalesce to NoOpAppStartExtender so the @NotNull field and getter can never be nulled out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(extend-app-start): Use setter method for appStartExtender The @nullable setter and @NotNull getter no longer form a Kotlin mutable property, so use setAppStartExtender(...) instead of the property-assignment syntax (matches setTransportFactory/setReplayController). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(extend-app-start): [2/4] Extract AppStartExtension component (#5606) * 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> * 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> * 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> * 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> * style(extend-app-start): Trim verbose finishTransaction comment 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): 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> * 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> * 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): Use setter method for appStartExtender The @nullable setter and @NotNull getter no longer form a Kotlin mutable property, so use setAppStartExtender(...) instead of the property-assignment syntax (matches setTransportFactory/setReplayController). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * feat(extend-app-start): [4/4] Add public Sentry app start extension API and update example app (#5628) * 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> * feat(extend-app-start): Add public Sentry app start extension API Static bridge delegating to options.getAppStartExtender(): extendAppStart(), finishAppStart(), getExtendedAppStartSpan(). No-op when the SDK is disabled or the platform provides no app start extender. Completes the extend app start feature (4/4). 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> * feat(extend-app-start): Show extending the app start in the Android sample 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 * 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> * feat(extend-app-start): Make Sentry.getExtendedAppStartSpan() nullable Mirrors Sentry.getSpan(): returns null when no extension is active instead of a NoOpSpan, so callers can tell there is no span running. Sample null-checks the span. Co-Authored-By: Claude Opus 4.8 (1M context) <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> * changelog --------- 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 5f1bb66 commit cc59f48

20 files changed

Lines changed: 1383 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44

55
### Features
66

7+
- Add `Sentry.extendAppStart()`, `Sentry.finishExtendedAppStart()`, and `Sentry.getExtendedAppStartSpan()` to extend the app start measurement past the first frame for extra launch-time work on Android ([#5604](https://github.com/getsentry/sentry-java/pull/5604))
8+
- Requires standalone app start tracing (`options.isEnableStandaloneAppStartTracing`). Call `extendAppStart()` in `Application.onCreate` after SDK init and `finishExtendedAppStart()` when done:
9+
10+
```kotlin
11+
Sentry.extendAppStart()
12+
13+
// Optionally, retrieve the extended app start span to attach your own child spans
14+
val child = Sentry.getExtendedAppStartSpan()?.startChild("preload", "Preload resources")
15+
// ... extra launch-time work ...
16+
child?.finish()
17+
18+
Sentry.finishExtendedAppStart()
19+
```
720
- Add `trace_metric_byte` data category and record byte-level client reports when trace metrics are discarded ([#5626](https://github.com/getsentry/sentry-java/pull/5626))
821
- Support the `io.sentry.tombstone.report-historical` manifest option to enable historical tombstone reporting via `AndroidManifest.xml` `<meta-data>` ([#5683](https://github.com/getsentry/sentry-java/pull/5683))
922

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,30 @@ 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 clear ()V
190+
public fun extendAppStart ()V
191+
public fun finishExtendedAppStart ()V
192+
public fun finishTransaction (Lio/sentry/SentryDate;)V
193+
public fun getExtendedAppStartSpan ()Lio/sentry/ISpan;
194+
public fun getExtendedEndTime ()Lio/sentry/SentryDate;
195+
public fun isActive ()Z
196+
public fun isExtended ()Z
197+
public fun setData (Ljava/lang/String;Ljava/lang/Object;)V
198+
public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V
199+
}
200+
201+
public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener {
202+
public abstract fun onExtendAppStartRequested ()Lio/sentry/android/core/AppStartExtension$ExtendedAppStart;
203+
}
204+
205+
public final class io/sentry/android/core/AppStartExtension$ExtendedAppStart {
206+
public final field span Lio/sentry/ISpan;
207+
public final field transaction Lio/sentry/ITransaction;
208+
public fun <init> (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V
209+
}
210+
187211
public final class io/sentry/android/core/AppState : java/io/Closeable {
188212
public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V
189213
public fun close ()V
@@ -739,12 +763,14 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
739763
public static final field staticLock Lio/sentry/util/AutoClosableReentrantLock;
740764
public fun <init> ()V
741765
public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V
766+
public fun canExtendAppStart ()Z
742767
public fun clear ()V
743768
public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan;
744769
public fun getActivityLifecycleTimeSpans ()Ljava/util/List;
745770
public fun getAppStartBaggageHeader ()Ljava/lang/String;
746771
public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler;
747772
public fun getAppStartEndTime ()Lio/sentry/SentryDate;
773+
public fun getAppStartExtension ()Lio/sentry/android/core/AppStartExtension;
748774
public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler;
749775
public fun getAppStartReason ()Ljava/lang/String;
750776
public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision;

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/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));

0 commit comments

Comments
 (0)