From b7c48457e583a46e9904c06630f537db59535c4d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 14 Sep 2026 16:15:14 +0200 Subject: [PATCH 1/2] feat(compose): Introduce LocalSentrySpan Commit defines a LocalSentrySpan that lets Sentry and host apps deliver ISpan instances throughout composable subtrees. The delivered span can be used to parent any spans the receiving subtree produces, thereby freeing child composables from having to care about the ISpan hierarchies constructed by their ancestors. Commit also contains an internal UnsetSentrySpan + bootstrapping system that lets existing Sentry composable infrastructure provide its own ISpan in situations where the environment doesn't set a LocalSentrySpan. Commit updates SentryTraced to use both LocalSentrySpan and the bootstrapping system. Co-authored-by: Tabish Ahmad --- CHANGELOG.md | 6 + sentry-compose/api/android/sentry-compose.api | 4 + .../io/sentry/compose/LocalSentrySpan.kt | 78 ++++++++++++ .../io/sentry/compose/SentryComposeTracing.kt | 3 +- .../io/sentry/compose/SentryTracedTest.kt | 119 ++++++++++++------ 5 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7160644a780..5fe19c253eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add `LocalSentrySpan` to `sentry-compose` so apps can provide a parent `ISpan` to a composable subtree and have nested `SentryTraced` spans attach to it ([#6112]https://github.com/getsentry/sentry-java/pull/6112) + ## 8.57.0 ### Behavioral Changes diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index 3ae9af627b1..356e507c1aa 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig { public fun ()V } +public final class io/sentry/compose/LocalSentrySpanKt { + public static final fun getLocalSentrySpan ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + public final class io/sentry/compose/SentryComposeHelperKt { public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect; } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt new file mode 100644 index 00000000000..c4927df8c4a --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt @@ -0,0 +1,78 @@ +package io.sentry.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.ExperimentalComposeUiApi +import io.sentry.ISpan +import io.sentry.NoOpSpan +import io.sentry.Sentry + +/** + * A [ProvidableCompositionLocal] for delivering [ISpan]s to composable subtrees. The delivered span + * should be used to parent any spans the receiving subtree produces. + * + * Lets child composables remain agnostic about the [ISpan] hierarchies constructed by their + * ancestors. + */ +@ExperimentalComposeUiApi +public val LocalSentrySpan: ProvidableCompositionLocal = compositionLocalOf { + UnsetSentrySpan +} + +/** + * A wrapper for any composable subtree that should be passed the provided [span] via + * [LocalSentrySpan]. + * + * The span is [normalized][normalize]. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun ProvideSentrySpan(span: ISpan?, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalSentrySpan provides span.normalize()) { + content() + } +} + +/** + * Returns the receiver as-is unless it's an [UnsetSentrySpan], in which case it returns the current + * transaction. + * + * `*Bootstrap` methods are for internal use only. They're designed for scenarios where + * [LocalSentrySpan] hasn't been set, but we want to provide a reasonable alternative or fall back + * to preexisting behavior. + */ +internal fun ISpan.orBootstrapCurrentTransaction(): ISpan = + if (this.isUnset()) { + Sentry.getCurrentScopes().transaction.normalize() + } else { + this + } + +/** + * A sentinel span indicating that [LocalSentrySpan] hasn't been set. For internal use only. + * + * Note: This must be a distinct type from [NoOpSpan] so that [isUnset] can determine whether + * `LocalSentrySpan` was set with a `NoOpSpan` or was never set at all. + * + * Our own implementations need that information because: + * + * 1. we should always honor the value of `LocalSentrySpan` if deliberately set, even when it vends + * a `NoOpSpan`; but + * + * 2. we'll often want supply our own default parent span if `LocalSentrySpan` hasn't been set. + * + * A sentinel type lets us do so without complicating our public API for a distinction irrelevant to + * host apps. + */ +private object UnsetSentrySpan : ISpan by NoOpSpan.getInstance() + +private fun ISpan.isUnset(): Boolean = this === UnsetSentrySpan + +private fun ISpan?.normalize(): ISpan = + when { + this === UnsetSentrySpan || this is NoOpSpan -> this + this == null || this.isFinished -> NoOpSpan.getInstance() + else -> this + } 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 ff2cc0e80f1..a5e7a84ce6e 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import io.sentry.ISpan import io.sentry.Instrumenter -import io.sentry.NoOpSpan import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SpanOptions @@ -71,7 +70,7 @@ public fun SentryTraced( ) { val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier val scopes = Sentry.getCurrentScopes() - val ownerSpan = scopes.transaction ?: NoOpSpan.getInstance() + val ownerSpan = LocalSentrySpan.current.orBootstrapCurrentTransaction() val alreadyComposed = remember(ownerSpan) { MutableRef(false) } val alreadyRendered = remember(ownerSpan) { MutableRef(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 c56e8c0361b..431ce297c46 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -28,6 +28,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.sentry.ISpan import io.sentry.ITransaction +import io.sentry.NoOpSpan import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.TransactionOptions @@ -75,7 +76,9 @@ class SentryTracedTest { fun `records a composition span for the initial composition`() { val tx = initSentryAndStartTransaction("tx") - rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } + rule.setContent { + ProvideSentrySpan(tx) { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } + } tx.waitForSpanCount(OP_COMPOSE, 1) assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) @@ -88,7 +91,7 @@ class SentryTracedTest { } @Test - fun `falls back to the current transaction when no owner span is provided`() { + fun `falls back to the current transaction when no owner span is provided via LocalSentrySpan`() { val tx = initSentryAndStartTransaction("tx") rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } @@ -112,12 +115,32 @@ class SentryTracedTest { } @Test - fun `renders content without spans when the current transaction is finished`() { + fun `renders content without spans when provided owner span is a no-op`() { + val tx = initSentryAndStartTransaction("tx") + + rule.setContent { + ProvideSentrySpan(NoOpSpan.getInstance()) { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + } + } + rule.waitForIdle() + + rule.onNodeWithTag("content").assertExists() + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(0) + assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(0) + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + assertThat(tx.countSpans(OP_RENDER)).isEqualTo(0) + } + + @Test + fun `renders content without spans when provided owner span is finished`() { val tx = initSentryAndStartTransaction("tx") rule.runOnUiThread { tx.finish() } rule.setContent { - SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + ProvideSentrySpan(tx) { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + } } rule.waitForIdle() @@ -136,7 +159,9 @@ class SentryTracedTest { } rule.setContent { - SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + ProvideSentrySpan(tx) { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + } } rule.waitForIdle() drawContent() @@ -153,9 +178,11 @@ class SentryTracedTest { val tx = initSentryAndStartTransaction("tx") rule.setContent { - Column { - SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } - SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) } + ProvideSentrySpan(tx) { + Column { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } + SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) } + } } } @@ -170,9 +197,11 @@ class SentryTracedTest { val tx = initSentryAndStartTransaction("tx") rule.setContent { - Column { - SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } - SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) } + ProvideSentrySpan(tx) { + Column { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } + SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) } + } } } tx.waitForSpanCount(OP_COMPOSE, 2) @@ -200,9 +229,11 @@ class SentryTracedTest { val tx = initSentryAndStartTransaction("tx") rule.setContent { - val currentStep = step - SentryTraced(tag = "product_info") { - Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep")) + ProvideSentrySpan(tx) { + val currentStep = step + SentryTraced(tag = "product_info") { + Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep")) + } } } tx.waitForSpanCount(OP_COMPOSE, 1) @@ -230,15 +261,17 @@ class SentryTracedTest { val tx = initSentryAndStartTransaction("tx") rule.setContent { - val currentStep = step - SentryTraced(tag = "product_info") { - val state = remember { - rememberedInstanceCount++ - Any() + ProvideSentrySpan(tx) { + val currentStep = step + SentryTraced(tag = "product_info") { + val state = remember { + rememberedInstanceCount++ + Any() + } + DisposableEffect(Unit) { onDispose { disposeCount++ } } + rememberedState = state + Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep")) } - DisposableEffect(Unit) { onDispose { disposeCount++ } } - rememberedState = state - Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep")) } } tx.waitForSpanCount(OP_COMPOSE, 1) @@ -260,18 +293,24 @@ class SentryTracedTest { @Test fun `starts recording once an owner span becomes available`() { var step by mutableStateOf(0) + var sentrySpan by mutableStateOf(NoOpSpan.getInstance()) rule.runOnUiThread { Sentry.close() } rule.setContent { - val currentStep = step - key(currentStep) { - SentryTraced(tag = "late-transaction-$currentStep") { Box(Modifier.size(1.dp)) } + ProvideSentrySpan(sentrySpan) { + val currentStep = step + key(currentStep) { + SentryTraced(tag = "late-transaction-$currentStep") { Box(Modifier.size(1.dp)) } + } } } rule.waitForIdle() val tx = initSentryAndStartTransaction("tx") - rule.runOnIdle { step = 1 } + rule.runOnIdle { + sentrySpan = tx + step = 1 + } rule.waitForIdle() tx.waitForSpanCount(OP_COMPOSE, 1) @@ -284,11 +323,14 @@ class SentryTracedTest { fun `records spans under replacement owner after previous owner finishes`() { var step by mutableStateOf(0) val firstTx = initSentryAndStartTransaction("first-tx") + var sentrySpan by mutableStateOf(firstTx) rule.setContent { - val currentStep = step - key(currentStep) { - SentryTraced(tag = "transaction-$currentStep") { Box(Modifier.size(1.dp)) } + ProvideSentrySpan(sentrySpan) { + val currentStep = step + key(currentStep) { + SentryTraced(tag = "transaction-$currentStep") { Box(Modifier.size(1.dp)) } + } } } firstTx.waitForSpanCount(OP_COMPOSE, 1) @@ -298,7 +340,10 @@ class SentryTracedTest { firstTx.finish() secondTx = startBoundTransaction("second-tx") } - rule.runOnIdle { step = 1 } + rule.runOnIdle { + sentrySpan = secondTx + step = 1 + } rule.waitForIdle() secondTx.waitForSpanCount(OP_COMPOSE, 1) @@ -310,13 +355,12 @@ class SentryTracedTest { @Test fun `records a new span group when the owner span changes for the same composable node`() { - var step by mutableStateOf(0) val firstTx = initSentryAndStartTransaction("first-tx") + var sentrySpan by mutableStateOf(firstTx) rule.setContent { - val currentStep = step - SentryTraced(tag = "transaction", modifier = Modifier.testTag("content-$currentStep")) { - Box(Modifier.size((currentStep + 1).dp)) + ProvideSentrySpan(sentrySpan) { + SentryTraced(tag = "transaction") { Box(Modifier.size(1.dp)) } } } firstTx.waitForSpanCount(OP_COMPOSE, 1) @@ -328,14 +372,13 @@ class SentryTracedTest { firstTx.finish() secondTx = startBoundTransaction("second-tx") } - rule.runOnIdle { step = 1 } + rule.runOnIdle { sentrySpan = secondTx } rule.waitForIdle() secondTx.waitForSpanCount(OP_COMPOSE, 1) drawContent() secondTx.waitForSpanCount(OP_RENDER, 1) - rule.onNodeWithTag("content-1").assertExists() assertThat(firstTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) assertThat(firstTx.countSpans(OP_PARENT_RENDER)).isEqualTo(1) assertThat(secondTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) @@ -349,7 +392,9 @@ class SentryTracedTest { val tx = initSentryAndStartTransaction("tx") assertFailsWith { - rule.setContent { SentryTraced(tag = "throws") { error("boom") } } + rule.setContent { + ProvideSentrySpan(tx) { SentryTraced(tag = "throws") { error("boom") } } + } } assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(0) From ab4cef247e512dea722a737e82e702e5b728592d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 16 Sep 2026 20:11:41 +0200 Subject: [PATCH 2/2] Minor doc updates --- .../kotlin/io/sentry/compose/LocalSentrySpan.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt index c4927df8c4a..afc3fcfb2db 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/LocalSentrySpan.kt @@ -56,15 +56,9 @@ internal fun ISpan.orBootstrapCurrentTransaction(): ISpan = * Note: This must be a distinct type from [NoOpSpan] so that [isUnset] can determine whether * `LocalSentrySpan` was set with a `NoOpSpan` or was never set at all. * - * Our own implementations need that information because: - * - * 1. we should always honor the value of `LocalSentrySpan` if deliberately set, even when it vends - * a `NoOpSpan`; but - * - * 2. we'll often want supply our own default parent span if `LocalSentrySpan` hasn't been set. - * - * A sentinel type lets us do so without complicating our public API for a distinction irrelevant to - * host apps. + * Our own implementations often need to make that distinction so they can fall back to a reasonable + * parent span if their environment doesn't provide one. We use a sentinel to avoid complicating our + * public API for a distinction irrelevant to host apps. */ private object UnsetSentrySpan : ISpan by NoOpSpan.getInstance()