From d3d38c7579b0774de8d0fa048ff6d06472ad60c2 Mon Sep 17 00:00:00 2001 From: Tabish Ahmad Date: Sat, 25 Jul 2026 00:49:01 +0530 Subject: [PATCH] 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" }) + } +}