From d3d38c7579b0774de8d0fa048ff6d06472ad60c2 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Sat, 25 Jul 2026 00:49:01 +0530 Subject: [PATCH 01/11] fix(compose): allow overriding the IScopes used by SentryTraced via LocalSentryScopes SDKs that report their own telemetry through a separate IScopes/Hub instance (distinct from the host app's Sentry setup) previously had no way to make SentryTraced trace against that instance, since it always read from the global Sentry.getCurrentScopes(). LocalSentryScopes can now be overridden via CompositionLocalProvider to scope tracing to a specific IScopes, while sibling SentryTraced calls under the same scopes still share one root composition/render span. Fixes #2668 --- CHANGELOG.md | 1 + sentry-compose/build.gradle.kts | 2 + .../io/sentry/compose/SentryComposeTracing.kt | 40 +++++-- .../io/sentry/compose/SentryTracedTest.kt | 113 ++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd2e8d0f78..72710d7073e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Improvements - Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) +- Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` (#2668) ### Fixes diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 4ebd9349662..2bc706562a6 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -68,9 +68,11 @@ kotlin { implementation(libs.androidx.test.rules) implementation(libs.androidx.test.runner) implementation(libs.kotlin.test.junit) + implementation(libs.androidx.compose.foundation) implementation(libs.mockito.inline) implementation(libs.mockito.kotlin) implementation(libs.roboelectric) + implementation(projects.sentryTestSupport) } } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index ca923e5d2c9..2afd08c3dd4 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.Sentry import io.sentry.SpanOptions @@ -24,15 +26,19 @@ private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" @Immutable private class ImmutableHolder(var item: T) -private fun getRootSpan(): ISpan? { +public val LocalSentryScopes: ProvidableCompositionLocal = staticCompositionLocalOf { + Sentry.getCurrentScopes() +} + +private fun getRootSpan(scopes: IScopes): ISpan? { var rootSpan: ISpan? = null - Sentry.configureScope { rootSpan = it.transaction } + scopes.configureScope { rootSpan = it.transaction } return rootSpan } -private val localSentryCompositionParentSpan = compositionLocalOf { +private fun createCompositionParentSpan(scopes: IScopes): ImmutableHolder = ImmutableHolder( - getRootSpan() + getRootSpan(scopes) ?.startChild( OP_PARENT_COMPOSITION, "Jetpack Compose Initial Composition", @@ -44,11 +50,10 @@ private val localSentryCompositionParentSpan = compositionLocalOf { ) ?.apply { spanContext.origin = OP_TRACE_ORIGIN } ) -} -private val localSentryRenderingParentSpan = compositionLocalOf { +private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder = ImmutableHolder( - getRootSpan() + getRootSpan(scopes) ?.startChild( OP_PARENT_RENDER, "Jetpack Compose Initial Render", @@ -60,8 +65,18 @@ private val localSentryRenderingParentSpan = compositionLocalOf { ) ?.apply { spanContext.origin = OP_TRACE_ORIGIN } ) + +private class RootSpans { + val compositionSpans = HashMap>() + val renderingSpans = HashMap>() } +// Cached once per Composition and shared by every SentryTraced call within it, mirroring the +// old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and +// reuses for every `.current` read that has no ancestor Provider, sibling or not). Keyed per +// IScopes so distinct custom scopes each get their own root span instead of colliding. +private val LocalRootSpans = staticCompositionLocalOf { RootSpans() } + @ExperimentalComposeUiApi @Composable public fun SentryTraced( @@ -70,8 +85,13 @@ public fun SentryTraced( enableUserInteractionTracing: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - val parentCompositionSpan = localSentryCompositionParentSpan.current - val parentRenderingSpan = localSentryRenderingParentSpan.current + val scopes = LocalSentryScopes.current + val rootSpans = LocalRootSpans.current + val parentCompositionSpan = + rootSpans.compositionSpans.getOrPut(scopes) { createCompositionParentSpan(scopes) } + val parentRenderingSpan = + rootSpans.renderingSpans.getOrPut(scopes) { createRenderingParentSpan(scopes) } + val compositionSpan = parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)?.apply { spanContext.origin = OP_TRACE_ORIGIN diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt new file mode 100644 index 00000000000..0dd03231bd0 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -0,0 +1,113 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.SentryOptions +import io.sentry.TransactionOptions +import io.sentry.test.createTestScopes +import kotlin.test.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@OptIn(ExperimentalComposeUiApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryTracedTest { + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = + object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager) + .addActivityIfNotPresent( + ComponentName(appContext.packageName, ComponentActivity::class.java.name) + ) + } + } + + @get:Rule(order = 2) val rule = createAndroidComposeRule() + + private fun newTracingScopes(): IScopes = + createTestScopes( + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + } + ) + + private fun IScopes.startBoundTransaction(name: String): ITransaction = + startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true }) + + @Test + fun `SentryTraced creates its root span on the scopes provided via LocalSentryScopes`() { + val scopes = newTracingScopes() + val tx = scopes.startBoundTransaction("custom-scopes-tx") + + rule.setContent { + CompositionLocalProvider(LocalSentryScopes provides scopes) { + SentryTraced(tag = "custom") { Box {} } + } + } + rule.waitForIdle() + + assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" }) + assertEquals(1, tx.spans.count { it.operation == "ui.compose" }) + } + + @Test + fun `sibling SentryTraced composables under the same scopes share one root span`() { + val scopes = newTracingScopes() + val tx = scopes.startBoundTransaction("custom-scopes-tx") + + rule.setContent { + CompositionLocalProvider(LocalSentryScopes provides scopes) { + SentryTraced(tag = "first") { Box {} } + SentryTraced(tag = "second") { Box {} } + } + } + rule.waitForIdle() + + assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" }) + assertEquals(2, tx.spans.count { it.operation == "ui.compose" }) + } + + @Test + fun `SentryTraced composables under different scopes do not interfere with each other`() { + val scopesA = newTracingScopes() + val txA = scopesA.startBoundTransaction("scopes-a-tx") + val scopesB = newTracingScopes() + val txB = scopesB.startBoundTransaction("scopes-b-tx") + + rule.setContent { + CompositionLocalProvider(LocalSentryScopes provides scopesA) { + SentryTraced(tag = "a") { Box {} } + } + CompositionLocalProvider(LocalSentryScopes provides scopesB) { + SentryTraced(tag = "b") { Box {} } + } + } + rule.waitForIdle() + + assertEquals(1, txA.spans.count { it.operation == "ui.compose.composition" }) + assertEquals(1, txA.spans.count { it.operation == "ui.compose" }) + assertEquals(1, txB.spans.count { it.operation == "ui.compose.composition" }) + assertEquals(1, txB.spans.count { it.operation == "ui.compose" }) + } +} From d1e90a3aaf2e97de6e112484e802d3c4c22232bd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 27 Jul 2026 09:58:29 +0200 Subject: [PATCH 02/11] Apply suggestion from @romtsn --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72710d7073e..dd80c8201e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Improvements - Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) -- Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` (#2668) +- Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` ([#5838](https://github.com/getsentry/sentry-java/pull/5838)) ### Fixes From 5a43f30f5805f94e86be89d4c67289a57653f369 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 27 Jul 2026 11:33:40 +0200 Subject: [PATCH 03/11] Apply suggestion from @romtsn --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd80c8201e0..40a819f2a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,23 @@ ### Improvements - Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) +## Unreleased + +### Features + - Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` ([#5838](https://github.com/getsentry/sentry-java/pull/5838)) + - Example usage: + ```kotlin + val scopes = Scopes(options) + CompositionLocalProvider(LocalSentryScopes provides scopes) { + // this uses customs scopes now + SentryTraced(tag = "custom") { Box {} } + } + ``` + +### Improvements + +- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) ### Fixes From 105fd6d5b1e3cc1009fefaa429bdf71bc178c0a2 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Mon, 27 Jul 2026 16:36:21 +0530 Subject: [PATCH 04/11] fix(compose): address PR review feedback on LocalSentryScopes root span caching Key scopes weakly in RootSpans so custom IScopes instances (and their cached parent spans) can be garbage collected once nothing else holds a reference, instead of being pinned for the lifetime of the root Composition. Also stop permanently caching a null parent span when no transaction is bound yet, so a later transaction on the same scopes is still picked up. Addresses review feedback from sentry-review-bot and cursor bugbot on getsentry/sentry-java#5838. --- CHANGELOG.md | 5 ----- .../io/sentry/compose/SentryComposeTracing.kt | 21 +++++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a819f2a4b..a156873443c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,6 @@ ## Unreleased -### Improvements - -- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) -## Unreleased - ### Features - Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` ([#5838](https://github.com/getsentry/sentry-java/pull/5838)) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index 2afd08c3dd4..e1d03a7e385 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -15,6 +15,7 @@ import io.sentry.ISpan import io.sentry.Sentry import io.sentry.SpanOptions import io.sentry.compose.SentryModifier.sentryTag +import java.util.WeakHashMap private const val OP_PARENT_COMPOSITION = "ui.compose.composition" private const val OP_COMPOSE = "ui.compose" @@ -67,10 +68,22 @@ private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder ) private class RootSpans { - val compositionSpans = HashMap>() - val renderingSpans = HashMap>() + // Weakly keyed so scopes instances that are no longer referenced elsewhere (e.g. a short-lived + // custom scopes provided via LocalSentryScopes for a finished screen/session) can be garbage + // collected instead of being pinned here for the lifetime of the root Composition. + val compositionSpans = WeakHashMap>() + val renderingSpans = WeakHashMap>() } +private fun getOrCreateParentSpan( + map: MutableMap>, + scopes: IScopes, + create: (IScopes) -> ImmutableHolder, +): ImmutableHolder = + // Only cache the holder once it actually contains a span; a null result (no transaction bound + // to the scopes yet) is recomputed on the next call so a later transaction is still picked up. + map[scopes] ?: create(scopes).also { if (it.item != null) map[scopes] = it } + // Cached once per Composition and shared by every SentryTraced call within it, mirroring the // old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and // reuses for every `.current` read that has no ancestor Provider, sibling or not). Keyed per @@ -88,9 +101,9 @@ public fun SentryTraced( val scopes = LocalSentryScopes.current val rootSpans = LocalRootSpans.current val parentCompositionSpan = - rootSpans.compositionSpans.getOrPut(scopes) { createCompositionParentSpan(scopes) } + getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan) val parentRenderingSpan = - rootSpans.renderingSpans.getOrPut(scopes) { createRenderingParentSpan(scopes) } + getOrCreateParentSpan(rootSpans.renderingSpans, scopes, ::createRenderingParentSpan) val compositionSpan = parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)?.apply { From dfa46df4ce1769f3624027486bc983b8086a3d06 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 14:17:50 +0530 Subject: [PATCH 05/11] fix(compose): regenerate api dump for LocalSentryScopes Missing apiDump entry for the public LocalSentryScopes CompositionLocal was failing the apiCheck CI job. Co-Authored-By: Claude Sonnet 5 --- sentry-compose/api/android/sentry-compose.api | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index 3ae9af627b1..52327934b6c 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -12,6 +12,7 @@ public final class io/sentry/compose/SentryComposeHelperKt { public final class io/sentry/compose/SentryComposeTracingKt { public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun getLocalSentryScopes ()Landroidx/compose/runtime/ProvidableCompositionLocal; } public final class io/sentry/compose/SentryModifier { From 446749f7f5bba681d1530f07ca58f76519693574 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 15:15:47 +0530 Subject: [PATCH 06/11] fix(compose): fix WeakHashMap leak and stale LocalSentryScopes default The cached ImmutableHolder values strongly referenced their own IScopes key (via Span's internal scopes field), which kept every scopes instance permanently reachable through RootSpans and defeated the WeakHashMap keying entirely. Wrap the cached holder in a WeakReference so the map no longer holds a strong path back to its own key. LocalSentryScopes now defaults to null instead of eagerly resolving Sentry.getCurrentScopes(): a CompositionLocal's default factory runs at most once per process, so the previous default would permanently cache whatever scopes were current on first read (e.g. NoOp scopes if read before Sentry.init()). SentryTraced now falls back to Sentry.getCurrentScopes() on every call when nothing was explicitly provided. Also adds missing Sentry.close() teardown to SentryTracedTest, and fixes a typo in the changelog example. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- .../io/sentry/compose/SentryComposeTracing.kt | 27 +++++++++++++------ .../io/sentry/compose/SentryTracedTest.kt | 7 +++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a156873443c..560bcc53815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ```kotlin val scopes = Scopes(options) CompositionLocalProvider(LocalSentryScopes provides scopes) { - // this uses customs scopes now + // this uses custom scopes now SentryTraced(tag = "custom") { Box {} } } ``` diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index e1d03a7e385..5462c00b204 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -15,6 +15,7 @@ import io.sentry.ISpan import io.sentry.Sentry import io.sentry.SpanOptions import io.sentry.compose.SentryModifier.sentryTag +import java.lang.ref.WeakReference import java.util.WeakHashMap private const val OP_PARENT_COMPOSITION = "ui.compose.composition" @@ -27,8 +28,13 @@ private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" @Immutable private class ImmutableHolder(var item: T) -public val LocalSentryScopes: ProvidableCompositionLocal = staticCompositionLocalOf { - Sentry.getCurrentScopes() +// Defaults to null rather than eagerly resolving Sentry.getCurrentScopes(): a CompositionLocal's +// default value is computed at most once for the process lifetime, so a non-null default would +// permanently cache whatever scopes were current on first read (e.g. a NoOp scopes if read before +// Sentry.init()). SentryTraced instead falls back to Sentry.getCurrentScopes() on every call when +// no value has been explicitly provided, so it always reflects the current scopes. +public val LocalSentryScopes: ProvidableCompositionLocal = staticCompositionLocalOf { + null } private fun getRootSpan(scopes: IScopes): ISpan? { @@ -70,19 +76,24 @@ private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder private class RootSpans { // Weakly keyed so scopes instances that are no longer referenced elsewhere (e.g. a short-lived // custom scopes provided via LocalSentryScopes for a finished screen/session) can be garbage - // collected instead of being pinned here for the lifetime of the root Composition. - val compositionSpans = WeakHashMap>() - val renderingSpans = WeakHashMap>() + // collected instead of being pinned here for the lifetime of the root Composition. The cached + // holder is itself wrapped in a WeakReference: its ISpan strongly references the scopes that + // created it (Span keeps its Scopes alive), so storing the holder directly as the map value + // would let that strong value -> key path keep every scopes instance permanently reachable, + // defeating the WeakHashMap keying entirely. + val compositionSpans = WeakHashMap>>() + val renderingSpans = WeakHashMap>>() } private fun getOrCreateParentSpan( - map: MutableMap>, + map: MutableMap>>, scopes: IScopes, create: (IScopes) -> ImmutableHolder, ): ImmutableHolder = // Only cache the holder once it actually contains a span; a null result (no transaction bound // to the scopes yet) is recomputed on the next call so a later transaction is still picked up. - map[scopes] ?: create(scopes).also { if (it.item != null) map[scopes] = it } + // A cleared reference (holder was collected) is also recomputed rather than reused. + map[scopes]?.get() ?: create(scopes).also { if (it.item != null) map[scopes] = WeakReference(it) } // Cached once per Composition and shared by every SentryTraced call within it, mirroring the // old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and @@ -98,7 +109,7 @@ public fun SentryTraced( enableUserInteractionTracing: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - val scopes = LocalSentryScopes.current + val scopes = LocalSentryScopes.current ?: Sentry.getCurrentScopes() val rootSpans = LocalRootSpans.current val parentCompositionSpan = getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan) diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index 0dd03231bd0..3d2de86d8f6 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -11,10 +11,12 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IScopes import io.sentry.ITransaction +import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.TransactionOptions import io.sentry.test.createTestScopes import kotlin.test.assertEquals +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher @@ -44,6 +46,11 @@ class SentryTracedTest { @get:Rule(order = 2) val rule = createAndroidComposeRule() + @After + fun tearDown() { + Sentry.close() + } + private fun newTracingScopes(): IScopes = createTestScopes( SentryOptions().apply { From 67024344613e4d43d192ac70bdade2cd684f2fee Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 15:30:41 +0530 Subject: [PATCH 07/11] fix(compose): use deterministic ref-counting instead of weak cache for root spans Wrapping the cached root span holder in a WeakReference fixed the prior leak but let GC collect it between recompositions even while a SentryTraced call under that scopes was still mounted, silently breaking the "sibling calls share one root span" guarantee. RootSpans now uses plain HashMaps with reference counting: each SentryTraced call retains its scopes entry via a DisposableEffect and releases it on dispose. An entry is only removed once the last SentryTraced call using that IScopes actually leaves composition, so cleanup no longer depends on GC timing. --- .../io/sentry/compose/SentryComposeTracing.kt | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index 5462c00b204..22390c631e9 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -3,6 +3,7 @@ package io.sentry.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.remember @@ -15,8 +16,6 @@ import io.sentry.ISpan import io.sentry.Sentry import io.sentry.SpanOptions import io.sentry.compose.SentryModifier.sentryTag -import java.lang.ref.WeakReference -import java.util.WeakHashMap private const val OP_PARENT_COMPOSITION = "ui.compose.composition" private const val OP_COMPOSE = "ui.compose" @@ -74,26 +73,40 @@ private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder ) private class RootSpans { - // Weakly keyed so scopes instances that are no longer referenced elsewhere (e.g. a short-lived - // custom scopes provided via LocalSentryScopes for a finished screen/session) can be garbage - // collected instead of being pinned here for the lifetime of the root Composition. The cached - // holder is itself wrapped in a WeakReference: its ISpan strongly references the scopes that - // created it (Span keeps its Scopes alive), so storing the holder directly as the map value - // would let that strong value -> key path keep every scopes instance permanently reachable, - // defeating the WeakHashMap keying entirely. - val compositionSpans = WeakHashMap>>() - val renderingSpans = WeakHashMap>>() + // Entries are cleaned up deterministically via reference counting (see retain/release) rather + // than relying on GC to reclaim a weakly-keyed map: a value here (ImmutableHolder -> Span) + // strongly references the IScopes that created it (Span keeps its Scopes alive), so a + // WeakHashMap keyed on IScopes would never actually evict its own key, and a value wrapped in a + // WeakReference could be collected between recompositions even while a SentryTraced call under + // that scopes is still in composition, silently breaking root span sharing. + val compositionSpans = HashMap>() + val renderingSpans = HashMap>() + private val refCounts = HashMap() + + fun retain(scopes: IScopes) { + refCounts[scopes] = (refCounts[scopes] ?: 0) + 1 + } + + fun release(scopes: IScopes) { + val remaining = (refCounts[scopes] ?: 1) - 1 + if (remaining <= 0) { + refCounts.remove(scopes) + compositionSpans.remove(scopes) + renderingSpans.remove(scopes) + } else { + refCounts[scopes] = remaining + } + } } private fun getOrCreateParentSpan( - map: MutableMap>>, + map: MutableMap>, scopes: IScopes, create: (IScopes) -> ImmutableHolder, ): ImmutableHolder = // Only cache the holder once it actually contains a span; a null result (no transaction bound // to the scopes yet) is recomputed on the next call so a later transaction is still picked up. - // A cleared reference (holder was collected) is also recomputed rather than reused. - map[scopes]?.get() ?: create(scopes).also { if (it.item != null) map[scopes] = WeakReference(it) } + map[scopes] ?: create(scopes).also { if (it.item != null) map[scopes] = it } // Cached once per Composition and shared by every SentryTraced call within it, mirroring the // old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and @@ -111,6 +124,10 @@ public fun SentryTraced( ) { val scopes = LocalSentryScopes.current ?: Sentry.getCurrentScopes() val rootSpans = LocalRootSpans.current + DisposableEffect(rootSpans, scopes) { + rootSpans.retain(scopes) + onDispose { rootSpans.release(scopes) } + } val parentCompositionSpan = getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan) val parentRenderingSpan = From 6bd3aa5c4aa0cf551cf1435533be95e37683ff1a Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 16:03:44 +0530 Subject: [PATCH 08/11] fix(compose): retain root span cache entries synchronously to avoid a dispose/remember race Retaining inside a DisposableEffect deferred the retain to the effect-application phase, but Compose dispatches an outgoing composable's onDispose before an incoming composable's DisposableEffect in the same recomposition. Replacing a SentryTraced call under a given scopes (e.g. during navigation) could therefore have the outgoing call's release clear the cache before the incoming call's retain ran, so a later SentryTraced call under the same scopes found nothing cached and created a duplicate root span. Retain now happens inside remember instead, which runs synchronously during composition, before any effect-phase work for that frame - including another SentryTraced call's dispose. Release stays in DisposableEffect's onDispose since its timing no longer matters. Adds a regression test that swaps which keyed SentryTraced composable is mounted under the same scopes twice in a row, which reproduces the duplicate span without this fix. --- .../io/sentry/compose/SentryComposeTracing.kt | 11 ++++--- .../io/sentry/compose/SentryTracedTest.kt | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index 22390c631e9..5ccb6bb0216 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -124,10 +124,13 @@ public fun SentryTraced( ) { val scopes = LocalSentryScopes.current ?: Sentry.getCurrentScopes() val rootSpans = LocalRootSpans.current - DisposableEffect(rootSpans, scopes) { - rootSpans.retain(scopes) - onDispose { rootSpans.release(scopes) } - } + // Retain synchronously during composition (not inside a DisposableEffect) so it always runs + // before any effect-phase work for this frame, including another SentryTraced call's dispose: + // Compose runs the dispose of an outgoing node before the effects of an incoming one in the + // same recomposition, so retaining from an effect could let the shared entry's refcount hit + // zero (and get evicted) between an old and a new SentryTraced call sharing the same scopes. + remember(rootSpans, scopes) { rootSpans.retain(scopes) } + DisposableEffect(rootSpans, scopes) { onDispose { rootSpans.release(scopes) } } val parentCompositionSpan = getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan) val parentRenderingSpan = diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index 3d2de86d8f6..6720898f747 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -5,6 +5,10 @@ import android.content.ComponentName import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.core.app.ApplicationProvider @@ -117,4 +121,30 @@ class SentryTracedTest { assertEquals(1, txB.spans.count { it.operation == "ui.compose.composition" }) assertEquals(1, txB.spans.count { it.operation == "ui.compose" }) } + + @Test + fun `repeatedly replacing a traced composable under the same scopes keeps sharing the root span`() { + // Each swap disposes the outgoing keyed composable and mounts a new one under the same + // scopes within a single recomposition. Compose dispatches the outgoing composable's + // onDispose before the incoming one's DisposableEffect runs, so a second swap is needed to + // surface a root span cache that was cleared out from under a still-live retain. + val scopes = newTracingScopes() + val tx = scopes.startBoundTransaction("custom-scopes-tx") + var step by mutableStateOf(0) + + rule.setContent { + CompositionLocalProvider(LocalSentryScopes provides scopes) { + key(step) { SentryTraced(tag = "step-$step") { Box {} } } + } + } + rule.waitForIdle() + + step = 1 + rule.waitForIdle() + + step = 2 + rule.waitForIdle() + + assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" }) + } } From 863894fc7cf5dd4b23777d10266f16d657c2b266 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 16:17:14 +0530 Subject: [PATCH 09/11] fix(compose): release root span refcount on abandoned composition too retain ran synchronously as a side effect of remember, while release only ran from DisposableEffect's onDispose. If the composition that retained never actually committed (e.g. an exception thrown elsewhere during the same composition), onDispose never fired, so the refcount was never decremented and the IScopes entry stayed pinned in RootSpans for the process lifetime. Replaced the plain remember + DisposableEffect pair with a single RememberObserver: retain happens in its constructor (still synchronous, so it keeps running before any effect-phase work for that frame), and release happens from either onForgotten (normal dispose) or onAbandoned (composition never committed), whichever Compose actually calls. --- .../io/sentry/compose/SentryComposeTracing.kt | 35 ++++++++++++++----- .../io/sentry/compose/SentryTracedTest.kt | 4 +-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index 5ccb6bb0216..d2b4be0f36d 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -3,9 +3,9 @@ package io.sentry.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi @@ -99,6 +99,31 @@ private class RootSpans { } } +// Retains scopes in the constructor so it always runs synchronously during composition, before +// any effect-phase work for this frame (including another SentryTraced call's dispose): Compose +// runs the dispose of an outgoing node before the effects of an incoming one in the same +// recomposition, so retaining from an effect could let a shared entry's refcount hit zero (and +// get evicted) between an old and a new SentryTraced call sharing the same scopes. Releasing from +// both onForgotten and onAbandoned (rather than a DisposableEffect) also covers a composition +// that never commits (e.g. an exception elsewhere during composition), which would otherwise +// leave the retain in place with no matching release. +private class ScopesRetention(private val rootSpans: RootSpans, private val scopes: IScopes) : + RememberObserver { + init { + rootSpans.retain(scopes) + } + + override fun onRemembered() {} + + override fun onForgotten() { + rootSpans.release(scopes) + } + + override fun onAbandoned() { + rootSpans.release(scopes) + } +} + private fun getOrCreateParentSpan( map: MutableMap>, scopes: IScopes, @@ -124,13 +149,7 @@ public fun SentryTraced( ) { val scopes = LocalSentryScopes.current ?: Sentry.getCurrentScopes() val rootSpans = LocalRootSpans.current - // Retain synchronously during composition (not inside a DisposableEffect) so it always runs - // before any effect-phase work for this frame, including another SentryTraced call's dispose: - // Compose runs the dispose of an outgoing node before the effects of an incoming one in the - // same recomposition, so retaining from an effect could let the shared entry's refcount hit - // zero (and get evicted) between an old and a new SentryTraced call sharing the same scopes. - remember(rootSpans, scopes) { rootSpans.retain(scopes) } - DisposableEffect(rootSpans, scopes) { onDispose { rootSpans.release(scopes) } } + remember(rootSpans, scopes) { ScopesRetention(rootSpans, scopes) } val parentCompositionSpan = getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan) val parentRenderingSpan = diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index 6720898f747..d7c76c6aa3e 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -126,8 +126,8 @@ class SentryTracedTest { fun `repeatedly replacing a traced composable under the same scopes keeps sharing the root span`() { // Each swap disposes the outgoing keyed composable and mounts a new one under the same // scopes within a single recomposition. Compose dispatches the outgoing composable's - // onDispose before the incoming one's DisposableEffect runs, so a second swap is needed to - // surface a root span cache that was cleared out from under a still-live retain. + // onForgotten before the incoming one's onRemembered, so a second swap is needed to surface + // a root span cache that was cleared out from under a still-live retain. val scopes = newTracingScopes() val tx = scopes.startBoundTransaction("custom-scopes-tx") var step by mutableStateOf(0) From 20dafa59487db0795463b3767f5146b8ecaed240 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 16:29:30 +0530 Subject: [PATCH 10/11] fix(compose): finish evicted root spans instead of leaving them open isIdle spans only auto-finish when the whole transaction finishes, so evicting a cached root span from RootSpans without finishing it left it open on the transaction. If the same scopes was used again later, a fresh root span was created alongside the still-open, now-untracked original, showing up as a duplicate root span once the transaction finished. release now finishes the evicted composition/rendering spans before dropping them from the cache. --- .../io/sentry/compose/SentryComposeTracing.kt | 8 +++++-- .../io/sentry/compose/SentryTracedTest.kt | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index d2b4be0f36d..7b8ad525b80 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -91,8 +91,12 @@ private class RootSpans { val remaining = (refCounts[scopes] ?: 1) - 1 if (remaining <= 0) { refCounts.remove(scopes) - compositionSpans.remove(scopes) - renderingSpans.remove(scopes) + // These are idle spans: they only auto-finish when the whole transaction finishes, so + // evicting them from the cache without finishing them here would leave them open on the + // transaction. If the same scopes is used again later, a fresh pair would be created + // alongside the still-open, now-untracked originals, showing up as duplicate root spans. + compositionSpans.remove(scopes)?.item?.finish() + renderingSpans.remove(scopes)?.item?.finish() } else { refCounts[scopes] = remaining } diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index d7c76c6aa3e..d853e627758 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -20,6 +20,7 @@ import io.sentry.SentryOptions import io.sentry.TransactionOptions import io.sentry.test.createTestScopes import kotlin.test.assertEquals +import kotlin.test.assertTrue import org.junit.After import org.junit.Rule import org.junit.Test @@ -147,4 +148,26 @@ class SentryTracedTest { assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" }) } + + @Test + fun `evicting a root span cache entry finishes the underlying span`() { + val scopes = newTracingScopes() + val tx = scopes.startBoundTransaction("custom-scopes-tx") + var mounted by mutableStateOf(true) + + rule.setContent { + if (mounted) { + CompositionLocalProvider(LocalSentryScopes provides scopes) { + SentryTraced(tag = "first") { Box {} } + } + } + } + rule.waitForIdle() + + mounted = false + rule.waitForIdle() + + val compositionRootSpan = tx.spans.first { it.operation == "ui.compose.composition" } + assertTrue(compositionRootSpan.isFinished) + } } From 0c54ea194e967c4b4eca8ad4288139120e827456 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Tue, 28 Jul 2026 17:04:03 +0530 Subject: [PATCH 11/11] fix(compose): stop finishing evicted root spans, restore retain on reactivation Finishing spans when a RootSpans cache entry is evicted turned out to be unsafe: Compose can deactivate a composable's remembered state (onForgotten) and later reactivate the same instance (onRemembered) without re-running remember, e.g. for LazyColumn item reuse. A refcount reaching zero during a brief deactivation doesn't reliably mean the scopes is done for good, so eagerly finishing there risked silently dropping compose/render spans for content that was still in use, just temporarily deactivated. release no longer finishes evicted spans; they still get finished automatically once the whole transaction finishes, same as before this cache existed. The trade-off is a possible extra root span if the same scopes is genuinely reused much later. ScopesRetention also now tracks whether it currently holds a retain and re-retains from onRemembered when reactivated without the constructor re-running, instead of only retaining once at construction time. Without this, a reactivated call's claim on a shared cache entry was silently lost. --- .../io/sentry/compose/SentryComposeTracing.kt | 44 +++++++++++++++---- .../io/sentry/compose/SentryTracedTest.kt | 11 +++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index 7b8ad525b80..3ccf3ed5010 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -91,12 +91,15 @@ private class RootSpans { val remaining = (refCounts[scopes] ?: 1) - 1 if (remaining <= 0) { refCounts.remove(scopes) - // These are idle spans: they only auto-finish when the whole transaction finishes, so - // evicting them from the cache without finishing them here would leave them open on the - // transaction. If the same scopes is used again later, a fresh pair would be created - // alongside the still-open, now-untracked originals, showing up as duplicate root spans. - compositionSpans.remove(scopes)?.item?.finish() - renderingSpans.remove(scopes)?.item?.finish() + // Deliberately not finishing these spans here: Compose can deactivate and later reactivate + // the same retained instance (e.g. LazyColumn item reuse) without necessarily re-mounting a + // new SentryTraced in between, so a refcount reaching zero doesn't reliably mean the scopes + // is done for good. Finishing eagerly would risk silently dropping compose/render spans + // started while briefly deactivated. These are idle spans, so they still get finished + // automatically once the whole transaction finishes, same as before this cache existed; the + // trade-off is a possible extra root span if the same scopes is genuinely reused much later. + compositionSpans.remove(scopes) + renderingSpans.remove(scopes) } else { refCounts[scopes] = remaining } @@ -111,20 +114,43 @@ private class RootSpans { // both onForgotten and onAbandoned (rather than a DisposableEffect) also covers a composition // that never commits (e.g. an exception elsewhere during composition), which would otherwise // leave the retain in place with no matching release. +// +// A single instance can also go through onForgotten/onRemembered more than once without the +// constructor ever running again: reusable content (e.g. LazyColumn item reuse) deactivates by +// forgetting all remembered objects and later reactivates existing ones by calling onRemembered +// directly. isRetained tracks whether this instance currently holds a retain so onRemembered can +// re-retain on reactivation, while still not double-retaining right after construction +// (onRemembered +// always fires once for a freshly remembered object too). private class ScopesRetention(private val rootSpans: RootSpans, private val scopes: IScopes) : RememberObserver { + private var isRetained = false + init { + retain() + } + + private fun retain() { rootSpans.retain(scopes) + isRetained = true } - override fun onRemembered() {} + override fun onRemembered() { + if (!isRetained) retain() + } override fun onForgotten() { - rootSpans.release(scopes) + if (isRetained) { + rootSpans.release(scopes) + isRetained = false + } } override fun onAbandoned() { - rootSpans.release(scopes) + if (isRetained) { + rootSpans.release(scopes) + isRetained = false + } } } diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index d853e627758..5243195789e 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -20,7 +20,6 @@ import io.sentry.SentryOptions import io.sentry.TransactionOptions import io.sentry.test.createTestScopes import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.junit.After import org.junit.Rule import org.junit.Test @@ -150,7 +149,9 @@ class SentryTracedTest { } @Test - fun `evicting a root span cache entry finishes the underlying span`() { + fun `fully unmounting and remounting under the same scopes creates a fresh root span`() { + // Confirms the cache entry is actually dropped once the last consumer of a scopes leaves + // composition (the fix for the original RootSpans leak), rather than being reused forever. val scopes = newTracingScopes() val tx = scopes.startBoundTransaction("custom-scopes-tx") var mounted by mutableStateOf(true) @@ -167,7 +168,9 @@ class SentryTracedTest { mounted = false rule.waitForIdle() - val compositionRootSpan = tx.spans.first { it.operation == "ui.compose.composition" } - assertTrue(compositionRootSpan.isFinished) + mounted = true + rule.waitForIdle() + + assertEquals(2, tx.spans.count { it.operation == "ui.compose.composition" }) } }