Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `SentryAndroidOptions.setEnableStandaloneAppStartTracing(true)` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable`
- Emits a transaction named `App Start Cold` / `App Start Warm` 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)

### Dependencies

- Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334))
Expand Down
10 changes: 10 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
public fun isEnablePerformanceV2 ()Z
public fun isEnableRootCheck ()Z
public fun isEnableScopeSync ()Z
public fun isEnableStandaloneAppStartTracing ()Z
public fun isEnableSystemEventBreadcrumbs ()Z
public fun isEnableSystemEventBreadcrumbsExtras ()Z
public fun isReportHistoricalAnrs ()Z
Expand Down Expand Up @@ -421,6 +422,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
public fun setEnablePerformanceV2 (Z)V
public fun setEnableRootCheck (Z)V
public fun setEnableScopeSync (Z)V
public fun setEnableStandaloneAppStartTracing (Z)V
public fun setEnableSystemEventBreadcrumbs (Z)V
public fun setEnableSystemEventBreadcrumbsExtras (Z)V
public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
Expand Down Expand Up @@ -715,7 +717,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler;
public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision;
public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
public fun getAppStartTimeSpanDirect ()Lio/sentry/android/core/performance/TimeSpan;
public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan;
public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId;
public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;
public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan;
public fun getClassLoadedUptimeMs ()J
Expand All @@ -739,8 +743,10 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr
public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V
public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V
public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V
public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V
public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V
public fun setClassLoadedUptimeMs (J)V
public fun setOnNoActivityStartedListener (Lio/sentry/android/core/performance/AppStartMetrics$OnNoActivityStartedListener;)V
public fun shouldSendStartMeasurements ()Z
}

Expand All @@ -752,6 +758,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy
public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;
}

public abstract interface class io/sentry/android/core/performance/AppStartMetrics$OnNoActivityStartedListener {
public abstract fun onNoActivityStarted ()V
}

public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable {
public fun <init> ()V
public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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;
Expand All @@ -55,6 +56,7 @@ public final class ActivityLifecycleIntegration
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {

static final String UI_LOAD_OP = "ui.load";
static final String APP_START_OP = "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";
Expand All @@ -77,6 +79,7 @@ public final class ActivityLifecycleIntegration

private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null;
private @Nullable ISpan appStartSpan;
private @Nullable ITransaction appStartTransaction;
private final @NotNull WeakHashMap<Activity, ISpan> ttidSpanMap = new WeakHashMap<>();
private final @NotNull WeakHashMap<Activity, ISpan> ttfdSpanMap = new WeakHashMap<>();
private final @NotNull WeakHashMap<Activity, ActivityLifecycleSpanHelper> activitySpanHelpers =
Expand Down Expand Up @@ -124,6 +127,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().setOnNoActivityStartedListener(this::onNoActivityStarted);
}

this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed.");
addIntegrationToSdkVersion("ActivityLifecycle");
}
Expand All @@ -135,6 +143,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options
@Override
public void close() throws IOException {
application.unregisterActivityLifecycleCallbacks(this);
AppStartMetrics.getInstance().setOnNoActivityStartedListener(null);

if (options != null) {
options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed.");
Expand Down Expand Up @@ -240,32 +249,80 @@ private void startTracing(final @NotNull Activity activity) {
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);
// If a non-activity app start transaction was created earlier, reuse its trace ID
final @Nullable SentryId storedAppStartTraceId =
AppStartMetrics.getInstance().getAppStartTraceId();
// When we reuse a stashed traceId, it means the process's app start has already been
// accounted for by a standalone transaction from the non-activity path — don't emit
// a second standalone here just because an activity subsequently showed up.
final boolean isFollowingNonActivityStart = (storedAppStartTraceId != null);

final ITransaction transaction;
if (storedAppStartTraceId != null) {
transaction =
scopes.startTransaction(
new TransactionContext(
storedAppStartTraceId,
activityName,
TransactionNameSource.COMPONENT,
UI_LOAD_OP,
appStartSamplingDecision),
transactionOptions);
AppStartMetrics.getInstance().setAppStartTraceId(null);
} else {
transaction =
scopes.startTransaction(
new TransactionContext(
activityName,
TransactionNameSource.COMPONENT,
UI_LOAD_OP,
appStartSamplingDecision),
transactionOptions);
}

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 (options.isEnableStandaloneAppStartTracing()
&& foregroundImportance
&& !isFollowingNonActivityStart) {
final TransactionOptions appStartTransactionOptions = new TransactionOptions();
appStartTransactionOptions.setBindToScope(false);
appStartTransactionOptions.setStartTimestamp(appStartTime);
appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null);
setSpanOrigin(appStartTransactionOptions);

appStartTransaction =
scopes.startTransaction(
new TransactionContext(
transaction.getSpanContext().getTraceId(),
getAppStartTxnName(coldStart),
TransactionNameSource.COMPONENT,
APP_START_OP,
appStartSamplingDecision),
appStartTransactionOptions);

// in case there's already an end time (e.g. due to deferred SDK init)
// we can finish the app start transaction
finishAppStartSpan();
} else if (!options.isEnableStandaloneAppStartTracing()) {
// Legacy behavior: app start span as child of activity transaction
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();
}
// else: flag ON but not foreground — non-activity launch path is handled
// via OnNoActivityStartedListener callback in checkCreateTimeOnMain()
}
final @NotNull ISpan ttidSpan =
transaction.startChild(
Expand Down Expand Up @@ -440,8 +497,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));
}
}

Expand Down Expand Up @@ -479,8 +535,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();
}
Expand Down Expand Up @@ -559,6 +614,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);
Expand All @@ -575,6 +633,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);
}
Expand Down Expand Up @@ -779,6 +838,24 @@ WeakHashMap<Activity, ISpan> 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 getAppStartTxnName(final boolean coldStart) {
if (coldStart) {
return "App Start Cold";
} else {
return "App Start Warm";
}
}

private @NotNull String getAppStartOp(final boolean coldStart) {
if (coldStart) {
return APP_START_COLD;
Expand All @@ -794,6 +871,48 @@ private void finishAppStartSpan() {
.getProjectedStopTimestamp();
if (performanceEnabled && appStartEndTime != null) {
finishSpan(appStartSpan, appStartEndTime);
if (appStartTransaction != null && !appStartTransaction.isFinished()) {
appStartTransaction.finish(SpanStatus.OK, appStartEndTime);
}
}
}

private void onNoActivityStarted() {
if (scopes == null || options == null || !performanceEnabled) {
return;
}

final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
// For non-activity starts, appLaunchedInForeground is false, so we can't use
// getAppStartTimeSpanWithFallback (which gates on foreground).
final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanDirect();

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 boolean coldStart = metrics.getAppStartType() == AppStartMetrics.AppStartType.COLD;

final TransactionOptions txnOptions = new TransactionOptions();
txnOptions.setBindToScope(false);
txnOptions.setStartTimestamp(startTime);
setSpanOrigin(txnOptions);

final @NotNull TransactionContext txnContext =
new TransactionContext(
getAppStartTxnName(coldStart), TransactionNameSource.COMPONENT, APP_START_OP, null);

final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions);

// Store trace ID so future activity transactions can share it
metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId());

transaction.finish(SpanStatus.OK, endTime);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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";
Expand Down Expand Up @@ -493,6 +496,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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.sentry.android.core;

import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD;
import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_OP;
import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM;
import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP;

Expand Down Expand Up @@ -84,9 +85,17 @@ 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 non-activity starts, appLaunchedInForeground is false, so
// shouldSendStartMeasurements() would return false. We still want to attach child spans.
final @Nullable SpanContext traceContext = transaction.getContexts().getTrace();
final boolean isStandaloneAppStartTxn =
traceContext != null && APP_START_OP.equals(traceContext.getOperation());

if (appStartMetrics.shouldSendStartMeasurements() || isStandaloneAppStartTxn) {
final @NotNull TimeSpan appStartTimeSpan =
appStartMetrics.getAppStartTimeSpanWithFallback(options);
isStandaloneAppStartTxn
? appStartMetrics.getAppStartTimeSpanDirect()
: appStartMetrics.getAppStartTimeSpanWithFallback(options);
final long appStartUpDurationMs = appStartTimeSpan.getDurationMs();

// if appStartUpDurationMs is 0, metrics are not ready to be sent
Expand Down Expand Up @@ -216,9 +225,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(APP_START_OP);
}

private void attachAppStartSpans(
Expand All @@ -245,6 +252,14 @@ 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 (APP_START_OP.equals(txnOp)) {
parentSpanId = traceContext.getSpanId();
}
}

// Process init
final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan();
if (processInitTimeSpan.hasStarted()
Expand Down
Loading
Loading