Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Improvements

- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790))
- Add `LocalSentryScopes` to `sentry-compose`, allowing `SentryTraced` to trace against a custom `IScopes` instance instead of always defaulting to `Sentry.getCurrentScopes()` (#2668)

### Fixes

Expand Down
2 changes: 2 additions & 0 deletions sentry-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ kotlin {
implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.kotlin.test.junit)
implementation(libs.androidx.compose.foundation)
implementation(libs.mockito.inline)
implementation(libs.mockito.kotlin)
implementation(libs.roboelectric)
implementation(projects.sentryTestSupport)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ 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.compositionLocalOf
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import io.sentry.IScopes
import io.sentry.ISpan
import io.sentry.Sentry
import io.sentry.SpanOptions
Expand All @@ -24,15 +26,19 @@ private const val OP_TRACE_ORIGIN = "auto.ui.jetpack_compose"

@Immutable private class ImmutableHolder<T>(var item: T)

private fun getRootSpan(): ISpan? {
public val LocalSentryScopes: ProvidableCompositionLocal<IScopes> = staticCompositionLocalOf {
Sentry.getCurrentScopes()
}

private fun getRootSpan(scopes: IScopes): ISpan? {
var rootSpan: ISpan? = null
Sentry.configureScope { rootSpan = it.transaction }
scopes.configureScope { rootSpan = it.transaction }
return rootSpan
}

private val localSentryCompositionParentSpan = compositionLocalOf {
private fun createCompositionParentSpan(scopes: IScopes): ImmutableHolder<ISpan?> =
ImmutableHolder(
getRootSpan()
getRootSpan(scopes)
?.startChild(
OP_PARENT_COMPOSITION,
"Jetpack Compose Initial Composition",
Expand All @@ -44,11 +50,10 @@ private val localSentryCompositionParentSpan = compositionLocalOf {
)
?.apply { spanContext.origin = OP_TRACE_ORIGIN }
)
}

private val localSentryRenderingParentSpan = compositionLocalOf {
private fun createRenderingParentSpan(scopes: IScopes): ImmutableHolder<ISpan?> =
ImmutableHolder(
getRootSpan()
getRootSpan(scopes)
?.startChild(
OP_PARENT_RENDER,
"Jetpack Compose Initial Render",
Expand All @@ -60,8 +65,18 @@ private val localSentryRenderingParentSpan = compositionLocalOf {
)
?.apply { spanContext.origin = OP_TRACE_ORIGIN }
)

private class RootSpans {
val compositionSpans = HashMap<IScopes, ImmutableHolder<ISpan?>>()
val renderingSpans = HashMap<IScopes, ImmutableHolder<ISpan?>>()
}

// Cached once per Composition and shared by every SentryTraced call within it, mirroring the
// old eagerly-computed `compositionLocalOf { ... }` default (which Compose resolves once and
// reuses for every `.current` read that has no ancestor Provider, sibling or not). Keyed per
// IScopes so distinct custom scopes each get their own root span instead of colliding.
private val LocalRootSpans = staticCompositionLocalOf { RootSpans() }

@ExperimentalComposeUiApi
@Composable
public fun SentryTraced(
Expand All @@ -70,8 +85,13 @@ public fun SentryTraced(
enableUserInteractionTracing: Boolean = true,
content: @Composable BoxScope.() -> Unit,
) {
val parentCompositionSpan = localSentryCompositionParentSpan.current
val parentRenderingSpan = localSentryRenderingParentSpan.current
val scopes = LocalSentryScopes.current
val rootSpans = LocalRootSpans.current
val parentCompositionSpan =
rootSpans.compositionSpans.getOrPut(scopes) { createCompositionParentSpan(scopes) }
val parentRenderingSpan =
rootSpans.renderingSpans.getOrPut(scopes) { createRenderingParentSpan(scopes) }

val compositionSpan =
parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)?.apply {
spanContext.origin = OP_TRACE_ORIGIN
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.sentry.compose

import android.app.Application
import android.content.ComponentName
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.IScopes
import io.sentry.ITransaction
import io.sentry.SentryOptions
import io.sentry.TransactionOptions
import io.sentry.test.createTestScopes
import kotlin.test.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.robolectric.Shadows
import org.robolectric.annotation.Config

@OptIn(ExperimentalComposeUiApi::class)
@RunWith(AndroidJUnit4::class)
@Config(sdk = [30])
class SentryTracedTest {
// workaround for robolectric tests with composeRule
// from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882
@get:Rule(order = 1)
val addActivityToRobolectricRule =
object : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
val appContext: Application = ApplicationProvider.getApplicationContext()
Shadows.shadowOf(appContext.packageManager)
.addActivityIfNotPresent(
ComponentName(appContext.packageName, ComponentActivity::class.java.name)
)
}
}

@get:Rule(order = 2) val rule = createAndroidComposeRule<ComponentActivity>()

private fun newTracingScopes(): IScopes =
createTestScopes(
SentryOptions().apply {
dsn = "https://key@sentry.io/proj"
tracesSampleRate = 1.0
}
)

private fun IScopes.startBoundTransaction(name: String): ITransaction =
startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true })

@Test
fun `SentryTraced creates its root span on the scopes provided via LocalSentryScopes`() {
val scopes = newTracingScopes()
val tx = scopes.startBoundTransaction("custom-scopes-tx")

rule.setContent {
CompositionLocalProvider(LocalSentryScopes provides scopes) {
SentryTraced(tag = "custom") { Box {} }
}
}
rule.waitForIdle()

assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" })
assertEquals(1, tx.spans.count { it.operation == "ui.compose" })
}

@Test
fun `sibling SentryTraced composables under the same scopes share one root span`() {
val scopes = newTracingScopes()
val tx = scopes.startBoundTransaction("custom-scopes-tx")

rule.setContent {
CompositionLocalProvider(LocalSentryScopes provides scopes) {
SentryTraced(tag = "first") { Box {} }
SentryTraced(tag = "second") { Box {} }
}
}
rule.waitForIdle()

assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" })
assertEquals(2, tx.spans.count { it.operation == "ui.compose" })
}

@Test
fun `SentryTraced composables under different scopes do not interfere with each other`() {
val scopesA = newTracingScopes()
val txA = scopesA.startBoundTransaction("scopes-a-tx")
val scopesB = newTracingScopes()
val txB = scopesB.startBoundTransaction("scopes-b-tx")

rule.setContent {
CompositionLocalProvider(LocalSentryScopes provides scopesA) {
SentryTraced(tag = "a") { Box {} }
}
CompositionLocalProvider(LocalSentryScopes provides scopesB) {
SentryTraced(tag = "b") { Box {} }
}
}
rule.waitForIdle()

assertEquals(1, txA.spans.count { it.operation == "ui.compose.composition" })
assertEquals(1, txA.spans.count { it.operation == "ui.compose" })
assertEquals(1, txB.spans.count { it.operation == "ui.compose.composition" })
assertEquals(1, txB.spans.count { it.operation == "ui.compose" })
}
}