diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bd32c9e3..a0741ec36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Improvements + +- Navigation 3: Add `BackStackEntryMapper.serialName()` for generating Sentry data from back stack entries ([#PR_NUMBER](https://github.com/getsentry/sentry-java/pull/PR_NUMBER)) + - Uses each entry's kotlinx.serialization `@SerialName`; if no serial name is available, falls back to `/unknown` + - Add a `BackStackEntryMapper.serialNameWithArguments()` which also records each entry's properties as `arguments` in Sentry data + ### Fixes - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b31df47f6..a2968ea7ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotl kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } @@ -143,6 +144,8 @@ kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +# Minimum version required by androidx.navigation3:navigation3-runtime +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version = "1.7.3" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } diff --git a/sentry-android-navigation3/README.md b/sentry-android-navigation3/README.md index bdfec781fc..443745f381 100644 --- a/sentry-android-navigation3/README.md +++ b/sentry-android-navigation3/README.md @@ -5,8 +5,26 @@ Use `SentryNavEffect` alongside your `NavDisplay` to record navigation transactions, breadcrumbs, screen names, and back stack context as users move around your app. Configure the data you want captured via `SentryNavOptions`. -Because Nav3 back stacks can have arbitrary key types, host apps need to provide `SentryNavEffect` with a mapping between their back stack -keys and the data Sentry should display for each (see `BackStackEntryMapper`). +Because Nav3 back stacks can have arbitrary key types, `SentryNavEffect` uses a `BackStackEntryMapper` to decide the name and arguments +Sentry displays for each back stack key. Pass `BackStackEntryMapper.serialName()` to name `@Serializable` keys after their +kotlinx.serialization serial names (the fully qualified class name, or the `@SerialName` value) without arguments. Keys that aren't +`@Serializable` are recorded as `/unknown`. Provide your own `BackStackEntryMapper` to choose different names or to add arguments; it can +delegate the keys it doesn't handle to `BackStackEntryMapper.serialName()`. + +To also record each key's properties as arguments, pass `BackStackEntryMapper.serialNameWithArguments()`. It sends the full content of +your keys, which may include personal data, so only use it if your keys are safe to send. Values whose property names match Sentry's +sensitive denylist (e.g. `token`, `password`, `session`) are replaced with `"[Filtered]"`. + +If you call `NavDisplay(entries, ...)` or `NavDisplay(sceneState, ...)`, pass `SentryNavEffect` the back stack your entries are built +from, not the entries themselves. `NavEntry` doesn't expose its key, so a list of entries can't be named: + +```kotlin +val backStack = rememberNavBackStack(Home) +SentryNavEffect(backStack, BackStackEntryMapper.serialName()) + +val entries = rememberDecoratedNavEntries(backStack, decorators, entryProvider) +NavDisplay(entries = entries, ...) +``` See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including installation, configuration, and limitations. diff --git a/sentry-android-navigation3/api/sentry-android-navigation3.api b/sentry-android-navigation3/api/sentry-android-navigation3.api index 45f0482ed8..9651400f57 100644 --- a/sentry-android-navigation3/api/sentry-android-navigation3.api +++ b/sentry-android-navigation3/api/sentry-android-navigation3.api @@ -1,7 +1,13 @@ public abstract interface class io/sentry/compose/navigation3/BackStackEntryMapper { + public static final field Companion Lio/sentry/compose/navigation3/BackStackEntryMapper$Companion; public abstract fun map (Ljava/lang/Object;)Lio/sentry/compose/navigation3/SentryBackStackEntry; } +public final class io/sentry/compose/navigation3/BackStackEntryMapper$Companion { + public final fun serialName ()Lio/sentry/compose/navigation3/BackStackEntryMapper; + public final fun serialNameWithArguments ()Lio/sentry/compose/navigation3/BackStackEntryMapper; +} + public final class io/sentry/compose/navigation3/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -21,6 +27,21 @@ public final class io/sentry/compose/navigation3/SentryBackStackEntry { public fun toString ()Ljava/lang/String; } +public final class io/sentry/compose/navigation3/SentryNavAutoInstrumentation { + public static final field $stable I + public static final field INSTANCE Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation; + public static final fun Effect (Ljava/util/List;Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType;Lio/sentry/compose/navigation3/SentryNavOptions;Landroidx/compose/runtime/Composer;I)V +} + +public final class io/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType : java/lang/Enum { + public static final field DEFAULT Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType; + public static final field NAMES_AND_ARGUMENTS Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType; + public static final field NAMES_ONLY Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType; + public static fun values ()[Lio/sentry/compose/navigation3/SentryNavAutoInstrumentation$BackStackMappingType; +} + public final class io/sentry/compose/navigation3/SentryNavEffectKt { public static final fun SentryNavEffect (Ljava/util/List;Lio/sentry/compose/navigation3/BackStackEntryMapper;Lio/sentry/compose/navigation3/SentryNavOptions;Landroidx/compose/runtime/Composer;II)V } diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index da7ad9f36f..2382892939 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -6,6 +6,8 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + // Only test sources declare @Serializable classes. + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -62,11 +64,13 @@ dependencies { implementation(projects.sentry) compileOnly(libs.androidx.compose.runtime) + compileOnly(libs.kotlinx.serialization.core) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.google.truth) + testImplementation(libs.kotlinx.serialization.core) testImplementation(libs.mockito.inline) testImplementation(libs.mockito.kotlin) testImplementation(libs.roboelectric) diff --git a/sentry-android-navigation3/proguard-rules.pro b/sentry-android-navigation3/proguard-rules.pro index 244282115a..23123f1740 100644 --- a/sentry-android-navigation3/proguard-rules.pro +++ b/sentry-android-navigation3/proguard-rules.pro @@ -5,3 +5,15 @@ -keepattributes LineNumberTable,SourceFile ##---------------End: proguard configuration for Compose ---------- + +##---------------Begin: proguard configuration for kotlinx.serialization ---------- + +# kotlinx.serialization is an optional (compileOnly) dependency used by +# BackStackEntryMapper.serialName(). Don't fail the build when it is absent. +-dontwarn kotlinx.serialization.** + +# BackStackEntryMapper.serialName() checks for kotlinx.serialization by class name. +# Keep that name so the check still succeeds in minified apps that include the library. +-keepnames class kotlinx.serialization.KSerializer + +##---------------End: proguard configuration for kotlinx.serialization ---------- diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt index 7455cfda5f..abef1a0702 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt @@ -54,7 +54,10 @@ public class SentryBackStackEntry( /** * Maps a back stack entry to a displayable name and optional diagnostic arguments. * - * To be implemented by the host app. + * Use [BackStackEntryMapper.serialName] to name `@Serializable` entries after their serial names, + * [BackStackEntryMapper.serialNameWithArguments] to also record their properties as arguments, or + * implement this interface to choose your own names and arguments. A custom mapper can delegate the + * entries it doesn't handle to [BackStackEntryMapper.serialName]. * * **Privacy / PII** * @@ -72,7 +75,8 @@ public class SentryBackStackEntry( * runtime class-name preservation. E.g., `Home`, `DetailScreen`, etc. * * In particular, avoid `::class.simpleName`, as R8 obfuscates class names and may associate them - * with different symbols across builds. + * with different symbols across builds. For `@Serializable` entries, prefer serial names, which + * survive minification (see [BackStackEntryMapper.serialName]). * * **Names fall back to "/unknown"** * @@ -115,10 +119,11 @@ public class SentryBackStackEntry( * * **Using kotlinx.serialization** * - * If your back stack contains `@Serializable` entry types, consider giving each entry an explicit - * `@SerialName` and using its `descriptor.serialName`. For performance reasons, don't serialize - * back stack keys in their entirety as `arguments` if they might be large, deeply nested, or - * contain PII or other sensitive information. + * If your back stack contains `@Serializable` entry types, [BackStackEntryMapper.serialName] names + * them after their `descriptor.serialName`. To add arguments for some entries, write a custom + * mapper that handles those entries and delegates the rest. For performance reasons, don't + * serialize back stack keys in their entirety as `arguments` if they might be large, deeply nested, + * or contain PII or other sensitive information. * * For instance: * ```kotlin @@ -134,21 +139,83 @@ public class SentryBackStackEntry( * * val backStackItemMapper = BackStackEntryMapper { entry -> * when (entry) { - * is Home -> SentryBackStackEntry(Home.serializer().descriptor.serialName) * is ProductDetail -> SentryBackStackEntry( * name = ProductDetail.serializer().descriptor.serialName, * // Select a subset of diagnostic arguments when serialization is unsafe * // or non-performant. * arguments = mapOf("product_id" to entry.productId) * ) - * ... + * // Home and any other entries are named after their serial names. + * else -> BackStackEntryMapper.serialName().map(entry) * } * } * ``` */ @ApiStatus.Experimental -public fun interface BackStackEntryMapper { +public fun interface BackStackEntryMapper { + public fun map(backStackEntry: T): SentryBackStackEntry? + + public companion object { + + /** + * Returns a mapper that names each back stack entry after its kotlinx.serialization serial + * name, without arguments. + * + * The name is the entry class's `descriptor.serialName`, passed through unchanged: the class's + * fully qualified name (e.g. `com.example.Route.Detail`), or its `@SerialName` value if it has + * one. Serial names are compile-time constants, so they survive R8 minification. Use + * `@SerialName` to choose shorter names. + * + * Entries are named "/unknown" when: + * - kotlinx.serialization is not on the app's runtime classpath; + * - the entry's class isn't annotated with `@Serializable`, including plain strings, enums, and + * other built-in types; or + * - kotlinx.serialization can't look up the class's serializer without type arguments (e.g. + * generic classes). + * + * Classes that share a custom serializer (`@Serializable(with = ...)`) share its serial name. + * + * kotlinx.serialization is an optional dependency of this library. Apps that use + * `@Serializable` already include it. + */ + @ApiStatus.Experimental + public fun serialName(): BackStackEntryMapper = SerialNameBackStackEntryMapper.instance + + /** + * Returns a mapper that names entries like [serialName] and also records each entry's + * properties as arguments, encoded with kotlinx.serialization. + * + * **Privacy** + * + * This records the full content of your back stack keys, which may include user IDs, emails, + * search queries, or other personal data. Only use it if your keys are safe to send to Sentry. + * Values whose property names match Sentry's built-in sensitive denylist (e.g. `token`, + * `password`, `session`, `key`; matched case-insensitively as substrings) are replaced with + * `"[Filtered]"` at every level of nesting. To send only some properties, use a custom mapper. + * + * **Encoding** + * - Classes and objects become maps from property name to value. Objects without properties + * record no arguments. + * - Lists and sets become lists; maps become maps with string keys. + * - Enums are recorded by serial name; value classes by their underlying value. + * - Polymorphic properties become `{"type": , "value": }`. + * - A key whose serializer doesn't encode to a structure (e.g. a custom serializer that encodes + * to a string) is recorded as `{"value": }`. + * - Properties marked `@Transient` are omitted. + * + * Properties with contextual or open polymorphic serializers can't be encoded, because no + * serializers module is available. Entries with such properties are named, but recorded without + * arguments. + * + * Arguments are subject to the same size and depth limits as those returned by custom mappers. + * Entries that can't be named are recorded as "/unknown" without arguments, as with + * [serialName]. + */ + @ApiStatus.Experimental + public fun serialNameWithArguments(): BackStackEntryMapper = + SerialNameBackStackEntryMapper.withArgumentsInstance + } } /** diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoder.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoder.kt new file mode 100644 index 0000000000..f62fc3d6be --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoder.kt @@ -0,0 +1,134 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.sentry.compose.navigation3 + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.AbstractEncoder +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Encodes a `@Serializable` back stack key into plain maps, lists and scalars, suitable as + * [SentryBackStackEntry.arguments]. + * + * kotlinx-serialization-core has no output format of its own, and Nav3 apps don't necessarily + * include kotlinx-serialization-json, so this is a minimal tree encoder: + * - classes and objects become maps from property name to value; + * - lists and sets become lists; maps become maps with stringified keys; + * - enums become their serial names; value classes become their underlying value; + * - polymorphic values become `{"type": , "value": }`. + * + * Only references kotlinx.serialization, so it must only be used once the library is known to be + * present (see [SerialNameBackStackEntryMapper]). + */ +internal object KotlinxArgumentsEncoder { + + /** Key used when the top-level value doesn't encode to a map (e.g. custom serializers). */ + const val VALUE_KEY: String = "value" + + /** + * Encodes [value] with [serializer]. + * + * @throws kotlinx.serialization.SerializationException if a value can't be encoded, e.g. a + * contextual or open polymorphic property with no registered serializer. + */ + fun encode(serializer: KSerializer, value: Any): Map { + var result: Any? = null + ValueEncoder { result = it }.encodeSerializableValue(serializer, value) + + @Suppress("UNCHECKED_CAST") + return result as? Map ?: mapOf(VALUE_KEY to result) + } + + /** Encodes a single value and hands it to [sink]. Structures are delegated to child encoders. */ + private open class ValueEncoder(private val sink: (Any?) -> Unit) : AbstractEncoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + + protected open fun put(value: Any?) { + sink(value) + } + + override fun encodeValue(value: Any) { + put(value) + } + + override fun encodeNull() { + put(null) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + put(enumDescriptor.getElementName(index)) + } + + override fun encodeInline(descriptor: SerialDescriptor): Encoder = this + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + when (descriptor.kind) { + StructureKind.LIST -> ListEncoder(::put) + StructureKind.MAP -> MapEncoder(::put) + else -> ClassEncoder(::put) + } + } + + /** Collects the elements of a class, object, or polymorphic value into a map. */ + private class ClassEncoder(private val parentSink: (Any?) -> Unit) : ValueEncoder(parentSink) { + private val values = LinkedHashMap() + private var currentKey = "" + + override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + currentKey = descriptor.getElementName(index) + return true + } + + override fun put(value: Any?) { + values[currentKey] = value + } + + override fun endStructure(descriptor: SerialDescriptor) { + parentSink(values) + } + } + + /** Collects the elements of a list or set. */ + private class ListEncoder(private val parentSink: (Any?) -> Unit) : ValueEncoder(parentSink) { + private val values = ArrayList() + + override fun put(value: Any?) { + values += value + } + + override fun endStructure(descriptor: SerialDescriptor) { + parentSink(values) + } + } + + /** Collects the entries of a map. Elements alternate between keys and values. */ + private class MapEncoder(private val parentSink: (Any?) -> Unit) : ValueEncoder(parentSink) { + private val values = LinkedHashMap() + private var isKey = true + private var currentKey = "" + + override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + isKey = index % 2 == 0 + return true + } + + override fun put(value: Any?) { + if (isKey) { + currentKey = value.toString() + } else { + values[currentKey] = value + } + } + + override fun endStructure(descriptor: SerialDescriptor) { + parentSink(values) + } + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookup.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookup.kt new file mode 100644 index 0000000000..fb648f0aea --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookup.kt @@ -0,0 +1,74 @@ +package io.sentry.compose.navigation3 + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializerOrNull + +/** + * The entry point for this library's kotlinx.serialization code, a `compileOnly` dependency. Don't + * touch it unless the library is known to be on the runtime classpath (see + * [SerialNameBackStackEntryMapper]). [KotlinxArgumentsEncoder] is only reached through here. + */ +internal object KotlinxSerialNameLookup : SerialNameLookup { + + // Serializer lookups use reflection, so cache them per class, including failed lookups. + private val serializers = ConcurrentHashMap, CachedSerializer>() + + override fun serialNameOrNull(type: Class<*>): String? = + cachedSerializerOrNull(type)?.descriptor?.serialName + + // Encoding failures mean no arguments; the caller logs them once per class. + @Suppress("SwallowedException") + override fun argumentsOrNull(backStackEntry: Any): Map? { + val serializer = cachedSerializerOrNull(backStackEntry.javaClass) ?: return null + return try { + @Suppress("UNCHECKED_CAST") + KotlinxArgumentsEncoder.encode(serializer as KSerializer, backStackEntry) + } catch (e: SerializationException) { + null + } + } + + private fun cachedSerializerOrNull(type: Class<*>): KSerializer<*>? = + serializers.getOrPut(type) { CachedSerializer(lookUpSerializer(type)) }.serializer + + // The private-object fallback replaces the exception with a result. + @Suppress("SwallowedException") + private fun lookUpSerializer(type: Class<*>): KSerializer<*>? { + // Only name classes the app marked @Serializable. kotlinx.serialization also provides + // serializers for built-in types (e.g. String, List) and unannotated enums. Their serial names + // would either be misleading ("kotlin.String") or derived from an obfuscated class name. + if (!type.isAnnotationPresent(Serializable::class.java)) { + return null + } + + return try { + serializerOrNull(type) + } catch (e: IllegalAccessException) { + // kotlinx.serialization reads a serializable object's INSTANCE field without making it + // accessible, which fails for private objects. + privateObjectSerializerOrNull(type) + } + } + + /** + * Looks up the serializer of a private `@Serializable object` through the members the + * serialization compiler plugin generates for it. kotlinx.serialization's consumer R8 rules keep + * both members. + */ + // A failed lookup means the name is unknown; the caller logs it once per class. + @Suppress("SwallowedException") + private fun privateObjectSerializerOrNull(type: Class<*>): KSerializer<*>? = + try { + val instance = type.getDeclaredField("INSTANCE").apply { isAccessible = true }.get(null) + type.getDeclaredMethod("serializer").apply { isAccessible = true }.invoke(instance) + as? KSerializer<*> + } catch (e: ReflectiveOperationException) { + null + } + + /** Wraps a nullable serializer, since [ConcurrentHashMap] doesn't accept `null` values. */ + private class CachedSerializer(val serializer: KSerializer<*>?) +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentation.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentation.kt new file mode 100644 index 0000000000..ea5ec65859 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentation.kt @@ -0,0 +1,88 @@ +package io.sentry.compose.navigation3 + +import androidx.compose.runtime.Composable +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import org.jetbrains.annotations.ApiStatus + +/** + * Entry point for the Sentry Android Gradle Plugin's Nav3 auto-instrumentation. Not for use by + * apps; call [SentryNavEffect] instead. + * + * The plugin injects a call to [Effect] before each `NavDisplay(backStack = ...)` call. Apps can + * combine any plugin version with any SDK version that has this entry point, so the compiled + * signatures of [Effect] and [BackStackMappingType] must never change. Add a new entry point + * instead. + */ +@ApiStatus.Internal +public object SentryNavAutoInstrumentation { + + /** How auto-instrumented back stack entries are named. */ + @ApiStatus.Internal + public enum class BackStackMappingType { + /** + * Lets the SDK choose at runtime. Currently resolves to [BackStackEntryMapper.serialName]: + * names only, no arguments. + */ + DEFAULT, + + /** [BackStackEntryMapper.serialName]: names only, no arguments. */ + NAMES_ONLY, + + /** [BackStackEntryMapper.serialNameWithArguments]: names plus filtered arguments. */ + NAMES_AND_ARGUMENTS, + } + + /** + * Same as [SentryNavEffect], with the back stack entry mapper chosen by [backStackMappingType]. + * + * Has no default parameters, so injected calls only need to pass the composer and `$changed`. + */ + @JvmStatic + @Composable + public fun Effect( + backStack: List, + backStackMappingType: BackStackMappingType, + options: SentryNavOptions, + ) { + Effect( + backStack = backStack, + backStackMappingType = backStackMappingType, + options = options, + scopes = ScopesAdapter.getInstance(), + ) + } + + @Composable + internal fun Effect( + backStack: List, + backStackMappingType: BackStackMappingType, + options: SentryNavOptions, + scopes: IScopes, + ) { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = backStackMappingType.toBackStackEntryMapper(), + options = options, + scopes = scopes, + ) + } +} + +internal fun SentryNavAutoInstrumentation.BackStackMappingType.toBackStackEntryMapper(): + BackStackEntryMapper = + when (this) { + SentryNavAutoInstrumentation.BackStackMappingType.DEFAULT -> defaultBackStackEntryMapper() + SentryNavAutoInstrumentation.BackStackMappingType.NAMES_ONLY -> + BackStackEntryMapper.serialName() + SentryNavAutoInstrumentation.BackStackMappingType.NAMES_AND_ARGUMENTS -> + BackStackEntryMapper.serialNameWithArguments() + } + +/** + * Resolves [SentryNavAutoInstrumentation.BackStackMappingType.DEFAULT]. The only place that decides + * what auto-instrumented apps record by default, so it can later read a runtime option (e.g. a Data + * Collection setting) without changing the injected call. + */ +private fun defaultBackStackEntryMapper(): BackStackEntryMapper = + BackStackEntryMapper.serialName() 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 8b6269752b..52917a883c 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 @@ -25,9 +25,7 @@ import org.jetbrains.annotations.ApiStatus * // destination get attributed to the appropriate nav transaction. * SentryNavEffect( * backStack = navBackStack, - * backStackEntryMapper = { entry -> - * SentryBackStackEntry(entry.toName(), entry.extractArguments()) - * }, + * backStackEntryMapper = BackStackEntryMapper.serialName(), * options = SentryNavOptions(), * ) * @@ -72,7 +70,10 @@ import org.jetbrains.annotations.ApiStatus * * @param backStack The navigation backstack to observe. * @param backStackEntryMapper Maps each entry of the [backStack] to a name and optional arguments - * for display in Sentry. See the [BackStackEntryMapper] KDoc for best practices. + * for display in Sentry. [BackStackEntryMapper.serialName] and + * [BackStackEntryMapper.serialNameWithArguments] can be used out of the box, or you can implement + * your own (e.g., if you need to omit specific arguments that contain sensitive info). See the + * [BackStackEntryMapper] KDoc for best practices. * @param options The kinds of navigation info this effect should record. */ @ApiStatus.Experimental diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapper.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapper.kt new file mode 100644 index 0000000000..602e6c9c7f --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapper.kt @@ -0,0 +1,169 @@ +package io.sentry.compose.navigation3 + +import io.sentry.ILogger +import io.sentry.ScopesAdapter +import io.sentry.SentryLevel.WARNING +import io.sentry.compose.navigation3.NormalizedSentryBackStackEntry.Companion.UNKNOWN_ENTRY_NAME +import io.sentry.util.HttpUtils +import io.sentry.util.LoadClass +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * Implementation of [BackStackEntryMapper.serialName] and + * [BackStackEntryMapper.serialNameWithArguments]. + * + * kotlinx.serialization is a `compileOnly` dependency. Only [KotlinxSerialNameLookup] and + * [KotlinxArgumentsEncoder] reference it, and this class only touches the lookup after confirming + * the library is present. That way, apps without kotlinx.serialization never load a class that + * references it. + * + * Resolved names, including failed lookups, are cached per class. Route types are bounded, so the + * cache is too. + * + * Arguments are encoded on every call, since they depend on the entry's values. Values whose keys + * match the Data Collection spec's sensitive denylist are replaced with [FILTERED], at every level + * of nesting. + * + * @param lookup resolves serial names and arguments, or `null` if kotlinx.serialization is + * unavailable. + * @param includeArguments whether to encode each entry's properties as arguments. + * @param logger supplies the logger at the time a warning is logged. + */ +internal class SerialNameBackStackEntryMapper( + private val lookup: SerialNameLookup?, + private val includeArguments: Boolean, + private val logger: () -> ILogger, +) : BackStackEntryMapper { + + private val cache = ConcurrentHashMap, CachedSerialName>() + private val typesWithUnencodableArguments = + Collections.newSetFromMap(ConcurrentHashMap, Boolean>()) + + override fun map(backStackEntry: Any): SentryBackStackEntry? { + val name = serialNameOf(backStackEntry.javaClass) ?: return null + // A resolved name implies a non-null lookup. + val arguments = if (includeArguments) lookup?.let { argumentsOf(it, backStackEntry) } else null + return SentryBackStackEntry(name = name, arguments = arguments) + } + + private fun serialNameOf(type: Class<*>): String? { + cache[type]?.let { + return it.name + } + + val resolved = CachedSerialName(lookup?.serialNameOrNull(type)) + val previous = cache.putIfAbsent(type, resolved) + if (previous != null) { + return previous.name + } + + if (resolved.name == null) { + logUnresolved(type) + } + return resolved.name + } + + private fun argumentsOf(lookup: SerialNameLookup, backStackEntry: Any): Map? { + val encoded = lookup.argumentsOrNull(backStackEntry) + if (encoded == null) { + logUnencodable(backStackEntry.javaClass) + return null + } + + @Suppress("UNCHECKED_CAST") val filtered = filterSensitiveValues(encoded) as Map + return filtered.ifEmpty { null } + } + + private fun logUnresolved(type: Class<*>) { + val reason = + if (lookup == null) { + "kotlinx.serialization is not available" + } else { + "the type is not @Serializable or its serializer can't be looked up without type arguments" + } + logger() + .log( + WARNING, + "Nav3 BackStackEntryMapper couldn't name back stack entries of type %s because %s. " + + "Using $UNKNOWN_ENTRY_NAME instead. Make the type @Serializable or use a custom " + + "BackStackEntryMapper.", + type.name, + reason, + ) + } + + private fun logUnencodable(type: Class<*>) { + if (!typesWithUnencodableArguments.add(type)) { + return + } + logger() + .log( + WARNING, + "Nav3 BackStackEntryMapper.serialNameWithArguments() couldn't encode the arguments of " + + "back stack entries of type %s. Recording their names without arguments. Contextual " + + "and open polymorphic properties aren't supported; use a custom BackStackEntryMapper.", + type.name, + ) + } + + /** Wraps a nullable name, since [ConcurrentHashMap] doesn't accept `null` values. */ + private class CachedSerialName(val name: String?) + + companion object { + /** Replaces values whose keys match the sensitive denylist. */ + const val FILTERED: String = "[Filtered]" + + private const val KSERIALIZER_CLASS_NAME = "kotlinx.serialization.KSerializer" + + private val defaultLogger: () -> ILogger = { ScopesAdapter.getInstance().options.logger } + + private val availableLookup: SerialNameLookup? by lazy { + if (LoadClass().isClassAvailable(KSERIALIZER_CLASS_NAME, defaultLogger())) { + KotlinxSerialNameLookup + } else { + null + } + } + + val instance: SerialNameBackStackEntryMapper by lazy { + SerialNameBackStackEntryMapper(availableLookup, includeArguments = false, defaultLogger) + } + + val withArgumentsInstance: SerialNameBackStackEntryMapper by lazy { + SerialNameBackStackEntryMapper(availableLookup, includeArguments = true, defaultLogger) + } + + /** + * Replaces the values of map entries whose keys match the sensitive denylist with [FILTERED], + * recursing into nested maps and lists. + */ + internal fun filterSensitiveValues(value: Any?): Any? = + when (value) { + is Map<*, *> -> + value.entries.associate { (key, entryValue) -> + val keyString = key.toString() + keyString to + if (HttpUtils.containsSensitiveDataKey(keyString)) { + FILTERED + } else { + filterSensitiveValues(entryValue) + } + } + is List<*> -> value.map(::filterSensitiveValues) + else -> value + } + } +} + +/** Resolves the serial name and arguments of a back stack entry. */ +internal interface SerialNameLookup { + /** Returns the serial name of [type], or `null` if it can't be resolved. */ + fun serialNameOrNull(type: Class<*>): String? + + /** + * Returns the properties of [backStackEntry] as plain maps, lists and scalars, or `null` if they + * can't be encoded. An entry without properties returns an empty map. + */ + fun argumentsOrNull(backStackEntry: Any): Map? +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoderTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoderTest.kt new file mode 100644 index 0000000000..fc57424e2b --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxArgumentsEncoderTest.kt @@ -0,0 +1,158 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import kotlin.jvm.JvmInline +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.junit.Test + +class KotlinxArgumentsEncoderTest { + + @Serializable data object Home + + @Serializable + enum class Sort { + @SerialName("price_asc") PRICE_ASC, + NEWEST, + } + + @Serializable data class Filters(val minPrice: Int, val brands: List) + + @Serializable @JvmInline value class ProductId(val value: String) + + @Serializable + sealed interface Origin { + @Serializable @SerialName("search") data class Search(val query: String) : Origin + + @Serializable @SerialName("deeplink") data object DeepLink : Origin + } + + @Serializable + data class Search( + val query: String, + val page: Int = 1, + val exact: Boolean, + val score: Double, + val initial: Char, + val sort: Sort, + val filters: Filters, + val tags: Set, + val counts: Map, + val productId: ProductId, + val previous: String?, + @Transient val cached: String = "not recorded", + ) + + @Serializable data class Detail(val productId: String, val origin: Origin) + + @Serializable(with = CustomSerializer::class) data class CustomSerialized(val id: String) + + object CustomSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("custom.Name", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CustomSerialized) { + encoder.encodeString(value.id) + } + + override fun deserialize(decoder: Decoder): CustomSerialized = + CustomSerialized(decoder.decodeString()) + } + + class Unregistered(val id: String) + + @Serializable data class WithContextual(val id: String, @Contextual val other: Unregistered) + + interface OpenOrigin + + @Serializable data class WithOpenPolymorphic(val id: String, @Polymorphic val origin: OpenOrigin) + + private val sut = KotlinxSerialNameLookup + + @Test + fun `encodes properties as plain maps lists and scalars`() { + val key = + Search( + query = "shoes", + exact = false, + score = 0.5, + initial = 's', + sort = Sort.PRICE_ASC, + filters = Filters(minPrice = 10, brands = listOf("a", "b")), + tags = setOf("new"), + counts = mapOf(1 to 2L), + productId = ProductId("p-1"), + previous = null, + ) + + assertThat(sut.argumentsOrNull(key)) + .isEqualTo( + mapOf( + "query" to "shoes", + "page" to 1, + "exact" to false, + "score" to 0.5, + "initial" to 's', + "sort" to "price_asc", + "filters" to mapOf("minPrice" to 10, "brands" to listOf("a", "b")), + "tags" to listOf("new"), + "counts" to mapOf("1" to 2L), + "productId" to "p-1", + "previous" to null, + ) + ) + } + + @Test + fun `encodes polymorphic properties with their serial name`() { + assertThat(sut.argumentsOrNull(Detail("p-1", Origin.Search("shoes")))) + .isEqualTo( + mapOf( + "productId" to "p-1", + "origin" to mapOf("type" to "search", "value" to mapOf("query" to "shoes")), + ) + ) + assertThat(sut.argumentsOrNull(Detail("p-1", Origin.DeepLink))) + .isEqualTo( + mapOf( + "productId" to "p-1", + "origin" to mapOf("type" to "deeplink", "value" to emptyMap()), + ) + ) + } + + @Test + fun `encodes objects as empty maps`() { + assertThat(sut.argumentsOrNull(Home)).isEmpty() + } + + @Test + fun `wraps values that don't encode to a structure`() { + assertThat(sut.argumentsOrNull(CustomSerialized("abc"))).isEqualTo(mapOf("value" to "abc")) + } + + @Test + fun `returns null for contextual properties without a registered serializer`() { + assertThat(sut.argumentsOrNull(WithContextual("1", Unregistered("2")))).isNull() + } + + @Test + fun `returns null for open polymorphic properties`() { + assertThat(sut.argumentsOrNull(WithOpenPolymorphic("1", object : OpenOrigin {}))).isNull() + } + + @Test + fun `returns null for types without a serializer`() { + assertThat(sut.argumentsOrNull("home")).isNull() + assertThat(sut.argumentsOrNull(Unregistered("1"))).isNull() + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookupTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookupTest.kt new file mode 100644 index 0000000000..fff96fc52b --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/KotlinxSerialNameLookupTest.kt @@ -0,0 +1,119 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.junit.Test + +class KotlinxSerialNameLookupTest { + + @Serializable data object Home + + @Serializable @SerialName("ProductDetail") data class Detail(val productId: String) + + @Serializable + sealed interface Route { + @Serializable data object Settings : Route + + @Serializable @SerialName("route.profile") data class Profile(val userId: String) : Route + } + + @Serializable + enum class Tab { + FEED, + SEARCH, + } + + enum class UnannotatedTab { + FEED + } + + data class NotSerializable(val id: String) + + @Serializable private data object PrivateObject + + @Serializable private data class PrivateClass(val id: String) + + @Serializable data class Generic(val value: T) + + @Serializable(with = CustomSerializer::class) data class CustomSerialized(val id: String) + + object CustomSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("custom.Name", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CustomSerialized) { + encoder.encodeString(value.id) + } + + override fun deserialize(decoder: Decoder): CustomSerialized = + CustomSerialized(decoder.decodeString()) + } + + private val sut = KotlinxSerialNameLookup + + @Test + fun `uses the fully qualified name when there is no SerialName`() { + assertThat(sut.serialNameOrNull(Home::class.java)) + .isEqualTo("io.sentry.compose.navigation3.KotlinxSerialNameLookupTest.Home") + } + + @Test + fun `uses the SerialName unchanged when present`() { + assertThat(sut.serialNameOrNull(Detail::class.java)).isEqualTo("ProductDetail") + assertThat(sut.serialNameOrNull(Route.Profile::class.java)).isEqualTo("route.profile") + } + + @Test + fun `names sealed subtypes after their own serial names`() { + assertThat(sut.serialNameOrNull(Route.Settings::class.java)) + .isEqualTo("io.sentry.compose.navigation3.KotlinxSerialNameLookupTest.Route.Settings") + } + + @Test + fun `names Serializable enums`() { + assertThat(sut.serialNameOrNull(Tab::class.java)) + .isEqualTo("io.sentry.compose.navigation3.KotlinxSerialNameLookupTest.Tab") + } + + @Test + fun `uses the custom serializer's serial name`() { + assertThat(sut.serialNameOrNull(CustomSerialized::class.java)).isEqualTo("custom.Name") + } + + @Test + fun `returns null for classes that aren't Serializable`() { + assertThat(sut.serialNameOrNull(NotSerializable::class.java)).isNull() + } + + @Test + fun `returns null for built-in types and unannotated enums`() { + assertThat(sut.serialNameOrNull(String::class.java)).isNull() + assertThat(sut.serialNameOrNull(Int::class.javaObjectType)).isNull() + assertThat(sut.serialNameOrNull(ArrayList::class.java)).isNull() + assertThat(sut.serialNameOrNull(UnannotatedTab::class.java)).isNull() + } + + @Test + fun `names private objects`() { + assertThat(sut.serialNameOrNull(PrivateObject::class.java)) + .isEqualTo("io.sentry.compose.navigation3.KotlinxSerialNameLookupTest.PrivateObject") + } + + @Test + fun `names private classes`() { + assertThat(sut.serialNameOrNull(PrivateClass::class.java)) + .isEqualTo("io.sentry.compose.navigation3.KotlinxSerialNameLookupTest.PrivateClass") + } + + @Test + fun `returns null for generic classes`() { + assertThat(sut.serialNameOrNull(Generic::class.java)).isNull() + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentationTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentationTest.kt new file mode 100644 index 0000000000..7aeacc7daa --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavAutoInstrumentationTest.kt @@ -0,0 +1,26 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.compose.navigation3.SentryNavAutoInstrumentation.BackStackMappingType +import org.junit.Test + +class SentryNavAutoInstrumentationTest { + + @Test + fun `DEFAULT resolves to serialName`() { + assertThat(BackStackMappingType.DEFAULT.toBackStackEntryMapper()) + .isSameInstanceAs(BackStackEntryMapper.serialName()) + } + + @Test + fun `NAMES_ONLY resolves to serialName`() { + assertThat(BackStackMappingType.NAMES_ONLY.toBackStackEntryMapper()) + .isSameInstanceAs(BackStackEntryMapper.serialName()) + } + + @Test + fun `NAMES_AND_ARGUMENTS resolves to serialNameWithArguments`() { + assertThat(BackStackMappingType.NAMES_AND_ARGUMENTS.toBackStackEntryMapper()) + .isSameInstanceAs(BackStackEntryMapper.serialNameWithArguments()) + } +} 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 index 7b8a4b9946..208c5ac46c 100644 --- 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 @@ -22,6 +22,8 @@ import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher @@ -124,6 +126,133 @@ class SentryNavEffectTest { .isEqualTo(listOf(mapOf("entry" to "/HomeRoute"))) } + @Serializable @SerialName("SerializableHome") private data object SerializableHomeRoute + + @Serializable private data class SerializableProfileRoute(val userId: String) + + @Test + fun `serialName mapper names entries after their serial names`() { + val fixture = Fixture() + val backStack = mutableStateListOf(SerializableHomeRoute) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = BackStackEntryMapper.serialName(), + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + backStack.add(SerializableProfileRoute("123")) + backStack.add(ProfileRoute("456")) + } + composeRule.waitForIdle() + + val profileName = "/io.sentry.compose.navigation3.SentryNavEffectTest.SerializableProfileRoute" + assertThat(fixture.breadcrumbs.first().data["to"]).isEqualTo("/SerializableHome") + assertThat(fixture.transactions.first().name).isEqualTo("/SerializableHome") + assertThat(fixture.scope.screen).isEqualTo("/unknown") + assertThat(fixture.scope.navigationBackStack()) + .isEqualTo( + listOf( + mapOf("entry" to "/unknown"), + mapOf("entry" to profileName), + mapOf("entry" to "/SerializableHome"), + ) + ) + } + + @Serializable + @SerialName("Checkout") + private data class CheckoutRoute(val cartId: String, val authToken: String) + + @Test + fun `serialNameWithArguments mapper records filtered arguments`() { + val fixture = Fixture() + val backStack = mutableStateListOf(CheckoutRoute(cartId = "c-1", authToken = "secret")) + + composeRule.setContent { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = BackStackEntryMapper.serialNameWithArguments(), + options = SentryNavOptions(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/Checkout") + assertThat(fixture.breadcrumbs.single().data["to_arguments"]) + .isEqualTo(mapOf("cartId" to "c-1", "authToken" to "[Filtered]")) + assertThat(fixture.transactions.single().getData("arguments")) + .isEqualTo(mapOf("cartId" to "c-1", "authToken" to "[Filtered]")) + } + + @Test + fun `auto-instrumentation effect with DEFAULT records names without arguments`() { + val fixture = Fixture() + val backStack = mutableStateListOf(CheckoutRoute(cartId = "c-1", authToken = "secret")) + + composeRule.setContent { + SentryNavAutoInstrumentation.Effect( + backStack = backStack, + backStackMappingType = SentryNavAutoInstrumentation.BackStackMappingType.DEFAULT, + options = SentryNavOptions.Builder().build(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/Checkout") + assertThat(fixture.breadcrumbs.single().data["to_arguments"]).isNull() + assertThat(fixture.transactions.single().name).isEqualTo("/Checkout") + assertThat(fixture.transactions.single().getData("arguments")).isNull() + assertThat(fixture.scope.navigationBackStack()).isEqualTo(listOf(mapOf("entry" to "/Checkout"))) + } + + @Test + fun `auto-instrumentation effect with NAMES_AND_ARGUMENTS records filtered arguments`() { + val fixture = Fixture() + val backStack = mutableStateListOf(CheckoutRoute(cartId = "c-1", authToken = "secret")) + + composeRule.setContent { + SentryNavAutoInstrumentation.Effect( + backStack = backStack, + backStackMappingType = + SentryNavAutoInstrumentation.BackStackMappingType.NAMES_AND_ARGUMENTS, + options = SentryNavOptions.Builder().build(), + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs.single().data["to"]).isEqualTo("/Checkout") + assertThat(fixture.breadcrumbs.single().data["to_arguments"]) + .isEqualTo(mapOf("cartId" to "c-1", "authToken" to "[Filtered]")) + } + + @Test + fun `auto-instrumentation effect applies the given options`() { + val fixture = Fixture() + val backStack = mutableStateListOf(SerializableHomeRoute) + + composeRule.setContent { + SentryNavAutoInstrumentation.Effect( + backStack = backStack, + backStackMappingType = SentryNavAutoInstrumentation.BackStackMappingType.DEFAULT, + options = SentryNavOptions { enableNavigationBreadcrumbs = false }, + scopes = fixture.scopes, + ) + } + composeRule.waitForIdle() + + assertThat(fixture.breadcrumbs).isEmpty() + assertThat(fixture.transactions.single().name).isEqualTo("/SerializableHome") + } + @Test fun `pushing a new top entry emits Sentry data for that entry and a new back stack copy`() { val fixture = Fixture() diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapperTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapperTest.kt new file mode 100644 index 0000000000..41a756c4e9 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SerialNameBackStackEntryMapperTest.kt @@ -0,0 +1,256 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import io.sentry.SentryLevel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +class SerialNameBackStackEntryMapperTest { + + @Serializable @SerialName("Home") data object Home + + @Serializable @SerialName("Detail") data class Detail(val id: String) + + data class NotSerializable(val id: String) + + private class Fixture { + val logger = mock() + val lookupCalls = mutableListOf>() + val argumentCalls = mutableListOf() + val names = mapOf, String>(Home::class.java to "Home", Detail::class.java to "Detail") + var arguments: (Any) -> Map? = { emptyMap() } + + fun getSut(isSerializationAvailable: Boolean = true, includeArguments: Boolean = false) = + SerialNameBackStackEntryMapper( + lookup = + if (isSerializationAvailable) { + object : SerialNameLookup { + override fun serialNameOrNull(type: Class<*>): String? { + lookupCalls += type + return names[type] + } + + override fun argumentsOrNull(backStackEntry: Any): Map? { + argumentCalls += backStackEntry + return arguments(backStackEntry) + } + } + } else { + null + }, + includeArguments = includeArguments, + logger = { logger }, + ) + } + + private val fixture = Fixture() + + @Test + fun `names entries after their serial names without arguments`() { + val sut = fixture.getSut() + + val mapped = sut.map(Detail("secret-id")) + + assertThat(mapped).isEqualTo(SentryBackStackEntry("Detail")) + assertThat(mapped?.arguments).isNull() + } + + @Test + fun `returns null when the serial name can't be resolved`() { + val sut = fixture.getSut() + + assertThat(sut.map(NotSerializable("1"))).isNull() + } + + @Test + fun `returns null when kotlinx serialization is unavailable`() { + val sut = fixture.getSut(isSerializationAvailable = false) + + assertThat(sut.map(Home)).isNull() + } + + @Test + fun `looks up each class once, including failed lookups`() { + val sut = fixture.getSut() + + repeat(3) { + sut.map(Home) + sut.map(Detail("$it")) + sut.map(NotSerializable("$it")) + } + + assertThat(fixture.lookupCalls) + .containsExactly(Home::class.java, Detail::class.java, NotSerializable::class.java) + } + + @Test + fun `logs a warning once per unresolvable class`() { + val sut = fixture.getSut() + + repeat(3) { + sut.map(NotSerializable("$it")) + sut.map(Home) + } + + verify(fixture.logger, times(1)) + .log( + eq(SentryLevel.WARNING), + any(), + eq(NotSerializable::class.java.name), + eq( + "the type is not @Serializable or its serializer can't be looked up without type arguments" + ), + ) + verify(fixture.logger, never()).log(any(), any(), eq(Home::class.java.name), any()) + } + + @Test + fun `logs that kotlinx serialization is unavailable once per class`() { + val sut = fixture.getSut(isSerializationAvailable = false) + + repeat(3) { + sut.map(Home) + sut.map(Detail("$it")) + } + + verify(fixture.logger, times(1)) + .log( + eq(SentryLevel.WARNING), + any(), + eq(Home::class.java.name), + eq("kotlinx.serialization is not available"), + ) + verify(fixture.logger, times(1)) + .log( + eq(SentryLevel.WARNING), + any(), + eq(Detail::class.java.name), + eq("kotlinx.serialization is not available"), + ) + } + + @Test + fun `serialName returns a shared mapper backed by kotlinx serialization`() { + val sut = BackStackEntryMapper.serialName() + + assertThat(sut).isSameInstanceAs(BackStackEntryMapper.serialName()) + assertThat(sut.map(Home)).isEqualTo(SentryBackStackEntry("Home")) + assertThat(sut.map(Detail("1"))).isEqualTo(SentryBackStackEntry("Detail")) + assertThat(sut.map(NotSerializable("1"))).isNull() + } + + @Test + fun `serialName can serve as the mapper for any entry type`() { + val homeMapper: BackStackEntryMapper = BackStackEntryMapper.serialName() + + assertThat(homeMapper.map(Home)).isEqualTo(SentryBackStackEntry("Home")) + } + + @Test + fun `custom mappers can delegate unhandled entries to serialName`() { + val sut = + BackStackEntryMapper { entry -> + when (entry) { + is Detail -> SentryBackStackEntry("Detail", mapOf("id" to entry.id)) + else -> BackStackEntryMapper.serialName().map(entry) + } + } + + assertThat(sut.map(Detail("1"))).isEqualTo(SentryBackStackEntry("Detail", mapOf("id" to "1"))) + assertThat(sut.map(Home)).isEqualTo(SentryBackStackEntry("Home")) + } + + @Test + fun `doesn't encode arguments unless asked to`() { + val sut = fixture.getSut() + + sut.map(Detail("1")) + + assertThat(fixture.argumentCalls).isEmpty() + } + + @Test + fun `records encoded arguments when asked to`() { + fixture.arguments = { mapOf("id" to (it as Detail).id) } + val sut = fixture.getSut(includeArguments = true) + + assertThat(sut.map(Detail("1"))).isEqualTo(SentryBackStackEntry("Detail", mapOf("id" to "1"))) + assertThat(sut.map(Detail("2"))).isEqualTo(SentryBackStackEntry("Detail", mapOf("id" to "2"))) + } + + @Test + fun `filters sensitive values at every level of nesting`() { + fixture.arguments = { + mapOf( + "id" to "1", + "authToken" to "secret", + "nested" to mapOf("Password" to "secret", "query" to "shoes"), + "items" to listOf(mapOf("sessionId" to "secret", "sku" to "A1")), + "apiKeys" to listOf("secret"), + ) + } + val sut = fixture.getSut(includeArguments = true) + + assertThat(sut.map(Detail("1"))?.arguments) + .isEqualTo( + mapOf( + "id" to "1", + "authToken" to "[Filtered]", + "nested" to mapOf("Password" to "[Filtered]", "query" to "shoes"), + "items" to listOf(mapOf("sessionId" to "[Filtered]", "sku" to "A1")), + "apiKeys" to "[Filtered]", + ) + ) + } + + @Test + fun `records no arguments for entries without properties`() { + fixture.arguments = { emptyMap() } + val sut = fixture.getSut(includeArguments = true) + + val mapped = sut.map(Home) + + assertThat(mapped).isEqualTo(SentryBackStackEntry("Home")) + assertThat(mapped?.arguments).isNull() + } + + @Test + fun `keeps the name and logs once per class when arguments can't be encoded`() { + fixture.arguments = { null } + val sut = fixture.getSut(includeArguments = true) + + repeat(3) { + assertThat(sut.map(Detail("$it"))).isEqualTo(SentryBackStackEntry("Detail")) + } + + verify(fixture.logger, times(1)) + .log(eq(SentryLevel.WARNING), any(), eq(Detail::class.java.name)) + } + + @Test + fun `doesn't encode arguments for entries it can't name`() { + val sut = fixture.getSut(includeArguments = true) + + assertThat(sut.map(NotSerializable("1"))).isNull() + assertThat(fixture.argumentCalls).isEmpty() + } + + @Test + fun `serialNameWithArguments returns a shared mapper backed by kotlinx serialization`() { + val sut = BackStackEntryMapper.serialNameWithArguments() + + assertThat(sut).isSameInstanceAs(BackStackEntryMapper.serialNameWithArguments()) + assertThat(sut).isNotSameInstanceAs(BackStackEntryMapper.serialName()) + assertThat(sut.map(Home)).isEqualTo(SentryBackStackEntry("Home")) + assertThat(sut.map(Detail("1"))).isEqualTo(SentryBackStackEntry("Detail", mapOf("id" to "1"))) + assertThat(sut.map(NotSerializable("1"))).isNull() + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 813a4691e8..2748976b9b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7985,6 +7985,7 @@ public abstract interface class io/sentry/util/HintUtils$SentryNullableConsumer public final class io/sentry/util/HttpUtils { public fun ()V + public static fun containsSensitiveDataKey (Ljava/lang/String;)Z public static fun containsSensitiveHeader (Ljava/lang/String;)Z public static fun filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 59ed67eb9f..32b9af4d4d 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -133,7 +133,11 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { } } - static boolean containsSensitiveDataKey(final @NotNull String key) { + /** + * Returns whether {@code key} contains a term from the Data Collection spec's sensitive denylist, + * matched partially and case-insensitively. + */ + public static boolean containsSensitiveDataKey(final @NotNull String key) { return containsTerm(key, SENSITIVE_DATA_KEYS); }