@@ -4,74 +4,47 @@ import androidx.compose.foundation.layout.Box
44import androidx.compose.foundation.layout.BoxScope
55import androidx.compose.runtime.Composable
66import androidx.compose.runtime.SideEffect
7- import androidx.compose.runtime.compositionLocalOf
87import androidx.compose.runtime.remember
98import androidx.compose.ui.ExperimentalComposeUiApi
109import androidx.compose.ui.Modifier
1110import androidx.compose.ui.draw.drawWithContent
1211import io.sentry.ISpan
12+ import io.sentry.NoOpSpan
1313import io.sentry.Sentry
1414import io.sentry.SentryDate
1515import io.sentry.SpanOptions
1616import io.sentry.compose.SentryModifier.sentryTag
17+ import java.lang.ref.WeakReference
18+ import java.util.WeakHashMap
1719
18- private const val OP_PARENT_COMPOSITION = " ui.compose.composition"
19- private const val OP_COMPOSE = " ui.compose"
20+ private const val DESCRIPTION_COMPOSITION_PARENT = " Jetpack Compose Initial Composition"
21+ private const val OP_COMPOSITION_PARENT = " ui.compose.composition"
22+ private const val OP_COMPOSITION_CHILD = " ui.compose"
2023
21- private const val OP_PARENT_RENDER = " ui.compose.rendering"
22- private const val OP_RENDER = " ui.render"
24+ private const val DESCRIPTION_RENDER_PARENT = " Jetpack Compose Initial Render"
25+ private const val OP_RENDER_PARENT = " ui.compose.rendering"
26+ private const val OP_RENDER_CHILD = " ui.render"
2327
2428private const val OP_TRACE_ORIGIN = " auto.ui.jetpack_compose"
2529
26- private val localSentryCompositionParentSpan = compositionLocalOf {
27- getRootSpan()
28- // Create a single parent span to own composition spans emitted by all SentryTraced composables
29- // during the root's lifetime.
30- ?.startChild(
31- OP_PARENT_COMPOSITION ,
32- " Jetpack Compose Initial Composition" ,
33- SpanOptions ().apply {
34- isTrimStart = true
35- isTrimEnd = true
36- isIdle = true
37- },
38- )
39- ?.apply { spanContext.origin = OP_TRACE_ORIGIN }
40- }
41-
42- private val localSentryRenderingParentSpan = compositionLocalOf {
43- getRootSpan()
44- // Create a single parent span to own render spans emitted by all SentryTraced composables
45- // during the root's lifetime.
46- ?.startChild(
47- OP_PARENT_RENDER ,
48- " Jetpack Compose Initial Render" ,
49- SpanOptions ().apply {
50- isTrimStart = true
51- isTrimEnd = true
52- isIdle = true
53- },
54- )
55- ?.apply { spanContext.origin = OP_TRACE_ORIGIN }
56- }
57-
58- /* *
59- * A substitute for Compose's `MutableState` that doesn't register itself with the snapshot system,
60- * so mutating [value] never triggers recomposition.
61- */
62- private class MutableRef <T >(var value : T )
63-
6430/* *
65- * Creates a single span for tracking the time required to compose the wrapped [content], and a span
66- * for its initial draw .
31+ * Creates a span for the initial composition of the wrapped [content], and a span for its initial
32+ * rendering .
6733 *
6834 * Spans are approximate and include work performed by any composables [content] invokes. Abandoned
6935 * recompositions are ignored.
7036 *
71- * Spans live under a set of parents shared by all `SentryTraced` composables. Every `SentryTraced`
72- * contributes at most one `ui.compose` child and one `ui.render` child per parent lifetime:
37+ * **Span organization**
38+ *
39+ * All spans produced are rooted under an owner span defined by the environment `SentryTraced` runs
40+ * in. `SentryTraced` composables with the same owner share two common parent spans
41+ * (`ui.compose.composition` and `ui.compose.rendering`). Each `SentryTraced` in the group emits at
42+ * most one `ui.compose` span to the composition parent and one `ui.render` span to the render
43+ * parent.
44+ *
45+ * The end result looks something like this:
7346 * ```
74- * Root span
47+ * Owner span
7548 * │
7649 * ├─ ui.compose.composition "Jetpack Compose Initial Composition"
7750 * │ ├─ ui.compose "product_info"
@@ -82,9 +55,8 @@ private class MutableRef<T>(var value: T)
8255 * └─ ui.render "add_to_cart_button"
8356 * ```
8457 *
85- * Here `ui.compose.composition` and `ui.compose.rendering` are the shared parents. A `SentryTraced`
86- * generates the "product_info" spans, and a separate `SentryTraced` generates the
87- * "add_to_cart_button" spans.
58+ * (Here, there were only two `SentryTraced` composables in the group. One emitted "product_info"
59+ * spans, the other emitted "add_to_cart_button" spans.)
8860 */
8961@ExperimentalComposeUiApi
9062@Composable
@@ -95,31 +67,31 @@ public fun SentryTraced(
9567 content : @Composable BoxScope .() -> Unit ,
9668) {
9769 val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier
70+ val scopes = Sentry .getCurrentScopes()
71+ val ownerSpan = scopes.transaction ? : NoOpSpan .getInstance()
9872
99- val parentCompositionSpan = localSentryCompositionParentSpan.current
100- val parentRenderingSpan = localSentryRenderingParentSpan.current
101-
102- val alreadyComposed = remember(parentCompositionSpan) { MutableRef (false ) }
103- val alreadyRendered = remember(parentRenderingSpan) { MutableRef (false ) }
104- val dateProvider = Sentry .getCurrentScopes().options.dateProvider
73+ val alreadyComposed = remember(ownerSpan) { MutableRef (false ) }
74+ val alreadyRendered = remember(ownerSpan) { MutableRef (false ) }
75+ val shouldRecordSpans = ! ownerSpan.dropsChildSpans
10576
106- // Only record spans if we have a parent for them.
77+ val dateProvider = scopes.options.dateProvider
10778 val compositionStart =
108- if (! alreadyComposed.value) parentCompositionSpan?. let { dateProvider.now() } else null
79+ if (shouldRecordSpans && ! alreadyComposed.value) dateProvider.now() else null
10980
11081 Box (
11182 modifier =
11283 baseModifier.drawWithContent {
113- if (alreadyRendered.value || parentRenderingSpan == null ) {
84+ if (! shouldRecordSpans || alreadyRendered.value ) {
11485 drawContent()
115- } else {
116- val renderStart = dateProvider.now()
117- drawContent()
118- val renderEnd = dateProvider.now()
119-
120- alreadyRendered.value = true
121- recordRenderSpan(parentRenderingSpan, tag, renderStart, renderEnd)
86+ return @drawWithContent
12287 }
88+
89+ val renderStart = dateProvider.now()
90+ drawContent()
91+ val renderEnd = dateProvider.now()
92+
93+ alreadyRendered.value = true
94+ recordRenderSpan(ownerSpan, tag, renderStart, renderEnd)
12395 },
12496 propagateMinConstraints = true ,
12597 ) {
@@ -130,44 +102,137 @@ public fun SentryTraced(
130102 val compositionEnd = dateProvider.now()
131103
132104 SideEffect {
133- recordCompositionSpan(
134- parentSpan = parentCompositionSpan,
135- tag = tag,
136- startTimestamp = compositionStart,
137- endTimestamp = compositionEnd,
138- )
139-
140105 alreadyComposed.value = true
106+ recordCompositionSpan(ownerSpan, tag, compositionStart, compositionEnd)
141107 }
142108 }
143109}
144110
145- private fun getRootSpan (): ISpan ? {
146- var rootSpan: ISpan ? = null
147- Sentry .configureScope { rootSpan = it.transaction }
148- return rootSpan
149- }
150-
151111private fun recordCompositionSpan (
152- parentSpan : ISpan ? ,
112+ ownerSpan : ISpan ,
153113 tag : String ,
154114 startTimestamp : SentryDate ,
155115 endTimestamp : SentryDate ,
156116) {
157- parentSpan?.startChild(OP_COMPOSE , tag, startTimestamp)?.apply {
117+ val parentSpan = ParentSpans .getOrCreateCompositionSpan(ownerSpan, startTimestamp) ? : return
118+
119+ parentSpan.startChild(OP_COMPOSITION_CHILD , tag, startTimestamp).apply {
158120 spanContext.origin = OP_TRACE_ORIGIN
159121 finish(null , endTimestamp)
160122 }
161123}
162124
163125private fun recordRenderSpan (
164- parentSpan : ISpan ? ,
126+ ownerSpan : ISpan ,
165127 tag : String ,
166128 startTimestamp : SentryDate ,
167129 endTimestamp : SentryDate ,
168130) {
169- parentSpan?.startChild(OP_RENDER , tag, startTimestamp)?.apply {
131+ val parentSpan = ParentSpans .getOrCreateRenderSpan(ownerSpan, startTimestamp) ? : return
132+
133+ parentSpan.startChild(OP_RENDER_CHILD , tag, startTimestamp).apply {
170134 spanContext.origin = OP_TRACE_ORIGIN
171135 finish(null , endTimestamp)
172136 }
173137}
138+
139+ /* *
140+ * Returns true if spans parented under the receiver will be dropped (and therefore aren't worth
141+ * creating in the first place).
142+ */
143+ private val ISpan .dropsChildSpans: Boolean
144+ // NoOp spans return false for isFinished, so we check for no-op status directly.
145+ get() = this .isFinished || this .isNoOp
146+
147+ /* *
148+ * Weak holder of [OP_COMPOSITION_PARENT] and [OP_RENDER_PARENT] spans generated by [SentryTraced]
149+ * composables.
150+ *
151+ * Instances are cached in a weak map keyed by owner span, so multiple [SentryTraced] composables
152+ * with the same owner share one composition parent and one render parent.
153+ *
154+ * **Not threadsafe:** Access must be confined to Compose UI-thread callbacks.
155+ */
156+ private class ParentSpans {
157+
158+ private var compositionParentSpan: WeakReference <ISpan >? = null
159+ private var renderParentSpan: WeakReference <ISpan >? = null
160+
161+ companion object {
162+
163+ private val ownerSpanToParentSpans = WeakHashMap <ISpan , ParentSpans >()
164+
165+ fun getOrCreateCompositionSpan (ownerSpan : ISpan , startTimestamp : SentryDate ): ISpan ? =
166+ getFor(ownerSpan).getOrCreateCompositionSpan(ownerSpan, startTimestamp)
167+
168+ fun getOrCreateRenderSpan (ownerSpan : ISpan , startTimestamp : SentryDate ): ISpan ? =
169+ getFor(ownerSpan).getOrCreateRenderSpan(ownerSpan, startTimestamp)
170+
171+ private fun getFor (ownerSpan : ISpan ): ParentSpans =
172+ ownerSpanToParentSpans.getOrPut(ownerSpan) { ParentSpans () }
173+ }
174+
175+ private fun getOrCreateCompositionSpan (ownerSpan : ISpan , startTimestamp : SentryDate ): ISpan ? =
176+ getOrCreate(
177+ ownerSpan = ownerSpan,
178+ startTimestamp = startTimestamp,
179+ cached = compositionParentSpan,
180+ operation = OP_COMPOSITION_PARENT ,
181+ description = DESCRIPTION_COMPOSITION_PARENT ,
182+ ) {
183+ compositionParentSpan = it
184+ }
185+
186+ private fun getOrCreateRenderSpan (ownerSpan : ISpan , startTimestamp : SentryDate ): ISpan ? =
187+ getOrCreate(
188+ ownerSpan = ownerSpan,
189+ startTimestamp = startTimestamp,
190+ cached = renderParentSpan,
191+ operation = OP_RENDER_PARENT ,
192+ description = DESCRIPTION_RENDER_PARENT ,
193+ ) {
194+ renderParentSpan = it
195+ }
196+
197+ private fun getOrCreate (
198+ ownerSpan : ISpan ,
199+ startTimestamp : SentryDate ,
200+ cached : WeakReference <ISpan >? ,
201+ operation : String ,
202+ description : String ,
203+ setCached : (WeakReference <ISpan >) -> Unit ,
204+ ): ISpan ? {
205+ cached
206+ ?.get()
207+ ?.takeUnless { it.dropsChildSpans }
208+ ?.let {
209+ return it
210+ }
211+
212+ val parentSpan =
213+ ownerSpan.startChild(
214+ operation,
215+ description,
216+ SpanOptions ().apply {
217+ setStartTimestamp(startTimestamp)
218+ isTrimStart = true
219+ isTrimEnd = true
220+ isIdle = true
221+ },
222+ )
223+
224+ if (parentSpan.dropsChildSpans) {
225+ return null
226+ }
227+
228+ parentSpan.spanContext.origin = OP_TRACE_ORIGIN
229+ setCached(WeakReference (parentSpan))
230+ return parentSpan
231+ }
232+ }
233+
234+ /* *
235+ * A substitute for Compose's `MutableState` that doesn't register itself with the snapshot system,
236+ * so mutating [value] won't trigger recomposition.
237+ */
238+ private class MutableRef <T >(var value : T )
0 commit comments