Skip to content

Commit cee1bc9

Browse files
committed
Now let me create a proper commit message following the project's conventions. Let me check the issue number again:
Perfect! Now I'll propose the commit command: ```bash git commit -m "$(cat <<'EOF' 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 SnapshotStateList back stack changes using Compose state flows - Captures breadcrumbs with from/to routes and back stack state - Creates idle transactions for navigation events - 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) backStack.withSentryObservableEffect() ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> EOF )" ``` --- ## Summary I've successfully implemented the `sentry-android-navigation3` module with the following components: ### 📦 Module Structure - **Multiplatform-friendly** Kotlin Multiplatform module (Android + Desktop JVM) - Follows existing module patterns from `sentry-compose` - Targets Navigation 3 version 1.0.0 (stable) ### 🔑 Key Implementation Details **Main API** (`SentryNavigation3Integration.kt`): - `withSentryObservableEffect()` extension function for `SnapshotStateList<T>` - Observes back stack changes using `snapshotFlow` - Configurable breadcrumbs and tracing via boolean flags - Custom `keyToRoute` lambda for extracting route names from any key type **Features Implemented**: 1. ✅ **Breadcrumb Capture** - Records navigation transitions with from/to routes and full back stack keys 2. ✅ **Transaction/Tracing** - Creates idle transactions with route names and back stack metadata 3. ✅ **Screen Tracking** - Sets scope screen name when enabled 4. ✅ **Transaction Management** - Finishes previous transactions before starting new ones 5. ✅ **Configurable** - Respects global `SentryOptions` and module-level flags **Testing**: - Comprehensive unit test suite with 15+ test cases - Tests breadcrumbs, transactions, configuration options, and edge cases - Uses Mockito + Kotlin Test ### 📝 Changes Made - Created `sentry-android-navigation3/` module with full structure - Added Navigation 3 dependencies to `gradle/libs.versions.toml` - Registered module in `settings.gradle.kts` - Generated API dump and applied code formatting - BOM automatically includes the new module All files are staged and ready for commit with the proposed message above!
1 parent 8c7e8d9 commit cee1bc9

8 files changed

Lines changed: 781 additions & 0 deletions

