From b9706dc47c5291471c77c2db3417484b95780499 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 3 Sep 2026 11:40:37 +0200 Subject: [PATCH] chore(compose): Update SentryTraced to emit a span only for initial composition Commit updates SentryTraced to: - emit a span only for initial composition, rather than for every composition of the wrapped composable; and - add an origin to ui.render spans, just like we already do for composition spans. Commit also cleans up use of ImmutableHolder and renames it to MutableRef. --- CHANGELOG.md | 1 + .../io/sentry/compose/SentryComposeTracing.kt | 99 ++++++++++++------- 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d53dc1b6be..aeba9cbc2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Improvements +- Emit a single `ui.compose` span per `SentryTraced` on initial composition instead of one on every recomposition, and set the origin on `ui.render` spans ([#6051](https://github.com/getsentry/sentry-java/pull/6051)) - Move ANR profiling out of experimental ([#6042](https://github.com/getsentry/sentry-java/pull/6042)) ### Fixes 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 278670c8c1..62679fa015 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -3,7 +3,6 @@ package io.sentry.compose 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 @@ -25,45 +24,67 @@ private const val OP_RENDER = "ui.render" private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose" private val localSentryCompositionParentSpan = compositionLocalOf { - ImmutableHolder( - getRootSpan() - ?.startChild( - OP_PARENT_COMPOSITION, - "Jetpack Compose Initial Composition", - SpanOptions().apply { - isTrimStart = true - isTrimEnd = true - isIdle = true - }, - ) - ?.apply { spanContext.origin = OP_TRACE_ORIGIN } - ) + 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 { - ImmutableHolder( - getRootSpan() - ?.startChild( - OP_PARENT_RENDER, - "Jetpack Compose Initial Render", - SpanOptions().apply { - isTrimStart = true - isTrimEnd = true - isIdle = true - }, - ) - ?.apply { spanContext.origin = OP_TRACE_ORIGIN } - ) + 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 } } -@Immutable internal class ImmutableHolder(var item: T) +/** + * 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 spans for tracking the time required to compose the wrapped [content], and a span for its - * initial draw. + * Creates a single span 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. + * + * 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: + * ``` + * Root span + * │ + * ├─ ui.compose.composition "Jetpack Compose Initial Composition" + * │ ├─ ui.compose "product_info" + * │ └─ ui.compose "add_to_cart_button" + * │ + * └─ ui.compose.rendering "Jetpack Compose Initial Render" + * ├─ ui.render "product_info" + * └─ 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. */ @ExperimentalComposeUiApi @Composable @@ -73,27 +94,30 @@ public fun SentryTraced( enableUserInteractionTracing: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - 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 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 // Only record spans if we have a parent for them. - val compositionStart = parentCompositionSpan?.let { dateProvider.now() } + val compositionStart = + if (!alreadyComposed.value) parentCompositionSpan?.let { dateProvider.now() } else null Box( modifier = baseModifier.drawWithContent { - if (alreadyRendered.item || parentRenderingSpan == null) { + if (alreadyRendered.value || parentRenderingSpan == null) { drawContent() } else { val renderStart = dateProvider.now() drawContent() val renderEnd = dateProvider.now() - alreadyRendered.item = true + alreadyRendered.value = true recordRenderSpan(parentRenderingSpan, tag, renderStart, renderEnd) } }, @@ -112,6 +136,8 @@ public fun SentryTraced( startTimestamp = compositionStart, endTimestamp = compositionEnd, ) + + alreadyComposed.value = true } } } @@ -141,6 +167,7 @@ private fun recordRenderSpan( endTimestamp: SentryDate, ) { parentSpan?.startChild(OP_RENDER, tag, startTimestamp)?.apply { + spanContext.origin = OP_TRACE_ORIGIN finish(null, endTimestamp) } }