diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbbede5794..35d7764c2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `options.isEnableStandaloneAppStartTracing = true` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) + ### Fixes - Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473)) diff --git a/scripts/test-standalone-app-start.sh b/scripts/test-standalone-app-start.sh new file mode 100755 index 00000000000..972f5358a8c --- /dev/null +++ b/scripts/test-standalone-app-start.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# End-to-end test harness for standalone app start tracing (issue #5046). +# Exercises scenarios 1a, 1c, 2a-2f against two running emulators. +# Requires: adb, two running emulators (API 36 + API 33), JDK 17. + +set -u + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +export JAVA_HOME="${JAVA_HOME:-/Library/Java/JavaVirtualMachines/openjdk-17.jdk/Contents/Home}" + +PKG="io.sentry.samples.android" +APK_SRC="sentry-samples/sentry-samples-android/build/outputs/apk/debug/sentry-samples-android-debug.apk" +EMU_API36="emulator-5554" +EMU_API33="emulator-5556" +OUT_DIR="/tmp/standalone-app-start-logs" +APK_A="$OUT_DIR/APK-A.apk" # flag=on, simulate-plugin=on (tier 1) +APK_B="$OUT_DIR/APK-B.apk" # flag=off, simulate-plugin=off (regression) +APK_C="$OUT_DIR/APK-C.apk" # flag=on, simulate-plugin=off (tier 2 / tier 3) +MAIN_ACTIVITY="${PKG}/.MainActivity" +RECEIVER="${PKG}/.TestBroadcastReceiver" +BROADCAST_ACTION="io.sentry.samples.android.TEST_BROADCAST" +SERVICE="${PKG}/.DummyService" + +# Wait durations (seconds). Must exceed the transaction deadline timeout (30s default) +# because the sample MainActivity keeps the main thread busy so idleTimeout doesn't fire. +WAIT_ACTIVITY=35 +WAIT_BROADCAST=8 +WAIT_COMBO=40 +# Max time we wait for Sentry to actually flush cached envelopes to sentry.io after we +# foreground the app. Emulators often fail on IPv6 first and take ~60-75s before falling +# back to IPv4 on the transport's retry, so 120s gives comfortable slack. +DELIVERY_TIMEOUT=120 + +mkdir -p "$OUT_DIR" +rm -f "$OUT_DIR"/*.log + +PASS=0 +FAIL=0 +FAIL_LINES=() + +red() { printf "\033[31m%s\033[0m" "$*"; } +green() { printf "\033[32m%s\033[0m" "$*"; } +bold() { printf "\033[1m%s\033[0m" "$*"; } + +banner() { echo ""; bold "========= $* ========="; echo ""; } + +build_apk() { + local flag=$1 simulate=$2 dest=$3 label=$4 + echo "--- build $label: -PstandaloneAppStart=$flag -PsimulateSentryGradlePlugin=$simulate ---" + ./gradlew :sentry-samples:sentry-samples-android:assembleDebug \ + -PstandaloneAppStart="$flag" \ + -PsimulateSentryGradlePlugin="$simulate" \ + -q 2>&1 | tail -3 + if [[ ! -f "$APK_SRC" ]]; then + echo "BUILD FAILED: APK not produced" + exit 1 + fi + cp "$APK_SRC" "$dest" + echo " saved -> $dest" +} + +install_from() { + local device=$1 apk=$2 + echo "--- install $(basename $apk) on $device ---" + adb -s "$device" install -r -t "$apk" 2>&1 | tail -1 +} + +wake_device() { + local device=$1 + adb -s "$device" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true + adb -s "$device" shell input keyevent 82 >/dev/null 2>&1 || true +} + +prep_for_run() { + local device=$1 + adb -s "$device" shell am force-stop "$PKG" + adb -s "$device" logcat -c + # brief pause to let system settle + sleep 0.3 +} + +dump_log() { + local device=$1 file=$2 + adb -s "$device" logcat -d -v time SentryE2E:D '*:S' > "$file" + # Also capture full Sentry SDK debug log for diagnostics. + adb -s "$device" logcat -d -v time Sentry:D SentryE2E:D StrictMode:D AndroidRuntime:E '*:S' \ + > "${file%.log}.full.log" +} + +# Extract traceIds of logged transactions in order. +extract_traceids() { + local file=$1 + grep -oE 'traceId=[a-f0-9]+' "$file" | sed 's/traceId=//' +} + +# Extract unique names of logged transactions in order. +extract_names() { + local file=$1 + awk '/SentryE2E.*TXN\|/ { + match($0, /name=[^|]+/); n = substr($0, RSTART+5, RLENGTH-5); print n + }' "$file" +} + +assert() { + local desc=$1 cmd=$2 + if eval "$cmd"; then + echo " $(green PASS) $desc" + PASS=$((PASS+1)) + else + echo " $(red FAIL) $desc" + FAIL=$((FAIL+1)) + FAIL_LINES+=("$desc") + fi +} + +# Post-scenario drain: foreground the app (via MainActivity) so AndroidConnectionStatusProvider +# flips from DISCONNECTED → CONNECTED (it reports null during broadcast-only cold starts), +# then poll logcat until at least one "Envelope sent successfully" appears. Safe to call after +# TXN assertions are done; the extra MainActivity launch emits its own ui.load but by then the +# scenario log was already captured. +verify_delivery() { + local device=$1 scenario=$2 + adb -s "$device" shell am start -n "$MAIN_ACTIVITY" >/dev/null + local drain_log="$OUT_DIR/${scenario}.delivery.log" + local deadline=$((SECONDS + DELIVERY_TIMEOUT)) + local sent=0 + echo " polling for envelope delivery (timeout ${DELIVERY_TIMEOUT}s)..." + while (( SECONDS < deadline )); do + adb -s "$device" logcat -d -v time Sentry:D '*:S' > "$drain_log" + sent=$(grep -c 'Envelope sent successfully' "$drain_log" || true) + [[ "$sent" -gt 0 ]] && break + sleep 3 + done + local queued + queued=$(grep -c 'Adding Envelope to offline storage' "$drain_log" || true) + assert "delivery: envelopes sent to Sentry (sent=$sent queued=$queued)" \ + "[[ '$sent' -gt '0' ]]" +} + +# ================= SCENARIOS ================= + +build_needed_variants() { + banner "Build variants" + [[ $needs_A -eq 1 ]] && build_apk true true "$APK_A" "APK-A (flag=on, simulate-plugin=on)" + [[ $needs_B -eq 1 ]] && build_apk false false "$APK_B" "APK-B (flag=off, simulate-plugin=off)" + [[ $needs_C -eq 1 ]] && build_apk true false "$APK_C" "APK-C (flag=on, simulate-plugin=off)" +} + +scenario_1a() { + banner "1a: Cold + flag ON (launcher) — API 36 / APK-A" + install_from "$EMU_API36" "$APK_A" + wake_device "$EMU_API36" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am start -n "$MAIN_ACTIVITY" >/dev/null + sleep $WAIT_ACTIVITY + local log="$OUT_DIR/1a.log" + dump_log "$EMU_API36" "$log" + + assert "App Start standalone txn emitted" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "MainActivity ui.load txn emitted" \ + "grep -qE 'name=MainActivity.*op=ui\.load' '$log'" + assert "ui.load txn does NOT contain app.start.* child span" \ + "! grep -E 'name=MainActivity.*op=ui\.load' '$log' | grep -qE 'children=.*app\.start\.(cold|warm)'" + assert "standalone txn has process.load or application.load child" \ + "grep -E '\|op=app\.start\|' '$log' | grep -qE 'children=.*(process\.load|application\.load)'" + # Both txns share same traceId + local tids=$(extract_traceids "$log" | sort -u) + local count=$(echo "$tids" | wc -l | tr -d ' ') + assert "standalone + ui.load share traceId (unique traceId count = 1)" \ + "[[ '$count' == '1' ]]" + verify_delivery "$EMU_API36" "1a" +} + +scenario_1c() { + banner "1c: Cold + flag OFF (regression) — API 36 / APK-B" + install_from "$EMU_API36" "$APK_B" + wake_device "$EMU_API36" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am start -n "$MAIN_ACTIVITY" >/dev/null + sleep $WAIT_ACTIVITY + local log="$OUT_DIR/1c.log" + dump_log "$EMU_API36" "$log" + + assert "MainActivity ui.load txn emitted" \ + "grep -qE 'name=MainActivity.*op=ui\.load' '$log'" + assert "ui.load txn CONTAINS app.start.* child span (legacy)" \ + "grep -E 'name=MainActivity.*op=ui\.load' '$log' | grep -qE 'children=.*app\.start\.(cold|warm)'" + assert "NO standalone App Start txn emitted" \ + "! grep -qE 'name=App Start\|op=app\.start\|' '$log'" + verify_delivery "$EMU_API36" "1c" +} + +scenario_2a() { + banner "2a: Broadcast cold, tier 1 (simulated plugin) — API 36 / APK-A" + install_from "$EMU_API36" "$APK_A" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am broadcast -a "$BROADCAST_ACTION" -n "$RECEIVER" >/dev/null + sleep $WAIT_BROADCAST + local log="$OUT_DIR/2a.log" + dump_log "$EMU_API36" "$log" + + assert "App Start standalone txn emitted" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "standalone has process.load child" \ + "grep -qE '\|op=app\.start\|.*children=.*process\.load' '$log'" + assert "standalone has application.load child" \ + "grep -qE '\|op=app\.start\|.*children=.*application\.load' '$log'" + assert "NO ui.load txn emitted" \ + "! grep -qE 'op=ui\.load' '$log'" + verify_delivery "$EMU_API36" "2a" +} + +scenario_2b() { + banner "2b: Broadcast cold, tier 2 (ApplicationStartInfo) — API 36 / APK-C" + install_from "$EMU_API36" "$APK_C" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am broadcast -a "$BROADCAST_ACTION" -n "$RECEIVER" >/dev/null + sleep $WAIT_BROADCAST + local log="$OUT_DIR/2b.log" + dump_log "$EMU_API36" "$log" + + assert "App Start standalone txn emitted (tier 2 API 35+)" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "standalone has process.load child" \ + "grep -qE '\|op=app\.start\|.*children=.*process\.load' '$log'" + assert "NO ui.load txn emitted" \ + "! grep -qE 'op=ui\.load' '$log'" + verify_delivery "$EMU_API36" "2b" +} + +scenario_2c() { + banner "2c: Broadcast cold, tier 3 (CLASS_LOADED fallback) — API 33 / APK-C" + install_from "$EMU_API33" "$APK_C" + prep_for_run "$EMU_API33" + adb -s "$EMU_API33" shell am broadcast -a "$BROADCAST_ACTION" -n "$RECEIVER" >/dev/null + sleep $WAIT_BROADCAST + local log="$OUT_DIR/2c.log" + dump_log "$EMU_API33" "$log" + + assert "App Start standalone txn emitted (tier 3 fallback)" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "standalone has process.load child" \ + "grep -qE '\|op=app\.start\|.*children=.*process\.load' '$log'" + assert "NO ui.load txn emitted" \ + "! grep -qE 'op=ui\.load' '$log'" + verify_delivery "$EMU_API33" "2c" +} + +scenario_2d() { + banner "2d: Foreground service cold start — API 36 / APK-A" + install_from "$EMU_API36" "$APK_A" + # Notification permission is needed for the foreground service notification + adb -s "$EMU_API36" shell pm grant "$PKG" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am start-foreground-service -n "$SERVICE" >/dev/null + sleep $WAIT_BROADCAST + local log="$OUT_DIR/2d.log" + dump_log "$EMU_API36" "$log" + + assert "App Start standalone txn emitted via foreground service" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "NO ui.load txn emitted" \ + "! grep -qE 'op=ui\.load' '$log'" + verify_delivery "$EMU_API36" "2d" +} + +scenario_2e() { + banner "2e: Broadcast → launcher (trace reuse) — API 36 / APK-A" + install_from "$EMU_API36" "$APK_A" + wake_device "$EMU_API36" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am broadcast -a "$BROADCAST_ACTION" -n "$RECEIVER" >/dev/null + sleep 3 + adb -s "$EMU_API36" shell am start -n "$MAIN_ACTIVITY" >/dev/null + sleep $WAIT_COMBO + local log="$OUT_DIR/2e.log" + dump_log "$EMU_API36" "$log" + + assert "App Start standalone txn emitted (from broadcast)" \ + "grep -qE 'name=App Start\|op=app\.start\|' '$log'" + assert "MainActivity ui.load txn emitted" \ + "grep -qE 'name=MainActivity.*op=ui\.load' '$log'" + assert "ui.load has NO app.start.* child" \ + "! grep -E 'name=MainActivity.*op=ui\.load' '$log' | grep -qE 'children=.*app\.start\.'" + local tids=$(extract_traceids "$log" | sort -u) + local count=$(echo "$tids" | wc -l | tr -d ' ') + assert "broadcast + launcher txns share same traceId" \ + "[[ '$count' == '1' ]]" + verify_delivery "$EMU_API36" "2e" +} + +scenario_2f() { + banner "2f: Broadcast + flag OFF (regression) — API 36 / APK-B" + install_from "$EMU_API36" "$APK_B" + prep_for_run "$EMU_API36" + adb -s "$EMU_API36" shell am broadcast -a "$BROADCAST_ACTION" -n "$RECEIVER" >/dev/null + sleep $WAIT_BROADCAST + local log="$OUT_DIR/2f.log" + dump_log "$EMU_API36" "$log" + + assert "NO transactions emitted (flag off + no activity)" \ + "! grep -qE 'SentryE2E.*TXN\\|' '$log'" +} + +usage() { + cat < ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..828d455ffd8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,6 +9,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -18,6 +20,7 @@ import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; @@ -33,6 +36,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -40,6 +44,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.Date; import java.util.Map; import java.util.WeakHashMap; @@ -55,12 +60,19 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; static final long TTFD_TIMEOUT_MILLIS = 25000; + // If a headless app start and the following activity's ui.load are more than this far apart, they + // are treated as unrelated and not connected into the same trace. + static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,6 +89,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -124,6 +137,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +153,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setHeadlessAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +258,95 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + + final boolean isAppStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + // Foreground starts create app.start first; ui.load then shares its trace. + final boolean createStandaloneAppStart = + isAppStart + && options.isEnableStandaloneAppStartTracing() + && !isFollowingHeadlessAppStart; + + if (createStandaloneAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + } + + // Continue either the foreground app.start above or an earlier headless app.start. + final @Nullable String continueSentryTrace; + final @Nullable String continueBaggage; + if (createStandaloneAppStart) { + continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); + final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); + continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); + } else if (isFollowingHeadlessAppStart + && isWithinAppStartContinuationWindow(ttidStartTime)) { + continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); + continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); + } else { + continueSentryTrace = null; + continueBaggage = null; + } + + final @Nullable TransactionContext continuedContext = + continueSentryTrace == null + ? null + : continueUiLoadTrace(continueSentryTrace, continueBaggage, activityName); + + final ITransaction transaction; + if (continuedContext != null) { + transaction = scopes.startTransaction(continuedContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } + + if (isFollowingHeadlessAppStart) { + // Consume the stored headless app-start trace so it isn't reused by another activity. + AppStartMetrics.getInstance().setAppStartTraceId(null); + AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); + AppStartMetrics.getInstance().setAppStartBaggageHeader(null); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (isAppStart) { + if (createStandaloneAppStart) { + finishAppStartSpan(); + } else if (!options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -316,6 +397,61 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { spanOptions.setOrigin(TRACE_ORIGIN); } + /** + * Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless + * app start to belong to the same trace. If they are more than {@link + * #APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS} apart, they are treated as unrelated. When + * the headless end time is unknown, we keep the previous behaviour and continue the trace. + */ + private boolean isWithinAppStartContinuationWindow(final @NotNull SentryDate uiLoadStartTime) { + final @Nullable SentryDate appStartEndTime = AppStartMetrics.getInstance().getAppStartEndTime(); + if (appStartEndTime == null) { + return true; + } + return uiLoadStartTime.diff(appStartEndTime) <= APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS; + } + + /** + * Builds a {@link TransactionContext} for the ui.load transaction that shares the standalone + * app.start trace (same traceId and sampleRand) while staying a sibling (no parentSpanId), rather + * than a child. The continued baggage keeps sampling decisions on the same sampleRand. Returns + * null if the trace cannot be continued, so callers can fall back. + */ + private @Nullable TransactionContext continueUiLoadTrace( + final @NotNull String sentryTrace, + final @Nullable String baggage, + final @NotNull String activityName) { + if (options == null || !options.isTracingEnabled()) { + return null; + } + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + options.getLogger(), + sentryTrace, + baggage == null ? null : Collections.singletonList(baggage), + options); + final @Nullable Boolean parentSampled = propagationContext.isSampled(); + final @NotNull Baggage continuedBaggage = propagationContext.getBaggage(); + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSampled == null + ? null + : new TracesSamplingDecision( + parentSampled, + continuedBaggage.getSampleRate(), + propagationContext.getSampleRand()); + final @NotNull TransactionContext context = + new TransactionContext( + propagationContext.getTraceId(), + propagationContext.getSpanId(), + null, + parentSamplingDecision, + continuedBaggage); + context.setName(activityName); + context.setTransactionNameSource(TransactionNameSource.COMPONENT); + context.setOperation(UI_LOAD_OP); + return context; + } + @VisibleForTesting void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( @@ -440,8 +576,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,8 +614,7 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } @@ -559,6 +693,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +712,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -637,22 +775,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +803,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +816,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +929,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +948,70 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } + } + } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; } + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + // Persist trace headers so a later ui.load can share traceId and sampleRand. + metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); + final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); + metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); + // Persist the end time so a later activity can decide whether its ui.load is close enough in + // time to continue this trace. + metrics.setAppStartEndTime(endTime); + + transaction.finish(SpanStatus.OK, endTime); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index e16d4b312fc..c34ee0dbfa9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -108,6 +108,9 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; @@ -502,6 +505,13 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..0b50b5080f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,7 +1,9 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -84,9 +86,21 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -216,9 +230,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +257,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +274,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +289,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +302,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +312,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bb9ec17aabd..a460642d9f2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -246,6 +246,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -677,6 +679,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

