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" }) + } +}