From a85abfe0ef82e5b24f1417987df3577ce9dba1c2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 2 Sep 2026 13:59:18 +0200 Subject: [PATCH 1/2] fix(compose): Prevent dangling spans in SentryTraced Commit repairs a few defects in the implementation of SentryTraced while aiming to maintain parity with the previous approach. In particular it: - Prevents dangling composition spans in cases where the Compose runtime abandons the composition. (We now delay creating the span until a SideEffect executes.) - Prevents dangling render spans in cases where drawContent() throws and the host app recovers. (Similarly, we delay creating the span until drawContent() completes successfully.) --- CHANGELOG.md | 1 + .../io/sentry/compose/SentryComposeTracing.kt | 95 ++++++++++++++----- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad8545dbe3..b80b51e0dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixes +- Prevent `SentryTraced` from producing dangling spans if recomposition is abandoned or drawing fails ([#6049](https://github.com/getsentry/sentry-java/pull/6049)) - Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) - Apply `Sentry.withScope` and `Sentry.withIsolationScope` data to events captured inside the callback when `globalHubMode` is enabled ([#6004](https://github.com/getsentry/sentry-java/pull/6004)) - `globalHubMode` is enabled by default on Android, where tags, extras, contexts and level set inside the callback were silently dropped 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 ca923e5d2c9..98938a90859 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi @@ -11,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import io.sentry.ISpan import io.sentry.Sentry +import io.sentry.SentryDate import io.sentry.SpanOptions import io.sentry.compose.SentryModifier.sentryTag @@ -22,14 +24,6 @@ private const val OP_RENDER = "ui.render" private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" -@Immutable private class ImmutableHolder(var item: T) - -private fun getRootSpan(): ISpan? { - var rootSpan: ISpan? = null - Sentry.configureScope { rootSpan = it.transaction } - return rootSpan -} - private val localSentryCompositionParentSpan = compositionLocalOf { ImmutableHolder( getRootSpan() @@ -62,6 +56,15 @@ private val localSentryRenderingParentSpan = compositionLocalOf { ) } +@Immutable internal class ImmutableHolder(var item: T) + +/** + * Creates spans for tracking the time required to compose the wrapped [content], and a span for its + * initial draw. + * + * Spans are approximate and include work performed by any composables [content] invokes. Abandoned + * recompositions are ignored. + */ @ExperimentalComposeUiApi @Composable public fun SentryTraced( @@ -70,32 +73,72 @@ public fun SentryTraced( enableUserInteractionTracing: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - val parentCompositionSpan = localSentryCompositionParentSpan.current - val parentRenderingSpan = localSentryRenderingParentSpan.current - val compositionSpan = - parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)?.apply { - spanContext.origin = OP_TRACE_ORIGIN - } - val firstRendered = remember { ImmutableHolder(false) } - + val alreadyRendered = remember { ImmutableHolder(false) } val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier + val parentCompositionSpan = localSentryCompositionParentSpan.current.item + val parentRenderingSpan = localSentryRenderingParentSpan.current.item + val dateProvider = Sentry.getCurrentScopes().options.dateProvider + + val compositionStart = dateProvider.now() + Box( modifier = baseModifier.drawWithContent { - val renderSpan = - if (!firstRendered.item) { - parentRenderingSpan.item?.startChild(OP_RENDER, tag) - } else { - null - } - drawContent() - firstRendered.item = true - renderSpan?.finish() + if (alreadyRendered.item) { + drawContent() + } else { + val renderStart = dateProvider.now() + drawContent() + val renderEnd = dateProvider.now() + + alreadyRendered.item = true + recordRenderSpan(parentRenderingSpan, tag, renderStart, renderEnd) + } }, propagateMinConstraints = true, ) { content() } - compositionSpan?.finish() + val compositionEnd = dateProvider.now() + + if (parentCompositionSpan != null) { + SideEffect { + recordCompositionSpan( + parentSpan = parentCompositionSpan, + tag = tag, + startTimestamp = compositionStart, + endTimestamp = compositionEnd, + ) + } + } +} + +private fun getRootSpan(): ISpan? { + var rootSpan: ISpan? = null + Sentry.configureScope { rootSpan = it.transaction } + return rootSpan +} + +private fun recordCompositionSpan( + parentSpan: ISpan?, + tag: String, + startTimestamp: SentryDate, + endTimestamp: SentryDate, +) { + parentSpan?.startChild(OP_COMPOSE, tag, startTimestamp)?.apply { + spanContext.origin = OP_TRACE_ORIGIN + finish(null, endTimestamp) + } +} + +private fun recordRenderSpan( + parentSpan: ISpan?, + tag: String, + startTimestamp: SentryDate, + endTimestamp: SentryDate, +) { + parentSpan?.startChild(OP_RENDER, tag, startTimestamp)?.apply { + finish(null, endTimestamp) + } } From 5b2d3729b9b450aa10c9c0c9c95839450d4b3eb8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 3 Sep 2026 11:21:25 +0200 Subject: [PATCH 2/2] Don't allocate dates if we don't have parent spans --- .../kotlin/io/sentry/compose/SentryComposeTracing.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 98938a90859..278670c8c19 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -80,12 +80,13 @@ public fun SentryTraced( val parentRenderingSpan = localSentryRenderingParentSpan.current.item val dateProvider = Sentry.getCurrentScopes().options.dateProvider - val compositionStart = dateProvider.now() + // Only record spans if we have a parent for them. + val compositionStart = parentCompositionSpan?.let { dateProvider.now() } Box( modifier = baseModifier.drawWithContent { - if (alreadyRendered.item) { + if (alreadyRendered.item || parentRenderingSpan == null) { drawContent() } else { val renderStart = dateProvider.now() @@ -100,9 +101,10 @@ public fun SentryTraced( ) { content() } - val compositionEnd = dateProvider.now() - if (parentCompositionSpan != null) { + if (compositionStart != null) { + val compositionEnd = dateProvider.now() + SideEffect { recordCompositionSpan( parentSpan = parentCompositionSpan,