From 00391cdde1d15cd9e0aec34742a1a8a3a57bbac3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 May 2026 11:46:05 +0200 Subject: [PATCH 1/6] feat(android): Add session trace lifecycle Reuse the active session propagation context for eligible mobile root transactions while preserving explicit continuations and force-new trace escapes. Add Android integration guards, configuration, and tests for the opt-in lifecycle behavior. Co-Authored-By: Claude --- .../core/ActivityLifecycleIntegration.java | 2 +- .../android/core/ManifestMetadataReader.java | 10 ++++ .../gestures/SentryGestureListener.java | 2 +- .../core/ActivityLifecycleIntegrationTest.kt | 24 ++++++++ .../core/ManifestMetadataReaderTest.kt | 10 ++++ .../SentryGestureListenerTracingTest.kt | 20 +++++++ .../navigation/SentryNavigationListener.kt | 4 +- .../SentryNavigationListenerTest.kt | 21 +++++++ .../OtelSentrySpanProcessor.java | 7 ++- .../sentry/opentelemetry/SentrySampler.java | 8 ++- sentry/api/sentry.api | 17 +++++- sentry/src/main/java/io/sentry/Baggage.java | 19 ++++++ .../main/java/io/sentry/ExternalOptions.java | 11 ++++ .../java/io/sentry/PropagationContext.java | 33 ++++++++-- sentry/src/main/java/io/sentry/Scope.java | 11 +++- sentry/src/main/java/io/sentry/Scopes.java | 46 ++++++++++++-- .../main/java/io/sentry/SentryOptions.java | 35 +++++++++++ .../java/io/sentry/TransactionContext.java | 60 +++++++++++++++++++ sentry/src/test/java/io/sentry/BaggageTest.kt | 18 ++++++ .../java/io/sentry/ExternalOptionsTest.kt | 7 +++ sentry/src/test/java/io/sentry/ScopeTest.kt | 9 ++- sentry/src/test/java/io/sentry/ScopesTest.kt | 42 +++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 7 +++ .../java/io/sentry/util/TracingUtilsTest.kt | 3 + 24 files changed, 408 insertions(+), 18 deletions(-) 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..02f9a2c072f 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 @@ -162,7 +162,7 @@ private void startTracing(final @NotNull Activity activity) { if (scopes != null && !isRunningTransactionOrTrace(activity)) { if (!performanceEnabled) { activitiesWithOngoingTransactions.put(activity, NoOpTransaction.getInstance()); - if (options.isEnableAutoTraceIdGeneration()) { + if (options.isEnableAutoTraceIdGeneration() && !options.isEnableSessionTraceLifecycle()) { TracingUtils.startNewTrace(scopes); } } else { 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 7dd6f1c1488..a895a4bbf4c 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 @@ -155,6 +155,9 @@ final class ManifestMetadataReader { static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; + static final String ENABLE_SESSION_TRACE_LIFECYCLE = + "io.sentry.traces.enable-session-trace-lifecycle"; + static final String DEADLINE_TIMEOUT = "io.sentry.traces.deadline-timeout"; static final String FEEDBACK_NAME_REQUIRED = "io.sentry.feedback.is-name-required"; @@ -510,6 +513,13 @@ static void applyMetadata( ENABLE_AUTO_TRACE_ID_GENERATION, options.isEnableAutoTraceIdGeneration())); + options.setEnableSessionTraceLifecycle( + readBool( + metadata, + logger, + ENABLE_SESSION_TRACE_LIFECYCLE, + options.isEnableSessionTraceLifecycle())); + options.setDeadlineTimeout( readLong(metadata, logger, DEADLINE_TIMEOUT, options.getDeadlineTimeout())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 8caffedad94..8f8318db3de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -203,7 +203,7 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { if (isNewInteraction) { - if (options.isEnableAutoTraceIdGeneration()) { + if (options.isEnableAutoTraceIdGeneration() && !options.isEnableSessionTraceLifecycle()) { TracingUtils.startNewTrace(scopes); } activeUiElement = target; 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..60425468096 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 @@ -1413,6 +1413,30 @@ class ActivityLifecycleIntegrationTest { assertSame(propagationContextAtStart, scope.propagationContext) } + @Test + fun `does not start a new trace if performance is disabled and session trace lifecycle is enabled`() { + val sut = fixture.getSut() + val activity = mock() + fixture.options.tracesSampleRate = null + fixture.options.isEnableAutoTraceIdGeneration = true + fixture.options.isEnableSessionTraceLifecycle = true + + val argumentCaptor: ArgumentCaptor = + ArgumentCaptor.forClass(ScopeCallback::class.java) + val scope = Scope(fixture.options) + val propagationContextAtStart = scope.propagationContext + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { + argumentCaptor.value.run(scope) + } + + sut.register(fixture.scopes, fixture.options) + sut.onActivityCreated(activity, fixture.bundle) + + // once for the screen + verify(fixture.scopes).configureScope(any()) + assertSame(propagationContextAtStart, scope.propagationContext) + } + @Test fun `sets the activity as the current screen`() { val sut = fixture.getSut() 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 52cb085b1ee..0d28802858b 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 @@ -136,6 +136,16 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.isEnableAutoSessionTracking) } + @Test + fun `applyMetadata reads session trace lifecycle to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_SESSION_TRACE_LIFECYCLE to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableSessionTraceLifecycle) + } + @Test fun `applyMetadata reads environment to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index fe994f4a828..2178d4a53fc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -29,6 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -62,6 +63,7 @@ class SentryGestureListenerTracingTest { isEnableUserInteractionTracing: Boolean = true, transaction: SentryTracer? = null, isEnableAutoTraceIdGeneration: Boolean = true, + isEnableSessionTraceLifecycle: Boolean = false, ): SentryGestureListener { options.tracesSampleRate = tracesSampleRate options.isEnableUserInteractionTracing = isEnableUserInteractionTracing @@ -69,6 +71,7 @@ class SentryGestureListenerTracingTest { options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) options.isEnableAutoTraceIdGeneration = isEnableAutoTraceIdGeneration + options.isEnableSessionTraceLifecycle = isEnableSessionTraceLifecycle whenever(scopes.options).thenReturn(options) @@ -398,6 +401,23 @@ class SentryGestureListenerTracingTest { ) } + @Test + fun `when tracing is disabled and session trace lifecycle is enabled, does not start a new trace`() { + val sut = + fixture.getSut( + tracesSampleRate = null, + isEnableAutoTraceIdGeneration = true, + isEnableSessionTraceLifecycle = true, + ) + val scope = Scope(fixture.options) + val initialPropagationContext = scope.propagationContext + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).configureScope(any()) + assertSame(initialPropagationContext, scope.propagationContext) + } + internal open class ScrollableListView : AbsListView(mock()) { override fun getAdapter(): ListAdapter = mock() diff --git a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt index b5e99b21865..0a9c4fb51a6 100644 --- a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -108,7 +108,9 @@ constructor( arguments: Map, ) { if (!isPerformanceEnabled) { - TracingUtils.startNewTrace(scopes) + if (!scopes.options.isEnableSessionTraceLifecycle) { + TracingUtils.startNewTrace(scopes) + } return } diff --git a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt index 16cc56ad889..9320563be1c 100644 --- a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt +++ b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt @@ -22,6 +22,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.kotlin.any @@ -60,12 +61,14 @@ class SentryNavigationListenerTest { hasViewIdInRes: Boolean = true, transaction: SentryTracer? = null, traceOriginAppendix: String? = null, + enableSessionTraceLifecycle: Boolean = false, ): SentryNavigationListener { options = SentryOptions().apply { dsn = "http://key@localhost/proj" setTracesSampleRate(tracesSampleRate) isEnableScreenTracking = enableScreenTracking + isEnableSessionTraceLifecycle = enableSessionTraceLifecycle } whenever(scopes.options).thenReturn(options) @@ -363,6 +366,24 @@ class SentryNavigationListenerTest { assertNotSame(propagationContextAtStart, scope.propagationContext) } + @Test + fun `does not start new trace if performance is disabled and session trace lifecycle is enabled`() { + val sut = fixture.getSut(enableNavigationTracing = false, enableSessionTraceLifecycle = true) + + val argumentCaptor: ArgumentCaptor = + ArgumentCaptor.forClass(ScopeCallback::class.java) + val scope = Scope(fixture.options) + val propagationContextAtStart = scope.propagationContext + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { + argumentCaptor.value.run(scope) + } + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.scopes).configureScope(any()) + assertSame(propagationContextAtStart, scope.propagationContext) + } + @Test fun `onDestinationChanged sets trace origin`() { val sut = fixture.getSut() diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index 1cf6fa5d833..5d4b2dda352 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -96,7 +96,12 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri final @NotNull PropagationContext propagationContext = new PropagationContext( - new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled); + new SentryId(traceId), + sentrySpanId, + sentryParentSpanId, + baggage, + sampled, + PropagationContext.Lifecycle.TRANSACTION); baggage = propagationContext.getBaggage(); baggage.setValuesFromSamplingDecision(samplingDecision); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java index 1a9e8724ca6..a3e6d8c89cf 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java @@ -90,7 +90,13 @@ public SamplingResult shouldSample( SpanId randomSpanId = new SpanId(); final @NotNull PropagationContext propagationContext = sentryTraceHeader == null - ? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null) + ? new PropagationContext( + new SentryId(traceId), + randomSpanId, + null, + baggage, + null, + PropagationContext.Lifecycle.TRANSACTION) : PropagationContext.fromHeaders( sentryTraceHeader, baggage, randomSpanId, scopes.getOptions()); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37c..2ab27057b6b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -530,6 +530,7 @@ public final class io/sentry/ExternalOptions { public fun isEnableMetrics ()Ljava/lang/Boolean; public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean; public fun isEnableQueueTracing ()Ljava/lang/Boolean; + public fun isEnableSessionTraceLifecycle ()Ljava/lang/Boolean; public fun isEnableSpotlight ()Ljava/lang/Boolean; public fun isEnabled ()Ljava/lang/Boolean; public fun isForceInit ()Ljava/lang/Boolean; @@ -550,6 +551,7 @@ public final class io/sentry/ExternalOptions { public fun setEnableMetrics (Ljava/lang/Boolean;)V public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V public fun setEnableQueueTracing (Ljava/lang/Boolean;)V + public fun setEnableSessionTraceLifecycle (Ljava/lang/Boolean;)V public fun setEnableSpotlight (Ljava/lang/Boolean;)V public fun setEnableUncaughtExceptionHandler (Ljava/lang/Boolean;)V public fun setEnabled (Ljava/lang/Boolean;)V @@ -2308,17 +2310,19 @@ public final class io/sentry/ProfilingTransactionData$JsonKeys { public final class io/sentry/PropagationContext { public fun ()V public fun (Lio/sentry/PropagationContext;)V - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/Baggage;Ljava/lang/Boolean;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/Baggage;Ljava/lang/Boolean;Lio/sentry/PropagationContext$Lifecycle;)V public static fun fromExistingTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/util/List;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/SentryTraceHeader;Lio/sentry/Baggage;Lio/sentry/SpanId;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public fun getBaggage ()Lio/sentry/Baggage; + public fun getLifecycle ()Lio/sentry/PropagationContext$Lifecycle; public fun getParentSpanId ()Lio/sentry/SpanId; public fun getSampleRand ()Ljava/lang/Double; public fun getSpanId ()Lio/sentry/SpanId; public fun getTraceId ()Lio/sentry/protocol/SentryId; public fun isSampled ()Ljava/lang/Boolean; + public fun setLifecycle (Lio/sentry/PropagationContext$Lifecycle;)V public fun setParentSpanId (Lio/sentry/SpanId;)V public fun setSampled (Ljava/lang/Boolean;)V public fun setSpanId (Lio/sentry/SpanId;)V @@ -2327,6 +2331,13 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public final class io/sentry/PropagationContext$Lifecycle : java/lang/Enum { + public static final field SESSION Lio/sentry/PropagationContext$Lifecycle; + public static final field TRANSACTION Lio/sentry/PropagationContext$Lifecycle; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/PropagationContext$Lifecycle; + public static fun values ()[Lio/sentry/PropagationContext$Lifecycle; +} + public abstract interface class io/sentry/ReplayBreadcrumbConverter { public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } @@ -3719,6 +3730,7 @@ public class io/sentry/SentryOptions { public fun isEnableQueueTracing ()Z public fun isEnableScopePersistence ()Z public fun isEnableScreenTracking ()Z + public fun isEnableSessionTraceLifecycle ()Z public fun isEnableShutdownHook ()Z public fun isEnableSpotlight ()Z public fun isEnableTimeToFullDisplayTracing ()Z @@ -3780,6 +3792,7 @@ public class io/sentry/SentryOptions { public fun setEnableQueueTracing (Z)V public fun setEnableScopePersistence (Z)V public fun setEnableScreenTracking (Z)V + public fun setEnableSessionTraceLifecycle (Z)V public fun setEnableShutdownHook (Z)V public fun setEnableSpotlight (Z)V public fun setEnableTimeToFullDisplayTracing (Z)V @@ -4589,7 +4602,9 @@ public final class io/sentry/TransactionContext : io/sentry/SpanContext { public fun getParentSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; public fun isForNextAppStart ()Z + public fun isForceNewTrace ()Z public fun setForNextAppStart (Z)V + public fun setForceNewTrace (Z)V public fun setName (Ljava/lang/String;)V public fun setParentSampled (Ljava/lang/Boolean;)V public fun setParentSampled (Ljava/lang/Boolean;Ljava/lang/Boolean;)V diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 4645df3f3a4..d651c67122e 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -219,6 +219,25 @@ public Baggage(final @NotNull Baggage baggage) { baggage.logger); } + @ApiStatus.Internal + static @NotNull Baggage copyWithOverrides( + final @NotNull Baggage baggage, + final @NotNull SentryId traceId, + final @Nullable Double sampleRand) { + final @NotNull ConcurrentHashMap keyValues = + new ConcurrentHashMap<>(baggage.keyValues); + keyValues.put(DSCKeys.TRACE_ID, traceId.toString()); + + return new Baggage( + keyValues, + baggage.sampleRate, + sampleRand, + baggage.thirdPartyHeader, + baggage.mutable, + baggage.shouldFreeze, + baggage.logger); + } + @ApiStatus.Internal public Baggage( final @NotNull ConcurrentHashMap keyValues, diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 4e44ea422ec..b974c575d2b 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -68,6 +68,7 @@ public final class ExternalOptions { private @Nullable ProfileLifecycle profileLifecycle; private @Nullable Boolean strictTraceContinuation; + private @Nullable Boolean enableSessionTraceLifecycle; private @Nullable String orgId; private @Nullable SentryOptions.Cron cron; @@ -229,6 +230,8 @@ public final class ExternalOptions { options.setStrictTraceContinuation( propertiesProvider.getBooleanProperty("enable-strict-trace-continuation")); + options.setEnableSessionTraceLifecycle( + propertiesProvider.getBooleanProperty("enable-session-trace-lifecycle")); options.setOrgId(propertiesProvider.getProperty("org-id")); options.setEnableSpotlight(propertiesProvider.getBooleanProperty("enable-spotlight")); @@ -647,6 +650,14 @@ public void setStrictTraceContinuation(final @Nullable Boolean strictTraceContin this.strictTraceContinuation = strictTraceContinuation; } + public @Nullable Boolean isEnableSessionTraceLifecycle() { + return enableSessionTraceLifecycle; + } + + public void setEnableSessionTraceLifecycle(final @Nullable Boolean enableSessionTraceLifecycle) { + this.enableSessionTraceLifecycle = enableSessionTraceLifecycle; + } + public @Nullable String getOrgId() { return orgId; } diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index a6779805276..2d51f86ed8f 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -12,6 +12,11 @@ @ApiStatus.Internal public final class PropagationContext { + public enum Lifecycle { + TRANSACTION, + SESSION + } + public static PropagationContext fromHeaders( final @NotNull ILogger logger, final @Nullable String sentryTraceHeader, @@ -59,7 +64,8 @@ public static PropagationContext fromHeaders( spanIdToUse, sentryTraceHeader.getSpanId(), baggage, - sentryTraceHeader.isSampled()); + sentryTraceHeader.isSampled(), + Lifecycle.TRANSACTION); } public static @NotNull PropagationContext fromExistingTrace( @@ -72,7 +78,8 @@ public static PropagationContext fromHeaders( new SpanId(), new SpanId(spanId), TracingUtils.ensureBaggage(null, null, decisionSampleRate, decisionSampleRand), - null); + null, + Lifecycle.TRANSACTION); } private @NotNull SentryId traceId; @@ -83,30 +90,38 @@ public static PropagationContext fromHeaders( private final @NotNull Baggage baggage; + private @NotNull Lifecycle lifecycle; + + @ApiStatus.Internal public PropagationContext() { - this(new SentryId(), new SpanId(), null, null, null); + this(new SentryId(), new SpanId(), null, null, null, Lifecycle.TRANSACTION); } + @ApiStatus.Internal public PropagationContext(final @NotNull PropagationContext propagationContext) { this( propagationContext.getTraceId(), propagationContext.getSpanId(), propagationContext.getParentSpanId(), propagationContext.getBaggage(), - propagationContext.isSampled()); + propagationContext.isSampled(), + propagationContext.getLifecycle()); } + @ApiStatus.Internal public PropagationContext( final @NotNull SentryId traceId, final @NotNull SpanId spanId, final @Nullable SpanId parentSpanId, final @Nullable Baggage baggage, - final @Nullable Boolean sampled) { + final @Nullable Boolean sampled, + final @NotNull Lifecycle lifecycle) { this.traceId = traceId; this.spanId = spanId; this.parentSpanId = parentSpanId; this.baggage = TracingUtils.ensureBaggage(baggage, sampled, null, null); this.sampled = sampled; + this.lifecycle = lifecycle; } public @NotNull SentryId getTraceId() { @@ -149,6 +164,14 @@ public void setSampled(final @Nullable Boolean sampled) { return baggage.toTraceContext(); } + public @NotNull Lifecycle getLifecycle() { + return lifecycle; + } + + public void setLifecycle(final @NotNull Lifecycle lifecycle) { + this.lifecycle = lifecycle; + } + public @NotNull SpanContext toSpanContext() { final SpanContext spanContext = new SpanContext(traceId, spanId, "default", null, null); spanContext.setOrigin("auto"); diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index fa44e90a194..66907593f9b 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -120,7 +120,16 @@ public Scope(final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "SentryOptions is required."); this.breadcrumbs = createBreadcrumbsList(this.options.getMaxBreadcrumbs()); this.featureFlags = FeatureFlagBuffer.create(options); - this.propagationContext = new PropagationContext(); + this.propagationContext = + new PropagationContext( + new SentryId(), + new SpanId(), + null, + null, + null, + options.isEnableSessionTraceLifecycle() + ? PropagationContext.Lifecycle.SESSION + : PropagationContext.Lifecycle.TRANSACTION); this.lastEventId = SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 3b67b94916e..2dc5667af20 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -388,6 +388,23 @@ public void startSession() { getClient().captureSession(pair.getPrevious(), hint); } + if (getOptions().isEnableSessionTraceLifecycle()) { + configureScope( + scope -> { + scope.withPropagationContext( + propagationContext -> { + scope.setPropagationContext( + new PropagationContext( + new SentryId(), + new SpanId(), + null, + null, + null, + PropagationContext.Lifecycle.SESSION)); + }); + }); + } + final Hint hint = HintUtils.createWithTypeCheckHint(new SessionStartHint()); getClient().captureSession(pair.getCurrent(), hint); @@ -957,13 +974,18 @@ public void flush(long timeoutMillis) { SentryLevel.INFO, "Tracing is disabled and this 'startTransaction' returns a no-op."); transaction = NoOpTransaction.getInstance(); } else { - final Double sampleRand = getSampleRand(transactionContext); + final @NotNull TransactionContext effectiveTransactionContext = + maybeApplySessionTraceLifecycle(transactionContext); + final Double sampleRand = getSampleRand(effectiveTransactionContext); final SamplingContext samplingContext = new SamplingContext( - transactionContext, transactionOptions.getCustomSamplingContext(), sampleRand, null); + effectiveTransactionContext, + transactionOptions.getCustomSamplingContext(), + sampleRand, + null); final @NotNull TracesSampler tracesSampler = getOptions().getInternalTracesSampler(); @NotNull TracesSamplingDecision samplingDecision = tracesSampler.sample(samplingContext); - transactionContext.setSamplingDecision(samplingDecision); + effectiveTransactionContext.setSamplingDecision(samplingDecision); final @Nullable ISpanFactory maybeSpanFactory = transactionOptions.getSpanFactory(); final @NotNull ISpanFactory spanFactory = @@ -977,7 +999,7 @@ public void flush(long timeoutMillis) { if (samplingDecision.getSampled() && getOptions().isContinuousProfilingEnabled() && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE - && transactionContext.getProfilerId().equals(SentryId.EMPTY_ID)) { + && effectiveTransactionContext.getProfilerId().equals(SentryId.EMPTY_ID)) { getOptions() .getContinuousProfiler() .startProfiler(ProfileLifecycle.TRACE, getOptions().getInternalTracesSampler()); @@ -985,7 +1007,7 @@ && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE transaction = spanFactory.createTransaction( - transactionContext, this, transactionOptions, compositePerformanceCollector); + effectiveTransactionContext, this, transactionOptions, compositePerformanceCollector); // new SentryTracer( // transactionContext, this, transactionOptions, // compositePerformanceCollector); @@ -1013,6 +1035,20 @@ && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE return transaction; } + private @NotNull TransactionContext maybeApplySessionTraceLifecycle( + final @NotNull TransactionContext transactionContext) { + final @NotNull PropagationContext propagationContext = + getCombinedScopeView().getPropagationContext(); + if (getOptions().isEnableSessionTraceLifecycle() + && propagationContext.getLifecycle() == PropagationContext.Lifecycle.SESSION + && transactionContext.getParentSpanId() == null + && !transactionContext.isForceNewTrace()) { + return TransactionContext.fromPropagationContextAsRoot( + propagationContext, transactionContext); + } + return transactionContext; + } + private @NotNull Double getSampleRand(final @NotNull TransactionContext transactionContext) { final @Nullable Baggage baggage = transactionContext.getBaggage(); if (baggage != null) { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 0d038482d07..f807a108bb4 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -290,6 +290,9 @@ public class SentryOptions { /** Whether to enable or disable automatic session tracking. */ private boolean enableAutoSessionTracking = true; + /** Whether root transactions should reuse the current session trace lifecycle. */ + private boolean enableSessionTraceLifecycle = false; + /** * The session tracking interval in millis. This is the interval to end a session if the App goes * to the background. @@ -1418,6 +1421,35 @@ public void setEnableAutoSessionTracking(final boolean enableAutoSessionTracking this.enableAutoSessionTracking = enableAutoSessionTracking; } + /** + * Returns whether root transactions should reuse the current session trace lifecycle. + * + *

This option is intended for Android/mobile SDKs where trace boundaries are managed by the + * SDK session lifecycle. Do not enable it for JVM backend, desktop, or other non-session-managed + * runtimes because unrelated root transactions may otherwise share the same trace. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableSessionTraceLifecycle() { + return enableSessionTraceLifecycle; + } + + /** + * Enables or disables session trace lifecycle. When enabled, root transactions can reuse the + * current session propagation context unless they force a new trace. + * + *

This option is intended for Android/mobile SDKs where trace boundaries are managed by the + * SDK session lifecycle. Do not enable it for JVM backend, desktop, or other non-session-managed + * runtimes because unrelated root transactions may otherwise share the same trace. + * + * @param enableSessionTraceLifecycle true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableSessionTraceLifecycle(final boolean enableSessionTraceLifecycle) { + this.enableSessionTraceLifecycle = enableSessionTraceLifecycle; + } + /** * Gets the default server name to be used in Sentry events. * @@ -3637,6 +3669,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isStrictTraceContinuation() != null) { setStrictTraceContinuation(options.isStrictTraceContinuation()); } + if (options.isEnableSessionTraceLifecycle() != null) { + setEnableSessionTraceLifecycle(options.isEnableSessionTraceLifecycle()); + } if (options.getOrgId() != null) { setOrgId(options.getOrgId()); } diff --git a/sentry/src/main/java/io/sentry/TransactionContext.java b/sentry/src/main/java/io/sentry/TransactionContext.java index 5785917add4..34686301b6b 100644 --- a/sentry/src/main/java/io/sentry/TransactionContext.java +++ b/sentry/src/main/java/io/sentry/TransactionContext.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import org.jetbrains.annotations.ApiStatus; @@ -17,6 +18,7 @@ public final class TransactionContext extends SpanContext { private @NotNull TransactionNameSource transactionNameSource; private @Nullable TracesSamplingDecision parentSamplingDecision; private boolean isForNextAppStart = false; + private boolean forceNewTrace = false; @ApiStatus.Internal public static TransactionContext fromPropagationContext( @@ -38,6 +40,42 @@ public static TransactionContext fromPropagationContext( baggage); } + @ApiStatus.Internal + static @NotNull TransactionContext fromPropagationContextAsRoot( + final @NotNull PropagationContext propagationContext, + final @NotNull TransactionContext transactionContext) { + final @NotNull Baggage baggage = + Baggage.copyWithOverrides( + propagationContext.getBaggage(), + propagationContext.getTraceId(), + propagationContext.getSampleRand()); + + final @NotNull TransactionContext sessionContext = + new TransactionContext(propagationContext.getTraceId(), new SpanId(), null, null, baggage); + sessionContext.setName(transactionContext.getName()); + sessionContext.setTransactionNameSource(transactionContext.getTransactionNameSource()); + sessionContext.setOperation(transactionContext.getOperation()); + sessionContext.setDescription(transactionContext.getDescription()); + sessionContext.setStatus(transactionContext.getStatus()); + sessionContext.setOrigin(transactionContext.getOrigin()); + sessionContext.setInstrumenter(transactionContext.getInstrumenter()); + sessionContext.setSamplingDecision(transactionContext.getSamplingDecision()); + sessionContext.setForNextAppStart(transactionContext.isForNextAppStart()); + sessionContext.setProfilerId(transactionContext.getProfilerId()); + final @Nullable java.util.Map copiedTags = + CollectionUtils.newConcurrentHashMap(transactionContext.tags); + if (copiedTags != null) { + sessionContext.tags = copiedTags; + } + final @Nullable java.util.Map copiedData = + CollectionUtils.newConcurrentHashMap(transactionContext.data); + if (copiedData != null) { + sessionContext.data = copiedData; + } + sessionContext.forceNewTrace = transactionContext.forceNewTrace; + return sessionContext; + } + public TransactionContext(final @NotNull String name, final @NotNull String operation) { this(name, operation, null); } @@ -146,6 +184,28 @@ public void setTransactionNameSource(final @NotNull TransactionNameSource transa this.transactionNameSource = transactionNameSource; } + /** + * Forces this transaction to start a new trace when session trace lifecycle is enabled. + * Explicitly continued traces with a parent span are still preserved. + * + * @return true if this transaction should not reuse the session propagation context. + */ + @ApiStatus.Experimental + public boolean isForceNewTrace() { + return forceNewTrace; + } + + /** + * Forces this transaction to start a new trace when session trace lifecycle is enabled. + * Explicitly continued traces with a parent span are still preserved. + * + * @param forceNewTrace true to keep this transaction on a new trace. + */ + @ApiStatus.Experimental + public void setForceNewTrace(final boolean forceNewTrace) { + this.forceNewTrace = forceNewTrace; + } + @ApiStatus.Internal public void setForNextAppStart(final boolean forNextAppStart) { isForNextAppStart = forNextAppStart; diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index e177645734d..1c1b0913941 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -439,6 +439,24 @@ class BaggageTest { ) } + @Test + fun `copy with overrides preserves frozen state`() { + val baggage = + Baggage.fromHeader( + "sentry-sample_rand=0.1,sentry-trace_id=75302ac48a024bde9a3b3734a82e36c8", + logger, + ) + baggage.freeze() + + val copy = Baggage.copyWithOverrides(baggage, SentryId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 0.2) + + assertFalse(copy.isMutable) + assertEquals("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", copy.traceId) + assertEquals(0.2, copy.sampleRand!!, 0.0001) + assertEquals("75302ac48a024bde9a3b3734a82e36c8", baggage.traceId) + assertEquals(0.1, baggage.sampleRand!!, 0.0001) + } + @Test fun `if header contains sentry values baggage is marked as shouldFreeze`() { val baggage = diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index fee707d31f3..f7e716d9175 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -116,6 +116,13 @@ class ExternalOptionsTest { withPropertiesFile("profiles-sample-rate=0.2") { assertEquals(0.2, it.profilesSampleRate) } } + @Test + fun `creates options with enableSessionTraceLifecycle using external properties`() { + withPropertiesFile("enable-session-trace-lifecycle=true") { + assertNotNull(it.isEnableSessionTraceLifecycle) { assertTrue(it) } + } + } + @Test fun `creates options with enableDeduplication using external properties`() { withPropertiesFile("enable-deduplication=true") { diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 7093473a60b..860488fd8fc 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -872,7 +872,14 @@ class ScopeTest { val scope = Scope(options) scope.propagationContext = - PropagationContext(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), SpanId(), null, null, null) + PropagationContext( + SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), + SpanId(), + null, + null, + null, + PropagationContext.Lifecycle.TRANSACTION, + ) verify(observer) .setTrace(argThat { traceId.toString() == "64cf554cc8d74c6eafa3e08b7c984f6d" }, eq(scope)) } diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index a4c2c76845e..bf5dd115e15 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1821,6 +1821,48 @@ class ScopesTest { assertEquals(contexts, transaction.root.spanContext) } + @Test + fun `when session trace lifecycle is enabled, startTransaction uses session propagation context`() { + val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } + var propagationContext: PropagationContext? = null + scopes.configureScope { propagationContext = it.propagationContext } + + val transaction = scopes.startTransaction(TransactionContext("name", "op")) + + assertTrue(transaction is SentryTracer) + assertEquals(propagationContext!!.traceId, transaction.root.spanContext.traceId) + assertNotEquals(propagationContext!!.spanId, transaction.root.spanContext.spanId) + assertNull(transaction.root.spanContext.parentSpanId) + assertEquals(propagationContext!!.sampleRand, transaction.root.spanContext.baggage!!.sampleRand) + } + + @Test + fun `when session trace lifecycle is enabled, forceNewTrace keeps transaction trace`() { + val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } + val context = TransactionContext("name", "op") + context.setForceNewTrace(true) + + val transaction = scopes.startTransaction(context) + + assertTrue(transaction is SentryTracer) + assertEquals(context.traceId, transaction.root.spanContext.traceId) + } + + @Test + fun `forceNewTrace does not override continued trace with parent span`() { + val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } + val traceId = "75302ac48a024bde9a3b3734a82e36c8" + val parentSpanId = "1000000000000000" + val context = scopes.continueTrace("$traceId-$parentSpanId-1", emptyList())!! + context.setForceNewTrace(true) + + val transaction = scopes.startTransaction(context) + + assertTrue(transaction is SentryTracer) + assertEquals(SentryId(traceId), transaction.root.spanContext.traceId) + assertEquals(SpanId(parentSpanId), transaction.root.spanContext.parentSpanId) + } + @Test fun `when startTransaction with bindToScope set to false, transaction is not attached to the scope`() { val scopes = generateScopes() diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 75a24dd68df..a85ab8c5a5b 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -49,6 +49,11 @@ class SentryOptionsTest { assertFalse(SentryOptions().isDebug) } + @Test + fun `when options is initialized, session trace lifecycle is disabled`() { + assertFalse(SentryOptions().isEnableSessionTraceLifecycle) + } + @Test fun `when options is initialized, integrations contain UncaughtExceptionHandlerIntegration`() { assertTrue(SentryOptions().integrations.any { it is UncaughtExceptionHandlerIntegration }) @@ -420,6 +425,7 @@ class SentryOptionsTest { externalOptions.profileSessionSampleRate = 0.8 externalOptions.profilingTracesDirPath = "/profiling-traces" externalOptions.profileLifecycle = ProfileLifecycle.TRACE + externalOptions.isEnableSessionTraceLifecycle = true val hash = StringUtils.calculateStringHash(externalOptions.dsn, mock()) val options = SentryOptions() @@ -484,6 +490,7 @@ class SentryOptionsTest { assertEquals(0.8, options.profileSessionSampleRate) assertEquals("/profiling-traces${File.separator}${hash}", options.profilingTracesDirPath) assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) + assertTrue(options.isEnableSessionTraceLifecycle) } @Test diff --git a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt index edfcc361b08..d8ae53cb325 100644 --- a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt @@ -179,6 +179,7 @@ class TracingUtilsTest { ) .also { it.freeze() }, true, + PropagationContext.Lifecycle.TRANSACTION, ) fixture.setup() @@ -311,6 +312,7 @@ class TracingUtilsTest { null, Baggage.fromHeader(fixture.preExistingBaggage), true, + PropagationContext.Lifecycle.TRANSACTION, ) TracingUtils.maybeUpdateBaggage(fixture.scope, fixture.options) @@ -335,6 +337,7 @@ class TracingUtilsTest { "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" ), true, + PropagationContext.Lifecycle.TRANSACTION, ) TracingUtils.maybeUpdateBaggage(fixture.scope, fixture.options) From 5aa927fab9c017aa99d87a64105cbe5ed78afec1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 May 2026 12:30:40 +0200 Subject: [PATCH 2/6] ref(core): Remove propagation context lifecycle mode Treat session trace lifecycle as an option-level behavior instead of storing lifecycle state on each propagation context. This keeps the current propagation context as the ambient trace source whenever session trace lifecycle is enabled. Co-Authored-By: Claude --- .../OtelSentrySpanProcessor.java | 7 +---- .../sentry/opentelemetry/SentrySampler.java | 8 +---- sentry/api/sentry.api | 11 +------ .../java/io/sentry/PropagationContext.java | 30 ++++--------------- sentry/src/main/java/io/sentry/Scope.java | 11 +------ sentry/src/main/java/io/sentry/Scopes.java | 16 +--------- sentry/src/test/java/io/sentry/ScopeTest.kt | 9 +----- sentry/src/test/java/io/sentry/ScopesTest.kt | 15 ++++++++++ .../java/io/sentry/util/TracingUtilsTest.kt | 3 -- 9 files changed, 26 insertions(+), 84 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index 5d4b2dda352..1cf6fa5d833 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -96,12 +96,7 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri final @NotNull PropagationContext propagationContext = new PropagationContext( - new SentryId(traceId), - sentrySpanId, - sentryParentSpanId, - baggage, - sampled, - PropagationContext.Lifecycle.TRANSACTION); + new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled); baggage = propagationContext.getBaggage(); baggage.setValuesFromSamplingDecision(samplingDecision); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java index a3e6d8c89cf..1a9e8724ca6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java @@ -90,13 +90,7 @@ public SamplingResult shouldSample( SpanId randomSpanId = new SpanId(); final @NotNull PropagationContext propagationContext = sentryTraceHeader == null - ? new PropagationContext( - new SentryId(traceId), - randomSpanId, - null, - baggage, - null, - PropagationContext.Lifecycle.TRANSACTION) + ? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null) : PropagationContext.fromHeaders( sentryTraceHeader, baggage, randomSpanId, scopes.getOptions()); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2ab27057b6b..180080e3b46 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2310,19 +2310,17 @@ public final class io/sentry/ProfilingTransactionData$JsonKeys { public final class io/sentry/PropagationContext { public fun ()V public fun (Lio/sentry/PropagationContext;)V - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/Baggage;Ljava/lang/Boolean;Lio/sentry/PropagationContext$Lifecycle;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/Baggage;Ljava/lang/Boolean;)V public static fun fromExistingTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/util/List;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/SentryTraceHeader;Lio/sentry/Baggage;Lio/sentry/SpanId;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; public fun getBaggage ()Lio/sentry/Baggage; - public fun getLifecycle ()Lio/sentry/PropagationContext$Lifecycle; public fun getParentSpanId ()Lio/sentry/SpanId; public fun getSampleRand ()Ljava/lang/Double; public fun getSpanId ()Lio/sentry/SpanId; public fun getTraceId ()Lio/sentry/protocol/SentryId; public fun isSampled ()Ljava/lang/Boolean; - public fun setLifecycle (Lio/sentry/PropagationContext$Lifecycle;)V public fun setParentSpanId (Lio/sentry/SpanId;)V public fun setSampled (Ljava/lang/Boolean;)V public fun setSpanId (Lio/sentry/SpanId;)V @@ -2331,13 +2329,6 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } -public final class io/sentry/PropagationContext$Lifecycle : java/lang/Enum { - public static final field SESSION Lio/sentry/PropagationContext$Lifecycle; - public static final field TRANSACTION Lio/sentry/PropagationContext$Lifecycle; - public static fun valueOf (Ljava/lang/String;)Lio/sentry/PropagationContext$Lifecycle; - public static fun values ()[Lio/sentry/PropagationContext$Lifecycle; -} - public abstract interface class io/sentry/ReplayBreadcrumbConverter { public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index 2d51f86ed8f..f324d779955 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -12,11 +12,6 @@ @ApiStatus.Internal public final class PropagationContext { - public enum Lifecycle { - TRANSACTION, - SESSION - } - public static PropagationContext fromHeaders( final @NotNull ILogger logger, final @Nullable String sentryTraceHeader, @@ -64,8 +59,7 @@ public static PropagationContext fromHeaders( spanIdToUse, sentryTraceHeader.getSpanId(), baggage, - sentryTraceHeader.isSampled(), - Lifecycle.TRANSACTION); + sentryTraceHeader.isSampled()); } public static @NotNull PropagationContext fromExistingTrace( @@ -78,8 +72,7 @@ public static PropagationContext fromHeaders( new SpanId(), new SpanId(spanId), TracingUtils.ensureBaggage(null, null, decisionSampleRate, decisionSampleRand), - null, - Lifecycle.TRANSACTION); + null); } private @NotNull SentryId traceId; @@ -90,11 +83,9 @@ public static PropagationContext fromHeaders( private final @NotNull Baggage baggage; - private @NotNull Lifecycle lifecycle; - @ApiStatus.Internal public PropagationContext() { - this(new SentryId(), new SpanId(), null, null, null, Lifecycle.TRANSACTION); + this(new SentryId(), new SpanId(), null, null, null); } @ApiStatus.Internal @@ -104,8 +95,7 @@ public PropagationContext(final @NotNull PropagationContext propagationContext) propagationContext.getSpanId(), propagationContext.getParentSpanId(), propagationContext.getBaggage(), - propagationContext.isSampled(), - propagationContext.getLifecycle()); + propagationContext.isSampled()); } @ApiStatus.Internal @@ -114,14 +104,12 @@ public PropagationContext( final @NotNull SpanId spanId, final @Nullable SpanId parentSpanId, final @Nullable Baggage baggage, - final @Nullable Boolean sampled, - final @NotNull Lifecycle lifecycle) { + final @Nullable Boolean sampled) { this.traceId = traceId; this.spanId = spanId; this.parentSpanId = parentSpanId; this.baggage = TracingUtils.ensureBaggage(baggage, sampled, null, null); this.sampled = sampled; - this.lifecycle = lifecycle; } public @NotNull SentryId getTraceId() { @@ -164,14 +152,6 @@ public void setSampled(final @Nullable Boolean sampled) { return baggage.toTraceContext(); } - public @NotNull Lifecycle getLifecycle() { - return lifecycle; - } - - public void setLifecycle(final @NotNull Lifecycle lifecycle) { - this.lifecycle = lifecycle; - } - public @NotNull SpanContext toSpanContext() { final SpanContext spanContext = new SpanContext(traceId, spanId, "default", null, null); spanContext.setOrigin("auto"); diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 66907593f9b..fa44e90a194 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -120,16 +120,7 @@ public Scope(final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "SentryOptions is required."); this.breadcrumbs = createBreadcrumbsList(this.options.getMaxBreadcrumbs()); this.featureFlags = FeatureFlagBuffer.create(options); - this.propagationContext = - new PropagationContext( - new SentryId(), - new SpanId(), - null, - null, - null, - options.isEnableSessionTraceLifecycle() - ? PropagationContext.Lifecycle.SESSION - : PropagationContext.Lifecycle.TRANSACTION); + this.propagationContext = new PropagationContext(); this.lastEventId = SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 2dc5667af20..47f6208d4b3 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -389,20 +389,7 @@ public void startSession() { } if (getOptions().isEnableSessionTraceLifecycle()) { - configureScope( - scope -> { - scope.withPropagationContext( - propagationContext -> { - scope.setPropagationContext( - new PropagationContext( - new SentryId(), - new SpanId(), - null, - null, - null, - PropagationContext.Lifecycle.SESSION)); - }); - }); + configureScope(scope -> scope.setPropagationContext(new PropagationContext())); } final Hint hint = HintUtils.createWithTypeCheckHint(new SessionStartHint()); @@ -1040,7 +1027,6 @@ && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE final @NotNull PropagationContext propagationContext = getCombinedScopeView().getPropagationContext(); if (getOptions().isEnableSessionTraceLifecycle() - && propagationContext.getLifecycle() == PropagationContext.Lifecycle.SESSION && transactionContext.getParentSpanId() == null && !transactionContext.isForceNewTrace()) { return TransactionContext.fromPropagationContextAsRoot( diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 860488fd8fc..7093473a60b 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -872,14 +872,7 @@ class ScopeTest { val scope = Scope(options) scope.propagationContext = - PropagationContext( - SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), - SpanId(), - null, - null, - null, - PropagationContext.Lifecycle.TRANSACTION, - ) + PropagationContext(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), SpanId(), null, null, null) verify(observer) .setTrace(argThat { traceId.toString() == "64cf554cc8d74c6eafa3e08b7c984f6d" }, eq(scope)) } diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index bf5dd115e15..8e9dac6e07a 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1863,6 +1863,21 @@ class ScopesTest { assertEquals(SpanId(parentSpanId), transaction.root.spanContext.parentSpanId) } + @Test + fun `when session trace lifecycle is enabled, root transaction uses current propagation context`() { + val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } + val traceId = "75302ac48a024bde9a3b3734a82e36c8" + val parentSpanId = "1000000000000000" + scopes.continueTrace("$traceId-$parentSpanId-1", emptyList()) + + val transaction = scopes.startTransaction(TransactionContext("name", "op")) + + assertTrue(transaction is SentryTracer) + assertEquals(SentryId(traceId), transaction.root.spanContext.traceId) + assertNotEquals(SpanId(parentSpanId), transaction.root.spanContext.spanId) + assertNull(transaction.root.spanContext.parentSpanId) + } + @Test fun `when startTransaction with bindToScope set to false, transaction is not attached to the scope`() { val scopes = generateScopes() diff --git a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt index d8ae53cb325..edfcc361b08 100644 --- a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt @@ -179,7 +179,6 @@ class TracingUtilsTest { ) .also { it.freeze() }, true, - PropagationContext.Lifecycle.TRANSACTION, ) fixture.setup() @@ -312,7 +311,6 @@ class TracingUtilsTest { null, Baggage.fromHeader(fixture.preExistingBaggage), true, - PropagationContext.Lifecycle.TRANSACTION, ) TracingUtils.maybeUpdateBaggage(fixture.scope, fixture.options) @@ -337,7 +335,6 @@ class TracingUtilsTest { "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" ), true, - PropagationContext.Lifecycle.TRANSACTION, ) TracingUtils.maybeUpdateBaggage(fixture.scope, fixture.options) From cc97e324d0d15cc1746fa7498f17d8911fb862bf Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 May 2026 13:28:51 +0200 Subject: [PATCH 3/6] fix(core): Preserve context state for session trace roots Reuse SpanContext copy logic when remapping root transactions onto the session propagation context. Preserve transaction identity while replacing the trace id and clearing the parent span id. Co-Authored-By: Claude --- .../src/main/java/io/sentry/SpanContext.java | 33 ++++++++---- .../java/io/sentry/TransactionContext.java | 29 +++------- .../java/io/sentry/TransactionContextTest.kt | 53 +++++++++++++++++++ 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index 19ab2c3ad87..ed07a23824b 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -122,24 +122,35 @@ public SpanContext(final @NotNull SpanContext spanContext) { this.traceId = spanContext.traceId; this.spanId = spanContext.spanId; this.parentSpanId = spanContext.parentSpanId; - setSamplingDecision(spanContext.samplingDecision); this.op = spanContext.op; - this.description = spanContext.description; - this.status = spanContext.status; - final Map copiedTags = CollectionUtils.newConcurrentHashMap(spanContext.tags); + copyNonTraceState(spanContext, this, spanContext.baggage); + } + + @ApiStatus.Internal + static void copyNonTraceState( + final @NotNull SpanContext source, + final @NotNull SpanContext target, + final @Nullable Baggage baggage) { + target.op = source.op; + target.description = source.description; + target.status = source.status; + target.origin = source.origin; + target.instrumenter = source.instrumenter; + target.baggage = baggage; + target.setSamplingDecision(source.samplingDecision); + final Map copiedTags = CollectionUtils.newConcurrentHashMap(source.tags); if (copiedTags != null) { - this.tags = copiedTags; + target.tags = copiedTags; } - final Map copiedUnknown = - CollectionUtils.newConcurrentHashMap(spanContext.unknown); + final Map copiedUnknown = CollectionUtils.newConcurrentHashMap(source.unknown); if (copiedUnknown != null) { - this.unknown = copiedUnknown; + target.unknown = copiedUnknown; } - this.baggage = spanContext.baggage; - final Map copiedData = CollectionUtils.newConcurrentHashMap(spanContext.data); + final Map copiedData = CollectionUtils.newConcurrentHashMap(source.data); if (copiedData != null) { - this.data = copiedData; + target.data = copiedData; } + target.profilerId = source.profilerId; } public void setOperation(final @NotNull String operation) { diff --git a/sentry/src/main/java/io/sentry/TransactionContext.java b/sentry/src/main/java/io/sentry/TransactionContext.java index 34686301b6b..25aa5403f48 100644 --- a/sentry/src/main/java/io/sentry/TransactionContext.java +++ b/sentry/src/main/java/io/sentry/TransactionContext.java @@ -2,7 +2,6 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; -import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import org.jetbrains.annotations.ApiStatus; @@ -51,27 +50,13 @@ public static TransactionContext fromPropagationContext( propagationContext.getSampleRand()); final @NotNull TransactionContext sessionContext = - new TransactionContext(propagationContext.getTraceId(), new SpanId(), null, null, baggage); - sessionContext.setName(transactionContext.getName()); - sessionContext.setTransactionNameSource(transactionContext.getTransactionNameSource()); - sessionContext.setOperation(transactionContext.getOperation()); - sessionContext.setDescription(transactionContext.getDescription()); - sessionContext.setStatus(transactionContext.getStatus()); - sessionContext.setOrigin(transactionContext.getOrigin()); - sessionContext.setInstrumenter(transactionContext.getInstrumenter()); - sessionContext.setSamplingDecision(transactionContext.getSamplingDecision()); - sessionContext.setForNextAppStart(transactionContext.isForNextAppStart()); - sessionContext.setProfilerId(transactionContext.getProfilerId()); - final @Nullable java.util.Map copiedTags = - CollectionUtils.newConcurrentHashMap(transactionContext.tags); - if (copiedTags != null) { - sessionContext.tags = copiedTags; - } - final @Nullable java.util.Map copiedData = - CollectionUtils.newConcurrentHashMap(transactionContext.data); - if (copiedData != null) { - sessionContext.data = copiedData; - } + new TransactionContext( + propagationContext.getTraceId(), transactionContext.getSpanId(), null, null, baggage); + copyNonTraceState(transactionContext, sessionContext, baggage); + sessionContext.name = transactionContext.name; + sessionContext.transactionNameSource = transactionContext.transactionNameSource; + sessionContext.parentSamplingDecision = transactionContext.parentSamplingDecision; + sessionContext.isForNextAppStart = transactionContext.isForNextAppStart; sessionContext.forceNewTrace = transactionContext.forceNewTrace; return sessionContext; } diff --git a/sentry/src/test/java/io/sentry/TransactionContextTest.kt b/sentry/src/test/java/io/sentry/TransactionContextTest.kt index 55603853a66..bba16a581c2 100644 --- a/sentry/src/test/java/io/sentry/TransactionContextTest.kt +++ b/sentry/src/test/java/io/sentry/TransactionContextTest.kt @@ -95,6 +95,59 @@ class TransactionContextTest { assertFalse(context.isForNextAppStart) } + @Test + fun `fromPropagationContextAsRoot copies non trace state`() { + val propagationBaggage = Baggage(NoOpLogger.getInstance()) + propagationBaggage.sampleRand = 0.42 + val propagationContext = + PropagationContext( + SentryId("75302ac48a024bde9a3b3734a82e36c8"), + SpanId("2000000000000000"), + SpanId("1000000000000000"), + propagationBaggage, + true, + ) + val samplingDecision = TracesSamplingDecision(true, 0.3, true, 0.4) + val transactionContext = TransactionContext("name", "op", samplingDecision) + transactionContext.transactionNameSource = TransactionNameSource.ROUTE + transactionContext.description = "description" + transactionContext.status = SpanStatus.OK + transactionContext.origin = "auto.test" + transactionContext.instrumenter = Instrumenter.OTEL + transactionContext.isForNextAppStart = true + transactionContext.profilerId = SentryId("12345678123456781234567812345678") + transactionContext.setTag("tag-key", "tag-value") + transactionContext.setData("data-key", "data-value") + transactionContext.unknown = mapOf("unknown-key" to "unknown-value") + transactionContext.addFeatureFlag("feature-flag", true) + + val context = + TransactionContext.fromPropagationContextAsRoot(propagationContext, transactionContext) + + assertEquals(propagationContext.traceId, context.traceId) + assertEquals(transactionContext.spanId, context.spanId) + assertNull(context.parentSpanId) + assertEquals("name", context.name) + assertEquals(TransactionNameSource.ROUTE, context.transactionNameSource) + assertEquals("op", context.operation) + assertEquals("description", context.description) + assertEquals(SpanStatus.OK, context.status) + assertEquals("auto.test", context.origin) + assertEquals(Instrumenter.OTEL, context.instrumenter) + assertTrue(context.isForNextAppStart) + assertEquals(SentryId("12345678123456781234567812345678"), context.profilerId) + assertEquals("tag-value", context.tags["tag-key"]) + assertEquals("data-value", context.data["data-key"]) + assertEquals("unknown-value", context.unknown!!["unknown-key"]) + assertEquals(true, context.sampled) + assertEquals(0.3, context.samplingDecision!!.sampleRate) + assertEquals(true, context.profileSampled) + assertEquals(0.4, context.samplingDecision!!.profileSampleRate) + assertEquals(0.42, context.baggage!!.sampleRand) + assertEquals(propagationContext.traceId.toString(), context.baggage!!.traceId) + assertNull(context.featureFlagBuffer.featureFlags) + } + @Test fun `setForNextAppStart sets the isForNextAppStart flag`() { val context = TransactionContext("name", "op") From 004741ce3ec5f9892aa5f13e059570c1e855c875 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 11 May 2026 06:10:29 +0200 Subject: [PATCH 4/6] ref(core): Remove force new trace transaction flag Use parent span presence as the session trace lifecycle opt-out instead of a separate TransactionContext flag. Co-Authored-By: Claude --- sentry/api/sentry.api | 2 -- sentry/src/main/java/io/sentry/Scopes.java | 3 +-- .../java/io/sentry/TransactionContext.java | 24 ------------------- sentry/src/test/java/io/sentry/ScopesTest.kt | 15 +----------- 4 files changed, 2 insertions(+), 42 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 180080e3b46..069c55ab26f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4593,9 +4593,7 @@ public final class io/sentry/TransactionContext : io/sentry/SpanContext { public fun getParentSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; public fun isForNextAppStart ()Z - public fun isForceNewTrace ()Z public fun setForNextAppStart (Z)V - public fun setForceNewTrace (Z)V public fun setName (Ljava/lang/String;)V public fun setParentSampled (Ljava/lang/Boolean;)V public fun setParentSampled (Ljava/lang/Boolean;Ljava/lang/Boolean;)V diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 47f6208d4b3..d45c4fee822 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -1027,8 +1027,7 @@ && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE final @NotNull PropagationContext propagationContext = getCombinedScopeView().getPropagationContext(); if (getOptions().isEnableSessionTraceLifecycle() - && transactionContext.getParentSpanId() == null - && !transactionContext.isForceNewTrace()) { + && transactionContext.getParentSpanId() == null) { return TransactionContext.fromPropagationContextAsRoot( propagationContext, transactionContext); } diff --git a/sentry/src/main/java/io/sentry/TransactionContext.java b/sentry/src/main/java/io/sentry/TransactionContext.java index 25aa5403f48..470e8f19759 100644 --- a/sentry/src/main/java/io/sentry/TransactionContext.java +++ b/sentry/src/main/java/io/sentry/TransactionContext.java @@ -17,7 +17,6 @@ public final class TransactionContext extends SpanContext { private @NotNull TransactionNameSource transactionNameSource; private @Nullable TracesSamplingDecision parentSamplingDecision; private boolean isForNextAppStart = false; - private boolean forceNewTrace = false; @ApiStatus.Internal public static TransactionContext fromPropagationContext( @@ -57,7 +56,6 @@ public static TransactionContext fromPropagationContext( sessionContext.transactionNameSource = transactionContext.transactionNameSource; sessionContext.parentSamplingDecision = transactionContext.parentSamplingDecision; sessionContext.isForNextAppStart = transactionContext.isForNextAppStart; - sessionContext.forceNewTrace = transactionContext.forceNewTrace; return sessionContext; } @@ -169,28 +167,6 @@ public void setTransactionNameSource(final @NotNull TransactionNameSource transa this.transactionNameSource = transactionNameSource; } - /** - * Forces this transaction to start a new trace when session trace lifecycle is enabled. - * Explicitly continued traces with a parent span are still preserved. - * - * @return true if this transaction should not reuse the session propagation context. - */ - @ApiStatus.Experimental - public boolean isForceNewTrace() { - return forceNewTrace; - } - - /** - * Forces this transaction to start a new trace when session trace lifecycle is enabled. - * Explicitly continued traces with a parent span are still preserved. - * - * @param forceNewTrace true to keep this transaction on a new trace. - */ - @ApiStatus.Experimental - public void setForceNewTrace(final boolean forceNewTrace) { - this.forceNewTrace = forceNewTrace; - } - @ApiStatus.Internal public void setForNextAppStart(final boolean forNextAppStart) { isForNextAppStart = forNextAppStart; diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 8e9dac6e07a..cfe99d14824 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1837,24 +1837,11 @@ class ScopesTest { } @Test - fun `when session trace lifecycle is enabled, forceNewTrace keeps transaction trace`() { - val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } - val context = TransactionContext("name", "op") - context.setForceNewTrace(true) - - val transaction = scopes.startTransaction(context) - - assertTrue(transaction is SentryTracer) - assertEquals(context.traceId, transaction.root.spanContext.traceId) - } - - @Test - fun `forceNewTrace does not override continued trace with parent span`() { + fun `continued trace with parent span is not remapped to session trace`() { val scopes = generateScopes { it.isEnableSessionTraceLifecycle = true } val traceId = "75302ac48a024bde9a3b3734a82e36c8" val parentSpanId = "1000000000000000" val context = scopes.continueTrace("$traceId-$parentSpanId-1", emptyList())!! - context.setForceNewTrace(true) val transaction = scopes.startTransaction(context) From f68c8741e2dd83b89e5626878ae43110c3e71020 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 11 May 2026 10:54:56 +0200 Subject: [PATCH 5/6] changelog Co-Authored-By: Claude --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 681753db082..21ef19b0cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add opt-in Android session trace lifecycle support ([#5398](https://github.com/getsentry/sentry-java/pull/5398)) + ## 8.41.0 ### Features From 341ba6b80bcc8818302e07d346fdf9cbb7d242ca Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 11 May 2026 14:08:04 +0200 Subject: [PATCH 6/6] docs(core): Update session trace lifecycle javadocs Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/SentryOptions.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f807a108bb4..c3bc7fcd4a5 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1436,8 +1436,8 @@ public boolean isEnableSessionTraceLifecycle() { } /** - * Enables or disables session trace lifecycle. When enabled, root transactions can reuse the - * current session propagation context unless they force a new trace. + * Enables or disables session trace lifecycle. When enabled, root transactions without a parent + * span can reuse the current session propagation context. * *

This option is intended for Android/mobile SDKs where trace boundaries are managed by the * SDK session lifecycle. Do not enable it for JVM backend, desktop, or other non-session-managed