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..278670c8c19 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,74 @@ 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 + + // Only record spans if we have a parent for them. + val compositionStart = parentCompositionSpan?.let { 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 || parentRenderingSpan == null) { + drawContent() + } else { + val renderStart = dateProvider.now() + drawContent() + val renderEnd = dateProvider.now() + + alreadyRendered.item = true + recordRenderSpan(parentRenderingSpan, tag, renderStart, renderEnd) + } }, propagateMinConstraints = true, ) { content() } - compositionSpan?.finish() + + if (compositionStart != null) { + val compositionEnd = dateProvider.now() + + 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) + } }