Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down
22 changes: 20 additions & 2 deletions sentry-android-navigation3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions sentry-android-navigation3/api/sentry-android-navigation3.api
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions sentry-android-navigation3/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions sentry-android-navigation3/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Original file line number Diff line number Diff line change
Expand Up @@ -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**
*
Expand All @@ -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"**
*
Expand Down Expand Up @@ -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
Expand All @@ -134,21 +139,83 @@ public class SentryBackStackEntry(
*
* val backStackItemMapper = BackStackEntryMapper<NavKey> { 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<T : Any> {
public fun interface BackStackEntryMapper<in T : Any> {

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<Any> = 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": <serial name>, "value": <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": <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<Any> =
SerialNameBackStackEntryMapper.withArgumentsInstance
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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": <serial name>, "value": <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<Any>, value: Any): Map<String, Any?> {
var result: Any? = null
ValueEncoder { result = it }.encodeSerializableValue(serializer, value)

@Suppress("UNCHECKED_CAST")
return result as? Map<String, Any?> ?: 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<String, Any?>()
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<Any?>()

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<String, Any?>()
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)
}
}
}
Loading
Loading