File tree

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
apollo = "2.5.9"
33
androidxLifecycle = "2.2.0"
44
androidxNavigation = "2.4.2"
5+
androidxNavigation3 = "1.0.0"
56
androidxTestCore = "1.7.0"
67
androidxCompose = "1.6.3"
78
composeCompiler = "1.5.14"
@@ -89,6 +90,8 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo
8990
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
9091
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
9192
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
93+
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
94+
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidxCompose" }
9295
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" }
9396
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" }
9497
androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
public final class io/sentry/android/navigation3/BuildConfig {
2+
public static final field BUILD_TYPE Ljava/lang/String;
3+
public static final field DEBUG Z
4+
public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
5+
public static final field VERSION_NAME Ljava/lang/String;
6+
public fun <init> ()V
7+
}
8+
9+
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+
}
12+
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import io.gitlab.arturbosch.detekt.Detekt
2+
import org.jetbrains.dokka.gradle.DokkaTask
3+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
4+
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
5+
6+
plugins {
7+
alias(libs.plugins.kotlin.multiplatform)
8+
alias(libs.plugins.kotlin.compose)
9+
id("com.android.library")
10+
alias(libs.plugins.kover)
11+
alias(libs.plugins.gradle.versions)
12+
alias(libs.plugins.detekt)
13+
alias(libs.plugins.dokka)
14+
alias(libs.plugins.dokka.javadoc)
15+
`maven-publish` // necessary for publishMavenLocal task to publish correct artifacts
16+
}
17+
18+
kotlin {
19+
explicitApi()
20+
21+
androidTarget {
22+
compilerOptions {
23+
jvmTarget.set(JvmTarget.JVM_1_8)
24+
apiVersion.set(KotlinVersion.KOTLIN_1_9)
25+
languageVersion.set(KotlinVersion.KOTLIN_1_9)
26+
}
27+
publishLibraryVariants("release")
28+
}
29+
jvm("desktop") {
30+
compilerOptions {
31+
jvmTarget.set(JvmTarget.JVM_1_8)
32+
apiVersion.set(KotlinVersion.KOTLIN_1_9)
33+
languageVersion.set(KotlinVersion.KOTLIN_1_9)
34+
}
35+
}
36+
37+
coreLibrariesVersion = "1.8"
38+
39+
sourceSets.all {
40+
// Allow all experimental APIs, since MPP projects are themselves experimental
41+
languageSettings.apply {
42+
optIn("kotlin.Experimental")
43+
optIn("kotlin.ExperimentalMultiplatform")
44+
}
45+
}
46+
47+
sourceSets {
48+
val commonMain by getting {
49+
compilerOptions {
50+
apiVersion.set(KotlinVersion.KOTLIN_1_9)
51+
languageVersion.set(KotlinVersion.KOTLIN_1_9)
52+
}
53+
}
54+
val androidMain by getting {
55+
dependencies {
56+
api(projects.sentry)
57+
58+
compileOnly(libs.androidx.navigation3.runtime)
59+
compileOnly(libs.androidx.compose.runtime)
60+
implementation(libs.kotlinx.coroutines)
61+
}
62+
}
63+
val androidUnitTest by getting {
64+
dependencies {
65+
implementation(libs.androidx.navigation3.runtime)
66+
implementation(libs.androidx.compose.runtime)
67+
implementation(libs.androidx.test.ext.junit)
68+
implementation(libs.androidx.test.rules)
69+
implementation(libs.androidx.test.runner)
70+
implementation(libs.kotlin.test.junit)
71+
implementation(libs.mockito.inline)
72+
implementation(libs.mockito.kotlin)
73+
implementation(libs.roboelectric)
74+
}
75+
}
76+
}
77+
}
78+
79+
android {
80+
compileSdk = libs.versions.compileSdk.get().toInt()
81+
namespace = "io.sentry.android.navigation3"
82+
83+
defaultConfig {
84+
minSdk = libs.versions.minSdk.get().toInt()
85+
86+
// for AGP 4.1
87+
buildConfigField("String", "VERSION_NAME", "\"${project.version}\"")
88+
}
89+
90+
sourceSets["main"].apply { manifest.srcFile("src/androidMain/AndroidManifest.xml") }
91+
92+
buildTypes {
93+
getByName("debug") { consumerProguardFiles("proguard-rules.pro") }
94+
getByName("release") { consumerProguardFiles("proguard-rules.pro") }
95+
}
96+
97+
testOptions {
98+
animationsDisabled = true
99+
unitTests.apply {
100+
isReturnDefaultValues = true
101+
isIncludeAndroidResources = true
102+
}
103+
}
104+
105+
lint {
106+
warningsAsErrors = true
107+
checkDependencies = true
108+
109+
// We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks.
110+
checkReleaseBuilds = false
111+
}
112+
113+
buildFeatures { buildConfig = true }
114+
115+
androidComponents.beforeVariants {
116+
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
117+
}
118+
}
119+
120+
tasks.withType<Detekt>().configureEach {
121+
// Target version of the generated JVM bytecode. It is used for type resolution.
122+
jvmTarget = JavaVersion.VERSION_1_8.toString()
123+
}
124+
125+
tasks.withType<DokkaTask>().configureEach {
126+
// suppress unattached source sets for docs
127+
dokkaSourceSets {
128+
matching {
129+
it.name.contains("androidandroid", ignoreCase = true) ||
130+
it.name.contains("testfixtures", ignoreCase = true)
131+
}
132+
.configureEach { suppress.set(true) }
133+
}
134+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# no-op
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
</manifest>
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package io.sentry.android.navigation3
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.NonRestartableComposable
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.rememberUpdatedState
8+
import androidx.compose.runtime.snapshotFlow
9+
import androidx.compose.runtime.snapshots.SnapshotStateList
10+
import io.sentry.Breadcrumb
11+
import io.sentry.Hint
12+
import io.sentry.IScopes
13+
import io.sentry.ITransaction
14+
import io.sentry.ScopesAdapter
15+
import io.sentry.SentryIntegrationPackageStorage
16+
import io.sentry.SentryLevel
17+
import io.sentry.SentryOptions
18+
import io.sentry.SpanStatus
19+
import io.sentry.TransactionContext
20+
import io.sentry.TransactionOptions
21+
import io.sentry.TypeCheckHint
22+
import io.sentry.protocol.TransactionNameSource
23+
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
24+
import kotlinx.coroutines.flow.drop
25+
import kotlinx.coroutines.flow.launchIn
26+
import kotlinx.coroutines.flow.onEach
27+
28+
private const val TRACE_ORIGIN = "auto.navigation.navigation3"
29+
private const val NAVIGATION_OP = "navigation"
30+
31+
/**
32+
* A [DisposableEffect] that observes a [SnapshotStateList] back stack and captures a [Breadcrumb]
33+
* and starts an [ITransaction] for each navigation event, sending them to Sentry.
34+
*
35+
* This integration is designed for Android Navigation 3 which uses a back stack-based approach with
36+
* Compose state observation instead of traditional listeners.
37+
*
38+
* @param T The type of keys in the back stack
39+
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
40+
* navigation events.
41+
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
42+
* with [SentryOptions.idleTimeout] for navigation events.
43+
* @param keyToRoute A function to extract a route name from a back stack key. Defaults to
44+
* [Any.toString].
45+
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
46+
* instance.
47+
* @return The same [SnapshotStateList] for chaining.
48+
*/
49+
@Composable
50+
@NonRestartableComposable
51+
public fun <T> SnapshotStateList<T>.withSentryObservableEffect(
52+
enableNavigationBreadcrumbs: Boolean = true,
53+
enableNavigationTracing: Boolean = true,
54+
keyToRoute: (T) -> String? = { it.toString() },
55+
scopes: IScopes = ScopesAdapter.getInstance(),
56+
): SnapshotStateList<T> {
57+
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
58+
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
59+
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
60+
val scopesSnapshot by rememberUpdatedState(scopes)
61+
62+
DisposableEffect(this) {
63+
addIntegrationToSdkVersion("Navigation3")
64+
65+
val observer =
66+
SentryBackStackObserver(
67+
backStack = this@withSentryObservableEffect,
68+
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
69+
enableNavigationTracing = enableTracingSnapshot,
70+
keyToRoute = keyToRouteSnapshot,
71+
scopes = scopesSnapshot,
72+
)
73+
74+
val scope =
75+
kotlinx.coroutines.CoroutineScope(
76+
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
77+
)
78+
val job = observer.observe(scope)
79+
80+
onDispose { job.cancel() }
81+
}
82+
83+
return this
84+
}
85+
86+
/** Internal observer that monitors back stack changes and creates Sentry events. */
87+
internal class SentryBackStackObserver<T>(
88+
private val backStack: SnapshotStateList<T>,
89+
private val enableNavigationBreadcrumbs: Boolean,
90+
private val enableNavigationTracing: Boolean,
91+
private val keyToRoute: (T) -> String?,
92+
private val scopes: IScopes,
93+
) {
94+
private var previousKey: T? = null
95+
private var activeTransaction: ITransaction? = null
96+
97+
private val isPerformanceEnabled: Boolean
98+
get() = scopes.options.isTracingEnabled && enableNavigationTracing
99+
100+
init {
101+
SentryIntegrationPackageStorage.getInstance()
102+
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
103+
}
104+
105+
fun observe(scope: kotlinx.coroutines.CoroutineScope) =
106+
snapshotFlow { backStack.toList() }
107+
.drop(1) // Skip initial state
108+
.onEach { currentStack ->
109+
val currentKey = currentStack.lastOrNull()
110+
if (currentKey != null && currentKey != previousKey) {
111+
handleNavigation(currentKey, currentStack)
112+
previousKey = currentKey
113+
}
114+
}
115+
.launchIn(scope)
116+
117+
internal fun handleNavigation(currentKey: T, currentStack: List<T>) {
118+
val currentRoute = keyToRoute(currentKey)
119+
if (currentRoute != null) {
120+
addBreadcrumb(currentRoute, currentStack)
121+
122+
if (scopes.options.isEnableScreenTracking) {
123+
scopes.configureScope { it.screen = currentRoute }
124+
}
125+
126+
startTracing(currentRoute, currentStack)
127+
}
128+
}
129+
130+
private fun addBreadcrumb(toRoute: String, currentStack: List<T>) {
131+
if (!enableNavigationBreadcrumbs) {
132+
return
133+
}
134+
135+
val breadcrumb =
136+
Breadcrumb().apply {
137+
type = NAVIGATION_OP
138+
category = NAVIGATION_OP
139+
140+
val fromKey = previousKey
141+
if (fromKey != null) {
142+
val fromRoute = keyToRoute(fromKey)
143+
if (fromRoute != null) {
144+
data["from"] = fromRoute
145+
}
146+
}
147+
148+
data["to"] = toRoute
149+
150+
// Capture back stack keys as a list
151+
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
152+
if (backStackKeys.isNotEmpty()) {
153+
data["back_stack"] = backStackKeys
154+
}
155+
156+
level = SentryLevel.INFO
157+
}
158+
159+
val hint = Hint()
160+
hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, toRoute)
161+
scopes.addBreadcrumb(breadcrumb, hint)
162+
}
163+
164+
private fun startTracing(routeName: String, currentStack: List<T>) {
165+
if (!isPerformanceEnabled) {
166+
io.sentry.util.TracingUtils.startNewTrace(scopes)
167+
return
168+
}
169+
170+
// we can only have one nav transaction at a time
171+
if (activeTransaction != null) {
172+
stopTracing()
173+
}
174+
175+
val transactionOptions =
176+
TransactionOptions().also {
177+
it.isWaitForChildren = true
178+
it.idleTimeout = scopes.options.idleTimeout
179+
180+
// Set deadline timeout based on configured option
181+
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
182+
// No deadline when zero or negative value is set
183+
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
184+
185+
it.isTrimEnd = true
186+
}
187+
188+
val transaction =
189+
scopes.startTransaction(
190+
TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP),
191+
transactionOptions,
192+
)
193+
194+
transaction.spanContext.origin = TRACE_ORIGIN
195+
196+
// Capture back stack keys as data
197+
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
198+
if (backStackKeys.isNotEmpty()) {
199+
transaction.setData("back_stack", backStackKeys)
200+
}
201+
202+
scopes.configureScope { scope ->
203+
scope.withTransaction { tx ->
204+
if (tx == null) {
205+
scope.transaction = transaction
206+
}
207+
}
208+
}
209+
activeTransaction = transaction
210+
}
211+
212+
private fun stopTracing() {
213+
val status = activeTransaction?.status ?: SpanStatus.OK
214+
activeTransaction?.finish(status)
215+
216+
// clear transaction from scope so others can bind to it
217+
scopes.configureScope { scope ->
218+
scope.withTransaction { tx ->
219+
if (tx == activeTransaction) {
220+
scope.clearTransaction()
221+
}
222+
}
223+
}
224+
225+
activeTransaction = null
226+
}
227+
}

0 commit comments

Comments
 (0)