Skip to content

Commit 075d24d

Browse files
markushiclaude
andcommitted
feat(android): Add sentry-android-navigation3 module (#5000)
Implements Sentry integration for Android Navigation 3 library. This new module provides automatic breadcrumb capture and performance tracing for navigation events in Compose applications using Navigation 3's back stack-based architecture. Key features: - Observes back stack state changes using Compose snapshotFlow - Captures breadcrumbs with from/to routes and back stack state - Creates idle transactions for navigation events - Each composable maintains independent transaction state for proper multi-pane/split-screen support - Supports custom keyToRoute extraction for any back stack key type - Multiplatform-friendly (Android + Desktop JVM targets) - Respects global SentryOptions for tracing and screen tracking Usage: ```kotlin val backStack = rememberNavBackStack<NavKey>(HomeScreen) // In your composable SentryNavigation3Traced( backStack = backStack.toList() ) // Or with custom configuration SentryNavigation3Traced( backStack = backStack.toList(), enableNavigationBreadcrumbs = true, enableNavigationTracing = true, keyToRoute = { it.route } ) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 2e756af commit 075d24d

3 files changed

Lines changed: 24 additions & 112 deletions

File tree

sentry-android-navigation3/api/android/sentry-android-navigation3.api

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ public final class io/sentry/android/navigation3/BuildConfig {
77
}
88

99
public final class io/sentry/android/navigation3/SentryNavigation3IntegrationKt {
10-
public static final fun withSentryObservableEffect (Landroidx/compose/runtime/snapshots/SnapshotStateList;ZZLkotlin/jvm/functions/Function1;Lio/sentry/IScopes;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/snapshots/SnapshotStateList;
11-
public static final fun withSentryObservableEffect (Landroidx/compose/runtime/snapshots/SnapshotStateList;ZZLkotlin/jvm/functions/Function1;Lio/sentry/IScopes;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
10+
public static final fun SentryNavigation3Traced (Ljava/util/List;ZZLkotlin/jvm/functions/Function1;Lio/sentry/IScopes;Landroidx/compose/runtime/Composer;II)V
1211
}
1312

sentry-android-navigation3/src/androidMain/kotlin/io/sentry/android/navigation3/SentryNavigation3Integration.kt

Lines changed: 23 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
package io.sentry.android.navigation3
22

33
import androidx.compose.runtime.Composable
4-
import androidx.compose.runtime.CompositionLocalProvider
54
import androidx.compose.runtime.DisposableEffect
6-
import androidx.compose.runtime.NonRestartableComposable
7-
import androidx.compose.runtime.compositionLocalOf
85
import androidx.compose.runtime.getValue
96
import androidx.compose.runtime.remember
107
import androidx.compose.runtime.rememberUpdatedState
118
import androidx.compose.runtime.snapshotFlow
12-
import androidx.compose.runtime.snapshots.SnapshotStateList
139
import io.sentry.Breadcrumb
1410
import io.sentry.Hint
1511
import io.sentry.IScopes
@@ -31,29 +27,24 @@ import kotlinx.coroutines.flow.onEach
3127
private const val TRACE_ORIGIN = "auto.navigation.navigation3"
3228
private const val NAVIGATION_OP = "navigation"
3329

34-
/**
35-
* CompositionLocal that holds the active navigation transaction for the current navigation scope.
36-
* This allows multiple navigation instances to maintain separate transaction states.
37-
*/
38-
internal val LocalNavigationTransaction = compositionLocalOf<NavigationTransactionHolder?> { null }
39-
4030
/** Holder for the active navigation transaction in a composition scope. */
4131
internal class NavigationTransactionHolder {
4232
internal var activeTransaction: ITransaction? = null
4333
}
4434

4535
/**
46-
* A [DisposableEffect] that observes a [SnapshotStateList] back stack and captures a [Breadcrumb]
47-
* and starts an [ITransaction] for each navigation event, sending them to Sentry.
36+
* A Composable that observes a back stack [List] and captures a [Breadcrumb] and starts an
37+
* [ITransaction] for each navigation event, sending them to Sentry.
4838
*
4939
* This integration is designed for Android Navigation 3 which uses a back stack-based approach with
5040
* Compose state observation instead of traditional listeners.
5141
*
52-
* This function creates a composition-local scope for transaction management, allowing multiple
53-
* navigation instances (e.g., in split-screen or multi-pane layouts) to maintain independent
54-
* transaction states.
42+
* Each invocation of this composable maintains independent transaction state, allowing multiple
43+
* navigation instances (e.g., in split-screen or multi-pane layouts) to coexist without
44+
* interference.
5545
*
5646
* @param T The type of keys in the back stack
47+
* @param backStack The current back stack to observe for navigation changes
5748
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
5849
* navigation events.
5950
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
@@ -62,30 +53,28 @@ internal class NavigationTransactionHolder {
6253
* [Any.toString].
6354
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
6455
* instance.
65-
* @param content The composable content to display with this navigation scope.
6656
*/
6757
@Composable
68-
@NonRestartableComposable
69-
public fun <T> SnapshotStateList<T>.withSentryObservableEffect(
58+
public fun <T> SentryNavigation3Traced(
59+
backStack: List<T>,
7060
enableNavigationBreadcrumbs: Boolean = true,
7161
enableNavigationTracing: Boolean = true,
7262
keyToRoute: (T) -> String? = { it.toString() },
7363
scopes: IScopes = ScopesAdapter.getInstance(),
74-
content: @Composable () -> Unit,
7564
) {
65+
val backStackSnapshot by rememberUpdatedState(backStack)
7666
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
7767
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
7868
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
7969
val scopesSnapshot by rememberUpdatedState(scopes)
8070

8171
val transactionHolder = remember { NavigationTransactionHolder() }
8272

83-
DisposableEffect(this) {
73+
DisposableEffect(Unit) {
8474
addIntegrationToSdkVersion("Navigation3")
8575

8676
val observer =
8777
SentryBackStackObserver(
88-
backStack = this@withSentryObservableEffect,
8978
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
9079
enableNavigationTracing = enableTracingSnapshot,
9180
keyToRoute = keyToRouteSnapshot,
@@ -97,82 +86,32 @@ public fun <T> SnapshotStateList<T>.withSentryObservableEffect(
9786
kotlinx.coroutines.CoroutineScope(
9887
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
9988
)
100-
val job = observer.observe(scope)
101-
102-
onDispose { job.cancel() }
103-
}
104-
105-
CompositionLocalProvider(LocalNavigationTransaction provides transactionHolder) { content() }
106-
}
107-
108-
/**
109-
* A [DisposableEffect] that observes a [SnapshotStateList] back stack and captures a [Breadcrumb]
110-
* and starts an [ITransaction] for each navigation event, sending them to Sentry.
111-
*
112-
* This variant does not require a content block and is used when the back stack observation should
113-
* apply to the entire composition.
114-
*
115-
* @param T The type of keys in the back stack
116-
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
117-
* navigation events.
118-
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
119-
* with [SentryOptions.idleTimeout] for navigation events.
120-
* @param keyToRoute A function to extract a route name from a back stack key. Defaults to
121-
* [Any.toString].
122-
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
123-
* instance.
124-
* @return The same [SnapshotStateList] for chaining.
125-
*/
126-
@Composable
127-
@NonRestartableComposable
128-
public fun <T> SnapshotStateList<T>.withSentryObservableEffect(
129-
enableNavigationBreadcrumbs: Boolean = true,
130-
enableNavigationTracing: Boolean = true,
131-
keyToRoute: (T) -> String? = { it.toString() },
132-
scopes: IScopes = ScopesAdapter.getInstance(),
133-
): SnapshotStateList<T> {
134-
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
135-
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
136-
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
137-
val scopesSnapshot by rememberUpdatedState(scopes)
138-
139-
val transactionHolder = remember { NavigationTransactionHolder() }
140-
141-
DisposableEffect(this) {
142-
addIntegrationToSdkVersion("Navigation3")
143-
144-
val observer =
145-
SentryBackStackObserver(
146-
backStack = this@withSentryObservableEffect,
147-
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
148-
enableNavigationTracing = enableTracingSnapshot,
149-
keyToRoute = keyToRouteSnapshot,
150-
scopes = scopesSnapshot,
151-
transactionHolder = transactionHolder,
152-
)
153-
154-
val scope =
155-
kotlinx.coroutines.CoroutineScope(
156-
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
157-
)
158-
val job = observer.observe(scope)
89+
val job =
90+
snapshotFlow { backStackSnapshot }
91+
.drop(1) // Skip initial state
92+
.onEach { currentStack ->
93+
val currentKey = currentStack.lastOrNull()
94+
val previousKey = observer.previousKey
95+
if (currentKey != null && currentKey != previousKey) {
96+
observer.handleNavigation(currentKey, currentStack)
97+
observer.previousKey = currentKey
98+
}
99+
}
100+
.launchIn(scope)
159101

160102
onDispose { job.cancel() }
161103
}
162-
163-
return this
164104
}
165105

166106
/** Internal observer that monitors back stack changes and creates Sentry events. */
167107
internal class SentryBackStackObserver<T>(
168-
private val backStack: SnapshotStateList<T>,
169108
private val enableNavigationBreadcrumbs: Boolean,
170109
private val enableNavigationTracing: Boolean,
171110
private val keyToRoute: (T) -> String?,
172111
private val scopes: IScopes,
173112
private val transactionHolder: NavigationTransactionHolder,
174113
) {
175-
private var previousKey: T? = null
114+
internal var previousKey: T? = null
176115

177116
private val isPerformanceEnabled: Boolean
178117
get() = scopes.options.isTracingEnabled && enableNavigationTracing
@@ -182,18 +121,6 @@ internal class SentryBackStackObserver<T>(
182121
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
183122
}
184123

185-
fun observe(scope: kotlinx.coroutines.CoroutineScope) =
186-
snapshotFlow { backStack.toList() }
187-
.drop(1) // Skip initial state
188-
.onEach { currentStack ->
189-
val currentKey = currentStack.lastOrNull()
190-
if (currentKey != null && currentKey != previousKey) {
191-
handleNavigation(currentKey, currentStack)
192-
previousKey = currentKey
193-
}
194-
}
195-
.launchIn(scope)
196-
197124
internal fun handleNavigation(currentKey: T, currentStack: List<T>) {
198125
val currentRoute = keyToRoute(currentKey)
199126
if (currentRoute != null) {

sentry-android-navigation3/src/androidUnitTest/kotlin/io/sentry/android/navigation3/SentryNavigation3IntegrationTest.kt

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ class SentryNavigation3IntegrationTest {
5454
fun `adds breadcrumb on navigation`() {
5555
val observer =
5656
SentryBackStackObserver(
57-
backStack = backStack,
5857
enableNavigationBreadcrumbs = true,
5958
enableNavigationTracing = false,
6059
keyToRoute = { it },
@@ -81,7 +80,6 @@ class SentryNavigation3IntegrationTest {
8180
fun `adds breadcrumb with from and to routes`() {
8281
val observer =
8382
SentryBackStackObserver(
84-
backStack = backStack,
8583
enableNavigationBreadcrumbs = true,
8684
enableNavigationTracing = false,
8785
keyToRoute = { it },
@@ -109,7 +107,6 @@ class SentryNavigation3IntegrationTest {
109107
fun `captures back stack keys in breadcrumb`() {
110108
val observer =
111109
SentryBackStackObserver(
112-
backStack = backStack,
113110
enableNavigationBreadcrumbs = true,
114111
enableNavigationTracing = false,
115112
keyToRoute = { it },
@@ -137,7 +134,6 @@ class SentryNavigation3IntegrationTest {
137134
fun `does not add breadcrumb when disabled`() {
138135
val observer =
139136
SentryBackStackObserver(
140-
backStack = backStack,
141137
enableNavigationBreadcrumbs = false,
142138
enableNavigationTracing = false,
143139
keyToRoute = { it },
@@ -161,7 +157,6 @@ class SentryNavigation3IntegrationTest {
161157

162158
val observer =
163159
SentryBackStackObserver(
164-
backStack = backStack,
165160
enableNavigationBreadcrumbs = false,
166161
enableNavigationTracing = true,
167162
keyToRoute = { it },
@@ -191,7 +186,6 @@ class SentryNavigation3IntegrationTest {
191186

192187
val observer =
193188
SentryBackStackObserver(
194-
backStack = backStack,
195189
enableNavigationBreadcrumbs = false,
196190
enableNavigationTracing = true,
197191
keyToRoute = { it },
@@ -215,7 +209,6 @@ class SentryNavigation3IntegrationTest {
215209

216210
val observer =
217211
SentryBackStackObserver(
218-
backStack = backStack,
219212
enableNavigationBreadcrumbs = false,
220213
enableNavigationTracing = true,
221214
keyToRoute = { it },
@@ -244,7 +237,6 @@ class SentryNavigation3IntegrationTest {
244237

245238
val observer =
246239
SentryBackStackObserver(
247-
backStack = backStack,
248240
enableNavigationBreadcrumbs = false,
249241
enableNavigationTracing = true,
250242
keyToRoute = { it },
@@ -272,7 +264,6 @@ class SentryNavigation3IntegrationTest {
272264

273265
val observer =
274266
SentryBackStackObserver(
275-
backStack = backStack,
276267
enableNavigationBreadcrumbs = false,
277268
enableNavigationTracing = true,
278269
keyToRoute = { it },
@@ -300,7 +291,6 @@ class SentryNavigation3IntegrationTest {
300291

301292
val observer =
302293
SentryBackStackObserver(
303-
backStack = backStack,
304294
enableNavigationBreadcrumbs = false,
305295
enableNavigationTracing = false,
306296
keyToRoute = { it },
@@ -321,7 +311,6 @@ class SentryNavigation3IntegrationTest {
321311

322312
val observer =
323313
SentryBackStackObserver(
324-
backStack = backStack,
325314
enableNavigationBreadcrumbs = false,
326315
enableNavigationTracing = false,
327316
keyToRoute = { it },
@@ -342,7 +331,6 @@ class SentryNavigation3IntegrationTest {
342331
val customBackStack = mutableStateListOf<NavKey>()
343332
val observer =
344333
SentryBackStackObserver(
345-
backStack = customBackStack,
346334
enableNavigationBreadcrumbs = true,
347335
enableNavigationTracing = false,
348336
keyToRoute = { it.route },
@@ -364,7 +352,6 @@ class SentryNavigation3IntegrationTest {
364352
fun `handles null route from keyToRoute`() {
365353
val observer =
366354
SentryBackStackObserver(
367-
backStack = backStack,
368355
enableNavigationBreadcrumbs = true,
369356
enableNavigationTracing = true,
370357
keyToRoute = { null },
@@ -393,7 +380,6 @@ class SentryNavigation3IntegrationTest {
393380

394381
val observer =
395382
SentryBackStackObserver(
396-
backStack = backStack,
397383
enableNavigationBreadcrumbs = false,
398384
enableNavigationTracing = true,
399385
keyToRoute = { it },

0 commit comments

Comments
 (0)