From 5105821809e6e84a21b54bc6a2a12dc4e07c1d96 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 14 Sep 2026 16:15:14 +0200 Subject: [PATCH] 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 | 1 + 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, 166 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 8a8eea2d9cf..f884f636b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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) - Sentry can now configure Log4j2 automatically for Spring Boot 4 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#5403](https://github.com/getsentry/sentry-java/pull/5403)) - Enable automatic appender registration with: ```properties 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)