The SDK reports app start through these paths: + * + *

    + *
  • With an Activity: the SDK sends an {@code App Start} transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
      + *
    • On Android API 35 and newer, the SDK can use {@code ApplicationStartInfo} to + * classify cold versus warm starts and find the {@code Application.onCreate} end + * time. + *
    • Before Android API 35, headless launches are treated as cold once {@code + * Application.onCreate} finishes without an Activity. The end time falls back to the + * best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 746805fcfdc..e39ddb14da6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -10,7 +10,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.MessageQueue; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -19,12 +18,14 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpLogger; +import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,15 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + // Captures a headless app.start so a later ui.load can share its trace. + private @Nullable SentryId appStartTraceId; + private @Nullable String appStartSentryTraceHeader; + private @Nullable String appStartBaggageHeader; + private @Nullable SentryDate appStartEndTime; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -161,6 +175,48 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + public @Nullable String getAppStartSentryTraceHeader() { + return appStartSentryTraceHeader; + } + + public void setAppStartSentryTraceHeader(final @Nullable String appStartSentryTraceHeader) { + this.appStartSentryTraceHeader = appStartSentryTraceHeader; + } + + public @Nullable String getAppStartBaggageHeader() { + return appStartBaggageHeader; + } + + public void setAppStartBaggageHeader(final @Nullable String appStartBaggageHeader) { + this.appStartBaggageHeader = appStartBaggageHeader; + } + + public @Nullable SentryDate getAppStartEndTime() { + return appStartEndTime; + } + + public void setAppStartEndTime(final @Nullable SentryDate appStartEndTime) { + this.appStartEndTime = appStartEndTime; + } + /** * Provides all collected content provider onCreate time spans * @@ -188,14 +244,30 @@ public void onAppStartSpansSent() { activityLifecycles.clear(); } + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements + && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + } + public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -258,6 +330,14 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + appStartSentryTraceHeader = null; + appStartBaggageHeader = null; + appStartEndTime = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -346,6 +426,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { activityManager.getHistoricalProcessStartReasons(1); if (!historicalProcessStartReasons.isEmpty()) { final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { appStartType = AppStartType.COLD; @@ -357,41 +438,64 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( - new MessageQueue.IdleHandler() { - @Override - public boolean queueIdle() { - firstIdle = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - return false; - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; }); - } else if (appStartType == AppStartType.UNKNOWN) { - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. + } else { final Handler handler = new Handler(Looper.getMainLooper()); handler.post( - new Runnable() { - @Override - public void run() { - // not technically correct, but close enough for pre-M - firstIdle = SystemClock.uptimeMillis(); - handler.post(() -> checkCreateTimeOnMain()); - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); }); } } - private void checkCreateTimeOnMain() { - // if no activity has ever been created, app was launched in background + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { if (activeActivitiesCounter.get() == 0) { + // SDK init happened after Application.onCreate (e.g. deferred/late init inside an Activity): + // we missed the Activity's onActivityCreated, but a foreground process means it was a real + // launch, not a headless start. Don't emit a (fake) headless app-start; just keep the counter + // consistent so the later onActivityDestroyed doesn't go negative. Gated on the listener so + // only the standalone-app-start path (which is what could emit a headless transaction) is + // affected. + if (headlessAppStartListener != null && ContextUtils.isForegroundImportance()) { + activeActivitiesCounter.compareAndSet(0, 1); + return; + } + appLaunchedInForeground.setValue(false); + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); @@ -401,6 +505,57 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateNanos != null) { + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (see + // ActivityThread.handleBindApplication), the same base as TimeSpan, so we can use it + // directly as an uptime-millis value without re-anchoring. + final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); + if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { + applicationOnCreate.setStoppedAt(onCreateUptimeMs); + } + stopHeadlessAppStartAt(onCreateUptimeMs); + return; + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); } } @@ -413,7 +568,9 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, consider it a warm start + // If the app (process) was launched more than 1 minute ago, consider it a warm start. + // NOTE: meaningless in standalone app start mode, where a headless start is already its own + // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground.getValue() || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 9e94d7b9905..3ddc6d3e89b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -7,6 +7,7 @@ import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -22,17 +23,21 @@ import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider import io.sentry.SentryNanotimeDate +import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.Span +import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.SpanStatus.OK import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty @@ -52,6 +57,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -64,6 +70,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +90,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +112,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +240,192 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("auto.app.start", options.origin) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +729,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1105,250 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertEquals("auto.app.start", fixture.capturedOptions[appStartIndex].origin) + assertEquals("auto.ui.activity", fixture.capturedOptions[uiLoadIndex].origin) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `activity following a headless start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + // headless start always stores the trace header alongside the trace id; the ui.load txn + // continues that trace via continueTrace, sharing the trace id. + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `activity within a minute of the headless start continues the same trace`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended right before the activity opens + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + } + + @Test + fun `activity more than a minute after the headless start starts a fresh trace`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended at epoch, but the activity opens more than a minute later + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(TimeUnit.MINUTES.toMillis(2)), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + // too far apart: the ui.load gets its own fresh trace, not the stored one + assertNotEquals(storedTraceId, context.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + val metrics = AppStartMetrics.getInstance() + val sentryTraceHeader = metrics.appStartSentryTraceHeader + val baggageHeader = metrics.appStartBaggageHeader + assertNotNull(sentryTraceHeader) + assertNotNull(baggageHeader) + // sentry-trace carries the standalone app.start trace id so a later ui.load txn can continue it + assertTrue(sentryTraceHeader.startsWith(transaction.spanContext.traceId.toString())) + } + + @Test + fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + // the app-start sampling decision carries the sampleRand the whole trace should share + AppStartMetrics.getInstance() + .setAppStartSamplingDecision(TracesSamplingDecision(true, 1.0, 0.42)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + // app.start is created first so it roots the trace; ui.load shares it + assertTrue(appStartIndex < uiLoadIndex) + + val appStartContext = fixture.capturedContexts[appStartIndex] + val uiLoadContext = fixture.capturedContexts[uiLoadIndex] + assertEquals(appStartContext.traceId, uiLoadContext.traceId) + // both share the same sampleRand + assertEquals(0.42, appStartContext.baggage?.sampleRand) + assertEquals(0.42, uiLoadContext.baggage?.sampleRand) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + } + + @Test + fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // 1) a headless start emits the standalone app.start and stores its trace headers + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + driveHeadlessAppStart() + val appStartTransaction = fixture.createdTransactions.single() + + // 2) an activity opens and shares the stored trace instead of emitting a second standalone + setAppStartTime() + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + assertFalse( + fixture.capturedContexts.drop(1).any { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + ) + assertEquals(appStartTransaction.spanContext.traceId, uiLoadContext.traceId) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + + // stored continuation state is consumed + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(AppStartMetrics.getInstance().appStartSentryTraceHeader) + assertNull(AppStartMetrics.getInstance().appStartBaggageHeader) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2204,59 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + // A headless start (broadcast/service) runs in a non-foreground-importance process. The + // foreground guard in AppStartMetrics suppresses the headless path for foreground processes + // (deferred init inside an Activity), so headless scenarios must simulate background + // importance. + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d8ac959601a..3b12e6489a7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1492,6 +1492,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..173b4e3d999 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -13,7 +13,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -87,7 +89,7 @@ class PerformanceAndroidEventProcessorTest { fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -95,11 +97,106 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = createStandaloneAppStartTransaction() + setStandaloneColdAppStartMetrics() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -111,7 +208,7 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.WARM) + var tr = createUiLoadTransactionWithAppStartChildSpan(coldStart = false) setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -123,7 +220,7 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -136,23 +233,40 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction(AppStartType.COLD) + var tr1 = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction(AppStartType.UNKNOWN) + var tr2 = createUiLoadTransaction() tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = createStandaloneAppStartTransaction() + tr1 = sut.process(tr1, Hint()) + + var tr2 = createStandaloneAppStartTransaction() + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -163,7 +277,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -174,7 +288,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -184,7 +298,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createTransaction("custom.op") tr = sut.process(tr, Hint()) @@ -194,7 +308,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -464,10 +578,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,13 +981,44 @@ class PerformanceAndroidEventProcessorTest { } } - private fun getTransaction(type: AppStartType): SentryTransaction { - val op = - when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } } + } + } + + private fun createUiLoadTransactionWithAppStartChildSpan( + coldStart: Boolean = true + ): SentryTransaction = + createUiLoadTransaction().also { txn -> + txn.spans.add(createAppStartSpan(txn.contexts.trace!!.traceId, coldStart)) + } + + private fun createUiLoadTransaction(): SentryTransaction = createTransaction(UI_LOAD_OP) + + private fun createStandaloneAppStartTransaction( + appStartScreen: String? = null + ): SentryTransaction = + createTransaction(STANDALONE_APP_START_OP).also { txn -> + if (appStartScreen != null) { + txn.contexts.trace!!.setData(APP_START_SCREEN_DATA, appStartScreen) + } + } + + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..0eec9502702 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index e7079bd46d0..a959c5dd865 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.app.ApplicationStartInfo import android.os.Build import org.robolectric.annotation.Implementation @@ -10,13 +11,25 @@ import org.robolectric.annotation.Implements class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setImportance(importance: Int) { + this.importance = importance + } + fun reset() { historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..9feb7e32b5c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -13,11 +13,14 @@ import io.sentry.DateUtils import io.sentry.IContinuousProfiler import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -27,6 +30,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -44,6 +48,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +70,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +84,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -167,10 +174,10 @@ class AppStartMetricsTest { // when the looper runs waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -180,7 +187,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -194,7 +201,7 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched + // when the handler callback is executed and the start is headless waitForMainLooperIdle() // isAppLaunchedInForeground should be false @@ -208,11 +215,157 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() = headlessProcess { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `foreground process does not fire HeadlessAppStartListener`() { + // Deferred/late SDK init inside an already-running Activity: we missed onActivityCreated, but + // the process is foreground (Robolectric default importance), so this is a real launch, not a + // headless start. The listener must not fire and the headless reclassification must not run. + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() } + // Simulates a real headless start (broadcast/service), i.e. a non-foreground-importance process. + // The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so headless + // scenarios must opt into a background importance explicitly. + private fun headlessProcess(block: () -> T): T = + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + block() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -331,12 +484,12 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created + // Main thread performs the check and sets the flag to false if the start is headless waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -369,11 +522,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -387,18 +540,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -585,7 +738,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +943,7 @@ class AppStartMetricsTest { whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() waitForMainLooperIdle() @@ -804,7 +956,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(secondActivity, null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() // Third activity - should still be warm @@ -812,7 +964,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..adffefeaddd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -1,18 +1,25 @@ package io.sentry.android.core.performance +import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -25,7 +32,9 @@ class AppStartMetricsTestApi35 { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -42,6 +51,22 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +106,108 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses ApplicationStartInfo onCreate uptime timestamp`() { + val appStartUptimeMs = 100L + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (the same + // base as TimeSpan), so it is used directly as an uptime value without any clock re-anchoring. + val onCreateUptimeMs = 350L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(appStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index bb2c3954ca6..6df73013164 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -76,9 +76,20 @@ android { } } + val standaloneAppStart = (project.findProperty("standaloneAppStart") as? String) ?: "true" + val simulateSentryGradlePlugin = + ((project.findProperty("simulateSentryGradlePlugin") as? String) ?: "false").toBoolean() + buildTypes { getByName("debug") { - addManifestPlaceholders(mapOf("sentryDebug" to true, "sentryEnvironment" to "debug")) + addManifestPlaceholders( + mapOf( + "sentryDebug" to true, + "sentryEnvironment" to "debug", + "standaloneAppStart" to standaloneAppStart, + ) + ) + buildConfigField("boolean", "SIMULATE_GRADLE_PLUGIN", simulateSentryGradlePlugin.toString()) } getByName("release") { isMinifyEnabled = true @@ -86,7 +97,14 @@ android { signingConfig = signingConfigs.getByName("debug") // to be able to run release mode isShrinkResources = true - addManifestPlaceholders(mapOf("sentryDebug" to false, "sentryEnvironment" to "release")) + addManifestPlaceholders( + mapOf( + "sentryDebug" to false, + "sentryEnvironment" to "release", + "standaloneAppStart" to standaloneAppStart, + ) + ) + buildConfigField("boolean", "SIMULATE_GRADLE_PLUGIN", simulateSentryGradlePlugin.toString()) } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 26f526124b4..a35cc3fe328 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ android:exported="true" android:foregroundServiceType="remoteMessaging" /> + + + + + + - - + + @@ -225,6 +236,10 @@ android:name="io.sentry.performance-v2.enable" android:value="true" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 572c4cdba72..b3e13d59f82 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -2,30 +2,109 @@ import android.app.Application; import android.os.StrictMode; +import android.util.Log; import io.sentry.Sentry; +import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.protocol.SentrySpan; +import io.sentry.protocol.SentryTransaction; +import java.util.Map; /** Apps. main Application. */ public class MyApplication extends Application { + private static final String E2E_TAG = "SentryE2E"; + @Override public void onCreate() { + // Mirrors the Gradle plugin's start/end hooks; missing the start hook produces invalid + // application.load timestamps. + if (BuildConfig.SIMULATE_GRADLE_PLUGIN) { + AppStartMetrics.onApplicationCreate(this); + } + + // beforeSendTransaction observes the transaction after PerformanceAndroidEventProcessor. + SentryAndroid.init( + this, + options -> { + options.setBeforeSendTransaction( + (txn, hint) -> { + logTransaction(txn); + return txn; + }); + }); + Sentry.startProfiler(); strictMode(); super.onCreate(); - // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. - // Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false - // SentryAndroid.init( - // this, - // options -> { - // /* - // use options, for example, to add a beforeSend callback: - // - // options.setBeforeSend((event, hint) -> { - // process event - // }); - // */ - // }); + if (BuildConfig.SIMULATE_GRADLE_PLUGIN) { + Log.d(E2E_TAG, "SIMULATE_GRADLE_PLUGIN=true -> calling onApplicationPostCreate"); + AppStartMetrics.onApplicationPostCreate(this); + } + + Log.d(E2E_TAG, "APP_ONCREATE_DONE"); + } + + private static void logTransaction(SentryTransaction txn) { + final String name = txn.getTransaction(); + final String eventId = txn.getEventId() != null ? txn.getEventId().toString() : "null"; + String op = "null"; + String traceId = "null"; + String rootSpanId = "null"; + if (txn.getContexts() != null && txn.getContexts().getTrace() != null) { + op = txn.getContexts().getTrace().getOperation(); + if (txn.getContexts().getTrace().getTraceId() != null) { + traceId = txn.getContexts().getTrace().getTraceId().toString(); + } + if (txn.getContexts().getTrace().getSpanId() != null) { + rootSpanId = txn.getContexts().getTrace().getSpanId().toString(); + } + } + + final StringBuilder measurements = new StringBuilder("["); + if (txn.getMeasurements() != null) { + boolean first = true; + for (Map.Entry e : + txn.getMeasurements().entrySet()) { + if (!first) measurements.append(","); + measurements.append(e.getKey()).append("=").append(e.getValue().getValue()); + first = false; + } + } + measurements.append("]"); + + final StringBuilder children = new StringBuilder("["); + if (txn.getSpans() != null) { + boolean first = true; + for (SentrySpan s : txn.getSpans()) { + if (!first) children.append(","); + final String parentInfo = + s.getParentSpanId() == null + ? "orphan" + : (s.getParentSpanId().toString().equals(rootSpanId) ? "root" : "nested"); + children.append(s.getOp()).append("(").append(parentInfo).append(")"); + first = false; + } + } + children.append("]"); + + Log.d( + E2E_TAG, + "TXN|name=" + + name + + "|op=" + + op + + "|eventId=" + + eventId + + "|traceId=" + + traceId + + "|rootSpanId=" + + rootSpanId + + "|measurements=" + + measurements + + "|children=" + + children); } private void strictMode() { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..a4c930b12c0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,30 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing app start importance. + * + *

