Skip to content

Commit d3d38c7

Browse files
committed
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
1 parent 6335e35 commit d3d38c7

4 files changed

Lines changed: 146 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Improvements
66

77
- 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))
8+
- Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` (#2668)
89

910
### Fixes
1011

sentry-compose/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ kotlin {
6868
implementation(libs.androidx.test.rules)
6969
implementation(libs.androidx.test.runner)
7070
implementation(libs.kotlin.test.junit)
71+
implementation(libs.androidx.compose.foundation)
7172
implementation(libs.mockito.inline)
7273
implementation(libs.mockito.kotlin)
7374
implementation(libs.roboelectric)
75+
implementation(projects.sentryTestSupport)
7476
}
7577
}
7678
}

sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import androidx.compose.foundation.layout.Box
44
import androidx.compose.foundation.layout.BoxScope
55
import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.Immutable
7-
import androidx.compose.runtime.compositionLocalOf
7+
import androidx.compose.runtime.ProvidableCompositionLocal
88
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.staticCompositionLocalOf
910
import androidx.compose.ui.ExperimentalComposeUiApi
1011
import androidx.compose.ui.Modifier
1112
import androidx.compose.ui.draw.drawWithContent
13+
import io.sentry.IScopes
1214
import io.sentry.ISpan
1315
import io.sentry.Sentry
1416
import io.sentry.SpanOptions
@@ -24,15 +26,19 @@ private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose"
2426

2527
@Immutable private class ImmutableHolder<T>(var item: T)
2628

27-
private fun getRootSpan(): ISpan? {
29+
public val LocalSentryScopes: ProvidableCompositionLocal<IScopes> = staticCompositionLocalOf {
30+
Sentry.getCurrentScopes()
31+
}
32+
33+
private fun getRootSpan(scopes: IScopes): ISpan? {
2834
var rootSpan: ISpan? = null
29-
Sentry.configureScope { rootSpan = it.transaction }
35+
scopes.configureScope { rootSpan = it.transaction }
3036
return rootSpan
3137
}
3238

33-
private val localSentryCompositionParentSpan = compositionLocalOf {
39+
private fun createCompositionParentSpan(scopes: IScopes): ImmutableHolder<ISpan?> =
3440
ImmutableHolder(
35-
getRootSpan()
41+
getRootSpan(scopes)
3642
?.startChild(
3743
OP_PARENT_COMPOSITION,
3844
"Jetpack Compose Initial Composition",
@@ -44,11 +50,10 @@ private val localSentryCompositionParentSpan = compositionLocalOf {
4450
)
4551
?.apply { spanContext.origin = OP_TRACE_ORIGIN }
4652
)
47-
}
4853

49-
private val localSentryRenderingParentSpan = compositionLocalOf {
54+
private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder<ISpan?> =
5055
ImmutableHolder(
51-
getRootSpan()
56+
getRootSpan(scopes)
5257
?.startChild(
5358
OP_PARENT_RENDER,
5459
"Jetpack Compose Initial Render",
@@ -60,8 +65,18 @@ private val localSentryRenderingParentSpan = compositionLocalOf {
6065
)
6166
?.apply { spanContext.origin = OP_TRACE_ORIGIN }
6267
)
68+
69+
private class RootSpans {
70+
val compositionSpans = HashMap<IScopes, ImmutableHolder<ISpan?>>()
71+
val renderingSpans = HashMap<IScopes, ImmutableHolder<ISpan?>>()
6372
}
6473

74+
// Cached once per Composition and shared by every SentryTraced call within it, mirroring the
75+
// old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and
76+
// reuses for every `.current` read that has no ancestor Provider, sibling or not). Keyed per
77+
// IScopes so distinct custom scopes each get their own root span instead of colliding.
78+
private val LocalRootSpans = staticCompositionLocalOf { RootSpans() }
79+
6580
@ExperimentalComposeUiApi
6681
@Composable
6782
public fun SentryTraced(
@@ -70,8 +85,13 @@ public fun SentryTraced(
7085
enableUserInteractionTracing: Boolean = true,
7186
content: @Composable BoxScope.() -> Unit,
7287
) {
73-
val parentCompositionSpan = localSentryCompositionParentSpan.current
74-
val parentRenderingSpan = localSentryRenderingParentSpan.current
88+
val scopes = LocalSentryScopes.current
89+
val rootSpans = LocalRootSpans.current
90+
val parentCompositionSpan =
91+
rootSpans.compositionSpans.getOrPut(scopes) { createCompositionParentSpan(scopes) }
92+
val parentRenderingSpan =
93+
rootSpans.renderingSpans.getOrPut(scopes) { createRenderingParentSpan(scopes) }
94+
7595
val compositionSpan =
7696
parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)?.apply {
7797
spanContext.origin = OP_TRACE_ORIGIN
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package io.sentry.compose
2+
3+
import android.app.Application
4+
import android.content.ComponentName
5+
import androidx.activity.ComponentActivity
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.runtime.CompositionLocalProvider
8+
import androidx.compose.ui.ExperimentalComposeUiApi
9+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
10+
import androidx.test.core.app.ApplicationProvider
11+
import androidx.test.ext.junit.runners.AndroidJUnit4
12+
import io.sentry.IScopes
13+
import io.sentry.ITransaction
14+
import io.sentry.SentryOptions
15+
import io.sentry.TransactionOptions
16+
import io.sentry.test.createTestScopes
17+
import kotlin.test.assertEquals
18+
import org.junit.Rule
19+
import org.junit.Test
20+
import org.junit.rules.TestWatcher
21+
import org.junit.runner.Description
22+
import org.junit.runner.RunWith
23+
import org.robolectric.Shadows
24+
import org.robolectric.annotation.Config
25+
26+
@OptIn(ExperimentalComposeUiApi::class)
27+
@RunWith(AndroidJUnit4::class)
28+
@Config(sdk = [30])
29+
class SentryTracedTest {
30+
// workaround for robolectric tests with composeRule
31+
// from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882
32+
@get:Rule(order = 1)
33+
val addActivityToRobolectricRule =
34+
object : TestWatcher() {
35+
override fun starting(description: Description?) {
36+
super.starting(description)
37+
val appContext: Application = ApplicationProvider.getApplicationContext()
38+
Shadows.shadowOf(appContext.packageManager)
39+
.addActivityIfNotPresent(
40+
ComponentName(appContext.packageName, ComponentActivity::class.java.name)
41+
)
42+
}
43+
}
44+
45+
@get:Rule(order = 2) val rule = createAndroidComposeRule<ComponentActivity>()
46+
47+
private fun newTracingScopes(): IScopes =
48+
createTestScopes(
49+
SentryOptions().apply {
50+
dsn = "https://key@sentry.io/proj"
51+
tracesSampleRate = 1.0
52+
}
53+
)
54+
55+
private fun IScopes.startBoundTransaction(name: String): ITransaction =
56+
startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true })
57+
58+
@Test
59+
fun `SentryTraced creates its root span on the scopes provided via LocalSentryScopes`() {
60+
val scopes = newTracingScopes()
61+
val tx = scopes.startBoundTransaction("custom-scopes-tx")
62+
63+
rule.setContent {
64+
CompositionLocalProvider(LocalSentryScopes provides scopes) {
65+
SentryTraced(tag = "custom") { Box {} }
66+
}
67+
}
68+
rule.waitForIdle()
69+
70+
assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" })
71+
assertEquals(1, tx.spans.count { it.operation == "ui.compose" })
72+
}
73+
74+
@Test
75+
fun `sibling SentryTraced composables under the same scopes share one root span`() {
76+
val scopes = newTracingScopes()
77+
val tx = scopes.startBoundTransaction("custom-scopes-tx")
78+
79+
rule.setContent {
80+
CompositionLocalProvider(LocalSentryScopes provides scopes) {
81+
SentryTraced(tag = "first") { Box {} }
82+
SentryTraced(tag = "second") { Box {} }
83+
}
84+
}
85+
rule.waitForIdle()
86+
87+
assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" })
88+
assertEquals(2, tx.spans.count { it.operation == "ui.compose" })
89+
}
90+
91+
@Test
92+
fun `SentryTraced composables under different scopes do not interfere with each other`() {
93+
val scopesA = newTracingScopes()
94+
val txA = scopesA.startBoundTransaction("scopes-a-tx")
95+
val scopesB = newTracingScopes()
96+
val txB = scopesB.startBoundTransaction("scopes-b-tx")
97+
98+
rule.setContent {
99+
CompositionLocalProvider(LocalSentryScopes provides scopesA) {
100+
SentryTraced(tag = "a") { Box {} }
101+
}
102+
CompositionLocalProvider(LocalSentryScopes provides scopesB) {
103+
SentryTraced(tag = "b") { Box {} }
104+
}
105+
}
106+
rule.waitForIdle()
107+
108+
assertEquals(1, txA.spans.count { it.operation == "ui.compose.composition" })
109+
assertEquals(1, txA.spans.count { it.operation == "ui.compose" })
110+
assertEquals(1, txB.spans.count { it.operation == "ui.compose.composition" })
111+
assertEquals(1, txB.spans.count { it.operation == "ui.compose" })
112+
}
113+
}

0 commit comments

Comments
 (0)