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