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)