From df8e13ad762d2cfecdcd7c428921e6576fc036db Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 18 Sep 2026 12:20:23 +0200 Subject: [PATCH 1/7] collection: android nav3 From 7300862b8219be435ff0e0c3ddcfdae083a7f007 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 24 Sep 2026 10:06:19 +0200 Subject: [PATCH 2/7] feat(android-nav3): [Android Nav3 1] Create navigation3 module (JAVA-274) (#6129) Introduce a new `sentry-android-navigation3` module in connection with our Nav3 support. --- .github/ISSUE_TEMPLATE/bug_report_android.yml | 1 + AGENTS.md | 2 +- buildSrc/src/main/java/Config.kt | 1 + sentry-android-navigation3/build.gradle.kts | 53 +++++++++++++++++++ sentry-android-navigation3/proguard-rules.pro | 7 +++ settings.gradle.kts | 1 + 6 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 sentry-android-navigation3/build.gradle.kts create mode 100644 sentry-android-navigation3/proguard-rules.pro diff --git a/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index 5dff43579c6..b16ab5ae7e2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -12,6 +12,7 @@ body: - sentry-android-ndk - sentry-android-timber - sentry-android-fragment + - sentry-android-navigation3 - sentry-android-sqlite - sentry-apollo - sentry-apollo-3 diff --git a/AGENTS.md b/AGENTS.md index 1027e513c4b..badfba1b3e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ The repository is organized into multiple modules: - **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul`, `sentry-android-timber` - **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-openfeign`, `sentry-apache-http-client-5` - **GraphQL**: `sentry-graphql*`, `sentry-apollo*` -- **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-compose` +- **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-android-navigation3`, `sentry-compose` - **Session Replay**: `sentry-android-replay` - **Database**: `sentry-jdbc`, `sentry-android-sqlite`, `sentry-jcache` - **Reactive**: `sentry-reactor`, `sentry-ktor-client` diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 52d7684620d..fbc210b6584 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..f967385a78b --- /dev/null +++ b/sentry-android-navigation3/build.gradle.kts @@ -0,0 +1,53 @@ +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.kotlin.compose) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.detekt) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.compose.navigation3" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + 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 + 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 334ddadf89a..e243795499d 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 5c8bd0a3d0fc5f318de00ade774244201993540d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 24 Sep 2026 18:24:47 +0200 Subject: [PATCH 3/7] feat(android-nav3): [Android Nav3 2] Model navigation routes (JAVA-274) (#6130) Introduce RouteTranslator as part of Nav3 support. It uses host app-provided extractors to convert back stack entries into Routes suitable for use in Sentry Nav3 data. --- gradle/libs.versions.toml | 1 + .../api/sentry-android-navigation3.api | 0 sentry-android-navigation3/build.gradle.kts | 12 + .../compose/navigation3/RouteExtractors.kt | 137 +++++ .../compose/navigation3/RouteTranslator.kt | 387 +++++++++++++ .../navigation3/RouteExtractorsTest.kt | 81 +++ .../navigation3/RouteTranslatorTest.kt | 539 ++++++++++++++++++ 7 files changed, 1157 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/RouteExtractors.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/RouteExtractorsTest.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 f967385a78b..3e35211b22f 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -47,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/RouteExtractors.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt new file mode 100644 index 00000000000..92dd05407f1 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt @@ -0,0 +1,137 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.snapshots.Snapshot +import org.jetbrains.annotations.ApiStatus + +/** + * Extracts a human-readable route name from a back stack entry. + * + * **Privacy / PII** + * + * Values returned from [extract] are ***not*** scrubbed by the Sentry SDK before being sent to + * Sentry. Only return names that are known to be safe or have been pre-scrubbed. + * + * **Choosing stable route names** + * + * Implementations should return stable, low-cardinality names that don't depend on object identity, + * argument values, or runtime class-name preservation. E.g., `Home`, `DetailScreen`, etc. + * + * In particular, avoid `::class.simpleName` in release builds, as R8 obfuscates class names and may + * map them to different symbols across builds. + * + * **Falls back to "/unknown"** + * + * If [extract] throws or returns a blank route name, Sentry records the destination as "/unknown". + * Doing so signals that name extraction needs to be fixed while avoiding misleading gaps in + * navigation data. + * + * For instance, if a user navigates from `/home -> /detail -> /settings`, but the name extractor + * for `/detail` throws, the back stack record will be `/home -> /unknown -> /settings` rather than + * `/home -> /settings`. + * + * **Using kotlinx.serialization** + * + * If your back stack contains `@Serializable` route types, you may want to consider mapping each + * route type to a stable serializer name. For instance: + * ```kotlin + * val nameExtractor = RouteNameExtractor { route -> + * when (route) { + * is HomeRoute -> HomeRoute.serializer().descriptor.serialName + * is ProfileRoute -> ProfileRoute.serializer().descriptor.serialName + * is SettingsRoute -> SettingsRoute.serializer().descriptor.serialName + * } + * } + * ``` + * + * Doing so prevents route names from being obfuscated while leaving per-route arguments to + * [RouteArgumentsExtractor]. + */ +@ApiStatus.Experimental +internal fun interface RouteNameExtractor { + fun extract(backStackEntry: T): String +} + +/** + * Extracts diagnostic route arguments from a back stack entry as map of argument name -> argument + * values. + * + * **Privacy / PII** + * + * Values returned from [extract] are ***not*** scrubbed by the Sentry SDK before being sent to + * Sentry. Only return arguments that are known to be safe or have been pre-scrubbed. + * + * **Choosing performant route arguments** + * + * Return only a small subset of route data useful for diagnostics. Data should be stable enough to + * inspect in Sentry. + * + * For performance reasons, implementations should avoid large structures. Cyclic or deeply nested + * containers will be skipped. (See `RouteTranslator` for more details.) + * + * **Accepted value types** + * + * Values may be any of the following scalar types: + * + * - [String] + * - [CharSequence] + * - [Char] + * - [Boolean] + * - any [Number] + * - enums (via [Enum.name]) + * - `null` + * + * Or any of the following container types: + * + * - [Array]s + * - primitive arrays + * - [Map]s + * - [Collection]s + * + * Container values may be nested, and they must bottom out in supported scalar types. + * + * **Falls back to `toString()` or nothing** + * + * All non-supported types are stringified via `toString()`. If [extract] throws, no arguments are + * recorded for the destination. + * + * **Using kotlinx.serialization** + * + * Even if your back stack contains `@Serializable` route types, consider mapping each route type to + * a small set of diagnostic arguments to avoid the cost of serializing and returning the entire + * route object. For instance: + * ```kotlin + * val argumentsExtractor = RouteArgumentsExtractor { route -> + * when (route) { + * is HomeRoute -> emptyMap() + * is ProfileRoute -> mapOf("userId" to route.userId, "tab" to route.tab) + * is SettingsRoute -> mapOf("section" to route.section) + * } + * } + * ``` + */ +@ApiStatus.Experimental +internal fun interface RouteArgumentsExtractor { + fun extract(backStackEntry: T): Map +} + +/** + * 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 [Route]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 RouteExtractors( + val nameExtractor: RouteNameExtractor, + val argumentsExtractor: RouteArgumentsExtractor?, +) { + + fun getName(backStackEntry: T): String = Snapshot.withoutReadObservation { + nameExtractor.extract(backStackEntry) + } + + fun getArguments(backStackEntry: T): Map? = Snapshot.withoutReadObservation { + argumentsExtractor?.extract(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..d687a7d890f --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteTranslator.kt @@ -0,0 +1,387 @@ +package io.sentry.compose.navigation3 + +import io.sentry.ILogger +import io.sentry.SentryLevel.WARNING +import io.sentry.util.ExceptionUtils +import java.util.IdentityHashMap +import org.jetbrains.annotations.TestOnly + +/** + * Translates app-defined back stack entries into input-ordered [Route]s. + * + * **Threading policy** + * + * This class performs work synchronously on the calling thread. Host-provided [extractors] are + * invoked on that same thread and should remain small, non-blocking, and safe for the caller's + * threading context. + */ +internal class RouteTranslator( + private val extractors: () -> RouteExtractors, + private val logger: ILogger, +) { + + companion object { + internal const val UNKNOWN_ROUTE_NAME = "/unknown" + } + + /** Translates the provided [backStackEntries] into [Route]s and returns them in input order. */ + fun translate(backStackEntries: List, policy: RetentionPolicy): List { + val warningState = WarningState() + val sanitizer = ArgumentSanitizer(logger, warningState) + + val routes = MutableList(backStackEntries.size) { null } + val indicesInPolicyOrder = + when (policy) { + RetentionPolicy.KEEP_FIRST -> backStackEntries.indices + RetentionPolicy.KEEP_LAST -> backStackEntries.indices.reversed() + } + + for (index in indicesInPolicyOrder) { + val entry = backStackEntries[index] + routes[index] = + Route( + name = extractRouteName(entry, warningState), + arguments = extractRouteArguments(entry, sanitizer), + ) + } + + return routes.requireNoNulls() + } + + /** + * Returns a route name for the provided [backStackEntry], based on this translator's + * [name extractor][RouteExtractors.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.) + */ + @TestOnly + @Suppress("TooGenericExceptionCaught") + fun extractRouteName(backStackEntry: T, warningState: WarningState): String { + val name: String? = + try { + extractors.invoke().getName(backStackEntry) + } catch (t: Throwable) { + // Route name extractors are host app callbacks. + ExceptionUtils.rethrowIfFatal(t) + warningState.logNameExtractorFailureWarning(logger, t) + return UNKNOWN_ROUTE_NAME + } + + val normalizedName = name?.trim()?.takeUnless { it.isEmpty() }?.removePrefix("/") + if (normalizedName == null) { + warningState.logInvalidRouteNameWarning(logger) + return UNKNOWN_ROUTE_NAME + } + + return "/$normalizedName" + } + + /** + * Returns the arguments for the provided [backStackEntry], based on this translator's + * [arguments extractor][RouteExtractors.argumentsExtractor]. + * + * The arguments are sanitized before being returned, i.e., bounded in size and depth, and + * converted into a serializable form. + */ + @TestOnly + @Suppress("TooGenericExceptionCaught") + fun extractRouteArguments( + backStackEntry: T, + sanitizer: ArgumentSanitizer, + ): Map { + val raw = + try { + extractors.invoke().getArguments(backStackEntry) ?: return emptyMap() + } catch (t: Throwable) { + // Route argument extractors are host app callbacks. + ExceptionUtils.rethrowIfFatal(t) + logger.log( + WARNING, + "Nav3 argumentsExtractor threw while resolving arguments. Skipping arguments.", + t, + ) + return emptyMap() + } + + return sanitizer.sanitizeEntry(raw) + } + + /** + * Specifies whether route info starting at the initial or final element of a back stack list + * should be preserved if a size budget is exceeded. + * + * Most clients will want to select the policy that starts at the top of their back stack. + */ + internal enum class RetentionPolicy { + + /** + * Retains route info for lower indexed elements in the back stack list if a particular info + * budget is reached. Retention starts at index 0 and increments until the budget is exhausted. + */ + KEEP_FIRST, + + /** + * Retains route info for higher indexed elements in the back stack list if a particular info + * budget is reached. Retention starts at lastIndex and decrements until the budget is + * exhausted. + */ + KEEP_LAST, + } + + /** + * Sanitizes a back stack update's argument maps into a serializable form. It bounds depth and + * total value count, and it rejects cyclic structures. + * + * One instance is shared across every entry in a single [translate] call, so the value budget is + * enforced across the whole update. Once the budget is spent, the overflowing entry and every + * older entry are dropped, while newer (already-processed) entries are preserved. + */ + internal class ArgumentSanitizer( + private val logger: ILogger, + private val warningState: WarningState, + ) { + + private val activeContainers = IdentityHashMap() + private var remainingValues = MAX_ARGUMENT_VALUES + private var budgetExhausted = false + + /** + * Sanitizes one entry's arguments, or returns an empty map to drop them, either because the + * structure is cyclic or too deeply nested (this entry only), or because the shared per-update + * value budget is spent (this entry and every older one). + */ + @Suppress("TooGenericExceptionCaught") + fun sanitizeEntry(raw: Map): Map { + if (budgetExhausted) { + return emptyMap() + } + + return try { + sanitizeMap(raw, depth = 0) + } catch (drop: DropSubtree) { + if (drop.exhaustsBudget) { + budgetExhausted = true + } + logger.log(WARNING, drop.warning) + emptyMap() + } catch (t: Throwable) { + // Extracted maps may invoke host app code while iterating or stringifying values. + ExceptionUtils.rethrowIfFatal(t) + logger.log(WARNING, STRUCTURE_WARNING, t) + emptyMap() + } + } + + private fun sanitizeMap(value: Map<*, *>, depth: Int): Map { + enter(value) + try { + val sanitized = LinkedHashMap() + for ((key, childValue) in value) { + sanitized[key.toString()] = sanitizeValue(childValue, depth + 1) + } + return sanitized + } finally { + exit(value) + } + } + + private fun sanitizeCollection(value: Collection<*>, depth: Int): List { + enter(value) + try { + // The value budget bounds allocation instead of the caller-provided collection size. + val sanitized = ArrayList() + for (childValue in value) { + sanitized += sanitizeValue(childValue, depth + 1) + } + return sanitized + } finally { + exit(value) + } + } + + private fun sanitizeValue(value: Any?, depth: Int): Any? { + 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, depth) + collection != null -> sanitizeCollection(collection, depth) + else -> { + warningState.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 + } + + /** + * Records a visit to one value, enforcing the per-entry depth cap and the shared per-update + * value budget. Throws [DropSubtree] to abort the current subtree when either is exceeded. + */ + private fun visit(depth: Int) { + if (depth > MAX_ARGUMENT_DEPTH) { + throw DropSubtree(STRUCTURE_WARNING, exhaustsBudget = false) + } + if (--remainingValues < 0) { + throw DropSubtree(BUDGET_WARNING, exhaustsBudget = true) + } + } + + private fun enter(container: Any) { + if (activeContainers.put(container, Unit) != null) { + throw DropSubtree(STRUCTURE_WARNING, exhaustsBudget = false) + } + } + + private fun exit(container: Any) { + activeContainers.remove(container) + } + + /** + * Control-flow signal to abort sanitization of the current subtree. Internal to + * [ArgumentSanitizer]. + * + * [exhaustsBudget] distinguishes an entry-local drop (cycle or over-deep structure) from an + * update-wide one (the shared value budget is spent). Overrides [fillInStackTrace] to skip + * stack-trace capture. + */ + private class DropSubtree(val warning: String, val exhaustsBudget: Boolean) : + RuntimeException() { + override fun fillInStackTrace(): Throwable = this + } + + private 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 + + private const val STRUCTURE_WARNING = + "Nav3 argument sanitization failed (possibly a cyclic or deeply nested structure). " + + "Skipping arguments." + + private const val BUDGET_WARNING = + "Nav3 arguments exceeded the maximum total value count for one backstack update. " + + "Skipping arguments for this and older captured entries." + } + } + + /** A small state wrapper that lets us avoid spamming logs when sanitizing arguments. */ + internal class WarningState { + private var hasLoggedUnsupportedValueWarning = false + private var hasLoggedInvalidRouteNameWarning = false + private var hasLoggedNameExtractorFailureWarning = 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 + } + + fun logInvalidRouteNameWarning(logger: ILogger) { + if (hasLoggedInvalidRouteNameWarning) { + return + } + + logger.log( + WARNING, + "Nav3 nameExtractor returned a blank route name while processing this back stack update. " + + "Using /unknown instead.", + ) + hasLoggedInvalidRouteNameWarning = true + } + + fun logNameExtractorFailureWarning(logger: ILogger, throwable: Throwable) { + if (hasLoggedNameExtractorFailureWarning) { + return + } + + logger.log( + WARNING, + "Nav3 nameExtractor threw while resolving a route name. Using /unknown instead.", + throwable, + ) + hasLoggedNameExtractorFailureWarning = true + } + } +} + +/** + * Summary information about a back stack entry from the host app, fit for use with Sentry data. + * + * All route names should be normalized to include a leading slash, and all arguments should be + * sanitized (i.e., bounded in size and depth, and converted into a serializable form). + */ +internal data class Route( + val name: String, + val arguments: Map = emptyMap(), +) { + + /** + * Returns this route in serialized form. E.g.: + * ``` + * { + * "route": "/ProductScreen" + * "args": { + * "product_id": 12345 + * "promo_id:": "spring-marketing-drive-2026" + * } + * } + * ``` + */ + fun serialize(): Map = buildMap { + put("route", name) + if (arguments.isNotEmpty()) { + put("args", arguments) + } + } +} + +internal fun List.serialize(): List> = map(Route::serialize) diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt new file mode 100644 index 00000000000..f97e5241ac1 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt @@ -0,0 +1,81 @@ +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 + +class RouteExtractorsTest { + + private data class HomeRoute(val id: String = "home") + + private data class ProfileRoute(val userId: String) + + private val defaultNameExtractor = RouteNameExtractor { it.id } + + @Test + fun `getArguments returns null when no arguments extractor is configured`() { + val sut = RouteExtractors(nameExtractor = defaultNameExtractor, argumentsExtractor = null) + + assertThat(sut.getArguments(HomeRoute())).isNull() + } + + @Test + fun `getName delegates to the configured extractor`() { + val route = ProfileRoute("123") + val sut = + RouteExtractors( + nameExtractor = RouteNameExtractor { entry -> "profile-${entry.userId}" }, + argumentsExtractor = null, + ) + + assertThat(sut.getName(route)).isEqualTo("profile-123") + } + + @Test + fun `getArguments delegates to the configured extractor`() { + val route = ProfileRoute("123") + val sut = + RouteExtractors( + nameExtractor = RouteNameExtractor { entry -> entry.userId }, + argumentsExtractor = RouteArgumentsExtractor { 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 = + RouteExtractors( + nameExtractor = RouteNameExtractor { routeName.value }, + argumentsExtractor = null, + ) + + assertThat(observeReads { sut.getName(HomeRoute()) }).isEqualTo(0) + } + + @Test + fun `getArguments hides extractor reads from snapshot observation`() { + val argumentValue = mutableStateOf("123") + val sut = + RouteExtractors( + nameExtractor = defaultNameExtractor, + argumentsExtractor = RouteArgumentsExtractor { mapOf("userId" to argumentValue.value) }, + ) + + assertThat(observeReads { sut.getArguments(HomeRoute()) }).isEqualTo(0) + } + + 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..ca2f8db3183 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteTranslatorTest.kt @@ -0,0 +1,539 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import io.sentry.SentryLevel.WARNING +import io.sentry.compose.navigation3.RouteTranslator.ArgumentSanitizer +import io.sentry.compose.navigation3.RouteTranslator.RetentionPolicy +import io.sentry.compose.navigation3.RouteTranslator.WarningState +import java.util.AbstractCollection +import kotlin.test.Test +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 ProductRoute(val productId: String) + + private data class SettingsRoute(val section: String) + + private enum class PrivacyMode { + PUBLIC, + PRIVATE, + } + + private val logger = mock() + private val defaultNameExtractor = + RouteNameExtractor { entry -> entry::class.simpleName ?: "unknown" } + + private fun getSut( + nameExtractor: RouteNameExtractor = defaultNameExtractor, + argumentsExtractor: RouteArgumentsExtractor? = null, + ): RouteTranslator = + RouteTranslator( + extractors = { RouteExtractors(nameExtractor, argumentsExtractor) }, + logger = logger, + ) + + @Test + fun `translate preserves input order`() { + val sut = getSut() + + val routes = + sut.translate( + listOf(SettingsRoute("privacy"), ProfileRoute("123"), HomeRoute()), + RetentionPolicy.KEEP_FIRST, + ) + + assertThat(routes) + .containsExactly(Route("/SettingsRoute"), Route("/ProfileRoute"), Route("/HomeRoute")) + .inOrder() + } + + @Test + fun `translate preserves input order when top entry is last`() { + val sut = getSut() + + val routes = + sut.translate( + listOf(HomeRoute(), ProfileRoute("123"), SettingsRoute("privacy")), + RetentionPolicy.KEEP_LAST, + ) + + assertThat(routes) + .containsExactly(Route("/HomeRoute"), Route("/ProfileRoute"), Route("/SettingsRoute")) + .inOrder() + } + + @Test + fun `translate returns empty routes for an empty back stack`() { + val sut = getSut() + + assertThat(sut.translate(emptyList(), RetentionPolicy.KEEP_FIRST)).isEmpty() + } + + @Test + fun `translate returns empty routes for an empty back stack when top entry is last`() { + val sut = getSut() + + assertThat(sut.translate(emptyList(), RetentionPolicy.KEEP_LAST)).isEmpty() + } + + @Test + fun `translate with KEEP_FIRST preserves arguments nearest index zero when budget is exceeded`() { + val first = SettingsRoute("privacy") + val middle = ProfileRoute("123") + val last = HomeRoute() + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { key -> + when (key) { + is SettingsRoute -> mapOf("section" to key.section) + is ProfileRoute -> mapOf("values" to List(999) { it }) + is HomeRoute -> mapOf("home" to true) + else -> emptyMap() + } + } + ) + + val routes = sut.translate(listOf(first, middle, last), RetentionPolicy.KEEP_FIRST) + + assertThat(routes) + .containsExactly( + Route("/SettingsRoute", mapOf("section" to "privacy")), + Route("/ProfileRoute"), + Route("/HomeRoute"), + ) + .inOrder() + } + + @Test + fun `translate with KEEP_LAST preserves arguments nearest lastIndex when budget is exceeded`() { + val first = HomeRoute() + val middle = ProfileRoute("123") + val last = SettingsRoute("privacy") + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { 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 routes = sut.translate(listOf(first, middle, last), RetentionPolicy.KEEP_LAST) + + assertThat(routes) + .containsExactly( + Route("/HomeRoute"), + Route("/ProfileRoute"), + Route("/SettingsRoute", mapOf("section" to "privacy")), + ) + .inOrder() + } + + @Test + fun `translate with KEEP_FIRST treats entries as distinct by position even if structurally equal`() { + val first = ProductRoute("sku-1") + val middle = ProfileRoute("123") + val last = ProductRoute("sku-1") + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is ProductRoute -> mapOf("productId" to entry.productId) + is ProfileRoute -> mapOf("values" to List(999) { it }) + else -> emptyMap() + } + } + ) + + val routes = sut.translate(listOf(first, middle, last), RetentionPolicy.KEEP_FIRST) + + assertThat(routes) + .containsExactly( + Route("/ProductRoute", mapOf("productId" to "sku-1")), + Route("/ProfileRoute"), + Route("/ProductRoute"), + ) + .inOrder() + } + + @Test + fun `translate with KEEP_LAST treats entries as distinct by position even if structurally equal`() { + val first = ProductRoute("sku-1") + val middle = ProfileRoute("123") + val last = ProductRoute("sku-1") + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is ProductRoute -> mapOf("productId" to entry.productId) + is ProfileRoute -> mapOf("values" to List(999) { it }) + else -> emptyMap() + } + } + ) + + val routes = sut.translate(listOf(first, middle, last), RetentionPolicy.KEEP_LAST) + + assertThat(routes) + .containsExactly( + Route("/ProductRoute"), + Route("/ProfileRoute"), + Route("/ProductRoute", mapOf("productId" to "sku-1")), + ) + .inOrder() + } + + @Test + fun `translate returns translated routes from one pass`() { + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + } + ) + + val routes = + sut.translate( + listOf(SettingsRoute("privacy"), ProfileRoute("123")), + RetentionPolicy.KEEP_FIRST, + ) + + assertThat(routes) + .containsExactly( + Route("/SettingsRoute"), + Route("/ProfileRoute", mapOf("userId" to "123")), + ) + .inOrder() + } + + @Test + fun `route serializes to back stack entry shape`() { + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> emptyMap() + is SettingsRoute -> mapOf("section" to entry.section) + else -> emptyMap() + } + } + ) + + val routes = + sut.translate( + listOf(SettingsRoute("privacy"), ProfileRoute("123")), + RetentionPolicy.KEEP_FIRST, + ) + + assertThat(routes.map(Route::serialize)) + .containsExactly( + mapOf("route" to "/SettingsRoute", "args" to mapOf("section" to "privacy")), + mapOf("route" to "/ProfileRoute"), + ) + .inOrder() + } + + @Test + fun `extractRouteName normalizes a custom name with a leading slash`() { + val sut = getSut(nameExtractor = { "profile" }) + + assertThat(sut.extractRouteName(ProfileRoute("123"), WarningState())).isEqualTo("/profile") + } + + @Test + fun `extractRouteName leaves leading slash on custom name if already present`() { + val sut = getSut(nameExtractor = { "/profile" }) + + assertThat(sut.extractRouteName(ProfileRoute("123"), WarningState())).isEqualTo("/profile") + } + + @Test + fun `extractRouteName returns the configured name extractor result`() { + val sut = getSut() + + assertThat(sut.extractRouteName(HomeRoute(), WarningState())).isEqualTo("/HomeRoute") + } + + @Test + fun `extractRouteName returns unknown when name extractor throws`() { + val sut = getSut(nameExtractor = { error("boom") }) + + assertThat(sut.extractRouteName(HomeRoute(), WarningState())) + .isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + verify(logger) + .log( + eq(WARNING), + eq("Nav3 nameExtractor threw while resolving a route name. Using /unknown instead."), + org.mockito.kotlin.any(), + ) + } + + @Test + fun `extractRouteName returns unknown when name extractor returns blank`() { + val sut = getSut(nameExtractor = { " " }) + + assertThat(sut.extractRouteName(HomeRoute(), WarningState())) + .isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + verify(logger) + .log( + eq(WARNING), + eq( + "Nav3 nameExtractor returned a blank route name while processing this back stack update. " + + "Using /unknown instead." + ), + ) + } + + @Test + fun `extractRouteArguments returns supported values in serializable form`() { + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { _ -> + 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.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .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 `extractRouteArguments sanitizes nested supported containers recursively`() { + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { _ -> + mapOf( + "nested" to + mapOf( + "items" to + arrayOf( + StringBuilder("x"), + listOf('y', PrivacyMode.PRIVATE), + booleanArrayOf(true, false), + charArrayOf('q'), + ) + ) + ) + } + ) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEqualTo( + mapOf( + "nested" to + mapOf("items" to listOf("x", listOf("y", "PRIVATE"), listOf(true, false), listOf("q"))) + ) + ) + } + + @Test + fun `extractRouteArguments coerces unsupported values to strings`() { + class OpaqueValue { + override fun toString(): String = "opaque-value" + } + + val sut = + getSut(argumentsExtractor = RouteArgumentsExtractor { _ -> mapOf("bad" to OpaqueValue()) }) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEqualTo(mapOf("bad" to "opaque-value")) + } + + @Test + fun `translate logs unsupported value warning once per back stack update`() { + class OpaqueValue { + override fun toString(): String = "opaque-value" + } + + val sut = + getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is HomeRoute -> mapOf("bad" to OpaqueValue()) + is ProfileRoute -> mapOf("alsoBad" to OpaqueValue()) + else -> emptyMap() + } + } + ) + + sut.translate(listOf(HomeRoute(), ProfileRoute("123")), RetentionPolicy.KEEP_FIRST) + + 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 = RouteArgumentsExtractor { _ -> mapOf("bad" to OpaqueValue()) }) + + sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState())) + clearInvocations(logger) + + sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState())) + + 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 `extractRouteArguments returns empty if no arguments extractor`() { + val sut = getSut(argumentsExtractor = null) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEmpty() + } + + @Test + fun `extractRouteArguments returns empty when arguments extractor throws`() { + val sut = getSut(argumentsExtractor = { error("boom") }) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEmpty() + verify(logger) + .log( + eq(WARNING), + eq("Nav3 argumentsExtractor threw while resolving arguments. Skipping arguments."), + org.mockito.kotlin.any(), + ) + } + + @Test + fun `extractRouteArguments returns empty for cyclic structures`() { + val cyclic = mutableMapOf() + cyclic["self"] = cyclic + + val sut = + getSut(argumentsExtractor = RouteArgumentsExtractor { _ -> mapOf("cyclic" to cyclic) }) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEmpty() + } + + @Test + fun `extractRouteArguments returns empty for deeply nested structures`() { + var nested: Any? = "value" + repeat(25) { nested = listOf(nested) } + + val sut = + getSut(argumentsExtractor = RouteArgumentsExtractor { _ -> mapOf("nested" to nested) }) + + assertThat( + sut.extractRouteArguments(ProfileRoute("123"), ArgumentSanitizer(logger, WarningState())) + ) + .isEmpty() + } + + @Test + fun `extractRouteArguments drops oversized payloads instead of truncating them`() { + val sut = + getSut( + argumentsExtractor = RouteArgumentsExtractor { _ -> mapOf("values" to List(1_001) { it }) } + ) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEmpty() + } + + @Test + fun `extractRouteArguments does not use caller collection size for allocation`() { + val values = + object : AbstractCollection() { + var wasSizeRead = false + + override val size: Int + get() { + wasSizeRead = true + return 2 + } + + override fun iterator(): MutableIterator = mutableListOf(1, 2).iterator() + } + val sut = + getSut(argumentsExtractor = RouteArgumentsExtractor { _ -> mapOf("values" to values) }) + + assertThat(sut.extractRouteArguments(HomeRoute(), ArgumentSanitizer(logger, WarningState()))) + .isEqualTo(mapOf("values" to listOf(1, 2))) + assertThat(values.wasSizeRead).isFalse() + } +} From d825db7e6aa65a83bd9aae8ab22b266c48ed7c9b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 25 Sep 2026 17:42:48 +0200 Subject: [PATCH 4/7] feat(android-nav3): [Android Nav3 3] Track navigation state transitions (JAVA-274) (#6131) Introduce BackStackObserver, which is responsible for converting Nav3 back stack changes into breadcrumbs, scope state, and navigation transactions. --- .../api/sentry-android-navigation3.api | 8 + sentry-android-navigation3/build.gradle.kts | 9 +- .../compose/navigation3/BackStackObserver.kt | 480 +++++++++++ .../compose/navigation3/RouteExtractors.kt | 24 +- .../compose/navigation3/RouteTranslator.kt | 12 +- .../compose/navigation3/SentryNavOptions.kt | 120 +++ .../navigation3/BackStackObserverTest.kt | 756 ++++++++++++++++++ .../navigation3/RouteExtractorsTest.kt | 2 +- .../navigation3/RouteTranslatorTest.kt | 2 +- .../navigation3/SentryNavOptionsTest.kt | 134 ++++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/TypeCheckHint.java | 4 + 12 files changed, 1537 insertions(+), 15 deletions(-) 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 3e35211b22f..973e071ad96 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -16,6 +16,8 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } buildTypes { @@ -32,6 +34,10 @@ android { compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } + testOptions { + unitTests.isReturnDefaultValues = true + } + lint { warningsAsErrors = true checkDependencies = true @@ -40,6 +46,8 @@ android { checkReleaseBuilds = false } + buildFeatures { buildConfig = true } + androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -54,7 +62,6 @@ dependencies { testImplementation(libs.androidx.compose.runtime) testImplementation(libs.google.truth) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.inline) testImplementation(libs.mockito.kotlin) } 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..cd1e09d14da --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt @@ -0,0 +1,480 @@ +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.INFO +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.TypeCheckHint +import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop +import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop +import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty +import io.sentry.compose.navigation3.RouteTranslator.RetentionPolicy +import io.sentry.protocol.App +import io.sentry.protocol.TransactionNameSource +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import java.lang.ref.WeakReference + +private const val NAVIGATION_OP: String = "navigation" + +/** + * Observes the back stack managed by a single [SentryNavEffect] and records Sentry state as the + * back stack is updated. + * + * **Top of the stack == the current screen** + * + * This class treats top of the back stack as the current navigation destination and visible screen. + * It knows nothing about composite Scenes or multipane navigation scenarios. + * + * **Entry identity determines whether the top has changed** + * + * Referential equality (===), not structural equality, is used to determine whether the top of the + * incoming back stack has changed. That approach: + * + * - matches the typical Nav3 SnapshotStateList, where an entry instance has a stable identity for + * its lifetime in the stack; + * - mirrors [BackStackKey]'s policy; + * - doesn't depend on host-provided `equals()` / `hashCode()`, which can be absent, incorrect, or + * expensive; and + * - ensures we don't miss reporting a genuine top-of-stack change. + * + * **Thread safety** + * + * 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, + extractors: () -> RouteExtractors, +) { + + private val routeTranslator = RouteTranslator(extractors, scopes.options.logger) + + private val navTransaction = NavTransaction(scopes) + private val navContext = NavContext(scopes, options) + private val navScreen = NavScreen() + private val navBreadcrumbs = NavBreadcrumbs(scopes) + + // Safe because the host back stack retains the current top entry strongly between updates. + private var previousTopEntry: WeakReference? = null + private var previousTopRoute: Route? = null + + private val areNavigationTransactionsEnabled: Boolean + get() = scopes.options.isTracingEnabled && options.enableNavigationTransactions + + init { + addIntegrationToSdkVersion("ComposeNavigation3") + } + + internal companion object { + init { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME) + } + } + + /** + * Updates Sentry nav data based on the provided [backStack]. + * + * **Data generated** + * + * By default, the following happens every time the top of the back stack changes: + * + * - a breadcrumb is emitted + * - a screen name is recorded + * - a new nav transaction is started and the old nav transaction, if any, is stopped. + * + * Names and other info for all of the above are derived from the new back stack top. + * + * By default, a record of the current back stack is recorded for every call, irrespective of + * whether the top changes. + * + * Defaults can be configured via the [SentryNavOptions] instance passed to this class's + * constructor. (Screen names can be disabled via [SentryOptions.setEnableScreenTracking].) + * + * **Not idempotent** + * + * This method is ***not*** idempotent. Callers should protect against repeat invocations with the + * same back stack to avoid emitting duplicate Sentry data. + */ + internal fun onBackStackChanged(backStack: List) { + val change = prepareChange(backStack) + scopes.configureScope { scope -> applyChange(scope, change) } + } + + internal fun cleanup() { + previousTopEntry = null + previousTopRoute = null + + scopes.configureScope { scope -> + navTransaction.stop(scope) + navScreen.clear(scope) + + if (options.captureBackStack) { + // This observer owns the nav 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 nav + // 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. + navContext.clear(scope) + } + } + } + + private fun prepareChange(backStack: List): PreparedChange { + val topEntry = backStack.lastOrNull() ?: return BackStackIsEmpty + val data = backStack.extractData() + + return if (topEntry === previousTopEntry?.get()) { + BackStackHasSameTop(data) + } else { + BackStackHasNewTop(previousTopRoute, data) + } + } + + private fun applyChange(scope: IScope, change: PreparedChange) { + when (change) { + is BackStackIsEmpty -> handleEmptyBackStack(scope) + + is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack) + is BackStackHasSameTop -> handleSameTop(scope, change.backStack) + } + } + + /** + * Extracts Sentry-compatible data from the receiver (i.e., a list of host app back stack entries) + * in the form of a [BackStackData]. + * + * Throws if the receiver is empty. + */ + private fun List.extractData(): BackStackData { + check(this.isNotEmpty()) + + val topEntry = this.last() + val shouldCaptureBackStack = options.captureBackStack && options.maxCapturedBackStackEntries > 0 + + val entriesToTranslate = + when { + shouldCaptureBackStack -> + // Reverse entries so they're displayed with the newest entry on top in the Sentry UI. + this.takeLast(options.maxCapturedBackStackEntries).asReversed() + + // We always need to translate the top entry for use with breadcrumbs, etc., even if we're + // not capturing the back stack. + else -> listOf(topEntry) + } + + val routes = + routeTranslator.translate( + backStackEntries = entriesToTranslate, + retentionPolicy = RetentionPolicy.KEEP_FIRST, + ) + + return BackStackData( + topEntry = topEntry, + topRoute = routes.first(), + capturedRoutes = if (shouldCaptureBackStack) routes else emptyList(), + ) + } + + private fun handleNewTop( + scope: IScope, + previousTop: Route?, + currentBackStack: BackStackData, + ) { + val currentTopRoute = currentBackStack.topRoute + + navContext.update(scope, currentBackStack.capturedRoutes) + + if (scopes.options.isEnableScreenTracking) { + navScreen.update(scope, currentTopRoute) + } + + if (options.enableNavigationBreadcrumbs) { + navBreadcrumbs.emit( + from = previousTop, + toEntry = currentBackStack.topEntry, + toRoute = currentBackStack.topRoute, + ) + } + + navTransaction.stop(scope) + + if (areNavigationTransactionsEnabled) { + navTransaction + .start( + scope, + currentTopRoute.name, + currentTopRoute.arguments, + ) + ?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) } + } else { + // Rotate the propagation context. + scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) } + } + + storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topRoute) + } + + private fun handleSameTop(scope: IScope, backStack: BackStackData) { + navContext.update(scope, backStack.capturedRoutes) + storeAsPreviousTop(backStack.topEntry, backStack.topRoute) + } + + private fun handleEmptyBackStack(scope: IScope) { + navTransaction.stop(scope) + navContext.clear(scope) + navScreen.clear(scope) + clearPreviousTop() + } + + private fun storeAsPreviousTop(topEntry: T, topRoute: Route) { + previousTopEntry = WeakReference(topEntry) + previousTopRoute = topRoute + } + + private fun clearPreviousTop() { + previousTopEntry = null + previousTopRoute = null + } +} + +/** + * A model for applying one back stack update. + * + * Lets us separate change preparation from its application so that the [IScopes.configureScope] + * callback in charge of application can use already-computed navigation state. Otherwise, any + * exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad + * `catch` clause. + */ +private sealed interface PreparedChange { + + /** The incoming back stack is empty. */ + data object BackStackIsEmpty : PreparedChange + + /** + * The top of the back stack has changed, and one or more entries below it may have been updated. + */ + data class BackStackHasNewTop( + val previousTop: Route?, + val backStack: BackStackData, + ) : PreparedChange + + /** The top of the back stack is unchanged, but one or more entries below it have been updated. */ + data class BackStackHasSameTop(val backStack: BackStackData) : PreparedChange +} + +/** Info extracted from the host app's back stack in a form suitable for Sentry data. */ +private data class BackStackData( + val topEntry: T, + val topRoute: Route, + /** + * [Route]s representing the newest [SentryNavOption.maxCapturedBackStackEntries] entries from the + * host app's back stack. Possibly empty. + */ + val capturedRoutes: List, +) + +/** A helper class for managing nav transactions. */ +private class NavTransaction(private val scopes: IScopes) { + + private companion object { + private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" + } + + private var activeNavTransaction: ITransaction? = null + + /** Starts an idle navigation transaction, or no-ops if another transaction is already active. */ + fun start( + scope: IScope, + name: String, + arguments: Map, + ): ITransaction? { + clearTransactionIfFinished(scope) + + if (scope.transaction != null) { + scopes.options.logger.log( + DEBUG, + "Nav3 transaction for route %s won't be created because another transaction is active.", + name, + ) + + return null + } + + 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 + it.origin = TRANSACTION_ORIGIN + } + + val transaction = + scopes.startTransaction( + TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP), + transactionOptions, + ) + + if (transaction.isNoOp) { + return null + } + + activeNavTransaction = transaction + + transaction.apply { + if (arguments.isNotEmpty()) { + setData("arguments", arguments) + } + } + + scope.withTransaction { tx -> + if (tx == null) { + scope.transaction = transaction + } + } + + return 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 clearTransactionIfFinished(scope: IScope) { + scope.withTransaction { tx -> + if (tx?.isFinished == true) { + scope.clearTransaction() + } + } + } +} + +/** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */ +private class NavContext(private val scopes: IScopes, private val options: SentryNavOptions) { + + private companion object { + private const val BACKSTACK_KEY = "backstack" + private const val NAVIGATION_CONTEXT_KEY = "navigation" + } + + fun update(scope: IScope, backStackRoutes: List) { + if (backStackRoutes.isEmpty()) { + clear(scope) + return + } + + scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackRoutes.toBackStackMap()) + } + + fun clear(scope: IScope) { + // We purposefully don't call IScope.removeContexts(), as it doesn't notify IScopeObserver and + // therefore doesn't write its updates to disk ¯\_ (ツ)_/¯. + scope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + + /** + * Updates the transaction with the provided navigation info. + * + * Needed because transactions inherit base scope context on a per-key basis unless transactions + * have their own values for those keys. In our case, we need to keep fresh back stack and route + * values in the base context for purposes of crash reporting. But those values will often advance + * past what's relevant to a given transaction. This method prevents misassociation by binding + * proper values to the transaction context instead. + */ + fun updateTransaction( + transaction: ITransaction, + scope: IScope, + backStack: BackStackData, + ) { + if (scopes.options.isEnableScreenTracking) { + val appContext = + transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App() + + appContext.viewNames = listOf(backStack.topRoute.name) + transaction.contexts.setApp(appContext) + } + + if (options.captureBackStack && backStack.capturedRoutes.isNotEmpty()) { + transaction.setContext(NAVIGATION_CONTEXT_KEY, backStack.capturedRoutes.toBackStackMap()) + } + } + + /** Builds the `{"backstack": [...]}` map bound under [NAVIGATION_CONTEXT_KEY]. */ + private fun List.toBackStackMap(): Map = mapOf(BACKSTACK_KEY to serialize()) +} + +/** A helper class for updating the tracked screen name. */ +private class NavScreen { + + private var lastScreenName: String? = null + + fun update(scope: IScope, currentRoute: Route) { + scope.screen = currentRoute.name + lastScreenName = currentRoute.name + } + + fun clear(scope: IScope) { + val routeName = lastScreenName ?: return + if (scope.screen == routeName) { + scope.screen = null + } + lastScreenName = null + } +} + +/** A helper class for generating nav breadcrumbs. */ +private class NavBreadcrumbs(private val scopes: IScopes) { + + fun emit( + from: Route?, + toEntry: T, + toRoute: Route, + ) { + val breadcrumb = + Breadcrumb().apply { + type = NAVIGATION_OP + category = NAVIGATION_OP + + from?.let { + data["from"] = it.name + if (it.arguments.isNotEmpty()) { + data["from_arguments"] = it.arguments + } + } + + data["to"] = toRoute.name + if (toRoute.arguments.isNotEmpty()) { + data["to_arguments"] = toRoute.arguments + } + + level = INFO + } + + val hint = Hint() + hint.set(TypeCheckHint.ANDROID_NAV3_DESTINATION, toEntry) + scopes.addBreadcrumb(breadcrumb, hint) + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt index 92dd05407f1..02ee056cba0 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt @@ -11,7 +11,7 @@ import org.jetbrains.annotations.ApiStatus * Values returned from [extract] are ***not*** scrubbed by the Sentry SDK before being sent to * Sentry. Only return names that are known to be safe or have been pre-scrubbed. * - * **Choosing stable route names** + * **Choosing appropriate route names** * * Implementations should return stable, low-cardinality names that don't depend on object identity, * argument values, or runtime class-name preservation. E.g., `Home`, `DetailScreen`, etc. @@ -19,6 +19,9 @@ import org.jetbrains.annotations.ApiStatus * In particular, avoid `::class.simpleName` in release builds, as R8 obfuscates class names and may * map them to different symbols across builds. * + * Extractors are invoked synchronously from [SentryNavEffect] on the same apply thread that runs + * the effect. Avoid non-performant extraction logic. + * * **Falls back to "/unknown"** * * If [extract] throws or returns a blank route name, Sentry records the destination as "/unknown". @@ -31,8 +34,8 @@ import org.jetbrains.annotations.ApiStatus * * **Using kotlinx.serialization** * - * If your back stack contains `@Serializable` route types, you may want to consider mapping each - * route type to a stable serializer name. For instance: + * If your back stack contains `@Serializable` route types, consider mapping each route type to a + * stable serializer name. For instance: * ```kotlin * val nameExtractor = RouteNameExtractor { route -> * when (route) { @@ -43,7 +46,7 @@ import org.jetbrains.annotations.ApiStatus * } * ``` * - * Doing so prevents route names from being obfuscated while leaving per-route arguments to + * Doing so gives each route type a stable, non-obfuscated name while leaving per-route arguments to * [RouteArgumentsExtractor]. */ @ApiStatus.Experimental @@ -60,13 +63,14 @@ internal fun interface RouteNameExtractor { * Values returned from [extract] are ***not*** scrubbed by the Sentry SDK before being sent to * Sentry. Only return arguments that are known to be safe or have been pre-scrubbed. * - * **Choosing performant route arguments** + * **Choosing appropriate route arguments** * * Return only a small subset of route data useful for diagnostics. Data should be stable enough to * inspect in Sentry. * - * For performance reasons, implementations should avoid large structures. Cyclic or deeply nested - * containers will be skipped. (See `RouteTranslator` for more details.) + * Extractors are invoked synchronously from [SentryNavEffect] on the same apply thread that runs + * the effect. For performance reasons, implementations should avoid large structures. Cyclic or + * deeply nested containers will be skipped. (See `RouteTranslator` for more details.) * * **Accepted value types** * @@ -96,9 +100,9 @@ internal fun interface RouteNameExtractor { * * **Using kotlinx.serialization** * - * Even if your back stack contains `@Serializable` route types, consider mapping each route type to - * a small set of diagnostic arguments to avoid the cost of serializing and returning the entire - * route object. For instance: + * If your back stack contains `@Serializable` route types, avoid returning the entire route object + * when it may be large, nested, or privacy-sensitive. Prefer a small set of diagnostic arguments + * instead. For instance: * ```kotlin * val argumentsExtractor = RouteArgumentsExtractor { route -> * when (route) { 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 index d687a7d890f..7007a8162f5 100644 --- 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 @@ -9,6 +9,14 @@ import org.jetbrains.annotations.TestOnly /** * Translates app-defined back stack entries into input-ordered [Route]s. * + * **Exception handling policy** + * + * Invocations of host-provided [extractors] and sanitization of host-defined arguments are + * protected by broad `try-catch` clauses, as each may throw arbitrary exceptions. We avoid failing + * fast on the assumption that navigation telemetry is supplemental from host apps' perspective, and + * that falling back to an `/unknown` route name or losing an argument map is preferable to + * crashing. + * * **Threading policy** * * This class performs work synchronously on the calling thread. Host-provided [extractors] are @@ -25,13 +33,13 @@ internal class RouteTranslator( } /** Translates the provided [backStackEntries] into [Route]s and returns them in input order. */ - fun translate(backStackEntries: List, policy: RetentionPolicy): List { + fun translate(backStackEntries: List, retentionPolicy: RetentionPolicy): List { val warningState = WarningState() val sanitizer = ArgumentSanitizer(logger, warningState) val routes = MutableList(backStackEntries.size) { null } val indicesInPolicyOrder = - when (policy) { + when (retentionPolicy) { RetentionPolicy.KEEP_FIRST -> backStackEntries.indices RetentionPolicy.KEEP_LAST -> backStackEntries.indices.reversed() } 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..add98ce3ef7 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt @@ -0,0 +1,120 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.Immutable +import org.jetbrains.annotations.ApiStatus + +// 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]. + * + * Instances are immutable; create one with the SentryNavOptions DSL: + * ```kotlin + * val options = SentryNavOptions { + * captureBackStack = false + * maxCapturedBackStackEntries = 5 + * } + * ``` + */ +@ApiStatus.Experimental +@Immutable +internal class SentryNavOptions +private constructor( + val enableNavigationBreadcrumbs: Boolean, + val enableNavigationTransactions: Boolean, + val captureBackStack: Boolean, + val maxCapturedBackStackEntries: Int, +) { + + init { + require(maxCapturedBackStackEntries >= 0) { + "maxCapturedBackStackEntries must be non-negative, was $maxCapturedBackStackEntries" + } + } + + /** + * Mutable builder for [SentryNavOptions]. Prefer the [SentryNavOptions] DSL to using this + * directly. + * + * Lets us keep the resulting instance [Immutable] while preserving binary compatibility, should + * new properties be added in the future. + */ + class Builder { + + /** + * Whether navigation should produce Sentry breadcrumbs. If `true`, a new nav destination + * generates a breadcrumb like `from=/Home` and `to=/Profile`. + */ + var 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. + */ + var 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. + */ + var 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. + */ + var maxCapturedBackStackEntries: Int = DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES + + fun build(): SentryNavOptions = + SentryNavOptions( + enableNavigationBreadcrumbs = enableNavigationBreadcrumbs, + enableNavigationTransactions = enableNavigationTransactions, + captureBackStack = captureBackStack, + maxCapturedBackStackEntries = 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 + } + + override fun toString(): String = + "SentryNavOptions(" + + "enableNavigationBreadcrumbs=$enableNavigationBreadcrumbs, " + + "enableNavigationTransactions=$enableNavigationTransactions, " + + "captureBackStack=$captureBackStack, " + + "maxCapturedBackStackEntries=$maxCapturedBackStackEntries)" +} + +/** + * Creates [SentryNavOptions]. Optionally configure it via [configure]. E.g.: + * ```kotlin + * val options = SentryNavOptions { + * captureBackStack = false + * maxCapturedBackStackEntries = 5 + * } + * ``` + */ +@ApiStatus.Experimental +internal fun SentryNavOptions( + configure: SentryNavOptions.Builder.() -> Unit = {} +): SentryNavOptions = SentryNavOptions.Builder().apply(configure).build() 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..5c510ac9fd2 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt @@ -0,0 +1,756 @@ +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.NoOpTransaction +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.App +import io.sentry.protocol.TransactionNameSource +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +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 CartRoute(val productId: 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 { + private val defaultNameExtractor = + RouteNameExtractor { entry -> entry::class.simpleName ?: "unknown" } + + 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 } + whenever(scopes.getTransaction()).thenAnswer { scope.transaction } + 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: RouteNameExtractor = defaultNameExtractor, + argumentsExtractor: RouteArgumentsExtractor? = 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 + }, + extractors = { RouteExtractors(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 = + RouteArgumentsExtractor { 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.ANDROID_NAV3_DESTINATION)) + .isSameInstanceAs(profile) + } + + @Test + fun `onBackStackChanged reuses the previous top snapshot for breadcrumb from payload`() { + val fixture = Fixture() + val previousProfile = ProfileRoute("123") + val replacementProfile = ProfileRoute("123") + var profileName = "profile" + var profileArguments = mapOf("userId" to "123") + val sut = + fixture.getSut( + nameExtractor = + RouteNameExtractor { entry -> + when (entry) { + is HomeRoute -> "home" + is ProfileRoute -> profileName + is SettingsRoute -> "settings" + else -> error("unknown route: $entry") + } + }, + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> profileArguments + is SettingsRoute -> mapOf("section" to entry.section) + else -> emptyMap() + } + }, + ) + + sut.onBackStackChanged(listOf(HomeRoute(), previousProfile)) + profileName = "mutated-profile" + profileArguments = mapOf("userId" to "999") + + sut.onBackStackChanged(listOf(HomeRoute(), replacementProfile, SettingsRoute("privacy"))) + + assertThat(fixture.breadcrumbs.last().data) + .containsExactly( + "from", + "/profile", + "from_arguments", + mapOf("userId" to "123"), + "to", + "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/settings", + "to_arguments", + mapOf("section" to "privacy"), + ) + } + + @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() + assertThat(fixture.startedTransactions.single().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 preserves top entry arguments when lower entries exhaust the shared budget`() { + val fixture = Fixture() + val sut = + fixture.getSut( + config = ObserverConfig(captureBackStack = true), + argumentsExtractor = + RouteArgumentsExtractor { entry -> + when (entry) { + is HomeRoute -> mapOf("values" to List(999) { it }) + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + }, + ) + + sut.onBackStackChanged(listOf(HomeRoute(), ProfileRoute("123"))) + + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "/ProfileRoute", "args" to mapOf("userId" to "123")), + mapOf("route" to "/HomeRoute"), + ) + ) + assertThat(fixture.startedTransactions.single().getData("arguments")) + .isEqualTo(mapOf("userId" to "123")) + } + + @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.ANDROID_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())) + + // Doesn't emit a back stack... + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + + // ...but continues to emit all other Sentry data. + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.startedTransactions).hasSize(1) + assertThat(fixture.startedTransactions.single().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + } + + @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())) + + // Doesn't emit a back stack... + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + + // ...but continues to emit all other Sentry data. + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/HomeRoute") + assertThat(fixture.startedTransactions).hasSize(1) + assertThat(fixture.startedTransactions.single().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + } + + // Like `onBackStackChanged does not emit a back stack copy when back stack capture is disabled`, + // but here we actually verify that no unnecessary work is done. + @Test + fun `onBackStackChanged skips lower back stack resolution when back stack capture is disabled`() { + val fixture = Fixture() + val home = HomeRoute() + val profile = ProfileRoute("123") + val nameCalls = mutableMapOf() + val argumentCalls = mutableMapOf() + val sut = + fixture.getSut( + config = ObserverConfig(captureBackStack = false), + nameExtractor = { entry -> + nameCalls[entry] = (nameCalls[entry] ?: 0) + 1 + entry::class.simpleName ?: "unknown" + }, + argumentsExtractor = { entry -> + argumentCalls[entry] = (argumentCalls[entry] ?: 0) + 1 + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + }, + ) + + sut.onBackStackChanged(listOf(home, profile)) + + assertThat(nameCalls[profile]).isEqualTo(1) + assertThat(argumentCalls[profile]).isEqualTo(1) + assertThat(nameCalls).doesNotContainKey(home) + assertThat(argumentCalls).doesNotContainKey(home) + } + + @Test + fun `onBackStackChanged resolves top entry arguments once per update`() { + val fixture = Fixture() + val home = HomeRoute() + val profile = ProfileRoute("123") + val argumentCalls = mutableMapOf() + val sut = + fixture.getSut( + argumentsExtractor = + RouteArgumentsExtractor { entry -> + argumentCalls[entry] = (argumentCalls[entry] ?: 0) + 1 + when (entry) { + is HomeRoute -> mapOf("tab" to entry.id) + is ProfileRoute -> mapOf("userId" to entry.userId) + else -> emptyMap() + } + } + ) + + sut.onBackStackChanged(listOf(home, profile)) + + assertThat(argumentCalls[profile]).isEqualTo(1) + assertThat(argumentCalls[home]).isEqualTo(1) + } + + @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 = + RouteArgumentsExtractor { 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) + } + + // Regression test: Setting origin after starting the transaction breaks the ignoredSpanOrigins + // check (see SentryOptions.getIgnoredSpanOrigins()). + @Test + fun `onBackStackChanged sets nav transaction origin before starting the transaction`() { + val fixture = Fixture() + val transactionOptionsCaptor = argumentCaptor() + whenever( + fixture.scopes.startTransaction( + any(), + transactionOptionsCaptor.capture(), + ) + ) + .thenAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, fixture.scopes, transactionOptions) + .also(fixture.startedTransactions::add) + } + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = true)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(transactionOptionsCaptor.firstValue.origin).isEqualTo("auto.navigation.nav3") + } + + @Test + fun `onBackStackChanged preserves scope app fields on the nav transaction`() { + val fixture = Fixture() + val scopeApp = + App().apply { + appName = "Demo App" + appIdentifier = "io.sentry.demo" + } + fixture.scope.contexts.setApp(scopeApp) + val sut = fixture.getSut(config = ObserverConfig(enableScreenTracking = true)) + + sut.onBackStackChanged(listOf(HomeRoute(), ProfileRoute("123"))) + + val transactionApp = fixture.startedTransactions.single().contexts.app + assertThat(transactionApp).isNotNull() + assertThat(transactionApp).isNotSameInstanceAs(scopeApp) + assertThat(transactionApp?.appName).isEqualTo("Demo App") + assertThat(transactionApp?.appIdentifier).isEqualTo("io.sentry.demo") + assertThat(transactionApp?.viewNames).isEqualTo(listOf("/ProfileRoute")) + } + + @Test + fun `onBackStackChanged creates a nav transaction when only 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).hasSize(1) + assertThat(fixture.startedTransactions.single().name).isEqualTo("/HomeRoute") + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + } + + @Test + fun `onBackStackChanged does not create a nav transaction when an ambient transaction is active`() { + val fixture = Fixture() + val ambientTransaction = + SentryTracer( + TransactionContext("ambient", TransactionNameSource.CUSTOM, "ui.load"), + fixture.scopes, + ) + ambientTransaction.startChild("db.query") + fixture.scope.transaction = ambientTransaction + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = true)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(ambientTransaction) + 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 does not bind a no-op nav transaction to the scope`() { + val fixture = Fixture() + whenever(fixture.scopes.startTransaction(any(), any())) + .thenReturn(NoOpTransaction.getInstance()) + val sut = fixture.getSut(config = ObserverConfig(enableNavigationTransactions = true)) + + sut.onBackStackChanged(listOf(HomeRoute())) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + } + + @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() + assertThat(fixture.scope.contexts.app?.viewNames).isNull() + assertThat(fixture.scope.contexts.containsKey("navigation")).isFalse() + assertThat(fixture.breadcrumbs).hasSize(1) + } + + @Test + @Suppress("LongMethod") + fun `onBackStackChanged records unknown route names when destination route name can't be extracted`() { + val fixture = Fixture() + val home = HomeRoute() + val profile = ProfileRoute(userId = "123") + val cart = CartRoute(productId = "987") + val settings = SettingsRoute(section = "privacy") + val sut = + fixture.getSut( + nameExtractor = + RouteNameExtractor { entry -> + when (entry) { + is HomeRoute -> "home" + is ProfileRoute -> " " + is CartRoute -> error("throwing in order to simulate a buggy name extractor") + is SettingsRoute -> "settings" + else -> error("unknown route: $entry") + } + } + ) + + // Navigate to the home screen and verify that a transaction has started and related Sentry data + // have been generated (i.e., screen name, breadcrumb, and updated back stack context), as the + // host app's RouteNameExtractor returned a valid route name for the home screen entry. + sut.onBackStackChanged(listOf(home)) + val transaction = fixture.startedTransactions.single() + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNotNull() + assertThat(fixture.scope.screen).isEqualTo("/home") + assertThat(fixture.scope.contexts.app?.viewNames).isEqualTo(listOf("/home")) + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.scope.navigationBackStack()).isEqualTo(listOf(mapOf("route" to "/home"))) + + // Navigate to the profile screen and verify the invalid route name is recorded as /unknown so + // the transition history remains intact. + sut.onBackStackChanged(listOf(home, profile)) + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.startedTransactions).hasSize(2) + val profileTransaction = fixture.startedTransactions.last() + assertThat(profileTransaction.isFinished).isFalse() + assertThat(profileTransaction.name).isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + assertThat(fixture.scope.transaction).isSameInstanceAs(profileTransaction) + assertThat(fixture.scope.screen).isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + assertThat(fixture.scope.contexts.app?.viewNames) + .isEqualTo(listOf(RouteTranslator.UNKNOWN_ROUTE_NAME)) + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data) + .containsExactly("from", "/home", "to", RouteTranslator.UNKNOWN_ROUTE_NAME) + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to RouteTranslator.UNKNOWN_ROUTE_NAME), + mapOf("route" to "/home"), + ) + ) + + // Navigate to the cart screen and verify the later failure is also recorded as /unknown rather + // than collapsing the route history. + sut.onBackStackChanged(listOf(home, profile, cart)) + assertThat(profileTransaction.isFinished).isTrue() + assertThat(fixture.startedTransactions).hasSize(3) + val cartTransaction = fixture.startedTransactions.last() + assertThat(cartTransaction.isFinished).isFalse() + assertThat(cartTransaction.name).isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + assertThat(fixture.scope.transaction).isSameInstanceAs(cartTransaction) + assertThat(fixture.scope.screen).isEqualTo(RouteTranslator.UNKNOWN_ROUTE_NAME) + assertThat(fixture.scope.contexts.app?.viewNames) + .isEqualTo(listOf(RouteTranslator.UNKNOWN_ROUTE_NAME)) + assertThat(fixture.breadcrumbs).hasSize(3) + assertThat(fixture.breadcrumbs.last().data) + .containsExactly( + "from", + RouteTranslator.UNKNOWN_ROUTE_NAME, + "to", + RouteTranslator.UNKNOWN_ROUTE_NAME, + ) + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to RouteTranslator.UNKNOWN_ROUTE_NAME), + mapOf("route" to RouteTranslator.UNKNOWN_ROUTE_NAME), + mapOf("route" to "/home"), + ) + ) + + // Navigate to the settings screen and verify a new /settings transaction is started and Sentry + // data are generated again, as we received a valid route name. + sut.onBackStackChanged(listOf(home, profile, cart, settings)) + + assertThat(cartTransaction.isFinished).isTrue() + assertThat(fixture.startedTransactions).hasSize(4) + val settingsTransaction = fixture.startedTransactions.last() + assertThat(settingsTransaction.isFinished).isFalse() + assertThat(settingsTransaction.name).isEqualTo("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/settings") + assertThat(fixture.scope.transaction).isSameInstanceAs(settingsTransaction) + assertThat(fixture.scope.screen).isEqualTo("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/settings") + assertThat(fixture.scope.contexts.app?.viewNames).isEqualTo(listOf("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/settings")) + assertThat(fixture.breadcrumbs).hasSize(4) + assertThat(fixture.breadcrumbs.last().data) + .containsExactly("from", RouteTranslator.UNKNOWN_ROUTE_NAME, "to", "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/settings") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("route" to "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/settings"), + mapOf("route" to RouteTranslator.UNKNOWN_ROUTE_NAME), + mapOf("route" to RouteTranslator.UNKNOWN_ROUTE_NAME), + mapOf("route" to "/home"), + ) + ) + } + + @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() + assertThat(fixture.scope.contexts.app?.viewNames).isNull() + 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/RouteExtractorsTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt index f97e5241ac1..fd0d7ff6e9e 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/RouteExtractorsTest.kt @@ -3,7 +3,7 @@ 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 org.junit.Test class RouteExtractorsTest { 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 index ca2f8db3183..b6301bce181 100644 --- 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 @@ -7,7 +7,7 @@ import io.sentry.compose.navigation3.RouteTranslator.ArgumentSanitizer import io.sentry.compose.navigation3.RouteTranslator.RetentionPolicy import io.sentry.compose.navigation3.RouteTranslator.WarningState import java.util.AbstractCollection -import kotlin.test.Test +import org.junit.Test import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock 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..742a7c0ed65 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavOptionsTest.kt @@ -0,0 +1,134 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import java.lang.reflect.Modifier +import org.junit.Assert.assertThrows +import org.junit.Test + +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 = + assertThrows(IllegalArgumentException::class.java) { + 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) + } + } + + @Test + fun `toString includes every property`() { + val options = SentryNavOptions() + val instanceFields = + SentryNavOptions::class + .java + .declaredFields + .filterNot { Modifier.isStatic(it.modifiers) } + .associate { field -> + field.isAccessible = true + field.name to field.get(options) + } + + instanceFields.forEach { (name, value) -> + assertThat(options.toString()).contains("$name=$value") + } + } + + @Test + fun `toString changes when any property changes`() { + val base = SentryNavOptions() + + propertyMutators.forEach { (_, mutate) -> + assertThat(mutate(base).toString()).isNotEqualTo(base.toString()) + } + } + + 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 f28fffd6b80..c77d94cc797 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4730,6 +4730,7 @@ public final class io/sentry/TypeCheckHint { public static final field ANDROID_FRAGMENT Ljava/lang/String; public static final field ANDROID_INTENT Ljava/lang/String; public static final field ANDROID_MOTION_EVENT Ljava/lang/String; + public static final field ANDROID_NAV3_DESTINATION Ljava/lang/String; public static final field ANDROID_NAV_DESTINATION Ljava/lang/String; public static final field ANDROID_NETWORK_CAPABILITIES Ljava/lang/String; public static final field ANDROID_SENSOR_EVENT 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..852f9601928 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -51,6 +51,10 @@ public final class TypeCheckHint { /** Used for Navigation breadrcrumbs. */ public static final String ANDROID_NAV_DESTINATION = "android:navigationDestination"; + /** Used for Navigation 3 breadcrumbs. */ + @ApiStatus.Experimental @ApiStatus.Internal + public static final String ANDROID_NAV3_DESTINATION = "android:nav3Destination"; + /** Used for Network breadrcrumbs. */ public static final String ANDROID_NETWORK_CAPABILITIES = "android:networkCapabilities"; From ecb62436b8e4a6ff805219649179e16f54ee16b4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 28 Sep 2026 13:24:03 +0200 Subject: [PATCH 5/7] feat(android-nav3): [Android Nav3 4] Introduce SentryNavEffect (#6132) Introduce SentryNavEffect as our primary entry point for Nav3 support. The initial aim is to provide parity with our existing Nav2 support. That means generating nav transactions, breadcrumbs, and screen names as the user navigates through the app. We also record up to N frames of the host app's back stack in current scope context. (N is 10 by default; it and other Sentry data configs can be adjusted via SentryNavOptions.) Similar to Nav2, we assume each tracked navigation destination represents the current screen's contents. We don't (yet) support the notion of Scenes (https://developer.android.com/guide/navigation/navigation-3/scenes) or multipane layouts. (Support for each is tracked under [JAVA-590](https://linear.app/getsentry/issue/JAVA-590/nav3-multipane-support). For more details, see the KDocs to SentryNavEffect, SentryNavOptions, RouteNameExtractor, and RouteArgumentsExtractor. SentryNavEffect is experimental and (for now) internal. We'll make it public in a later commit when we're ready to release. --- sentry-android-navigation3/build.gradle.kts | 10 +- .../compose/navigation3/BackStackKey.kt | 35 ++ .../compose/navigation3/SentryNavEffect.kt | 123 ++++ .../compose/navigation3/BackStackKeyTest.kt | 79 +++ .../navigation3/SentryNavEffectTest.kt | 553 ++++++++++++++++++ 5 files changed, 798 insertions(+), 2 deletions(-) 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 973e071ad96..da7ad9f36f4 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -46,7 +46,10 @@ android { checkReleaseBuilds = false } - buildFeatures { buildConfig = true } + buildFeatures { + buildConfig = true + compose = true + } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -60,10 +63,13 @@ 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.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..baa4e18ff75 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt @@ -0,0 +1,123 @@ +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 +import org.jetbrains.annotations.ApiStatus + +/** + * 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, + * nameExtractor = { route -> route.extractName() }, + * argumentsExtractor = { route -> route.extractArgument() }, + * options = SentryNavOptions(), + * ) + * + * // 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 entries) + * + * 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. + * + * @param backStack The navigation backstack to observe. + * @param nameExtractor Extracts a human-readable route name from each entry of the [backStack]. + * @param argumentsExtractor Optional extractor for a map of argument name -> argument values from + * each entry of the [backStack]. If not provided, no arguments are attached. + * @param options The kinds of navigation info this effect should record. + */ +@ApiStatus.Experimental +@Composable +@Suppress("FunctionNaming") +internal fun SentryNavEffect( + backStack: List, + nameExtractor: RouteNameExtractor, + argumentsExtractor: RouteArgumentsExtractor? = null, + options: SentryNavOptions = SentryNavOptions(), +) { + SentryNavEffect( + backStack = backStack, + nameExtractor = nameExtractor, + argumentsExtractor = argumentsExtractor, + options = options, + scopes = ScopesAdapter.getInstance(), + ) +} + +@Composable +@Suppress("FunctionNaming") +internal fun SentryNavEffect( + backStack: List, + nameExtractor: RouteNameExtractor, + argumentsExtractor: RouteArgumentsExtractor? = null, + options: SentryNavOptions = SentryNavOptions(), + scopes: IScopes, +) { + val routeExtractors = rememberUpdatedState(RouteExtractors(nameExtractor, argumentsExtractor)) + + val observer = + remember(scopes, options) { + BackStackObserver( + scopes = scopes, + options = options, + extractors = { routeExtractors.value }, + ) + } + + // The incoming back stack is mutable and shared with the host app; copy it so that BackStackKey + // and BackStackObserver are guaranteed to have the same (stable) view. + val copy = backStack.toList() + + DisposableEffect(observer, BackStackKey(copy)) { + observer.onBackStackChanged(backStack = copy) + 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..16ec65cc4af --- /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 org.junit.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..863d8e4ef34 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt @@ -0,0 +1,553 @@ +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 org.junit.Rule +import org.junit.Test +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 { + + private val defaultNameExtractor = + RouteNameExtractor { entry -> entry::class.simpleName ?: "unknown" } + + @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 + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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 `changing back stack with capture limit of 0 still emits top-entry Sentry data but not back stack copy`() { + val fixture = Fixture() + val backStack = mutableStateListOf(HomeRoute(), ProfileRoute("123")) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/ProfileRoute") + assertThat(fixture.transactions.single().name).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.screen).isEqualTo("/ProfileRoute") + assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() + } + + @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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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, + nameExtractor = defaultNameExtractor, + options = SentryNavOptions(), + 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>( + RouteNameExtractor { entry -> entry::class.simpleName ?: "unknown" } + ) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + nameExtractor = nameExtractor.value, + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + nameExtractor.value = RouteNameExtractor { 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>( + RouteNameExtractor { entry -> entry::class.simpleName ?: "unknown" } + ) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + nameExtractor = nameExtractor.value, + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + nameExtractor.value = RouteNameExtractor { 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?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + nameExtractor = defaultNameExtractor, + argumentsExtractor = argumentsExtractor.value, + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + argumentsExtractor.value = RouteArgumentsExtractor { 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?>(null) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + nameExtractor = defaultNameExtractor, + argumentsExtractor = argumentsExtractor.value, + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + argumentsExtractor.value = RouteArgumentsExtractor { 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, + options = options.value, + nameExtractor = defaultNameExtractor, + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + val originalTransaction = composeRule.runOnIdle { fixture.transactions.single() } + + composeRule.runOnIdle { options.value = SentryNavOptions { captureBackStack = false } } + composeRule.waitForIdle() + + assertThat(originalTransaction.isFinished).isTrue() + assertThat(fixture.transactions).hasSize(2) + assertThat(fixture.transactions.last().name).isEqualTo("/HomeRoute") + assertThat(fixture.transactions.last().isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.transactions.last()) + assertThat(fixture.scope.screen).isEqualTo("/HomeRoute") + 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, + nameExtractor = defaultNameExtractor, + 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() + assertThat(fixture.scope.contexts.app?.viewNames).isNull() + 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" + } +} From da14227372c02c967dbb404c9f4a5e33fcaa6487 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 28 Sep 2026 13:57:12 +0200 Subject: [PATCH 6/7] feat(android): Make SentryNavEffect API public for sample app purposes (#6137) Make SentryNavEffect and related APIs (technically) public so they can be called from the Nav3 sample app. For now, keep them marked as @ApiStatus.Internal until we actually release Nav3 support (at which point we'll include an appropriate CHANGELOG entry). --- .../api/sentry-android-navigation3.api | 42 +++++++++++++++++++ .../compose/navigation3/RouteExtractors.kt | 10 +++-- .../compose/navigation3/SentryNavEffect.kt | 3 +- .../compose/navigation3/SentryNavOptions.kt | 29 ++++++------- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/sentry-android-navigation3/api/sentry-android-navigation3.api b/sentry-android-navigation3/api/sentry-android-navigation3.api index be90eab5cf0..4ef05794fee 100644 --- a/sentry-android-navigation3/api/sentry-android-navigation3.api +++ b/sentry-android-navigation3/api/sentry-android-navigation3.api @@ -6,3 +6,45 @@ public final class io/sentry/compose/navigation3/BuildConfig { public fun ()V } +public abstract interface class io/sentry/compose/navigation3/RouteArgumentsExtractor { + public abstract fun extract (Ljava/lang/Object;)Ljava/util/Map; +} + +public abstract interface class io/sentry/compose/navigation3/RouteNameExtractor { + public abstract fun extract (Ljava/lang/Object;)Ljava/lang/String; +} + +public final class io/sentry/compose/navigation3/SentryNavEffectKt { + public static final fun SentryNavEffect (Ljava/util/List;Lio/sentry/compose/navigation3/RouteNameExtractor;Lio/sentry/compose/navigation3/RouteArgumentsExtractor;Lio/sentry/compose/navigation3/SentryNavOptions;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/sentry/compose/navigation3/SentryNavOptions { + public static final field $stable I + public synthetic fun (ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getCaptureBackStack ()Z + public final fun getEnableNavigationBreadcrumbs ()Z + public final fun getEnableNavigationTransactions ()Z + public final fun getMaxCapturedBackStackEntries ()I + public fun hashCode ()I +} + +public final class io/sentry/compose/navigation3/SentryNavOptions$Builder { + public static final field $stable I + public fun ()V + public final fun build ()Lio/sentry/compose/navigation3/SentryNavOptions; + public final fun getCaptureBackStack ()Z + public final fun getEnableNavigationBreadcrumbs ()Z + public final fun getEnableNavigationTransactions ()Z + public final fun getMaxCapturedBackStackEntries ()I + public final fun setCaptureBackStack (Z)V + public final fun setEnableNavigationBreadcrumbs (Z)V + public final fun setEnableNavigationTransactions (Z)V + public final fun setMaxCapturedBackStackEntries (I)V +} + +public final class io/sentry/compose/navigation3/SentryNavOptionsKt { + public static final fun SentryNavOptions (Lkotlin/jvm/functions/Function1;)Lio/sentry/compose/navigation3/SentryNavOptions; + public static synthetic fun SentryNavOptions$default (Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/sentry/compose/navigation3/SentryNavOptions; +} + diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt index 02ee056cba0..fc11e1ef238 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/RouteExtractors.kt @@ -50,8 +50,9 @@ import org.jetbrains.annotations.ApiStatus * [RouteArgumentsExtractor]. */ @ApiStatus.Experimental -internal fun interface RouteNameExtractor { - fun extract(backStackEntry: T): String +@ApiStatus.Internal +public fun interface RouteNameExtractor { + public fun extract(backStackEntry: T): String } /** @@ -114,8 +115,9 @@ internal fun interface RouteNameExtractor { * ``` */ @ApiStatus.Experimental -internal fun interface RouteArgumentsExtractor { - fun extract(backStackEntry: T): Map +@ApiStatus.Internal +public fun interface RouteArgumentsExtractor { + public fun extract(backStackEntry: T): Map } /** 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 index baa4e18ff75..0dcf8e6ca80 100644 --- 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 @@ -71,9 +71,10 @@ import org.jetbrains.annotations.ApiStatus * @param options The kinds of navigation info this effect should record. */ @ApiStatus.Experimental +@ApiStatus.Internal @Composable @Suppress("FunctionNaming") -internal fun SentryNavEffect( +public fun SentryNavEffect( backStack: List, nameExtractor: RouteNameExtractor, argumentsExtractor: RouteArgumentsExtractor? = null, 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 index add98ce3ef7..628e0820dab 100644 --- 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 @@ -19,13 +19,14 @@ private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 10 * ``` */ @ApiStatus.Experimental +@ApiStatus.Internal @Immutable -internal class SentryNavOptions +public class SentryNavOptions private constructor( - val enableNavigationBreadcrumbs: Boolean, - val enableNavigationTransactions: Boolean, - val captureBackStack: Boolean, - val maxCapturedBackStackEntries: Int, + public val enableNavigationBreadcrumbs: Boolean, + public val enableNavigationTransactions: Boolean, + public val captureBackStack: Boolean, + public val maxCapturedBackStackEntries: Int, ) { init { @@ -41,26 +42,26 @@ private constructor( * Lets us keep the resulting instance [Immutable] while preserving binary compatibility, should * new properties be added in the future. */ - class Builder { + public class Builder { /** * Whether navigation should produce Sentry breadcrumbs. If `true`, a new nav destination * generates a breadcrumb like `from=/Home` and `to=/Profile`. */ - var enableNavigationBreadcrumbs: Boolean = true + public var 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. */ - var enableNavigationTransactions: Boolean = true + public var 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. */ - var captureBackStack: Boolean = true + public var captureBackStack: Boolean = true /** * Maximum number of entries Sentry should record per captured back stack (starting with the @@ -70,9 +71,9 @@ private constructor( * whenever your back stack changes. Keep name and argument extractors lightweight, and reduce * the max captured count if extractor work is unusually expensive. */ - var maxCapturedBackStackEntries: Int = DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES + public var maxCapturedBackStackEntries: Int = DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES - fun build(): SentryNavOptions = + public fun build(): SentryNavOptions = SentryNavOptions( enableNavigationBreadcrumbs = enableNavigationBreadcrumbs, enableNavigationTransactions = enableNavigationTransactions, @@ -115,6 +116,6 @@ private constructor( * ``` */ @ApiStatus.Experimental -internal fun SentryNavOptions( - configure: SentryNavOptions.Builder.() -> Unit = {} -): SentryNavOptions = SentryNavOptions.Builder().apply(configure).build() +@ApiStatus.Internal +public fun SentryNavOptions(configure: SentryNavOptions.Builder.() -> Unit = {}): SentryNavOptions = + SentryNavOptions.Builder().apply(configure).build() From a4d1f06c498892254d5df954257910894c5d291f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 29 Sep 2026 09:23:22 +0200 Subject: [PATCH 7/7] Re-run apiDump --- sentry-android-navigation3/api/sentry-android-navigation3.api | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-navigation3/api/sentry-android-navigation3.api b/sentry-android-navigation3/api/sentry-android-navigation3.api index 4ef05794fee..12da93310ea 100644 --- a/sentry-android-navigation3/api/sentry-android-navigation3.api +++ b/sentry-android-navigation3/api/sentry-android-navigation3.api @@ -27,6 +27,7 @@ public final class io/sentry/compose/navigation3/SentryNavOptions { public final fun getEnableNavigationTransactions ()Z public final fun getMaxCapturedBackStackEntries ()I public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class io/sentry/compose/navigation3/SentryNavOptions$Builder {