From 85a12e78252eaa06b6505c9af72b39a4d3b3fbc8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 12 Sep 2026 07:05:07 +0200 Subject: [PATCH 1/5] collection: android nav3 From 6c2b8f9f5682efc23b262fee05923bfc749c2953 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 12 Sep 2026 06:59:17 +0200 Subject: [PATCH 2/5] feat(android-nav3): Create the navigation3 module Establish the new Android Navigation 3 integration module and register it with the build and publication metadata. This keeps the first step focused on the module boundary itself, without introducing generated API surface or implementation code yet. Co-Authored-By: OpenCode --- buildSrc/src/main/java/Config.kt | 1 + sentry-android-navigation3/build.gradle.kts | 49 +++++++++++++++++++ sentry-android-navigation3/proguard-rules.pro | 7 +++ settings.gradle.kts | 1 + 4 files changed, 58 insertions(+) create mode 100644 sentry-android-navigation3/build.gradle.kts create mode 100644 sentry-android-navigation3/proguard-rules.pro diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 09d2869988b..7420f5fbeb5 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -98,6 +98,7 @@ object Config { "sentry-android-ndk", "sentry-android-fragment", "sentry-android-navigation", + "sentry-android-navigation3", "sentry-android-timber", "sentry-compose-android", "sentry-android-sqlite", diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts new file mode 100644 index 00000000000..e16f145c3df --- /dev/null +++ b/sentry-android-navigation3/build.gradle.kts @@ -0,0 +1,49 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.detekt) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.compose.navigation3" + + defaultConfig { + minSdk = 23 // Nav3 requires minSdk 23 + } + + buildTypes { + getByName("debug") { consumerProguardFiles("proguard-rules.pro") } + getByName("release") { consumerProguardFiles("proguard-rules.pro") } + } + + kotlin { + compilerOptions.jvmTarget = JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +kotlin { explicitApi() } + +tasks.withType().configureEach { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-navigation3/proguard-rules.pro b/sentry-android-navigation3/proguard-rules.pro new file mode 100644 index 00000000000..244282115a5 --- /dev/null +++ b/sentry-android-navigation3/proguard-rules.pro @@ -0,0 +1,7 @@ +##---------------Begin: proguard configuration for Compose ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for Compose ---------- diff --git a/settings.gradle.kts b/settings.gradle.kts index 665e85477df..d77f01ac01d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,6 +51,7 @@ include( "sentry-android-timber", "sentry-android-fragment", "sentry-android-navigation", + "sentry-android-navigation3", "sentry-android-sqlite", "sentry-android-replay", "sentry-compose", From c9f806c17a0694d96e254dea98be42a6b1e460df Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 12 Sep 2026 07:00:33 +0200 Subject: [PATCH 3/5] feat(android-nav3): Model navigation routes Introduce the route translation layer for Navigation 3 so later stages can reason about route names and sanitized arguments in one place. At this stage the module still exposes no meaningful public API surface. --- gradle/libs.versions.toml | 1 + .../api/sentry-android-navigation3.api | 0 sentry-android-navigation3/build.gradle.kts | 16 + .../compose/navigation3/RouteResolvers.kt | 25 ++ .../compose/navigation3/RouteTranslator.kt | 308 ++++++++++++++++++ .../compose/navigation3/RouteResolversTest.kt | 88 +++++ .../navigation3/RouteTranslatorTest.kt | 303 +++++++++++++++++ 7 files changed, 741 insertions(+) create mode 100644 sentry-android-navigation3/api/sentry-android-navigation3.api create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteResolvers.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteTranslator.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteResolversTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteTranslatorTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbe9ef0177a..ad2e5624b9d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,6 +90,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidxCompose" } androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidxCompose" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.4.0" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidxCompose" } androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version="1.7.8" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version="1.7.8" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } diff --git a/sentry-android-navigation3/api/sentry-android-navigation3.api b/sentry-android-navigation3/api/sentry-android-navigation3.api new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index e16f145c3df..0a5bdba6306 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -22,6 +22,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = JVM_1_8 compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 @@ -43,6 +47,18 @@ android { kotlin { explicitApi() } +dependencies { + implementation(projects.sentry) + + compileOnly(libs.androidx.compose.runtime) + + testImplementation(libs.androidx.compose.runtime) + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) +} + tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteResolvers.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteResolvers.kt new file mode 100644 index 00000000000..509c396b691 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteResolvers.kt @@ -0,0 +1,25 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.snapshots.Snapshot + +/** + * Holds host app-defined extractors, which convert a back stack entry of type [T] into a route name + * and a map of zero or more route arguments. Extracted values are eventually grouped into + * [RouteEntry]s for display. + * + * Extractor invocations are hidden from Compose snapshot observation so they don't impact + * invalidation of the recompose scope that reads them. + */ +internal class RouteResolvers( + val nameExtractor: ((T) -> String)?, + val argumentsExtractor: ((T) -> Map)?, +) { + + fun getName(backStackEntry: T): String? = Snapshot.withoutReadObservation { + nameExtractor?.invoke(backStackEntry) + } + + fun getArguments(backStackEntry: T): Map? = Snapshot.withoutReadObservation { + argumentsExtractor?.invoke(backStackEntry) + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteTranslator.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteTranslator.kt new file mode 100644 index 00000000000..b4d6c3367d4 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteTranslator.kt @@ -0,0 +1,308 @@ +package io.sentry.compose.navigation3 + +import io.sentry.ILogger +import io.sentry.SentryLevel.WARNING +import io.sentry.util.ExceptionUtils +import java.util.IdentityHashMap + +/** Translates app-defined back stack entries into displayable [RouteEntry]s. */ +internal class RouteTranslator( + private val resolvers: () -> RouteResolvers, + private val maxCapturedBackStackEntries: Int, + private val logger: ILogger, +) { + + internal class UpdateWarningState { + private var hasLoggedUnsupportedValueWarning = false + + fun logUnsupportedValueWarning(typeName: String?, logger: ILogger) { + if (hasLoggedUnsupportedValueWarning) { + return + } + + logger.log( + WARNING, + "Nav3 argumentsExtractor returned unsupported value of type %s while processing this back " + + "stack update. Falling back to toString(). Use String, CharSequence, Char, Number, " + + "Boolean, Enum, Map, Collection, object Array, and primitive array values for reliable " + + "results.", + typeName, + ) + hasLoggedUnsupportedValueWarning = true + } + } + + companion object { + + /** + * Max nesting depth allowed while sanitizing a single argument value for a given back stack + * entry. + * + * If exceeded, all arguments for that back stack entry are dropped. + */ + private const val MAX_ARGUMENT_DEPTH = 20 + + /** + * Max number of argument values visited while sanitizing all entries in a given back stack + * update. + * + * If exceeded, the entry that overflows loses its arguments, as do older entries; newer entries + * are preserved. E.g., suppose we have the following back stack: + * + * - /Checkout -> Top of the stack and processed first + * - /ProductDetail -> Processed second and overflows the `MAX_ARGUMENT_VALUES` budget + * - /Home + * + * Then /ProductDetail and /Home will have no arguments, but /Checkout will. + */ + private const val MAX_ARGUMENT_VALUES = 1_000 + } + + /** Converts the provided [backStack] into a serializable list of [RouteEntry]s. */ + fun toRouteEntries( + backStack: List, + updateWarningState: UpdateWarningState = UpdateWarningState(), + ): List { + val state = ArgumentSanitizationState() + + return backStack.takeLast(maxCapturedBackStackEntries).asReversed().map { entry -> + buildMap { + put("route", resolveRouteName(entry)) + + val args = + if (state.isValueBudgetExceeded()) { + emptyMap() + } else { + resolveArguments(entry, state, updateWarningState) + } + if (args.isNotEmpty()) { + put("args", args) + } + } + } + } + + /** + * Returns a route name for the provided [backStackEntry], based on this translator's + * [name extractor][RouteResolvers.nameExtractor]. + * + * The returned name is normalized to always include a leading slash. E.g., both `PromoDialog` and + * `/PromoDialog` are resolved to `/PromoDialog`. (Doing so maintains parity with our Nav2 + * convention.) + */ + @Suppress("TooGenericExceptionCaught") + fun resolveRouteName(backStackEntry: T): String { + val name = + try { + resolvers.invoke().getName(backStackEntry) + } catch (t: Throwable) { + ExceptionUtils.rethrowIfFatal(t) + logger.log( + WARNING, + "Nav3 nameExtractor threw while resolving a route name. Falling back to class simpleName.", + t, + ) + null + } ?: backStackEntry::class.simpleName ?: "unknown" + + return "/${name.removePrefix("/")}" + } + + /** + * Returns the arguments for the provided [backStackEntry], based on this translator's + * [arguments extractor][RouteResolvers.argumentsExtractor]. + * + * The arguments are sanitized before being returned, i.e., bounded in size and depth, and + * converted into a serializable form. + */ + @Suppress("TooGenericExceptionCaught") + fun resolveArguments( + backStackEntry: T, + updateWarningState: UpdateWarningState = UpdateWarningState(), + ): Map = + resolveArguments(backStackEntry, ArgumentSanitizationState(), updateWarningState) + + @Suppress("TooGenericExceptionCaught") + private fun resolveArguments( + backStackEntry: T, + state: ArgumentSanitizationState, + updateWarningState: UpdateWarningState, + ): Map { + val raw = + try { + resolvers.invoke().getArguments(backStackEntry) ?: return emptyMap() + } catch (t: Throwable) { + ExceptionUtils.rethrowIfFatal(t) + logger.log( + WARNING, + "Nav3 argumentsExtractor threw while resolving arguments. Skipping arguments.", + t, + ) + return emptyMap() + } + + return try { + sanitizeArguments(raw, state, updateWarningState) + } catch (_: ArgumentValueBudgetExceededException) { + logger.log( + WARNING, + "Nav3 arguments exceeded the maximum total value count for one backstack update. " + + "Skipping arguments for this and older captured entries.", + ) + emptyMap() + } catch (_: ArgumentStructureException) { + logger.log( + WARNING, + "Nav3 argument sanitization failed (possibly a cyclic or deeply nested structure). " + + "Skipping arguments.", + ) + emptyMap() + } catch (t: Throwable) { + ExceptionUtils.rethrowIfFatal(t) + logger.log( + WARNING, + "Nav3 argument sanitization failed (possibly a cyclic or deeply nested structure). " + + "Skipping arguments.", + t, + ) + emptyMap() + } + } + + private fun sanitizeArguments( + args: Map, + state: ArgumentSanitizationState, + updateWarningState: UpdateWarningState, + ): Map = sanitizeMap(args, state, depth = 0, updateWarningState) + + private fun sanitizeMap( + value: Map<*, *>, + state: ArgumentSanitizationState, + depth: Int, + updateWarningState: UpdateWarningState, + ): Map { + state.enter(value) + try { + val sanitized = LinkedHashMap() + for ((key, childValue) in value) { + sanitized[key.toString()] = sanitizeValue(childValue, state, depth + 1, updateWarningState) + } + return sanitized + } finally { + state.exit(value) + } + } + + private fun sanitizeCollection( + value: Collection<*>, + state: ArgumentSanitizationState, + depth: Int, + updateWarningState: UpdateWarningState, + ): List { + state.enter(value) + try { + val sanitized = ArrayList() + for (childValue in value) { + sanitized += sanitizeValue(childValue, state, depth + 1, updateWarningState) + } + return sanitized + } finally { + state.exit(value) + } + } + + private fun sanitizeValue( + value: Any?, + state: ArgumentSanitizationState, + depth: Int, + updateWarningState: UpdateWarningState, + ): Any? { + state.visit(depth) + val collection = value?.asSanitizableCollectionOrNull() + + return when { + value == null || value is String || value is Number || value is Boolean -> value + value is CharSequence || value is Char -> value.toString() + value is Enum<*> -> value.name + value is Map<*, *> -> sanitizeMap(value, state, depth, updateWarningState) + collection != null -> sanitizeCollection(collection, state, depth, updateWarningState) + else -> { + updateWarningState.logUnsupportedValueWarning(value::class.simpleName, logger) + value.toString() + } + } + } + + private fun Any.asSanitizableCollectionOrNull(): Collection<*>? = + when (this) { + is Collection<*> -> this + is Array<*> -> asList() + is BooleanArray -> asList() + is ByteArray -> asList() + is ShortArray -> asList() + is IntArray -> asList() + is LongArray -> asList() + is FloatArray -> asList() + is DoubleArray -> asList() + is CharArray -> asList() + else -> null + } + + private class ArgumentSanitizationState { + + private val activeContainers = IdentityHashMap() + private var valueCount = 0 + private var valueBudgetExceeded = false + + fun visit(depth: Int) { + if (depth > MAX_ARGUMENT_DEPTH) { + throw ArgumentStructureException("Nav3 arguments exceed the maximum depth") + } + if (++valueCount > MAX_ARGUMENT_VALUES) { + valueBudgetExceeded = true + throw ArgumentValueBudgetExceededException( + "Nav3 arguments exceed the maximum total value count for one backstack update" + ) + } + } + + fun enter(container: Any) { + if (activeContainers.put(container, Unit) != null) { + throw ArgumentStructureException("Nav3 arguments contain a cyclic reference") + } + } + + fun exit(container: Any) { + activeContainers.remove(container) + } + + fun isValueBudgetExceeded(): Boolean = valueBudgetExceeded + } + + private open class ArgumentSanitizationException(message: String) : + IllegalArgumentException(message) + + private class ArgumentStructureException(message: String) : ArgumentSanitizationException(message) + + private class ArgumentValueBudgetExceededException(message: String) : + ArgumentSanitizationException(message) +} + +/** + * A map consisting of a single route <> route name pair, and zero or more argument pairs. + * + * E.g., in serialized form: + * ``` + * { + * "route": "/ProductScreen" + * "args": { + * "product_id": 12345 + * "promo_id:": "spring-marketing-drive-2026" + * } + * } + * ``` + * + * By convention, all route names are normalized to include a leading slash, and all arguments are + * sanitized (i.e., bounded in size and depth, and converted into a serializable form). + */ +internal typealias RouteEntry = Map diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteResolversTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteResolversTest.kt new file mode 100644 index 00000000000..f74f0816424 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteResolversTest.kt @@ -0,0 +1,88 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RouteResolversTest { + + private data class HomeRoute(val id: String = "home") + + private data class ProfileRoute(val userId: String) + + @Test + fun `getName returns null when no name extractor is configured`() { + val sut = RouteResolvers(nameExtractor = null, argumentsExtractor = null) + + assertNull(sut.getName(HomeRoute())) + } + + @Test + fun `getArguments returns null when no arguments extractor is configured`() { + val sut = RouteResolvers(nameExtractor = null, argumentsExtractor = null) + + assertNull(sut.getArguments(HomeRoute())) + } + + @Test + fun `getName delegates to the configured extractor`() { + val route = ProfileRoute("123") + val sut = + RouteResolvers( + nameExtractor = { entry -> "profile-${entry.userId}" }, + argumentsExtractor = null, + ) + + assertEquals("profile-123", sut.getName(route)) + } + + @Test + fun `getArguments delegates to the configured extractor`() { + val route = ProfileRoute("123") + val sut = + RouteResolvers( + nameExtractor = null, + argumentsExtractor = { entry -> mapOf("userId" to entry.userId) }, + ) + + assertThat(sut.getArguments(route)).isEqualTo(mapOf("userId" to "123")) + } + + @Test + fun `getName hides extractor reads from snapshot observation`() { + val routeName = mutableStateOf("home") + val sut = + RouteResolvers( + nameExtractor = { routeName.value }, + argumentsExtractor = null, + ) + + assertEquals(0, observeReads { sut.getName(HomeRoute()) }) + } + + @Test + fun `getArguments hides extractor reads from snapshot observation`() { + val argumentValue = mutableStateOf("123") + val sut = + RouteResolvers( + nameExtractor = null, + argumentsExtractor = { mapOf("userId" to argumentValue.value) }, + ) + + assertEquals(0, observeReads { sut.getArguments(HomeRoute()) }) + } + + private fun observeReads(block: () -> Unit): Int { + var reads = 0 + val snapshot = Snapshot.takeSnapshot(readObserver = { reads++ }) + try { + snapshot.enter(block) + } finally { + snapshot.dispose() + } + return reads + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteTranslatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteTranslatorTest.kt new file mode 100644 index 00000000000..a5d3040cf0b --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteTranslatorTest.kt @@ -0,0 +1,303 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import io.sentry.SentryLevel.WARNING +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +class RouteTranslatorTest { + + private data class HomeRoute(val id: String = "home") + + private data class ProfileRoute(val userId: String) + + private data class SettingsRoute(val section: String) + + private enum class PrivacyMode { + PUBLIC, + PRIVATE, + } + + private val logger = mock() + + private fun getSut( + nameExtractor: ((Any) -> String)? = null, + argumentsExtractor: ((Any) -> Map)? = null, + maxCapturedBackStackEntries: Int = 30, + ): RouteTranslator = + RouteTranslator( + resolvers = { RouteResolvers(nameExtractor, argumentsExtractor) }, + maxCapturedBackStackEntries = maxCapturedBackStackEntries, + logger = logger, + ) + + @Test + fun `toRouteEntries returns the newest captured entries first`() { + val sut = getSut(maxCapturedBackStackEntries = 2) + + val stack = + sut.toRouteEntries(listOf(HomeRoute(), ProfileRoute("123"), SettingsRoute("privacy"))) + + assertThat(stack.map { it["route"] }) + .containsExactly("/SettingsRoute", "/ProfileRoute") + .inOrder() + } + + @Test + fun `toRouteEntries preserves newer entry arguments when the shared budget overflows`() { + val sut = + getSut( + argumentsExtractor = { key -> + when (key) { + is HomeRoute -> mapOf("home" to true) + is ProfileRoute -> mapOf("values" to List(999) { it }) + is SettingsRoute -> mapOf("section" to key.section) + else -> emptyMap() + } + } + ) + + val stack = + sut.toRouteEntries(listOf(HomeRoute(), ProfileRoute("123"), SettingsRoute("privacy"))) + + assertThat(stack).hasSize(3) + assertThat(stack[0]) + .isEqualTo(mapOf("route" to "/SettingsRoute", "args" to mapOf("section" to "privacy"))) + assertThat(stack[1]["route"]).isEqualTo("/ProfileRoute") + assertNull(stack[1]["args"]) + assertThat(stack[2]["route"]).isEqualTo("/HomeRoute") + assertNull(stack[2]["args"]) + } + + @Test + fun `toRouteEntries returns no entries when capture limit is zero`() { + val sut = getSut(maxCapturedBackStackEntries = 0) + + assertThat(sut.toRouteEntries(listOf(HomeRoute(), ProfileRoute("123")))).isEmpty() + } + + @Test + fun `resolveRouteName normalizes a custom name with a leading slash`() { + val sut = getSut(nameExtractor = { "profile" }) + + assertEquals("/profile", sut.resolveRouteName(ProfileRoute("123"))) + } + + @Test + fun `resolveRouteName leaves leading slash on custom name if already present`() { + val sut = getSut(nameExtractor = { "/profile" }) + + assertEquals("/profile", sut.resolveRouteName(ProfileRoute("123"))) + } + + @Test + fun `resolveRouteName falls back to class simple name when no name extractor is configured`() { + val sut = getSut() + + assertEquals("/HomeRoute", sut.resolveRouteName(HomeRoute())) + } + + @Test + fun `resolveRouteName falls back to class simple name when name extractor throws`() { + val sut = getSut(nameExtractor = { error("boom") }) + + assertEquals("/HomeRoute", sut.resolveRouteName(HomeRoute())) + verify(logger) + .log( + eq(WARNING), + eq( + "Nav3 nameExtractor threw while resolving a route name. Falling back to class simpleName." + ), + org.mockito.kotlin.any(), + ) + } + + @Test + fun `resolveArguments returns supported values in serializable form`() { + val sut = + getSut( + argumentsExtractor = { _ -> + val text = StringBuilder("hello") + mapOf( + "str" to "hello", + "charSequence" to text, + "char" to 'x', + "num" to 42, + "bool" to true, + "enum" to PrivacyMode.PRIVATE, + "nil" to null, + "nested" to mapOf("inner" to "value"), + "tags" to listOf("a", "b", "c"), + "array" to arrayOf("a", 1, false, PrivacyMode.PUBLIC, 'z'), + "ints" to intArrayOf(1, 2, 3), + "chars" to charArrayOf('a', 'b'), + "bytes" to byteArrayOf(4, 5), + ) + } + ) + + assertThat(sut.resolveArguments(HomeRoute())) + .isEqualTo( + mapOf( + "str" to "hello", + "charSequence" to "hello", + "char" to "x", + "num" to 42, + "bool" to true, + "enum" to "PRIVATE", + "nil" to null, + "nested" to mapOf("inner" to "value"), + "tags" to listOf("a", "b", "c"), + "array" to listOf("a", 1, false, "PUBLIC", "z"), + "ints" to listOf(1, 2, 3), + "chars" to listOf("a", "b"), + "bytes" to listOf(4.toByte(), 5.toByte()), + ) + ) + } + + @Test + fun `resolveArguments sanitizes nested supported containers recursively`() { + val sut = + getSut( + argumentsExtractor = { _ -> + mapOf( + "nested" to + mapOf( + "items" to + arrayOf( + StringBuilder("x"), + listOf('y', PrivacyMode.PRIVATE), + booleanArrayOf(true, false), + charArrayOf('q'), + ) + ) + ) + } + ) + + assertThat(sut.resolveArguments(HomeRoute())) + .isEqualTo( + mapOf( + "nested" to + mapOf("items" to listOf("x", listOf("y", "PRIVATE"), listOf(true, false), listOf("q"))) + ) + ) + } + + @Test + fun `resolveArguments coerces unsupported values to strings`() { + class OpaqueValue { + override fun toString(): String = "opaque-value" + } + + val sut = getSut(argumentsExtractor = { _ -> mapOf("bad" to OpaqueValue()) }) + + assertThat(sut.resolveArguments(HomeRoute())).isEqualTo(mapOf("bad" to "opaque-value")) + } + + @Test + fun `unsupported value warning is logged once per shared update state across direct and batch reads`() { + class OpaqueValue { + override fun toString(): String = "opaque-value" + } + + val sut = getSut(argumentsExtractor = { _ -> mapOf("bad" to OpaqueValue()) }) + val updateWarningState = RouteTranslator.UpdateWarningState() + + sut.resolveArguments(HomeRoute(), updateWarningState) + sut.toRouteEntries(listOf(HomeRoute(), ProfileRoute("123")), updateWarningState) + + verify(logger, times(1)) + .log( + eq(WARNING), + eq( + "Nav3 argumentsExtractor returned unsupported value of type %s while processing this " + + "back stack update. Falling back to toString(). Use String, CharSequence, Char, " + + "Number, Boolean, Enum, Map, Collection, object Array, and primitive array values " + + "for reliable results." + ), + eq("OpaqueValue"), + ) + } + + @Test + fun `unsupported value warning can recur with a fresh update state`() { + class OpaqueValue { + override fun toString(): String = "opaque-value" + } + + val sut = getSut(argumentsExtractor = { _ -> mapOf("bad" to OpaqueValue()) }) + + sut.resolveArguments(HomeRoute(), RouteTranslator.UpdateWarningState()) + clearInvocations(logger) + + sut.resolveArguments(HomeRoute(), RouteTranslator.UpdateWarningState()) + + verify(logger, times(1)) + .log( + eq(WARNING), + eq( + "Nav3 argumentsExtractor returned unsupported value of type %s while processing this " + + "back stack update. Falling back to toString(). Use String, CharSequence, Char, " + + "Number, Boolean, Enum, Map, Collection, object Array, and primitive array values " + + "for reliable results." + ), + eq("OpaqueValue"), + ) + } + + @Test + fun `resolveArguments returns empty if no arguments extractor`() { + val sut = getSut(argumentsExtractor = null) + + assertThat(sut.resolveArguments(HomeRoute())).isEmpty() + } + + @Test + fun `resolveArguments returns empty when arguments extractor throws`() { + val sut = getSut(argumentsExtractor = { error("boom") }) + + assertThat(sut.resolveArguments(HomeRoute())).isEmpty() + verify(logger) + .log( + eq(WARNING), + eq("Nav3 argumentsExtractor threw while resolving arguments. Skipping arguments."), + org.mockito.kotlin.any(), + ) + } + + @Test + fun `resolveArguments returns empty for cyclic structures`() { + val cyclic = mutableMapOf() + cyclic["self"] = cyclic + + val sut = getSut(argumentsExtractor = { _ -> mapOf("cyclic" to cyclic) }) + + assertThat(sut.resolveArguments(HomeRoute())).isEmpty() + } + + @Test + fun `resolveArguments returns empty for deeply nested structures`() { + var nested: Any? = "value" + repeat(25) { nested = listOf(nested) } + + val sut = getSut(argumentsExtractor = { _ -> mapOf("nested" to nested) }) + + assertThat(sut.resolveArguments(ProfileRoute("123"))).isEmpty() + } + + @Test + fun `resolveArguments drops oversized payloads instead of truncating them`() { + val sut = getSut(argumentsExtractor = { _ -> mapOf("values" to List(1_001) { it }) }) + + assertThat(sut.resolveArguments(HomeRoute())).isEmpty() + } +} From d1870f0e7474da745c41f5dcead041e17fd01058 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 12 Sep 2026 07:01:51 +0200 Subject: [PATCH 4/5] feat(android-nav3): Track navigation state transitions Add the observer layer that turns Navigation 3 back stack changes into breadcrumbs, scope state, and navigation transactions. This is also the point where the module starts carrying generated BuildConfig version metadata and its first non-empty API snapshot. --- .../api/sentry-android-navigation3.api | 8 + sentry-android-navigation3/build.gradle.kts | 9 + .../compose/navigation3/BackStackObserver.kt | 351 +++++++++++++++ .../compose/navigation3/SentryNavOptions.kt | 63 +++ .../navigation3/BackStackObserverTest.kt | 402 ++++++++++++++++++ .../navigation3/SentryNavOptionsTest.kt | 107 +++++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/TypeCheckHint.java | 3 + 8 files changed, 944 insertions(+) create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavOptionsTest.kt diff --git a/sentry-android-navigation3/api/sentry-android-navigation3.api b/sentry-android-navigation3/api/sentry-android-navigation3.api index e69de29bb2d..be90eab5cf0 100644 --- a/sentry-android-navigation3/api/sentry-android-navigation3.api +++ b/sentry-android-navigation3/api/sentry-android-navigation3.api @@ -0,0 +1,8 @@ +public final class io/sentry/compose/navigation3/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index 0a5bdba6306..09c293830b0 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -15,6 +15,9 @@ android { defaultConfig { minSdk = 23 // Nav3 requires minSdk 23 + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } buildTypes { @@ -32,6 +35,10 @@ android { compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } + testOptions { + unitTests.isReturnDefaultValues = true + } + lint { warningsAsErrors = true checkDependencies = true @@ -40,6 +47,8 @@ android { checkReleaseBuilds = false } + buildFeatures { buildConfig = true } + androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt new file mode 100644 index 00000000000..b4b736fe577 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt @@ -0,0 +1,351 @@ +package io.sentry.compose.navigation3 + +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.INFO +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.TypeCheckHint +import io.sentry.protocol.App +import io.sentry.protocol.TransactionNameSource +import io.sentry.util.ExceptionUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import java.lang.ref.WeakReference + +/** + * Observes the back stack managed by a single [SentryNavEffect] and records Sentry state as the + * back stack is updated. + * + * **Warning!** This class is not thread-safe. Clients should serialize calls to + * [onBackStackChanged] and [cleanup] (e.g., via invocation from an `*Effect` or another form of + * thread confinement). + */ +internal class BackStackObserver( + private val scopes: IScopes, + private val options: SentryNavOptions, + private val resolvers: () -> RouteResolvers, +) { + + private val routeTranslator = + RouteTranslator(resolvers, options.maxCapturedBackStackEntries, scopes.options.logger) + + private var previousBackStackEntry: WeakReference? = null + private val screenTracker = ScreenTracker() + + private val areNavigationTransactionsEnabled: Boolean + get() = scopes.options.isTracingEnabled && options.enableNavigationTransactions + + private val navTransactions = NavTransactionManager(scopes, NAVIGATION_OP, TRANSACTION_ORIGIN) + + init { + addIntegrationToSdkVersion("ComposeNavigation3") + } + + internal companion object { + + private const val NAVIGATION_CONTEXT_KEY = "navigation" + private const val NAVIGATION_OP: String = "navigation" + private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" + + init { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME) + } + } + + /** + * Updates recorded Sentry data based on the provided [backStack]. + * + * Note: This method is **not** idempotent. Callers should protect against repeat invocations with + * the same back stack. + */ + internal fun onBackStackChanged(backStack: List) { + val updateWarningState = RouteTranslator.UpdateWarningState() + + guard("onBackStackChanged") { + scopes.configureScope { scope -> + // Always update the recorded backstack, as any of its entries may have changed. + scope.updateNavigationContext(backStack, options, updateWarningState) + + // Return early if there's nowhere to go or if the top of the back stack hasn't changed... + val previousTop: T? = previousBackStackEntry?.get() + val currentTop: T? = backStack.lastOrNull() + + if (currentTop == null) { + handleEmptyBackStack(scope) + return@configureScope + } + if (previousTop === currentTop) { + return@configureScope + } + + // ...otherwise record data for the new nav destination. + handleNewTop(scope, previousTop, currentTop, backStack, updateWarningState) + } + } + } + + internal fun cleanup() { + previousBackStackEntry = null + + scopes.configureScope { scope -> + navTransactions.stop(scope) + screenTracker.clear(scope) + + if (options.captureBackStack) { + // This observer owns the Nav3 navigation context while it's in the composition, and cleanup + // removes it to avoid leaking stale back stack data after observation stops. If the host + // app replaces one observer with another, there may be a brief gap where events lack + // navigation context. Apps should keep the observer at the nav root so cleanup only runs + // when the navigation session is ending, not during normal destination changes. + scope.removeContexts(NAVIGATION_CONTEXT_KEY) + } + } + } + + private fun handleNewTop( + scope: IScope, + previousTop: T?, + currentTop: T, + backStack: List, + updateWarningState: RouteTranslator.UpdateWarningState, + ) { + val routeName = routeTranslator.resolveRouteName(currentTop) + val arguments = routeTranslator.resolveArguments(currentTop, updateWarningState) + + if (scopes.options.isEnableScreenTracking) { + screenTracker.track(scope, routeName) + } + + if (options.enableNavigationBreadcrumbs) { + scopes.addNav3Breadcrumb(previousTop, currentTop, routeName, arguments, updateWarningState) + } + + navTransactions.stop(scope) + + if (areNavigationTransactionsEnabled) { + navTransactions.start(routeName, arguments) { transaction -> + transaction.snapshotTrackedNavigationContext(routeName, backStack, updateWarningState) + } + } else { + scope.rotatePropagationContext() + } + + previousBackStackEntry = WeakReference(currentTop) + } + + private fun handleEmptyBackStack(scope: IScope) { + navTransactions.stop(scope) + screenTracker.clear(scope) + previousBackStackEntry = null + } + + private fun IScope.updateNavigationContext( + backStack: List, + options: SentryNavOptions, + updateWarningState: RouteTranslator.UpdateWarningState, + ) { + if (!options.captureBackStack) { + this.removeContexts(NAVIGATION_CONTEXT_KEY) + return + } + + val entries = routeTranslator.toRouteEntries(backStack, updateWarningState) + if (entries.isEmpty()) { + this.removeContexts(NAVIGATION_CONTEXT_KEY) + } else { + this.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("backstack" to entries)) + } + } + + /** + * Snapshots the current route and back stack onto the transaction itself so late-finishing child + * spans cannot cause the event to inherit newer scope state from a later navigation update. + */ + private fun ITransaction.snapshotTrackedNavigationContext( + routeName: String, + backStack: List, + updateWarningState: RouteTranslator.UpdateWarningState, + ) { + val appContext = contexts.app ?: App().also { contexts.setApp(it) } + appContext.viewNames = listOf(routeName) + + if (options.captureBackStack) { + val entries = routeTranslator.toRouteEntries(backStack, updateWarningState) + if (entries.isNotEmpty()) { + setContext(NAVIGATION_CONTEXT_KEY, mapOf("backstack" to entries)) + } + } + } + + private fun IScope.rotatePropagationContext() { + withPropagationContext { setPropagationContext(PropagationContext()) } + } + + private fun IScopes.addNav3Breadcrumb( + fromEntry: T?, + toEntry: T, + routeName: String, + arguments: Map, + updateWarningState: RouteTranslator.UpdateWarningState, + ) { + val breadcrumb = + Breadcrumb().apply { + type = NAVIGATION_OP + category = NAVIGATION_OP + + fromEntry?.let { prev -> + data["from"] = routeTranslator.resolveRouteName(prev) + val fromArgs = routeTranslator.resolveArguments(prev, updateWarningState) + if (fromArgs.isNotEmpty()) { + data["from_arguments"] = fromArgs + } + } + + data["to"] = routeName + if (arguments.isNotEmpty()) { + data["to_arguments"] = arguments + } + + level = INFO + } + + val hint = Hint() + hint.set(TypeCheckHint.NAV3_DESTINATION, toEntry) + this.addBreadcrumb(breadcrumb, hint) + } + + @Suppress("TooGenericExceptionCaught") + private inline fun guard(operation: String, body: () -> Unit) { + try { + body() + } catch (t: Throwable) { + ExceptionUtils.rethrowIfFatal(t) + scopes.options.logger.log( + ERROR, + t, + "Nav3 instrumentation failed during %s. Skipping this navigation update.", + operation, + ) + } + } +} + +private class ScreenTracker { + + private var lastScreenRouteName: String? = null + + /** Tracks the provided [routeName] as the current visible screen. */ + fun track(scope: IScope, routeName: String) { + scope.screen = routeName + val app = scope.contexts.app ?: App().also { scope.contexts.setApp(it) } + app.viewNames = listOf(routeName) + lastScreenRouteName = routeName + } + + fun clear(scope: IScope) { + val routeName = lastScreenRouteName ?: return + if (scope.screen == routeName) { + scope.screen = null + } + if (scope.contexts.app?.viewNames == listOf(routeName)) { + scope.contexts.app?.viewNames = null + } + lastScreenRouteName = null + } +} + +private class NavTransactionManager( + private val scopes: IScopes, + private val navigationOp: String, + private val transactionOrigin: String, +) { + + private var activeNavTransaction: ITransaction? = null + + /** Starts an idle navigation transaction, or no-ops if another span context is already active. */ + fun start( + routeName: String, + arguments: Map, + snapshot: (ITransaction) -> Unit, + ) { + clearFinishedScopeTransaction() + + if (scopes.span != null) { + scopes.options.logger.log( + DEBUG, + "Nav3 transaction for route %s won't be created because another transaction or span is active.", + routeName, + ) + + return + } + + val transactionOptions = + TransactionOptions().also { + it.isWaitForChildren = true + it.idleTimeout = scopes.options.idleTimeout + val deadlineTimeoutMillis = scopes.options.deadlineTimeout + it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis + it.isTrimEnd = true + } + + val transaction = + scopes.startTransaction( + TransactionContext(routeName, TransactionNameSource.ROUTE, navigationOp), + transactionOptions, + ) + + activeNavTransaction = transaction + + transaction.apply { + spanContext.origin = transactionOrigin + if (arguments.isNotEmpty()) { + setData("arguments", arguments) + } + snapshot(this) + } + + scopes.configureScope { scope -> + scope.withTransaction { tx -> + if (tx == null) { + scope.transaction = transaction + } + } + } + } + + /** Finishes and unsets the active navigation transaction, if one exists. */ + fun stop(scope: IScope) { + val transaction = activeNavTransaction ?: return + val status = transaction.status ?: SpanStatus.OK + transaction.finish(status) + + scope.withTransaction { tx -> + if (tx == transaction) { + scope.clearTransaction() + } + } + + activeNavTransaction = null + } + + /** Clears a stale finished transaction that's still bound to the default scope. */ + private fun clearFinishedScopeTransaction() { + scopes.configureScope { scope -> + scope.withTransaction { tx -> + if (tx?.isFinished == true) { + scope.clearTransaction() + } + } + } + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt new file mode 100644 index 00000000000..1bd9a231e0e --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt @@ -0,0 +1,63 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.Immutable + +// Keep the default low: every captured entry may require route-name extraction, argument +// extraction, and recursive argument sanitization when navigation changes are observed. +private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 10 + +/** Configuration info for a [SentryNavEffect]. */ +@Immutable +internal class SentryNavOptions( + /** + * Whether navigation should produce Sentry breadcrumbs. If `true`, a new nav destination + * generates a breadcrumb like `from=/Home` and `to=/Profile`. + */ + val enableNavigationBreadcrumbs: Boolean = true, + + /** + * Whether navigation should start a Sentry transaction. If `true`, navigating from `/Home` to + * `/Profile` starts a `/Profile` transaction and finishes the current `/Home` transaction. + */ + val enableNavigationTransactions: Boolean = true, + + /** + * Whether Sentry should record back stack information for inclusion with crashes, errors, and + * other captured events. If `true`, a stack like `/Home -> /Profile` is recorded alongside the + * event, ordered with the current/top entry first. + */ + val captureBackStack: Boolean = true, + + /** + * Maximum number of entries Sentry should record per captured back stack (starting with the most + * recent). Set to `0` to capture no back stack entries. + * + * Note: Sentry resolves and sanitizes up to [maxCapturedBackStackEntries] names + argument maps + * whenever your back stack changes. Keep name and argument extractors lightweight, and reduce the + * max captured count if extractor work is unusually expensive. + */ + val maxCapturedBackStackEntries: Int = DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES, +) { + + init { + require(maxCapturedBackStackEntries >= 0) { + "maxCapturedBackStackEntries must be non-negative, was $maxCapturedBackStackEntries" + } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is SentryNavOptions && + enableNavigationBreadcrumbs == other.enableNavigationBreadcrumbs && + enableNavigationTransactions == other.enableNavigationTransactions && + captureBackStack == other.captureBackStack && + maxCapturedBackStackEntries == other.maxCapturedBackStackEntries) + + override fun hashCode(): Int { + var result = enableNavigationBreadcrumbs.hashCode() + result = 31 * result + enableNavigationTransactions.hashCode() + result = 31 * result + captureBackStack.hashCode() + result = 31 * result + maxCapturedBackStackEntries + return result + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt new file mode 100644 index 00000000000..5f3c6f24843 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt @@ -0,0 +1,402 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.TypeCheckHint +import io.sentry.protocol.TransactionNameSource +import kotlin.test.Test +import kotlin.test.assertNull +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BackStackObserverTest { + + private data class HomeRoute(val id: String = "home") + + private data class ProfileRoute(val userId: String) + + private data class SettingsRoute(val section: String) + + private data class ObserverConfig( + val enableNavigationBreadcrumbs: Boolean = true, + val enableNavigationTransactions: Boolean = true, + val captureBackStack: Boolean = true, + val maxCapturedBackStackEntries: Int = 10, + val enableScreenTracking: Boolean = true, + ) + + private class Fixture { + val logger = mock() + val scope = Scope(createOptions(logger)) + val scopes = mock() + val breadcrumbs = mutableListOf() + val breadcrumbHints = mutableListOf() + val startedTransactions = mutableListOf() + + init { + whenever(scopes.options).thenReturn(scope.options) + whenever(scopes.getSpan()).thenAnswer { scope.span } + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + doAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, scopes, transactionOptions) + .also(startedTransactions::add) + } + .whenever(scopes) + .startTransaction(any(), any()) + doAnswer { + breadcrumbs += it.arguments[0] as Breadcrumb + breadcrumbHints += it.arguments[1] as Hint + null + } + .whenever(scopes) + .addBreadcrumb(any(), any()) + } + + fun getSut( + config: ObserverConfig = ObserverConfig(), + nameExtractor: ((Any) -> String)? = null, + argumentsExtractor: ((Any) -> Map)? = null, + ): BackStackObserver { + scope.options.isEnableScreenTracking = config.enableScreenTracking + + return BackStackObserver( + scopes = scopes, + options = + SentryNavOptions( + enableNavigationBreadcrumbs = config.enableNavigationBreadcrumbs, + enableNavigationTransactions = config.enableNavigationTransactions, + captureBackStack = config.captureBackStack, + maxCapturedBackStackEntries = config.maxCapturedBackStackEntries, + ), + resolvers = { RouteResolvers(nameExtractor, argumentsExtractor) }, + ) + } + + private companion object { + fun createOptions(logger: ILogger): SentryOptions = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + setTracesSampleRate(1.0) + isEnableScreenTracking = true + isDebug = true + setLogger(logger) + idleTimeout = null + deadlineTimeout = 0 + } + } + } + + @Test + fun `onBackStackChanged emits a breadcrumb for the top back stack entry when breadcrumbs are enabled`() { + val fixture = Fixture() + val sut = + fixture.getSut( + config = ObserverConfig(enableNavigationBreadcrumbs = true), + argumentsExtractor = { entry -> + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + }, + ) + val home = HomeRoute() + val profile = ProfileRoute("123") + + sut.onBackStackChanged(listOf(home)) + sut.onBackStackChanged(listOf(home, profile)) + + val breadcrumb = fixture.breadcrumbs.last() + assertThat(breadcrumb.type).isEqualTo("navigation") + assertThat(breadcrumb.category).isEqualTo("navigation") + assertThat(breadcrumb.data) + .containsExactly( + "from", + "/HomeRoute", + "from_arguments", + mapOf("tab" to "home"), + "to", + "/ProfileRoute", + "to_arguments", + mapOf("userId" to "123"), + ) + assertThat(fixture.breadcrumbHints.last().get(TypeCheckHint.NAV3_DESTINATION)) + .isSameInstanceAs(profile) + } + + @Test + fun `onBackStackChanged does not emit a breadcrumb when breadcrumbs are disabled`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(enableNavigationBreadcrumbs = false)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.breadcrumbs).isEmpty() + } + + @Test + fun `onBackStackChanged emits a screen name for the top back stack entry when screen tracking is enabled`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(enableScreenTracking = true)) + + sut.onBackStackChanged(listOf(HomeRoute(), ProfileRoute("123"))) + + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.contexts.app?.viewNames).isEqualTo(listOf("/ProfileRoute")) + } + + @Test + fun `onBackStackChanged does not emit a screen name when screen tracking is disabled`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(enableScreenTracking = false)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts.app?.viewNames).isNull() + } + + @Test + fun `onBackStackChanged emits a copy of the back stack up to max captured entries when enabled`() { + val fixture = Fixture() + val sut = + fixture.getSut( + config = ObserverConfig(captureBackStack = true, maxCapturedBackStackEntries = 2) + ) + + sut.onBackStackChanged(listOf(HomeRoute(), ProfileRoute("123"), SettingsRoute("privacy"))) + + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/SettingsRoute"), mapOf("route" to "/ProfileRoute"))) + } + + @Test + fun `onBackStackChanged emits an updated copy of the back stack even when the top entry is unchanged`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(captureBackStack = true)) + val home = HomeRoute() + val profile = ProfileRoute("123") + + sut.onBackStackChanged(listOf(home, profile)) + sut.onBackStackChanged(listOf(home, SettingsRoute("privacy"), profile)) + + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.startedTransactions).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "/ProfileRoute"), + mapOf("route" to "/SettingsRoute"), + mapOf("route" to "/HomeRoute"), + ) + ) + } + + @Test + fun `onBackStackChanged emits new top-entry data when the top entry is replaced by an equal new instance`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(captureBackStack = true)) + val home = HomeRoute() + val firstProfile = ProfileRoute("123") + val replacementProfile = ProfileRoute("123") + + sut.onBackStackChanged(listOf(home, firstProfile)) + sut.onBackStackChanged(listOf(home, replacementProfile)) + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data["from"]).isEqualTo("/ProfileRoute") + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.breadcrumbHints.last().get(TypeCheckHint.NAV3_DESTINATION)) + .isSameInstanceAs(replacementProfile) + assertThat(fixture.startedTransactions).hasSize(2) + assertThat(fixture.startedTransactions.last().name).isEqualTo("/ProfileRoute") + assertThat(fixture.startedTransactions.first().isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/ProfileRoute"), mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `onBackStackChanged does not emit a back stack copy when max captured entries is 0`() { + val fixture = Fixture() + val sut = + fixture.getSut( + config = ObserverConfig(captureBackStack = true, maxCapturedBackStackEntries = 0) + ) + fixture.scope.setContexts( + "navigation", + mapOf("backstack" to listOf(mapOf("route" to "/Stale"))), + ) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + } + + @Test + fun `onBackStackChanged does not emit a back stack copy when back stack capture is disabled`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(captureBackStack = false)) + fixture.scope.setContexts( + "navigation", + mapOf("backstack" to listOf(mapOf("route" to "/Stale"))), + ) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + } + + @Test + fun `onBackStackChanged creates a nav transaction when enabled and no ambient transaction is active`() { + val fixture = Fixture() + val sut = + fixture.getSut( + config = ObserverConfig(enableNavigationTransactions = true), + argumentsExtractor = { entry -> + when (entry) { + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + }, + ) + + sut.onBackStackChanged(listOf(HomeRoute(), ProfileRoute("123"))) + + val transaction = fixture.startedTransactions.single() + + assertThat(transaction.name).isEqualTo("/ProfileRoute") + assertThat(transaction.transactionNameSource).isEqualTo(TransactionNameSource.ROUTE) + assertThat(transaction.operation).isEqualTo("navigation") + assertThat(transaction.spanContext.origin).isEqualTo("auto.navigation.nav3") + assertThat(transaction.getData("arguments")).isEqualTo(mapOf("userId" to "123")) + assertThat(transaction.contexts.app?.viewNames).isEqualTo(listOf("/ProfileRoute")) + assertThat(transaction.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "/ProfileRoute", "args" to mapOf("userId" to "123")), + mapOf("route" to "/HomeRoute"), + ) + ) + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `onBackStackChanged does not create a nav transaction when an ambient span is active`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = true)) + + fixture.scope.setActiveSpan(mock()) + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + } + + @Test + fun `onBackStackChanged does not create a nav transaction when navigation transactions are disabled`() { + val fixture = Fixture() + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = false)) + val originalPropagationContext = fixture.scope.propagationContext + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(originalPropagationContext) + } + + @Test + fun `onBackStackChanged clears a finished stale scope transaction before starting a fresh nav transaction`() { + val fixture = Fixture() + val staleTransaction = + SentryTracer( + TransactionContext("stale", TransactionNameSource.CUSTOM, "ui.load"), + fixture.scopes, + ) + staleTransaction.finish() + fixture.scope.transaction = staleTransaction + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = true)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.startedTransactions).hasSize(1) + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + } + + @Test + fun `onBackStackChanged clears tracked scope state when the back stack becomes empty`() { + val fixture = Fixture() + val sut = fixture.getSut() + + sut.onBackStackChanged(listOf(HomeRoute())) + val transaction = fixture.startedTransactions.single() + + sut.onBackStackChanged(emptyList()) + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertNull(fixture.scope.contexts.app?.viewNames) + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + assertThat(fixture.breadcrumbs).hasSize(1) + } + + @Test + fun `cleanup clears observer owned tracked state`() { + val fixture = Fixture() + val sut = fixture.getSut() + + sut.onBackStackChanged(listOf(HomeRoute())) + val transaction = fixture.startedTransactions.single() + + sut.cleanup() + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertNull(fixture.scope.contexts.app?.viewNames) + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + } + + private fun IScope.navigationBackStack(): List>? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + + @Suppress("UNCHECKED_CAST") + return navigationContext[BACKSTACK_KEY] as? List> + } + + private fun ITransaction.navigationBackStack(): List>? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + + @Suppress("UNCHECKED_CAST") + return navigationContext[BACKSTACK_KEY] as? List> + } + + private companion object { + const val NAVIGATION_CONTEXT_KEY = "navigation" + const val BACKSTACK_KEY = "backstack" + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavOptionsTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavOptionsTest.kt new file mode 100644 index 00000000000..88bf20c32e4 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavOptionsTest.kt @@ -0,0 +1,107 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import java.lang.reflect.Modifier +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class SentryNavOptionsTest { + + @Test + fun `accepts positive max captured backstack entries`() { + val options = SentryNavOptions(maxCapturedBackStackEntries = 1) + + assertThat(options.maxCapturedBackStackEntries).isEqualTo(1) + } + + @Test + fun `accepts zero max captured backstack entries`() { + val options = SentryNavOptions(maxCapturedBackStackEntries = 0) + + assertThat(options.maxCapturedBackStackEntries).isEqualTo(0) + } + + @Test + fun `rejects negative max captured backstack entries`() { + val exception = + assertFailsWith { + SentryNavOptions(maxCapturedBackStackEntries = -1) + } + + assertThat(exception) + .hasMessageThat() + .isEqualTo("maxCapturedBackStackEntries must be non-negative, was -1") + } + + @Test + fun `equal instances share the same hash code`() { + val first = SentryNavOptions() + val second = SentryNavOptions() + + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } + + @Test + fun `equals and hash code include every property`() { + val base = SentryNavOptions() + val instanceFields = + SentryNavOptions::class + .java + .declaredFields + .filterNot { Modifier.isStatic(it.modifiers) } + .map { it.name } + + assertThat(propertyMutators.keys).containsExactlyElementsIn(instanceFields) + + propertyMutators.forEach { (propertyName, mutate) -> + val changed = mutate(base) + + assertThat(changed).isNotEqualTo(base) + assertThat(changed.hashCode()).isNotEqualTo(base.hashCode()) + assertThat(propertyName).isIn(instanceFields) + } + } + + private companion object { + val propertyMutators = + mapOf SentryNavOptions>( + "enableNavigationBreadcrumbs" to + { options -> + SentryNavOptions( + enableNavigationBreadcrumbs = !options.enableNavigationBreadcrumbs, + enableNavigationTransactions = options.enableNavigationTransactions, + captureBackStack = options.captureBackStack, + maxCapturedBackStackEntries = options.maxCapturedBackStackEntries, + ) + }, + "enableNavigationTransactions" to + { options -> + SentryNavOptions( + enableNavigationBreadcrumbs = options.enableNavigationBreadcrumbs, + enableNavigationTransactions = !options.enableNavigationTransactions, + captureBackStack = options.captureBackStack, + maxCapturedBackStackEntries = options.maxCapturedBackStackEntries, + ) + }, + "captureBackStack" to + { options -> + SentryNavOptions( + enableNavigationBreadcrumbs = options.enableNavigationBreadcrumbs, + enableNavigationTransactions = options.enableNavigationTransactions, + captureBackStack = !options.captureBackStack, + maxCapturedBackStackEntries = options.maxCapturedBackStackEntries, + ) + }, + "maxCapturedBackStackEntries" to + { options -> + SentryNavOptions( + enableNavigationBreadcrumbs = options.enableNavigationBreadcrumbs, + enableNavigationTransactions = options.enableNavigationTransactions, + captureBackStack = options.captureBackStack, + maxCapturedBackStackEntries = options.maxCapturedBackStackEntries + 1, + ) + }, + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e6b83beb214..878ed181447 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4744,6 +4744,7 @@ public final class io/sentry/TypeCheckHint { public static final field KTOR_CLIENT_RESPONSE Ljava/lang/String; public static final field LOG4J_LOG_EVENT Ljava/lang/String; public static final field LOGBACK_LOGGING_EVENT Ljava/lang/String; + public static final field NAV3_DESTINATION Ljava/lang/String; public static final field OKHTTP_REQUEST Ljava/lang/String; public static final field OKHTTP_RESPONSE Ljava/lang/String; public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index 3260b46f16b..0f3b41e27bb 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -51,6 +51,9 @@ public final class TypeCheckHint { /** Used for Navigation breadrcrumbs. */ public static final String ANDROID_NAV_DESTINATION = "android:navigationDestination"; + /** Used for Navigation 3 breadcrumbs. */ + @ApiStatus.Internal public static final String NAV3_DESTINATION = "navigation3:destination"; + /** Used for Network breadrcrumbs. */ public static final String ANDROID_NETWORK_CAPABILITIES = "android:networkCapabilities"; From ec8bdd97148b56f30c223bafcf6aa5ecc8911bc0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 12 Sep 2026 07:03:15 +0200 Subject: [PATCH 5/5] feat(android-nav3): Introduce SentryNavEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Compose-facing abstraction that binds Navigation 3 back stack changes to the observer layer. This establishes the integration’s primary API shape while keeping it internal until the rest of the sequence is ready to expose it. --- sentry-android-navigation3/build.gradle.kts | 10 +- .../compose/navigation3/BackStackKey.kt | 35 ++ .../compose/navigation3/SentryNavEffect.kt | 115 +++++ .../compose/navigation3/BackStackKeyTest.kt | 79 +++ .../navigation3/SentryNavEffectTest.kt | 467 ++++++++++++++++++ 5 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackKey.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackKeyTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index 09c293830b0..617cf3970b5 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -47,7 +48,10 @@ android { checkReleaseBuilds = false } - buildFeatures { buildConfig = true } + buildFeatures { + buildConfig = true + compose = true + } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -62,10 +66,14 @@ dependencies { compileOnly(libs.androidx.compose.runtime) testImplementation(libs.androidx.compose.runtime) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.inline) testImplementation(libs.mockito.kotlin) + testImplementation(libs.roboelectric) } tasks.withType().configureEach { diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackKey.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackKey.kt new file mode 100644 index 00000000000..83ebf0b88ed --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackKey.kt @@ -0,0 +1,35 @@ +package io.sentry.compose.navigation3 + +/** + * A key for distinguishing back stacks over time. + * + * Lets `*Effect`s restart when either the identity of a stack entry changes or the stack's entries + * are reordered. + */ +internal class BackStackKey(private val backStack: List) { + + override fun equals(other: Any?): Boolean { + // Use of identity rather than structural equality frees us from entries' equals() and + // hashCode() implementations, which are provided by the host app and may be incomplete, + // expensive, or incorrect for our purposes. + if (this === other) { + return true + } + if (other !is BackStackKey<*>) { + return false + } + if (backStack.size != other.backStack.size) { + return false + } + + return backStack.indices.all { index -> backStack[index] === other.backStack[index] } + } + + override fun hashCode(): Int { + var result = backStack.size + for (entry in backStack) { + result = 31 * result + System.identityHashCode(entry) + } + return result + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt new file mode 100644 index 00000000000..bf90090709d --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt @@ -0,0 +1,115 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryOptions + +/** + * An effect for generating Sentry data from your Nav3 backstack. Configure it via [options] and + * call it before you invoke your `NavDisplay`. + * + * ```kotlin + * @Composable + * fun AppNavigation() { + * val navBackStack = rememberNavBackStack(Home) + * + * // Place SentryNavEffect in the same composable as your NavDisplay and call + * // the effect first. Doing so ensures the effect's lifecycle matches your + * // NavDisplay, and that any Sentry data produced by your nav destinations + * // get attributed to the appropriate nav transaction. + * SentryNavEffect( + * backStack = navBackStack, + * options = SentryNavOptions(maxCapturedBackStackEntries = 10), + * nameExtractor = { route -> route.extractName() }, + * argumentsExtractor = { route -> route.extractArgument() }, + * ) + * + * // Configure your NavDisplay like usual. + * NavDisplay( + * backStack = navBackStack, + * ... + * ) + * } + * ``` + * + * **Data generated** + * + * By default, the following data is produced for each nav destination: + * + * - a breadcrumb + * - a screen name + * - a record of the current back stack (last 10 frames) + * + * A new transaction is started at each nav destination, assuming another non-nav transaction isn't + * already active. + * + * You can configure the above defaults via [SentryNavOptions]. (Screen names can be disabled via + * [SentryOptions.setEnableScreenTracking].) + * + * **Limitations** + * + * `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In + * particular, it has no awareness of + * [`Scene`](https://developer.android.com/guide/navigation/navigation-3/scenes)s. Transaction + * routes, breadcrumbs, and screen names are all derived from the top entry of the back stack and + * are updated as it changes. + * + * `SentryNavEffect` also doesn't make any special accommodations for + * [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) + * gestures. That means, for instance, that spans produced by predictively rendered composables can + * show up under the current destination's transaction. + * + * **Privacy / PII** + * + * Values returned from [nameExtractor] and [argumentsExtractor] are ***not*** scrubbed by the + * Sentry SDK before being sent to Sentry. Only return route names and arguments that are known to + * be safe or have been pre-scrubbed. + * + * @param backStack The navigation backstack to observe. + * @param scopes A scopes instance used to track generated Sentry data. + * @param options The kinds of navigation info this effect should record. + * @param nameExtractor Optional lambda to extract a human-readable route name from the top entry of + * the [backStack]. If not provided, defaults to the simple name of the entry's class. + * @param argumentsExtractor Optional lambda to extract a map of argument name -> argument values + * from the top entry of the [backStack]. If not provided, no arguments are attached. The + * following scalar values are supported: [String], [CharSequence], [Char], [Boolean], any + * [Number], enums (via [Enum.name]), and `null`. Supported container values are: [Array]s, + * primitive arrays, [Map]s, and [Collection]s of supported values, including nested containers. + * All other types are stringified via `toString()`. Cyclic or deeply nested containers are + * skipped. Return only the arguments needed for diagnostics and avoid large structures. + */ +@Composable +@Suppress("FunctionNaming") +internal fun SentryNavEffect( + backStack: List, + scopes: IScopes = ScopesAdapter.getInstance(), + options: SentryNavOptions = SentryNavOptions(), + nameExtractor: ((T) -> String)? = null, + argumentsExtractor: ((T) -> Map)? = null, +) { + val routeResolvers = rememberUpdatedState(RouteResolvers(nameExtractor, argumentsExtractor)) + + val observer = + remember(scopes, options) { + BackStackObserver( + scopes = scopes, + options = options, + resolvers = { routeResolvers.value }, + ) + } + + val capturedBackStack = backStack.toList() + + DisposableEffect(observer, BackStackKey(capturedBackStack)) { + observer.onBackStackChanged(backStack = capturedBackStack) + onDispose {} + } + + DisposableEffect(observer) { + onDispose { observer.cleanup() } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackKeyTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackKeyTest.kt new file mode 100644 index 00000000000..cd083ce4c29 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackKeyTest.kt @@ -0,0 +1,79 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class BackStackKeyTest { + + private data class HomeScreen(val dummy: String = "") + + private data class ProfileScreen(val userId: String) + + @Test + fun `keys are equal when entry identity and order are equal`() { + val home = HomeScreen() + val profile = ProfileScreen("123") + + val first = BackStackKey(listOf(home, profile)) + val second = BackStackKey(listOf(home, profile)) + + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } + + @Test + fun `keys are not equal when entries are equal by value but not by identity`() { + val first = BackStackKey(listOf(ProfileScreen("123"))) + val second = BackStackKey(listOf(ProfileScreen("123"))) + + assertThat(first).isNotEqualTo(second) + } + + @Test + fun `keys are not equal when entry order changes`() { + val home = HomeScreen() + val profile = ProfileScreen("123") + + val first = BackStackKey(listOf(home, profile)) + val second = BackStackKey(listOf(profile, home)) + + assertThat(first).isNotEqualTo(second) + } + + @Test + fun `keys are not equal when stack size changes`() { + val home = HomeScreen() + + val first = BackStackKey(listOf(home)) + val second = BackStackKey(listOf(home, ProfileScreen("123"))) + + assertThat(first).isNotEqualTo(second) + } + + @Test + fun `equals does not call entry equals`() { + val entry = ExplodingEqualityKey() + + val first = BackStackKey(listOf(entry)) + val second = BackStackKey(listOf(entry)) + + assertThat(first).isEqualTo(second) + } + + @Test + fun `hash code does not call entry hash code`() { + val entry = ExplodingEqualityKey() + + val first = BackStackKey(listOf(entry)) + val second = BackStackKey(listOf(entry)) + + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } + + private class ExplodingEqualityKey { + + override fun equals(other: Any?): Boolean = error("equals boom") + + override fun hashCode(): Int = error("hashCode boom") + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt new file mode 100644 index 00000000000..66fc5131e75 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt @@ -0,0 +1,467 @@ +package io.sentry.compose.navigation3 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import kotlin.test.Test +import kotlin.test.assertNull +import org.junit.Rule +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryNavEffectTest { + + @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 composeRule = createAndroidComposeRule() + + private data class HomeRoute(val id: String = "home") + + private data class ProfileRoute(val userId: String) + + private class Fixture { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + setTracesSampleRate(1.0) + isEnableScreenTracking = true + setLogger(logger) + idleTimeout = null + deadlineTimeout = 0 + } + val scope = Scope(options) + val scopes = mock() + val breadcrumbs = mutableListOf() + val transactions = mutableListOf() + + init { + whenever(scopes.options).thenReturn(options) + whenever(scopes.getSpan()).thenAnswer { scope.span } + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + doAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, scopes, transactionOptions).also(transactions::add) + } + .whenever(scopes) + .startTransaction(any(), any()) + doAnswer { + breadcrumbs += it.arguments[0] as Breadcrumb + null + } + .whenever(scopes) + .addBreadcrumb(any(), any()) + } + } + + @Test + fun `initial composition emits Sentry data for the top entry and a back stack copy`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.transactions.single().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `pushing a new top entry emits Sentry data for that entry and a new back stack copy`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { backStack.add(ProfileRoute("123")) } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions).hasSize(2) + assertThat(fixture.transactions.last().name).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/ProfileRoute"), mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `popping the top entry emits Sentry data for the new top entry and a new back stack copy`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute(), ProfileRoute("123")) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { backStack.removeAt(backStack.lastIndex) } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.transactions).hasSize(2) + assertThat(fixture.transactions.last().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `replacing the back stack emits Sentry data for the new top entry and a new back stack copy`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute(), ProfileRoute("123")) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + backStack.clear() + backStack.add(ProfileRoute("999")) + backStack.add(HomeRoute("replacement")) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.transactions).hasSize(2) + assertThat(fixture.transactions.last().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/HomeRoute"), mapOf("route" to "/ProfileRoute"))) + } + + @Test + fun `changing non-top entries does not re-emit top-entry Sentry data but does emit the new back stack copy`() { + val fixture = Fixture() + val home = HomeRoute() + val profile = ProfileRoute("123") + val backStack = mutableStateListOf(home, profile) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { backStack.add(1, HomeRoute("inserted")) } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions).hasSize(1) + assertThat(fixture.transactions.single().name).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "/ProfileRoute"), + mapOf("route" to "/HomeRoute"), + mapOf("route" to "/HomeRoute"), + ) + ) + } + + @Test + fun `unrelated recomposition does not re-emit Sentry data`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val recomposeTick = mutableIntStateOf(0) + + composeRule.setContent { + recomposeTick.intValue + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { recomposeTick.intValue++ } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.transactions).hasSize(1) + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.transactions.single().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/HomeRoute"))) + } + + /** + * We want to make sure any composable `*Effect`s run in the nav destination can see the new nav + * transaction, otherwise their spans will be misparented under the previous nav transaction. + * + * Note: Test assumes that [SentryNavEffect] is invoked before the destination composable, e.g., + * because `SentryNavEffect` is called before the host app invokes its `NavDisplay`. + * `SentryNavEffect` docs contain instructions to that effect. + */ + @Test + fun `composable effects after navigation see the new nav transaction`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val observedTransactionNames = mutableListOf() + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + + val currentTop = backStack.last() + LaunchedEffect(currentTop) { + val transaction = fixture.scopes.getSpan() as? SentryTracer + observedTransactionNames += transaction?.name ?: "" + } + } + composeRule.waitForIdle() + + composeRule.runOnIdle { backStack.add(ProfileRoute("123")) } + composeRule.waitForIdle() + + assertThat(observedTransactionNames).containsExactly("/HomeRoute", "/ProfileRoute").inOrder() + } + + @Test + fun `updated name extractor is used for later navigation changes`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val nameExtractor = mutableStateOf<((Any) -> String)?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + scopes = fixture.scopes, + nameExtractor = nameExtractor.value, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + nameExtractor.value = { entry -> + if (entry is ProfileRoute) "profile-updated" else "home-updated" + } + } + composeRule.waitForIdle() + composeRule.runOnIdle { backStack.add(ProfileRoute("123")) } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/profile-updated") + assertThat(fixture.transactions.last().name).isEqualTo("/profile-updated") + assertThat(fixture.scope.screen).isEqualTo("/profile-updated") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/profile-updated"), mapOf("route" to "/home-updated"))) + } + + @Test + fun `changing the name extractor alone does not re-emit Sentry data for the current top entry`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute(), ProfileRoute("123")) + val nameExtractor = mutableStateOf<((Any) -> String)?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + scopes = fixture.scopes, + nameExtractor = nameExtractor.value, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + nameExtractor.value = { entry -> + if (entry is ProfileRoute) "profile-updated" else "home-updated" + } + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions).hasSize(1) + assertThat(fixture.transactions.single().name).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/ProfileRoute"), mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `updated arguments extractor is used for later navigation changes`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val argumentsExtractor = mutableStateOf<((Any) -> Map)?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + scopes = fixture.scopes, + argumentsExtractor = argumentsExtractor.value, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + argumentsExtractor.value = { entry -> + if (entry is ProfileRoute) mapOf("userId" to entry.userId) else emptyMap() + } + } + composeRule.waitForIdle() + composeRule.runOnIdle { backStack.add(ProfileRoute("123")) } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.last().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.breadcrumbs.last().data["to_arguments"]).isEqualTo(mapOf("userId" to "123")) + assertThat(fixture.transactions.last().name).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions.last().getData("arguments")).isEqualTo(mapOf("userId" to "123")) + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "/ProfileRoute", "args" to mapOf("userId" to "123")), + mapOf("route" to "/HomeRoute"), + ) + ) + } + + @Test + fun `changing the arguments extractor alone does not re-emit Sentry data for the current top entry`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute(), ProfileRoute("123")) + val argumentsExtractor = mutableStateOf<((Any) -> Map)?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + scopes = fixture.scopes, + argumentsExtractor = argumentsExtractor.value, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + argumentsExtractor.value = { entry -> + if (entry is ProfileRoute) mapOf("userId" to entry.userId) else emptyMap() + } + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.breadcrumbs.single().data["to_arguments"]).isNull() + assertThat(fixture.transactions).hasSize(1) + assertThat(fixture.transactions.single().name).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions.single().getData("arguments")).isNull() + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo(listOf(mapOf("route" to "/ProfileRoute"), mapOf("route" to "/HomeRoute"))) + } + + @Test + fun `changing options applies the new observer configuration`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val options = mutableStateOf(SentryNavOptions(captureBackStack = true)) + + composeRule.setContent { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes, options = options.value) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { options.value = SentryNavOptions(captureBackStack = false) } + composeRule.waitForIdle() + + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + } + + @Test + fun `removal from composition clears tracked state`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute()) + val isShown = mutableStateOf(true) + + composeRule.setContent { + if (isShown.value) { + SentryNavEffect(backStack = backStack, scopes = fixture.scopes) + } + } + composeRule.waitForIdle() + + val transaction = composeRule.runOnIdle { fixture.transactions.single() } + composeRule.runOnIdle { isShown.value = false } + composeRule.waitForIdle() + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertNull(fixture.scope.contexts.app?.viewNames) + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + } + + private fun IScope.navigationBackStack(): List>? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + + @Suppress("UNCHECKED_CAST") + return navigationContext[BACKSTACK_KEY] as? List> + } + + private fun ITransaction.navigationBackStack(): List>? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + + @Suppress("UNCHECKED_CAST") + return navigationContext[BACKSTACK_KEY] as? List> + } + + private companion object { + const val NAVIGATION_CONTEXT_KEY = "navigation" + const val BACKSTACK_KEY = "backstack" + } +}