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 @@ -4,6 +4,7 @@

### Features

- Add `LocalSentrySpan` to `sentry-compose` so apps can provide a parent `ISpan` to a composable subtree and have nested `SentryTraced` spans attach to it ([#6112]https://github.com/getsentry/sentry-java/pull/6112)
- Sentry can now configure Log4j2 automatically for Spring Boot 4 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#5403](https://github.com/getsentry/sentry-java/pull/5403))
- Enable automatic appender registration with:
```properties
Expand Down
4 changes: 4 additions & 0 deletions sentry-compose/api/android/sentry-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig {
public fun <init> ()V
}

public final class io/sentry/compose/LocalSentrySpanKt {
public static final fun getLocalSentrySpan ()Landroidx/compose/runtime/ProvidableCompositionLocal;
}

public final class io/sentry/compose/SentryComposeHelperKt {
public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.sentry.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.ExperimentalComposeUiApi
import io.sentry.ISpan
import io.sentry.NoOpSpan
import io.sentry.Sentry

/**
* A [ProvidableCompositionLocal] for delivering [ISpan]s to composable subtrees. The delivered span
* should be used to parent any spans the receiving subtree produces.
*
* Lets child composables remain agnostic about the [ISpan] hierarchies constructed by their
* ancestors.
*/
@ExperimentalComposeUiApi
public val LocalSentrySpan: ProvidableCompositionLocal<ISpan> = compositionLocalOf {
UnsetSentrySpan
}

/**
* A wrapper for any composable subtree that should be passed the provided [span] via
* [LocalSentrySpan].
*
* The span is [normalized][normalize].
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun ProvideSentrySpan(span: ISpan?, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalSentrySpan provides span.normalize()) {
content()
}
}

/**
* Returns the receiver as-is unless it's an [UnsetSentrySpan], in which case it returns the current
* transaction.
*
* `*Bootstrap` methods are for internal use only. They're designed for scenarios where
* [LocalSentrySpan] hasn't been set, but we want to provide a reasonable alternative or fall back
* to preexisting behavior.
*/
internal fun ISpan.orBootstrapCurrentTransaction(): ISpan =
if (this.isUnset()) {
Sentry.getCurrentScopes().transaction.normalize()
} else {
this
}

/**
* A sentinel span indicating that [LocalSentrySpan] hasn't been set. For internal use only.
*
* Note: This must be a distinct type from [NoOpSpan] so that [isUnset] can determine whether
* `LocalSentrySpan` was set with a `NoOpSpan` or was never set at all.
*
* Our own implementations need that information because:
*
* 1. we should always honor the value of `LocalSentrySpan` if deliberately set, even when it vends
* a `NoOpSpan`; but
*
* 2. we'll often want supply our own default parent span if `LocalSentrySpan` hasn't been set.
*
* A sentinel type lets us do so without complicating our public API for a distinction irrelevant to
* host apps.
*/
private object UnsetSentrySpan : ISpan by NoOpSpan.getInstance()

private fun ISpan.isUnset(): Boolean = this === UnsetSentrySpan

private fun ISpan?.normalize(): ISpan =
when {
this === UnsetSentrySpan || this is NoOpSpan -> this
this == null || this.isFinished -> NoOpSpan.getInstance()
else -> this
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import io.sentry.ISpan
import io.sentry.Instrumenter
import io.sentry.NoOpSpan
import io.sentry.Sentry
import io.sentry.SentryDate
import io.sentry.SpanOptions
Expand Down Expand Up @@ -71,7 +70,7 @@ public fun SentryTraced(
) {
val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier
val scopes = Sentry.getCurrentScopes()
val ownerSpan = scopes.transaction ?: NoOpSpan.getInstance()
val ownerSpan = LocalSentrySpan.current.orBootstrapCurrentTransaction()

val alreadyComposed = remember(ownerSpan) { MutableRef(false) }
val alreadyRendered = remember(ownerSpan) { MutableRef(false) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.sentry.ISpan
import io.sentry.ITransaction
import io.sentry.NoOpSpan
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.TransactionOptions
Expand Down Expand Up @@ -75,7 +76,9 @@ class SentryTracedTest {
fun `records a composition span for the initial composition`() {
val tx = initSentryAndStartTransaction("tx")

rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } }
rule.setContent {
ProvideSentrySpan(tx) { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } }
}
tx.waitForSpanCount(OP_COMPOSE, 1)

assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1)
Expand All @@ -88,7 +91,7 @@ class SentryTracedTest {
}

@Test
fun `falls back to the current transaction when no owner span is provided`() {
fun `falls back to the current transaction when no owner span is provided via LocalSentrySpan`() {
val tx = initSentryAndStartTransaction("tx")

rule.setContent { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } }
Expand All @@ -112,12 +115,32 @@ class SentryTracedTest {
}

@Test
fun `renders content without spans when the current transaction is finished`() {
fun `renders content without spans when provided owner span is a no-op`() {
val tx = initSentryAndStartTransaction("tx")

rule.setContent {
ProvideSentrySpan(NoOpSpan.getInstance()) {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) }
}
}
rule.waitForIdle()

rule.onNodeWithTag("content").assertExists()
assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(0)
assertThat(tx.countSpans(OP_COMPOSE)).isEqualTo(0)
assertThat(tx.countSpans(OP_PARENT_RENDER)).isEqualTo(0)
assertThat(tx.countSpans(OP_RENDER)).isEqualTo(0)
}

@Test
fun `renders content without spans when provided owner span is finished`() {
val tx = initSentryAndStartTransaction("tx")
rule.runOnUiThread { tx.finish() }

rule.setContent {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) }
ProvideSentrySpan(tx) {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) }
}
}
rule.waitForIdle()

Expand All @@ -136,7 +159,9 @@ class SentryTracedTest {
}

rule.setContent {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) }
ProvideSentrySpan(tx) {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp).testTag("content")) }
}
}
rule.waitForIdle()
drawContent()
Expand All @@ -153,9 +178,11 @@ class SentryTracedTest {
val tx = initSentryAndStartTransaction("tx")

rule.setContent {
Column {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) }
SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) }
ProvideSentrySpan(tx) {
Column {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) }
SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) }
}
}
}

Expand All @@ -170,9 +197,11 @@ class SentryTracedTest {
val tx = initSentryAndStartTransaction("tx")

rule.setContent {
Column {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) }
SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) }
ProvideSentrySpan(tx) {
Column {
SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) }
SentryTraced(tag = "add_to_cart_button") { Box(Modifier.size(1.dp)) }
}
}
}
tx.waitForSpanCount(OP_COMPOSE, 2)
Expand Down Expand Up @@ -200,9 +229,11 @@ class SentryTracedTest {
val tx = initSentryAndStartTransaction("tx")

rule.setContent {
val currentStep = step
SentryTraced(tag = "product_info") {
Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep"))
ProvideSentrySpan(tx) {
val currentStep = step
SentryTraced(tag = "product_info") {
Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep"))
}
}
}
tx.waitForSpanCount(OP_COMPOSE, 1)
Expand Down Expand Up @@ -230,15 +261,17 @@ class SentryTracedTest {
val tx = initSentryAndStartTransaction("tx")

rule.setContent {
val currentStep = step
SentryTraced(tag = "product_info") {
val state = remember {
rememberedInstanceCount++
Any()
ProvideSentrySpan(tx) {
val currentStep = step
SentryTraced(tag = "product_info") {
val state = remember {
rememberedInstanceCount++
Any()
}
DisposableEffect(Unit) { onDispose { disposeCount++ } }
rememberedState = state
Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep"))
}
DisposableEffect(Unit) { onDispose { disposeCount++ } }
rememberedState = state
Box(Modifier.size((currentStep + 1).dp).testTag("content-$currentStep"))
}
}
tx.waitForSpanCount(OP_COMPOSE, 1)
Expand All @@ -260,18 +293,24 @@ class SentryTracedTest {
@Test
fun `starts recording once an owner span becomes available`() {
var step by mutableStateOf(0)
var sentrySpan by mutableStateOf<ISpan>(NoOpSpan.getInstance())

rule.runOnUiThread { Sentry.close() }
rule.setContent {
val currentStep = step
key(currentStep) {
SentryTraced(tag = "late-transaction-$currentStep") { Box(Modifier.size(1.dp)) }
ProvideSentrySpan(sentrySpan) {
val currentStep = step
key(currentStep) {
SentryTraced(tag = "late-transaction-$currentStep") { Box(Modifier.size(1.dp)) }
}
}
}
rule.waitForIdle()

val tx = initSentryAndStartTransaction("tx")
rule.runOnIdle { step = 1 }
rule.runOnIdle {
sentrySpan = tx
step = 1
}
rule.waitForIdle()

tx.waitForSpanCount(OP_COMPOSE, 1)
Expand All @@ -284,11 +323,14 @@ class SentryTracedTest {
fun `records spans under replacement owner after previous owner finishes`() {
var step by mutableStateOf(0)
val firstTx = initSentryAndStartTransaction("first-tx")
var sentrySpan by mutableStateOf<ISpan>(firstTx)

rule.setContent {
val currentStep = step
key(currentStep) {
SentryTraced(tag = "transaction-$currentStep") { Box(Modifier.size(1.dp)) }
ProvideSentrySpan(sentrySpan) {
val currentStep = step
key(currentStep) {
SentryTraced(tag = "transaction-$currentStep") { Box(Modifier.size(1.dp)) }
}
}
}
firstTx.waitForSpanCount(OP_COMPOSE, 1)
Expand All @@ -298,7 +340,10 @@ class SentryTracedTest {
firstTx.finish()
secondTx = startBoundTransaction("second-tx")
}
rule.runOnIdle { step = 1 }
rule.runOnIdle {
sentrySpan = secondTx
step = 1
}
rule.waitForIdle()

secondTx.waitForSpanCount(OP_COMPOSE, 1)
Expand All @@ -310,13 +355,12 @@ class SentryTracedTest {

@Test
fun `records a new span group when the owner span changes for the same composable node`() {
var step by mutableStateOf(0)
val firstTx = initSentryAndStartTransaction("first-tx")
var sentrySpan by mutableStateOf<ISpan>(firstTx)

rule.setContent {
val currentStep = step
SentryTraced(tag = "transaction", modifier = Modifier.testTag("content-$currentStep")) {
Box(Modifier.size((currentStep + 1).dp))
ProvideSentrySpan(sentrySpan) {
SentryTraced(tag = "transaction") { Box(Modifier.size(1.dp)) }
}
}
firstTx.waitForSpanCount(OP_COMPOSE, 1)
Expand All @@ -328,14 +372,13 @@ class SentryTracedTest {
firstTx.finish()
secondTx = startBoundTransaction("second-tx")
}
rule.runOnIdle { step = 1 }
rule.runOnIdle { sentrySpan = secondTx }
rule.waitForIdle()

secondTx.waitForSpanCount(OP_COMPOSE, 1)
drawContent()
secondTx.waitForSpanCount(OP_RENDER, 1)

rule.onNodeWithTag("content-1").assertExists()
assertThat(firstTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1)
assertThat(firstTx.countSpans(OP_PARENT_RENDER)).isEqualTo(1)
assertThat(secondTx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(1)
Expand All @@ -349,7 +392,9 @@ class SentryTracedTest {
val tx = initSentryAndStartTransaction("tx")

assertFailsWith<IllegalStateException> {
rule.setContent { SentryTraced(tag = "throws") { error("boom") } }
rule.setContent {
ProvideSentrySpan(tx) { SentryTraced(tag = "throws") { error("boom") } }
}
}

assertThat(tx.countSpans(OP_PARENT_COMPOSITION)).isEqualTo(0)
Expand Down
Loading