From d3d38c7579b0774de8d0fa048ff6d06472ad60c2 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Sat, 25 Jul 2026 00:49:01 +0530 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 {