Skip to content

Commit e508334

Browse files
committed
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.
1 parent a9e53be commit e508334

4 files changed

Lines changed: 596 additions & 84 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixes
6+
7+
`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+
39
## 8.55.0
410

511
### Features

‎sentry-compose/build.gradle.kts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,14 @@ kotlin {
6262
}
6363
getByName("androidUnitTest") {
6464
dependencies {
65+
implementation(libs.androidx.compose.foundation)
66+
implementation(libs.androidx.compose.foundation.layout)
6567
implementation(libs.androidx.compose.ui.test.junit4)
6668
implementation(libs.androidx.navigation.compose)
6769
implementation(libs.androidx.test.ext.junit)
6870
implementation(libs.androidx.test.rules)
6971
implementation(libs.androidx.test.runner)
72+
implementation(libs.google.truth)
7073
implementation(libs.kotlin.test.junit)
7174
implementation(libs.mockito.inline)
7275
implementation(libs.mockito.kotlin)

‎sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt‎

Lines changed: 149 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -4,74 +4,47 @@ import androidx.compose.foundation.layout.Box
44
import androidx.compose.foundation.layout.BoxScope
55
import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.SideEffect
7-
import androidx.compose.runtime.compositionLocalOf
87
import androidx.compose.runtime.remember
98
import androidx.compose.ui.ExperimentalComposeUiApi
109
import androidx.compose.ui.Modifier
1110
import androidx.compose.ui.draw.drawWithContent
1211
import io.sentry.ISpan
12+
import io.sentry.NoOpSpan
1313
import io.sentry.Sentry
1414
import io.sentry.SentryDate
1515
import io.sentry.SpanOptions
1616
import 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

2428
private 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-
151111
private 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

163125
private 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

Comments
 (0)