From e5083341eef9e856bad2d549afcf7dce0944ec0b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 3 Sep 2026 13:56:45 +0200 Subject: [PATCH 1/7] fix(compose): Stop SentryTraced from reusing stale parent spans Fix the stale parent problem that causes spans from all SentryTraced instances for the entire app process to be dropped once the initial active transaction finishes. Prior to this commit, SentryTraced used process-wide composition locals to bind the transaction active whenever the first SentryTraced for an app process entered the composition, and to reuse it for all SentryTraced instances thereafter. That meant all SentryTraced spans for the entire app would be dropped for the lifetime of the app process once the initial transaction finished. Oof. This commit sets things right by having each SentryTraced composable request the current active transaction and update the generation of spans accordingly. Because we're no longer relying on a single transaction + parent span pair, SentryTraced now needs to manage the creation of possibly multiple parent span pairs, as the owning transaction updates. That logic lives in the new ParentSpans class. --- CHANGELOG.md | 6 + sentry-compose/build.gradle.kts | 3 + .../io/sentry/compose/SentryComposeTracing.kt | 233 ++++++---- .../io/sentry/compose/SentryTracedTest.kt | 438 ++++++++++++++++++ 4 files changed, 596 insertions(+), 84 deletions(-) create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f69853487f..a60df8605b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +`SentryTraced` now checks for its owning transaction dynamically rather than once per app process, which led to `SentryTraced` spans being dropped once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057)) + ## 8.55.0 ### Features diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 8b835ba16fe..ad2c1237314 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -62,11 +62,14 @@ kotlin { } getByName("androidUnitTest") { dependencies { + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.ui.test.junit4) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.test.ext.junit) implementation(libs.androidx.test.rules) implementation(libs.androidx.test.runner) + implementation(libs.google.truth) implementation(libs.kotlin.test.junit) implementation(libs.mockito.inline) implementation(libs.mockito.kotlin) 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 62679fa0158..029f189acb8 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -4,74 +4,47 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import io.sentry.ISpan +import io.sentry.NoOpSpan import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SpanOptions import io.sentry.compose.SentryModifier.sentryTag +import java.lang.ref.WeakReference +import java.util.WeakHashMap -private const val OP_PARENT_COMPOSITION = "ui.compose.composition" -private const val OP_COMPOSE = "ui.compose" +private const val DESCRIPTION_COMPOSITION_PARENT = "Jetpack Compose Initial Composition" +private const val OP_COMPOSITION_PARENT = "ui.compose.composition" +private const val OP_COMPOSITION_CHILD = "ui.compose" -private const val OP_PARENT_RENDER = "ui.compose.rendering" -private const val OP_RENDER = "ui.render" +private const val DESCRIPTION_RENDER_PARENT = "Jetpack Compose Initial Render" +private const val OP_RENDER_PARENT = "ui.compose.rendering" +private const val OP_RENDER_CHILD = "ui.render" private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" -private val localSentryCompositionParentSpan = compositionLocalOf { - getRootSpan() - // Create a single parent span to own composition spans emitted by all SentryTraced composables - // during the root's lifetime. - ?.startChild( - OP_PARENT_COMPOSITION, - "Jetpack Compose Initial Composition", - SpanOptions().apply { - isTrimStart = true - isTrimEnd = true - isIdle = true - }, - ) - ?.apply { spanContext.origin = OP_TRACE_ORIGIN } -} - -private val localSentryRenderingParentSpan = compositionLocalOf { - getRootSpan() - // Create a single parent span to own render spans emitted by all SentryTraced composables - // during the root's lifetime. - ?.startChild( - OP_PARENT_RENDER, - "Jetpack Compose Initial Render", - SpanOptions().apply { - isTrimStart = true - isTrimEnd = true - isIdle = true - }, - ) - ?.apply { spanContext.origin = OP_TRACE_ORIGIN } -} - -/** - * A substitute for Compose's `MutableState` that doesn't register itself with the snapshot system, - * so mutating [value] never triggers recomposition. - */ -private class MutableRef(var value: T) - /** - * Creates a single span for tracking the time required to compose the wrapped [content], and a span - * for its initial draw. + * Creates a span for the initial composition of the wrapped [content], and a span for its initial + * rendering. * * Spans are approximate and include work performed by any composables [content] invokes. Abandoned * recompositions are ignored. * - * Spans live under a set of parents shared by all `SentryTraced` composables. Every `SentryTraced` - * contributes at most one `ui.compose` child and one `ui.render` child per parent lifetime: + * **Span organization** + * + * All spans produced are rooted under an owner span defined by the environment `SentryTraced` runs + * in. `SentryTraced` composables with the same owner share two common parent spans + * (`ui.compose.composition` and `ui.compose.rendering`). Each `SentryTraced` in the group emits at + * most one `ui.compose` span to the composition parent and one `ui.render` span to the render + * parent. + * + * The end result looks something like this: * ``` - * Root span + * Owner span * │ * ├─ ui.compose.composition "Jetpack Compose Initial Composition" * │ ├─ ui.compose "product_info" @@ -82,9 +55,8 @@ private class MutableRef(var value: T) * └─ ui.render "add_to_cart_button" * ``` * - * Here `ui.compose.composition` and `ui.compose.rendering` are the shared parents. A `SentryTraced` - * generates the "product_info" spans, and a separate `SentryTraced` generates the - * "add_to_cart_button" spans. + * (Here, there were only two `SentryTraced` composables in the group. One emitted "product_info" + * spans, the other emitted "add_to_cart_button" spans.) */ @ExperimentalComposeUiApi @Composable @@ -95,31 +67,31 @@ public fun SentryTraced( content: @Composable BoxScope.() -> Unit, ) { val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier + val scopes = Sentry.getCurrentScopes() + val ownerSpan = scopes.transaction ?: NoOpSpan.getInstance() - val parentCompositionSpan = localSentryCompositionParentSpan.current - val parentRenderingSpan = localSentryRenderingParentSpan.current - - val alreadyComposed = remember(parentCompositionSpan) { MutableRef(false) } - val alreadyRendered = remember(parentRenderingSpan) { MutableRef(false) } - val dateProvider = Sentry.getCurrentScopes().options.dateProvider + val alreadyComposed = remember(ownerSpan) { MutableRef(false) } + val alreadyRendered = remember(ownerSpan) { MutableRef(false) } + val shouldRecordSpans = !ownerSpan.dropsChildSpans - // Only record spans if we have a parent for them. + val dateProvider = scopes.options.dateProvider val compositionStart = - if (!alreadyComposed.value) parentCompositionSpan?.let { dateProvider.now() } else null + if (shouldRecordSpans && !alreadyComposed.value) dateProvider.now() else null Box( modifier = baseModifier.drawWithContent { - if (alreadyRendered.value || parentRenderingSpan == null) { + if (!shouldRecordSpans || alreadyRendered.value) { drawContent() - } else { - val renderStart = dateProvider.now() - drawContent() - val renderEnd = dateProvider.now() - - alreadyRendered.value = true - recordRenderSpan(parentRenderingSpan, tag, renderStart, renderEnd) + return@drawWithContent } + + val renderStart = dateProvider.now() + drawContent() + val renderEnd = dateProvider.now() + + alreadyRendered.value = true + recordRenderSpan(ownerSpan, tag, renderStart, renderEnd) }, propagateMinConstraints = true, ) { @@ -130,44 +102,137 @@ public fun SentryTraced( val compositionEnd = dateProvider.now() SideEffect { - recordCompositionSpan( - parentSpan = parentCompositionSpan, - tag = tag, - startTimestamp = compositionStart, - endTimestamp = compositionEnd, - ) - alreadyComposed.value = true + recordCompositionSpan(ownerSpan, tag, compositionStart, compositionEnd) } } } -private fun getRootSpan(): ISpan? { - var rootSpan: ISpan? = null - Sentry.configureScope { rootSpan = it.transaction } - return rootSpan -} - private fun recordCompositionSpan( - parentSpan: ISpan?, + ownerSpan: ISpan, tag: String, startTimestamp: SentryDate, endTimestamp: SentryDate, ) { - parentSpan?.startChild(OP_COMPOSE, tag, startTimestamp)?.apply { + val parentSpan = ParentSpans.getOrCreateCompositionSpan(ownerSpan, startTimestamp) ?: return + + parentSpan.startChild(OP_COMPOSITION_CHILD, tag, startTimestamp).apply { spanContext.origin = OP_TRACE_ORIGIN finish(null, endTimestamp) } } private fun recordRenderSpan( - parentSpan: ISpan?, + ownerSpan: ISpan, tag: String, startTimestamp: SentryDate, endTimestamp: SentryDate, ) { - parentSpan?.startChild(OP_RENDER, tag, startTimestamp)?.apply { + val parentSpan = ParentSpans.getOrCreateRenderSpan(ownerSpan, startTimestamp) ?: return + + parentSpan.startChild(OP_RENDER_CHILD, tag, startTimestamp).apply { spanContext.origin = OP_TRACE_ORIGIN finish(null, endTimestamp) } } + +/** + * Returns true if spans parented under the receiver will be dropped (and therefore aren't worth + * creating in the first place). + */ +private val ISpan.dropsChildSpans: Boolean + // NoOp spans return false for isFinished, so we check for no-op status directly. + get() = this.isFinished || this.isNoOp + +/** + * Weak holder of [OP_COMPOSITION_PARENT] and [OP_RENDER_PARENT] spans generated by [SentryTraced] + * composables. + * + * Instances are cached in a weak map keyed by owner span, so multiple [SentryTraced] composables + * with the same owner share one composition parent and one render parent. + * + * **Not threadsafe:** Access must be confined to Compose UI-thread callbacks. + */ +private class ParentSpans { + + private var compositionParentSpan: WeakReference? = null + private var renderParentSpan: WeakReference? = null + + companion object { + + private val ownerSpanToParentSpans = WeakHashMap() + + fun getOrCreateCompositionSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = + getFor(ownerSpan).getOrCreateCompositionSpan(ownerSpan, startTimestamp) + + fun getOrCreateRenderSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = + getFor(ownerSpan).getOrCreateRenderSpan(ownerSpan, startTimestamp) + + private fun getFor(ownerSpan: ISpan): ParentSpans = + ownerSpanToParentSpans.getOrPut(ownerSpan) { ParentSpans() } + } + + private fun getOrCreateCompositionSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = + getOrCreate( + ownerSpan = ownerSpan, + startTimestamp = startTimestamp, + cached = compositionParentSpan, + operation = OP_COMPOSITION_PARENT, + description = DESCRIPTION_COMPOSITION_PARENT, + ) { + compositionParentSpan = it + } + + private fun getOrCreateRenderSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = + getOrCreate( + ownerSpan = ownerSpan, + startTimestamp = startTimestamp, + cached = renderParentSpan, + operation = OP_RENDER_PARENT, + description = DESCRIPTION_RENDER_PARENT, + ) { + renderParentSpan = it + } + + private fun getOrCreate( + ownerSpan: ISpan, + startTimestamp: SentryDate, + cached: WeakReference?, + operation: String, + description: String, + setCached: (WeakReference) -> Unit, + ): ISpan? { + cached + ?.get() + ?.takeUnless { it.dropsChildSpans } + ?.let { + return it + } + + val parentSpan = + ownerSpan.startChild( + operation, + description, + SpanOptions().apply { + setStartTimestamp(startTimestamp) + isTrimStart = true + isTrimEnd = true + isIdle = true + }, + ) + + if (parentSpan.dropsChildSpans) { + return null + } + + parentSpan.spanContext.origin = OP_TRACE_ORIGIN + setCached(WeakReference(parentSpan)) + return parentSpan + } +} + +/** + * A substitute for Compose's `MutableState` that doesn't register itself with the snapshot system, + * so mutating [value] won't trigger recomposition. + */ +private class MutableRef(var value: T) 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..c89f3b6cf0b --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -0,0 +1,438 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +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.Sentry +import io.sentry.SentryOptions +import io.sentry.TransactionOptions +import kotlin.test.assertFailsWith +import org.junit.After +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 +import org.robolectric.annotation.GraphicsMode + +@OptIn(ExperimentalComposeUiApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SentryTracedTest { + + // workaround for robolectric tests with composeRule taken + // 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() + + @After + fun tearDown() { + rule.runOnUiThread { Sentry.close() } + Sentry.close() + } + + @Test + fun `records a composition span for the initial composition`() { + val tx = initSentryAndStartTransaction("tx") + + rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } + tx.waitForSpanCount(OP_COMPOSE, 1) + + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(1) + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + } + + @Test + fun `falls back to the current transaction when no owner span is provided`() { + val tx = initSentryAndStartTransaction("tx") + + rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } + + tx.waitForSpanCount(OP_COMPOSE, 1) + + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(1) + } + + @Test + fun `renders content without spans when no live owner span is available`() { + rule.runOnUiThread { Sentry.close() } + + rule.setContent { + SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) } + } + rule.waitForIdle() + + rule.onNodeWithTag("content").assertExists() + } + + @Test + fun `renders content without spans when the current transaction is finished`() { + val tx = initSentryAndStartTransaction("tx") + rule.runOnUiThread { tx.finish() } + + rule.setContent { + 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 `sibling traced composables with the same owner share the composition parent`() { + 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)) } + } + } + + tx.waitForSpanCount(OP_COMPOSE, 2) + + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + } + + @Test + fun `sibling traced composables with the same owner share the render parent`() { + 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)) } + } + } + tx.waitForSpanCount(OP_COMPOSE, 2) + + drawContent() + tx.waitForSpanCount(OP_RENDER, 2) + + val renderParent = tx.singleSpan(OP_PARENT_RENDER) + val renderSpans = tx.spans.filter { it.operation == OP_RENDER } + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(1) + assertThat(renderSpans).hasSize(2) + assertThat(renderSpans.map { it.parentSpanId }) + .containsExactly( + renderParent.spanContext.spanId, + renderParent.spanContext.spanId, + ) + } + + @Test + fun `records at most one composition span and one render span per owner span`() { + var step by mutableStateOf(0) + val tx = initSentryAndStartTransaction("tx") + + rule.setContent { + val currentStep = step + SentryTraced(tag = "product_info") { + Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep")) + } + } + tx.waitForSpanCount(OP_COMPOSE, 1) + drawContent() + tx.waitForSpanCount(OP_RENDER, 1) + + rule.runOnIdle { step = 1 } + rule.waitForIdle() + drawContent() + rule.waitForIdle() + + rule.onNodeWithTag("content-1").assertExists() + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(1) + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(1) + assertThat(tx.countSpans(OP_RENDER)).isEqualTo(1) + } + + @Test + fun `preserves content state after tracing completes`() { + var step by mutableStateOf(0) + var rememberedInstanceCount = 0 + var disposeCount = 0 + var rememberedState: Any? = null + val tx = initSentryAndStartTransaction("tx") + + rule.setContent { + 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")) + } + } + tx.waitForSpanCount(OP_COMPOSE, 1) + drawContent() + tx.waitForSpanCount(OP_RENDER, 1) + val firstRememberedState = rememberedState + + rule.runOnIdle { step = 1 } + rule.waitForIdle() + drawContent() + rule.waitForIdle() + + rule.onNodeWithTag("content-1").assertExists() + assertThat(rememberedInstanceCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + assertThat(rememberedState).isSameInstanceAs(firstRememberedState) + } + + @Test + fun `starts recording once an owner span becomes available`() { + var step by mutableStateOf(0) + + rule.runOnUiThread { Sentry.close() } + rule.setContent { + 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.waitForIdle() + + tx.waitForSpanCount(OP_COMPOSE, 1) + + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(1) + } + + @Test + fun `records spans under replacement owner after previous owner finishes`() { + var step by mutableStateOf(0) + val firstTx = initSentryAndStartTransaction("first-tx") + + rule.setContent { + val currentStep = step + key(currentStep) { + SentryTraced(tag = "transaction-$currentStep") { Box(Modifier.size(1.dp)) } + } + } + firstTx.waitForSpanCount(OP_COMPOSE, 1) + + lateinit var secondTx: ITransaction + rule.runOnUiThread { + firstTx.finish() + secondTx = startBoundTransaction("second-tx") + } + rule.runOnIdle { step = 1 } + rule.waitForIdle() + + secondTx.waitForSpanCount(OP_COMPOSE, 1) + + assertThat(firstTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(secondTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(secondTx.countSpans(OP_COMPOSE)).isEqualTo(1) + } + + @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") + + rule.setContent { + val currentStep = step + SentryTraced(tag = "transaction", modifier = Modifier.testTag("content-$currentStep")) { + Box(Modifier.size((currentStep + 1).dp)) + } + } + firstTx.waitForSpanCount(OP_COMPOSE, 1) + drawContent() + firstTx.waitForSpanCount(OP_RENDER, 1) + + lateinit var secondTx: ITransaction + rule.runOnUiThread { + firstTx.finish() + secondTx = startBoundTransaction("second-tx") + } + rule.runOnIdle { step = 1 } + 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) + assertThat(secondTx.countSpans(OP_COMPOSE)).isEqualTo(1) + assertThat(secondTx.countSpans(OP_PARENT_RENDER)).isEqualTo(1) + assertThat(secondTx.countSpans(OP_RENDER)).isEqualTo(1) + } + + @Test + fun `failed composition does not emit parent spans`() { + val tx = initSentryAndStartTransaction("tx") + + assertFailsWith { + rule.setContent { SentryTraced(tag = "throws") { error("boom") } } + } + + assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(0) + assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + } + + @Test + fun `render spans use the owner captured during composition`() { + val placeContent = mutableStateOf(false) + val compositionTx = initSentryAndStartTransaction("composition-tx") + + rule.setContent { + Layout( + content = { + SentryTraced( + tag = "traced", + modifier = Modifier.fillMaxSize(), + enableUserInteractionTracing = false, + ) {} + } + ) { measurables, constraints -> + val placeable = measurables.single().measure(constraints) + layout(placeable.width, placeable.height) { + if (placeContent.value) { + placeable.place(0, 0) + } + } + } + } + rule.waitForIdle() + + assertThat(compositionTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) + assertThat(compositionTx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + + lateinit var renderingTx: ITransaction + rule.runOnUiThread { + renderingTx = startBoundTransaction("rendering-tx") + placeContent.value = true + } + rule.waitForIdle() + drawContent() + + val renderingParent = compositionTx.spans.single { it.operation == OP_PARENT_RENDER } + val renderSpan = compositionTx.spans.single { it.operation == OP_RENDER } + assertThat(renderingParent.parentSpanId).isEqualTo(compositionTx.spanContext.spanId) + assertThat(renderSpan.parentSpanId).isEqualTo(renderingParent.spanContext.spanId) + assertThat(renderSpan.spanContext.origin).isEqualTo(OP_TRACE_ORIGIN) + assertThat(renderingTx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + assertThat(renderingTx.countSpans(OP_RENDER)).isEqualTo(0) + } + + private fun initSentryAndStartTransaction(name: String): ITransaction { + lateinit var tx: ITransaction + rule.runOnUiThread { + Sentry.init( + { options: SentryOptions -> + options.dsn = "https://key@sentry.io/proj" + options.tracesSampleRate = 1.0 + }, + true, + ) + tx = startBoundTransaction(name) + var boundTransaction: ITransaction? = null + Sentry.configureScope { boundTransaction = it.transaction } + assertThat(boundTransaction).isSameInstanceAs(tx) + } + return tx + } + + private fun startBoundTransaction(name: String): ITransaction { + val transaction = + Sentry.startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true }) + Sentry.configureScope { it.setTransaction(transaction) } + return transaction + } + + private fun ITransaction.waitForSpanCount(operation: String, count: Int) { + rule.waitUntil(timeoutMillis = 5_000) { countSpans(operation) >= count } + } + + private fun ITransaction.countSpans(operation: String): Int = spans.count { + it.operation == operation + } + + private fun ITransaction.singleSpan(operation: String): ISpan = spans.single { + it.operation == operation + } + + private fun drawContent() { + rule.runOnUiThread { + val rootView = rule.activity.findViewById(android.R.id.content) + val bitmap = + Bitmap.createBitmap( + rootView.width.coerceAtLeast(1), + rootView.height.coerceAtLeast(1), + Bitmap.Config.ARGB_8888, + ) + try { + rootView.draw(Canvas(bitmap)) + } finally { + bitmap.recycle() + } + } + } + + private companion object { + private const val OP_PARENT_COMPOSITION = "ui.compose.composition" + private const val OP_COMPOSE = "ui.compose" + private const val OP_PARENT_RENDER = "ui.compose.rendering" + private const val OP_RENDER = "ui.render" + private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" + } +} From 68f3f69c43b770ab4b169cf53a7f9b855485da86 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 5 Sep 2026 13:31:27 +0200 Subject: [PATCH 2/7] Doc updates, incl. resolving Warden issue --- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 11 +++++++---- .../kotlin/io/sentry/compose/SentryTracedTest.kt | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) 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 029f189acb8..70ffae8c34a 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -145,11 +145,14 @@ private val ISpan.dropsChildSpans: Boolean get() = this.isFinished || this.isNoOp /** - * Weak holder of [OP_COMPOSITION_PARENT] and [OP_RENDER_PARENT] spans generated by [SentryTraced] - * composables. + * Manages the creation of parent [OP_COMPOSITION_PARENT] and [OP_RENDER_PARENT] spans as owner + * spans rotate over time. It does so for all [SentryTraced] instances throughout the app process. + * (Process-wide logic and state lives in the companion object; per-SentryTraced state is + * implemented by the instance properties.) * - * Instances are cached in a weak map keyed by owner span, so multiple [SentryTraced] composables - * with the same owner share one composition parent and one render parent. + * Under the hood this class tracks which parent spans have been created for which owner span, so it + * knows when new parent spans need to be created. But it doesn't own the lifecycle of either and + * holds only weak references. * * **Not threadsafe:** Access must be confined to Compose UI-thread callbacks. */ 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 c89f3b6cf0b..17633fcdd00 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -48,8 +48,8 @@ import org.robolectric.annotation.GraphicsMode @GraphicsMode(GraphicsMode.Mode.NATIVE) class SentryTracedTest { - // workaround for robolectric tests with composeRule taken - // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + // Register ComponentActivity explicitly so Robolectric can launch createAndComposeRule. + // Context: https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 @get:Rule(order = 1) val addActivityToRobolectricRule = object : TestWatcher() { From c0b7806b9337e578165f15b372cd0623f94ac4df Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 5 Sep 2026 13:53:26 +0200 Subject: [PATCH 3/7] fix(compose): Preserve traced parent span start times Pass the captured start timestamp through the timestamp-aware span overload when creating shared SentryTraced parent spans. This keeps parent spans from starting after their child composition or render spans. Add regression coverage for parent span start ordering. Co-Authored-By: OpenAI GPT-5.5 --- CHANGELOG.md | 2 +- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 4 +++- .../kotlin/io/sentry/compose/SentryTracedTest.kt | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a60df8605b1..a55390674d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -`SentryTraced` now checks for its owning transaction dynamically rather than once per app process, which led to `SentryTraced` spans being dropped once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057)) +`SentryTraced` now checks for its owning transaction dynamically rather than once per app process. The latter caused `SentryTraced` spans to be dropped process-wide once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057)) ## 8.55.0 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 70ffae8c34a..5a965512719 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi 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 @@ -216,8 +217,9 @@ private class ParentSpans { ownerSpan.startChild( operation, description, + startTimestamp, + Instrumenter.SENTRY, SpanOptions().apply { - setStartTimestamp(startTimestamp) isTrimStart = true isTrimEnd = true isIdle = true 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 17633fcdd00..1d2464d9582 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -81,6 +81,10 @@ class SentryTracedTest { assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1) assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(1) assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0) + assertThat( + tx.singleSpan(OP_PARENT_COMPOSITION).startDate.isAfter(tx.singleSpan(OP_COMPOSE).startDate) + ) + .isFalse() } @Test @@ -160,6 +164,9 @@ class SentryTracedTest { val renderSpans = tx.spans.filter { it.operation == OP_RENDER } assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(1) assertThat(renderSpans).hasSize(2) + renderSpans.forEach { renderSpan -> + assertThat(renderParent.startDate.isAfter(renderSpan.startDate)).isFalse() + } assertThat(renderSpans.map { it.parentSpanId }) .containsExactly( renderParent.spanContext.spanId, From cd8bb105b966d616ccaefa4910f2e97af6f6a472 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 8 Sep 2026 05:43:47 +0200 Subject: [PATCH 4/7] Add WeakReference comment to properties from ParentSpans --- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 3 +++ 1 file changed, 3 insertions(+) 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 5a965512719..7c5d54d4f27 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -159,6 +159,9 @@ private val ISpan.dropsChildSpans: Boolean */ private class ParentSpans { + // Parent spans must be weakly held because spans keep a reference to their owning transaction. If + // the owning transaction is the owner span or an ancestor of it, a strong reference here would + // interfere with cleanup of the corresponding ownerSpanToParentSpans entry. private var compositionParentSpan: WeakReference? = null private var renderParentSpan: WeakReference? = null From 3779c15b3f9d70938571b5efd70a40622eced2db Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 8 Sep 2026 08:58:54 +0200 Subject: [PATCH 5/7] Rename parent spans -> bucket spans --- .../io/sentry/compose/SentryComposeTracing.kt | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) 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 7c5d54d4f27..5c770db3525 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -18,19 +18,19 @@ import io.sentry.compose.SentryModifier.sentryTag import java.lang.ref.WeakReference import java.util.WeakHashMap -private const val DESCRIPTION_COMPOSITION_PARENT = "Jetpack Compose Initial Composition" -private const val OP_COMPOSITION_PARENT = "ui.compose.composition" -private const val OP_COMPOSITION_CHILD = "ui.compose" +private const val DESCRIPTION_COMPOSITION_BUCKET = "Jetpack Compose Initial Composition" +private const val OP_COMPOSITION_BUCKET = "ui.compose.composition" +private const val OP_COMPOSITION_SPAN = "ui.compose" -private const val DESCRIPTION_RENDER_PARENT = "Jetpack Compose Initial Render" -private const val OP_RENDER_PARENT = "ui.compose.rendering" -private const val OP_RENDER_CHILD = "ui.render" +private const val DESCRIPTION_RENDER_BUCKET = "Jetpack Compose Initial Render" +private const val OP_RENDER_BUCKET = "ui.compose.rendering" +private const val OP_RENDER_SPAN = "ui.render" private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" /** * Creates a span for the initial composition of the wrapped [content], and a span for its initial - * rendering. + * rendering, each of which lives under a shared "bucket" span (see "Span organization" below). * * Spans are approximate and include work performed by any composables [content] invokes. Abandoned * recompositions are ignored. @@ -38,26 +38,28 @@ private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" * **Span organization** * * All spans produced are rooted under an owner span defined by the environment `SentryTraced` runs - * in. `SentryTraced` composables with the same owner share two common parent spans + * in. `SentryTraced` composables with the same owner share two common "bucket" spans * (`ui.compose.composition` and `ui.compose.rendering`). Each `SentryTraced` in the group emits at - * most one `ui.compose` span to the composition parent and one `ui.render` span to the render - * parent. + * most one `ui.compose` span to the composition bucket and one `ui.render` span to the render + * bucket. * * The end result looks something like this: * ``` * Owner span * │ * ├─ ui.compose.composition "Jetpack Compose Initial Composition" + * │ ├─ ui.compose "marketing_banner" * │ ├─ ui.compose "product_info" * │ └─ ui.compose "add_to_cart_button" * │ * └─ ui.compose.rendering "Jetpack Compose Initial Render" + * ├─ ui.render "marketing_banner" * ├─ ui.render "product_info" * └─ ui.render "add_to_cart_button" * ``` * - * (Here, there were only two `SentryTraced` composables in the group. One emitted "product_info" - * spans, the other emitted "add_to_cart_button" spans.) + * (Here, there were three `SentryTraced` composables in the owner group. One emitted + * "marketing_banner" spans, another "product_info" spans, and another "add_to_cart_button" spans.) */ @ExperimentalComposeUiApi @Composable @@ -115,9 +117,9 @@ private fun recordCompositionSpan( startTimestamp: SentryDate, endTimestamp: SentryDate, ) { - val parentSpan = ParentSpans.getOrCreateCompositionSpan(ownerSpan, startTimestamp) ?: return + val bucketSpan = BucketSpans.getOrCreateCompositionSpan(ownerSpan, startTimestamp) ?: return - parentSpan.startChild(OP_COMPOSITION_CHILD, tag, startTimestamp).apply { + bucketSpan.startChild(OP_COMPOSITION_SPAN, tag, startTimestamp).apply { spanContext.origin = OP_TRACE_ORIGIN finish(null, endTimestamp) } @@ -129,9 +131,9 @@ private fun recordRenderSpan( startTimestamp: SentryDate, endTimestamp: SentryDate, ) { - val parentSpan = ParentSpans.getOrCreateRenderSpan(ownerSpan, startTimestamp) ?: return + val bucketSpan = BucketSpans.getOrCreateRenderSpan(ownerSpan, startTimestamp) ?: return - parentSpan.startChild(OP_RENDER_CHILD, tag, startTimestamp).apply { + bucketSpan.startChild(OP_RENDER_SPAN, tag, startTimestamp).apply { spanContext.origin = OP_TRACE_ORIGIN finish(null, endTimestamp) } @@ -146,28 +148,28 @@ private val ISpan.dropsChildSpans: Boolean get() = this.isFinished || this.isNoOp /** - * Manages the creation of parent [OP_COMPOSITION_PARENT] and [OP_RENDER_PARENT] spans as owner - * spans rotate over time. It does so for all [SentryTraced] instances throughout the app process. - * (Process-wide logic and state lives in the companion object; per-SentryTraced state is + * Manages the creation of [OP_COMPOSITION_BUCKET] and [OP_RENDER_BUCKET] spans as owner spans + * rotate over time. It does so for all [SentryTraced] instances throughout the app process. + * (Process-wide logic and state lives in the companion object; per-`SentryTraced` state is * implemented by the instance properties.) * - * Under the hood this class tracks which parent spans have been created for which owner span, so it - * knows when new parent spans need to be created. But it doesn't own the lifecycle of either and + * Under the hood this class tracks which bucket spans have been created for which owner span, so it + * knows when new bucket spans need to be created. But it doesn't own the lifecycle of either and * holds only weak references. * * **Not threadsafe:** Access must be confined to Compose UI-thread callbacks. */ -private class ParentSpans { +private class BucketSpans { - // Parent spans must be weakly held because spans keep a reference to their owning transaction. If + // Bucket spans must be weakly held because spans keep a reference to their owning transaction. If // the owning transaction is the owner span or an ancestor of it, a strong reference here would - // interfere with cleanup of the corresponding ownerSpanToParentSpans entry. - private var compositionParentSpan: WeakReference? = null - private var renderParentSpan: WeakReference? = null + // interfere with cleanup of the corresponding ownerSpanToBucketSpans entry. + private var compositionBucketSpan: WeakReference? = null + private var renderBucketSpan: WeakReference? = null companion object { - private val ownerSpanToParentSpans = WeakHashMap() + private val ownerSpanToBucketSpans = WeakHashMap() fun getOrCreateCompositionSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = getFor(ownerSpan).getOrCreateCompositionSpan(ownerSpan, startTimestamp) @@ -175,30 +177,30 @@ private class ParentSpans { fun getOrCreateRenderSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = getFor(ownerSpan).getOrCreateRenderSpan(ownerSpan, startTimestamp) - private fun getFor(ownerSpan: ISpan): ParentSpans = - ownerSpanToParentSpans.getOrPut(ownerSpan) { ParentSpans() } + private fun getFor(ownerSpan: ISpan): BucketSpans = + ownerSpanToBucketSpans.getOrPut(ownerSpan) { BucketSpans() } } private fun getOrCreateCompositionSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = getOrCreate( ownerSpan = ownerSpan, startTimestamp = startTimestamp, - cached = compositionParentSpan, - operation = OP_COMPOSITION_PARENT, - description = DESCRIPTION_COMPOSITION_PARENT, + cached = compositionBucketSpan, + operation = OP_COMPOSITION_BUCKET, + description = DESCRIPTION_COMPOSITION_BUCKET, ) { - compositionParentSpan = it + compositionBucketSpan = it } private fun getOrCreateRenderSpan(ownerSpan: ISpan, startTimestamp: SentryDate): ISpan? = getOrCreate( ownerSpan = ownerSpan, startTimestamp = startTimestamp, - cached = renderParentSpan, - operation = OP_RENDER_PARENT, - description = DESCRIPTION_RENDER_PARENT, + cached = renderBucketSpan, + operation = OP_RENDER_BUCKET, + description = DESCRIPTION_RENDER_BUCKET, ) { - renderParentSpan = it + renderBucketSpan = it } private fun getOrCreate( @@ -216,7 +218,7 @@ private class ParentSpans { return it } - val parentSpan = + val bucketSpan = ownerSpan.startChild( operation, description, @@ -229,13 +231,13 @@ private class ParentSpans { }, ) - if (parentSpan.dropsChildSpans) { + if (bucketSpan.dropsChildSpans) { return null } - parentSpan.spanContext.origin = OP_TRACE_ORIGIN - setCached(WeakReference(parentSpan)) - return parentSpan + bucketSpan.spanContext.origin = OP_TRACE_ORIGIN + setCached(WeakReference(bucketSpan)) + return bucketSpan } } From 02fb27c57b6daa8a5b7103e1a399a97634f1cb28 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 8 Sep 2026 09:15:16 +0200 Subject: [PATCH 6/7] Add comment clarifying use of ownerSpan in record*Span() methods --- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 5c770db3525..1142c2304ee 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -111,6 +111,12 @@ public fun SentryTraced( } } +/** + * Creates a [OP_COMPOSITION_SPAN] under the [ownerSpan]'s composition bucket. + * + * If the owner doesn't yet have a composition bucket, this method creates one for its own use and + * for use by other `SentryTraced` composables in the same owner group. + */ private fun recordCompositionSpan( ownerSpan: ISpan, tag: String, @@ -125,6 +131,12 @@ private fun recordCompositionSpan( } } +/** + * Creates a [OP_RENDER_SPAN] under the [ownerSpan]'s composition bucket. + * + * If the owner doesn't yet have a render bucket, this method creates one for its own use and for + * use by other `SentryTraced` composables in the same owner group. + */ private fun recordRenderSpan( ownerSpan: ISpan, tag: String, From 87d272f2242cd7e276c3b6c34dcab811fbff7d73 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 8 Sep 2026 09:22:36 +0200 Subject: [PATCH 7/7] Fix typo --- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1142c2304ee..95ed5425cff 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -132,7 +132,7 @@ private fun recordCompositionSpan( } /** - * Creates a [OP_RENDER_SPAN] under the [ownerSpan]'s composition bucket. + * Creates a [OP_RENDER_SPAN] under the [ownerSpan]'s render bucket. * * If the owner doesn't yet have a render bucket, this method creates one for its own use and for * use by other `SentryTraced` composables in the same owner group.