When this receiver triggers a cold start (process was dead), Application.onCreate() runs + * first. We can then check if importance == IMPORTANCE_FOREGROUND even though no activity will + * launch. + * + *

Test with: + * + *

{@code
+ * adb shell am force-stop io.sentry.samples.android && \
+ * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
+ *   -n io.sentry.samples.android/.TestBroadcastReceiver
+ * }
+ */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} diff --git a/standalone_app_start_test_plan.md b/standalone_app_start_test_plan.md new file mode 100644 index 00000000000..8ae1b744ce1 --- /dev/null +++ b/standalone_app_start_test_plan.md @@ -0,0 +1,239 @@ +# Test Plan — `feat/standalone-app-start-tracing` + +## Context + +The branch adds a new, opt-in `SentryAndroidOptions.enableStandaloneAppStartTracing` option (and matching manifest key `io.sentry.standalone-app-start-tracing.enable`) that changes how app-start data is reported: + +- **Flag OFF (default):** legacy behavior — `app.start.cold` is a child span nested inside `MainActivity ui.load`. +- **Flag ON:** a separate standalone `App Start Cold/Warm` transaction is emitted alongside `ui.load` (shared `traceId`); the activity's `ui.load` carries no `app.start.*` child. A **non-activity** cold start (broadcast receiver, foreground service) now emits the standalone transaction on its own, without any activity. + +The branch also ships two SDK bug fixes that were uncovered during manual E2E validation (see `standalone_app_start_report.md`): + +1. **Bug 1 (classification)** — `AppStartMetrics.checkCreateTimeOnMain()` defaults `appStartType` from `UNKNOWN → COLD` on API < 35 when `Application.onCreate()` finishes with no activity. +2. **Bug 2 (duplicate emission)** — `ActivityLifecycleIntegration.onActivityPreCreated` derives `isFollowingNonActivityStart` from `AppStartMetrics.getAppStartTraceId()` and suppresses the activity's standalone block when a non-activity standalone already ran. + +**Problem this plan addresses:** zero automated tests were added across the 5 feature commits (`ddd9bed03e`, `fdd26df9b8`, `e5b7a1844a`, `11898dc637`, `215a549a93`). The current guardrails are the manual E2E harness (`scripts/test-standalone-app-start.sh`) and shape verification against Sentry — neither runs in CI. This plan closes that gap with targeted unit tests and reasons explicitly about redundancy, so we don't over-test. + +--- + +## Files to modify (tests only — no production code change) + +| # | File | Change | +|---|---|---| +| T1 | `sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt` | +3 tests | +| T2 | `sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt` | +1 test | +| T3 | `sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt` | +8 tests, +1 extension | +| T3b | `sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt` | +2 tests (API 35 tier-2 coverage) | +| T4 | `sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt` | +3 tests | +| T5 | `sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt` | +11 tests, +1 fixture update | +| T6 | `scripts/test-standalone-app-start.sh` | strengthen scenario 2e assertions | +| T7 | `sentry/src/test/java/io/sentry/TransactionContextTest.kt` | +2 tests | +| T8 | `sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/StandaloneAppStartTracingIntegrationTest.kt` | optional best-effort on-device smoke tests only | +| T9 | `sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/StandaloneAppStartActivity.kt` + `.../src/main/AndroidManifest.xml` | optional lightweight activity fixture for T8 | + +**Feature code under test (no edits — reference only):** + +- `sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java:53–55,92–95,173–196,430–496` (listener interface, new fields, accessors, `checkCreateTimeOnMain` Bug 1 fix, `resolveNonActivityAppStartEndTime`) +- `sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java:130–132,145,252–309,841–849,851–857,867–878,880–920` (listener registration, trace-id reuse, Bug 2 guard, standalone creation, cleanup, `onNoActivityStarted`) +- `sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java:86–119,259–265` (standalone-txn measurement branch, parent-span fallback) +- `sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java` (new option, default `false`) +- `sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java:109–110` (manifest key) +- `sentry/src/main/java/io/sentry/TransactionContext.java:87–104` (constructor that reuses explicit `traceId` for sibling transactions in the same trace) +- Optional on-device smoke path, if T8/T9 are implemented, exercised via `MockRelay` and `assertEnvelopeTransaction` patterns in `sentry-android-integration-tests/sentry-uitest-android` + +**Existing helpers / patterns to reuse:** + +- `AppStartMetricsTest.waitForMainLooperIdle()` (file:211) — drives the idle handler that invokes `checkCreateTimeOnMain()`. +- `AppStartMetrics.getInstance().registerLifecycleCallbacks(mock())` — canonical way to trigger `checkCreateTimeOnMain` in tests, modeled after `if activity is never started, stops app start profiler if running` (file:248). +- `AppStartMetricsTestApi35` + `SentryShadowActivityManager` — existing API-35 test harness proving this module can unit-test `ApplicationStartInfo` behavior under Robolectric. +- `ActivityLifecycleIntegrationTest.Fixture.getSut()` (file:87) + the `argumentCaptor` + `SentryTracer` wrapping trick (file:101–108) — lets us inspect created transactions rather than stubbing them. **Before T5 multi-transaction tests, extend this fixture to keep `createdTransactions`, `capturedContexts`, and `capturedOptions` lists instead of only the last `fixture.transaction`; append to those lists from the existing `thenAnswer`.** +- `ActivityLifecycleIntegrationTest.setAppStartTime()` (file:1740+) — standard helper to populate `AppStartMetrics` for activity-path tests. +- Add a small `ActivityLifecycleIntegrationTest.waitForMainLooperIdle()` helper mirroring `AppStartMetricsTest.waitForMainLooperIdle()` for tests that drive `OnNoActivityStartedListener`. +- `PerformanceAndroidEventProcessorTest.createAppStartSpan()` (file:62) — canonical way to build a `SentrySpan` with `APP_START_COLD/WARM` op. +- `ManifestMetadataReaderTest` pattern — `bundleOf(ManifestMetadataReader.KEY to value)` + `fixture.getContext(metaData = bundle)` (file:127–138). +- `@BeforeTest` pattern: `AppStartMetrics.getInstance().clear()` + `ContextUtils.resetInstance()` — required to avoid singleton bleed. + +--- + +## Tests to add + +Each test below lists: **intent → covers**, and where it's a negative case or directly covers a bug fix it's flagged. + +### T1 — `ManifestMetadataReaderTest.kt` + +Follows the existing `applyMetadata reads