diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2c73ae6a25..2e9ddb0bd9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,6 +3,7 @@ animalsniffer = "2.0.1"
apollo = "2.5.9"
androidxLifecycle = "2.2.0"
androidxNavigation = "2.9.8"
+androidxNavigation3 = "1.1.5"
androidxTestCore = "1.7.0"
androidxCompose = "1.9.5"
asyncProfiler = "4.4"
@@ -109,6 +110,8 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process",
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" }
+androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
+androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" }
diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md
index 99d0edcd1c..b065b91e95 100644
--- a/sentry-samples/sentry-samples-android/README.md
+++ b/sentry-samples/sentry-samples-android/README.md
@@ -21,6 +21,9 @@ You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsent
In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property.
+For LLM-driven verification, use `adb` to inspect and interact with the running emulator. Compose-based screens often expose stable
+`Modifier.testTag()` values, which can be used with the UI hierarchy dump to locate controls reliably.
+
## Build modes
### With or without SAGP
diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts
index 40e5bbc982..4fee4a1b92 100644
--- a/sentry-samples/sentry-samples-android/build.gradle.kts
+++ b/sentry-samples/sentry-samples-android/build.gradle.kts
@@ -187,6 +187,7 @@ dependencies {
implementation(projects.sentryAndroid)
implementation(projects.sentryAndroidFragment)
implementation(projects.sentryAndroidNavigation)
+ implementation(projects.sentryAndroidNavigation3)
implementation(projects.sentryAndroidSqlite)
implementation(projects.sentryAndroidTimber)
implementation(projects.sentryCompose)
@@ -212,6 +213,8 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.fragment)
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.browser)
implementation(libs.androidx.room3.runtime)
diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
index 9b10327462..4e80ddfd86 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -112,6 +112,17 @@
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Main" />
+
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
index 54214b8ff5..9660f52ed3 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
@@ -93,6 +93,7 @@ import io.sentry.compose.SentryTraced
import io.sentry.protocol.Feedback
import io.sentry.protocol.User
import io.sentry.samples.android.memory.MemoryLimiterActivity
+import io.sentry.samples.android.navigation.nav3.Nav3SetupActivity
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -912,6 +913,16 @@ fun IntegrationsScreen() {
}
}
}
+ item {
+ SentryTraced("open_nav3_activity") {
+ OutlinedButton(
+ onClick = { activity.startActivity(Intent(activity, Nav3SetupActivity::class.java)) },
+ modifier = Modifier,
+ ) {
+ Text("Open Nav3 Activity", maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
item {
SentryTraced("open_sample_fragment") {
OutlinedButton(
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md
new file mode 100644
index 0000000000..848720b019
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md
@@ -0,0 +1,32 @@
+# Nav2 and Nav3 Samples
+
+## Nav Sample Transaction Tips
+
+When inspecting route-level transactions from `Nav2Activity` or `Nav3Activity`, note that the sample app also enables automatic Activity
+tracing by default. That can produce overlapping `ui.load` transactions for the Activity itself, which may make it harder to isolate the
+navigation transactions generated by the Nav2 or Nav3 integrations.
+
+If you want the route transactions to be the primary trace surface while testing those sample screens, temporarily disable automatic
+Activity tracing in `src/main/AndroidManifest.xml`:
+
+```xml
+
+```
+
+This is mainly useful for debugging and comparison in the sample app. It is not a general recommendation for production apps.
+
+## Navigation Performance Scenarios
+
+Both navigation samples include an interactive performance scenario for exercising stack mutations and route rendering.
+
+### Nav2
+
+Open Nav2 from the integrations screen, select the `Performance` tab, and use the controls to build a stack or replace the top destination.
+The sample also supports automatic recompositions and navigation for sustained stress testing.
+
+### Nav3
+
+Open Nav3 from the integrations screen, select the `Performance` tab, and use the presets or performance controls to run a scenario with the
+desired stack depth, workload, integration mode, and argument shape.
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt
index a846e51b8e..f64a78b051 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt
@@ -76,7 +76,8 @@ internal data class Nav2TraceSpan(
private fun SentryTransaction.toNav2TransactionTrace(dsn: String?): Nav2TransactionTrace {
val trace = contexts.trace
val startTimestamp = startTimestamp
- val endTimestamp = timestamp ?: startTimestamp
+ val endTimestamp =
+ timestamp ?: spans.mapNotNull { span -> span.timestamp }.maxOrNull() ?: startTimestamp
val durationMillis = ((endTimestamp - startTimestamp) * 1_000.0).coerceAtLeast(0.0)
val rootSpanId = trace?.spanId?.toString()
val traceId = trace?.traceId?.toString().orEmpty()
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt
new file mode 100644
index 0000000000..5b935a01e0
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt
@@ -0,0 +1,603 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.ui.NavDisplay
+import io.sentry.compose.navigation3.SentryBackStackEntry
+import io.sentry.compose.navigation3.SentryNavEffect
+import io.sentry.compose.navigation3.SentryNavOptions
+import io.sentry.samples.android.navigation.common.NavigationSampleConfig
+import io.sentry.samples.android.navigation.common.NavigationSampleConfigSnapshot
+import io.sentry.samples.android.navigation.common.RouteWorkOption
+import io.sentry.samples.android.navigation.common.applyToCurrentOptions
+import io.sentry.samples.android.navigation.common.currentNavigationSampleConfigSnapshot
+import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions
+import io.sentry.samples.android.navigation.common.showRouteWorkDialog
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Sample app Activity for testing Sentry's
+ * [Nav3](https://developer.android.com/guide/navigation/navigation-3) integrations.
+ *
+ * Look at Google's [nav3-recipes](https://github.com/android/nav3-recipes) for helpful patterns to
+ * test against. (This Activity doesn't address all of them yet, so update its implementation as
+ * needed.)
+ */
+class Nav3Activity : ComponentActivity() {
+
+ private lateinit var previousConfig: NavigationSampleConfigSnapshot
+
+ private val performanceState =
+ Nav3PerformanceState(
+ measureRenderLatency = true,
+ diagnosticsSurfaceName = "Nav3",
+ )
+
+ private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN))
+
+ private var isTransactionHistoryActive = false
+ private val transactionHistory =
+ NavigationTransactionHistory(isActive = { isTransactionHistoryActive })
+ private var showTransactionHistorySheet by mutableStateOf(false)
+
+ private var showActivityUiLoadTransactionDelayMessage = false
+
+ private var showCrashConfirmation by mutableStateOf(false)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ previousConfig =
+ intent.previousNav3SampleConfigSnapshot(currentNavigationSampleConfigSnapshot())
+
+ val configuration = intent.nav3SampleConfig()
+ configuration.applyToCurrentOptions()
+ showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions
+
+ if (!configuration.enableActivityUiLoadTransaction) {
+ // Cancel the already-started Activity transaction before Compose installs SentryNavEffect,
+ // otherwise the initial /Landing route transaction gets preempted by the lingering ui.load.
+ cancelCurrentActivityUiLoadTransaction()
+ }
+
+ transactionHistory.install()
+
+ setContent {
+ Nav3SampleTheme {
+ Nav3SampleApp(
+ performanceState = performanceState,
+ configuration = configuration,
+ transactions = transactionHistory.transactions,
+ showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage,
+ routeWorkOptions = routeWorkOptions,
+ showTransactionHistorySheet = showTransactionHistorySheet,
+ showCrashConfirmation = showCrashConfirmation,
+ onShowTransactionHistorySheet = { showTransactionHistorySheet = true },
+ onDismissTransactionHistorySheet = { showTransactionHistorySheet = false },
+ onShowRouteWorkSettings = { showRouteWorkSettings() },
+ onShowCrashConfirmation = { showCrashConfirmation = true },
+ onDismissCrashConfirmation = { showCrashConfirmation = false },
+ onOpenTransaction = { url -> openTransactionInSentry(url) },
+ onDumpTransactionUrl = { url -> dumpTransactionUrl(url) },
+ onCopyTransactionUrl = { url -> copyTransactionUrl(url) },
+ )
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ isTransactionHistoryActive = true
+ }
+
+ override fun onStop() {
+ isTransactionHistoryActive = false
+ performanceState.stopAutomaticWork()
+ super.onStop()
+ }
+
+ override fun onDestroy() {
+ if (isFinishing) {
+ previousConfig.applyToCurrentOptions()
+ }
+ transactionHistory.uninstall()
+ super.onDestroy()
+ }
+
+ private fun openTransactionInSentry(url: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ private fun dumpTransactionUrl(url: String) {
+ Log.i(NAV3_TAG, "Sentry transaction URL: $url")
+ Toast.makeText(this, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun copyTransactionUrl(url: String) {
+ val clipboard = getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url))
+ Toast.makeText(this, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun showRouteWorkSettings() {
+ showRouteWorkDialog(this, routeWorkOptions) { selectedOptions ->
+ routeWorkOptions = selectedOptions
+ }
+ }
+}
+
+@SuppressLint("ContextCastToActivity")
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun Nav3SampleApp(
+ performanceState: Nav3PerformanceState,
+ configuration: NavigationSampleConfig,
+ transactions: List,
+ showActivityUiLoadTransactionDelayMessage: Boolean,
+ routeWorkOptions: Set,
+ showTransactionHistorySheet: Boolean,
+ showCrashConfirmation: Boolean,
+ onShowTransactionHistorySheet: () -> Unit,
+ onDismissTransactionHistorySheet: () -> Unit,
+ onShowRouteWorkSettings: () -> Unit,
+ onShowCrashConfirmation: () -> Unit,
+ onDismissCrashConfirmation: () -> Unit,
+ onOpenTransaction: (String) -> Unit,
+ onDumpTransactionUrl: (String) -> Unit,
+ onCopyTransactionUrl: (String) -> Unit,
+) {
+ val activity = LocalContext.current as? ComponentActivity
+ val initialScenario =
+ if (configuration.enableActivityUiLoadTransaction) {
+ Nav3Scenario.SINGLE_STACK
+ } else {
+ Nav3Scenario.LANDING
+ }
+ val backStack = rememberSaveableNav3BackStack(initialScenario.initialRoute)
+ val dialogSceneStrategy = remember { Nav3DialogSceneStrategy() }
+ val bottomSheetSceneStrategy = remember { Nav3BottomSheetSceneStrategy() }
+
+ var enableNavigationBreadcrumbs by remember {
+ mutableStateOf(configuration.enableNavigationBreadcrumbs)
+ }
+ var enableNavigationTransactions by remember {
+ mutableStateOf(configuration.enableNavigationTransactions)
+ }
+ var captureBackStack by remember { mutableStateOf(configuration.captureBackStack) }
+ var maxCapturedBackStackEntries by remember {
+ mutableIntStateOf(configuration.maxCapturedBackStackEntries)
+ }
+ var selectedScenario by rememberSaveable { mutableStateOf(initialScenario) }
+ var customTransactionMode by rememberSaveable {
+ mutableStateOf(Nav3CustomTransactionMode.PER_SCREEN)
+ }
+ var asyncBrowseProductsJob by remember { mutableStateOf(null) }
+ var isAsyncBrowseProductsRunning by remember { mutableStateOf(false) }
+ val performanceScope = rememberCoroutineScope()
+ val customTransactionsScope = rememberCoroutineScope()
+ val customTransactionController = remember { Nav3CustomTransactionController() }
+
+ if (selectedScenario == Nav3Scenario.PERFORMANCE) {
+ @Suppress("UNUSED_EXPRESSION") performanceState.recompositionTick
+ }
+
+ val isPerformanceScenario = selectedScenario == Nav3Scenario.PERFORMANCE
+ val sentryBackStack = if (isPerformanceScenario) backStack.toList() else backStack
+ val integrationMode = performanceState.integrationMode
+ val effectiveCaptureBackStack =
+ if (isPerformanceScenario) integrationMode.captureBackStack else captureBackStack
+ val sentryNavOptions =
+ remember(
+ enableNavigationBreadcrumbs,
+ enableNavigationTransactions,
+ effectiveCaptureBackStack,
+ maxCapturedBackStackEntries,
+ ) {
+ SentryNavOptions {
+ this.enableNavigationBreadcrumbs = enableNavigationBreadcrumbs
+ this.enableNavigationTransactions = enableNavigationTransactions
+ this.captureBackStack = effectiveCaptureBackStack
+ this.maxCapturedBackStackEntries = maxCapturedBackStackEntries
+ }
+ }
+ val performanceExtractorMode = performanceState.extractorMode
+ val performanceArgumentMode = performanceState.argumentMode
+ val backStackEntryMapper =
+ remember(
+ isPerformanceScenario,
+ performanceExtractorMode,
+ performanceArgumentMode,
+ integrationMode.includeArguments,
+ ) {
+ { route: Nav3Route ->
+ val name =
+ if (isPerformanceScenario) {
+ performanceState.recordNameExtraction("Nav3Stress.nameExtraction") {
+ consumeNav3PerformanceExtractorWork(
+ performanceExtractorMode,
+ route.performanceSeed,
+ )
+ route.routeName
+ }
+ } else {
+ route.routeName
+ }
+
+ val arguments =
+ if (isPerformanceScenario && !integrationMode.includeArguments) {
+ null
+ } else if (isPerformanceScenario) {
+ performanceState.recordArgumentExtraction("Nav3Stress.argumentsExtraction") {
+ consumeNav3PerformanceExtractorWork(
+ performanceExtractorMode,
+ route.performanceSeed,
+ )
+ if (route is Nav3Route.Performance) {
+ nav3PerformanceArguments(
+ performanceArgumentMode,
+ route.index,
+ route.generation,
+ )
+ } else {
+ route.arguments
+ }
+ }
+ } else {
+ route.arguments
+ }
+
+ SentryBackStackEntry(name = name, arguments = arguments)
+ }
+ }
+
+ if (!isPerformanceScenario || integrationMode != Nav3PerformanceIntegrationMode.DISABLED) {
+ Nav3CustomTransactionEffect(
+ selectedScenario = selectedScenario,
+ mode = customTransactionMode,
+ backStack = backStack,
+ controller = customTransactionController,
+ )
+
+ SentryNavEffect(
+ backStack = sentryBackStack,
+ backStackEntryMapper = backStackEntryMapper,
+ options = sentryNavOptions,
+ )
+
+ if (isPerformanceScenario) {
+ DisposableEffect(sentryBackStack.toList(), sentryNavOptions) {
+ performanceState.recordSentryNavEffect(
+ processedCall = true,
+ resolvedEntryCount =
+ if (integrationMode.captureBackStack) {
+ minOf(sentryBackStack.size, maxCapturedBackStackEntries)
+ } else {
+ 0
+ },
+ )
+ onDispose {}
+ }
+ }
+ }
+
+ SideEffect { performanceState.recordComposition() }
+
+ val applyPerformancePreset: (Nav3PerformancePreset) -> Unit = { preset ->
+ if (!performanceState.performanceRunActive) {
+ performanceScope.launch {
+ prepareNav3PerformancePreset(
+ preset = preset,
+ state = performanceState,
+ backStack = backStack,
+ onMaxCapturedBackStackEntriesChange = { maxCapturedBackStackEntries = it },
+ )
+ }
+ }
+ }
+ val runPerformance: (Nav3PerformanceRun) -> Unit = { run ->
+ if (!performanceState.performanceRunActive) {
+ performanceScope.launch {
+ runNav3Performance(
+ run = run,
+ state = performanceState,
+ backStack = backStack,
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(selectedScenario, sentryBackStack.lastOrNull()) {
+ tagCurrentNavigationSampleScenario(selectedScenario.label)
+ }
+
+ LaunchedEffect(selectedScenario, customTransactionMode) {
+ if (
+ selectedScenario != Nav3Scenario.CUSTOM ||
+ customTransactionMode != Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION
+ ) {
+ asyncBrowseProductsJob?.cancel()
+ asyncBrowseProductsJob = null
+ isAsyncBrowseProductsRunning = false
+ }
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize().safeDrawingPadding(),
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ Nav3TopBar(
+ backStack = backStack,
+ selectedScenario = selectedScenario,
+ maxCapturedBackStackEntries = maxCapturedBackStackEntries,
+ onTransactionHistoryClick = onShowTransactionHistorySheet,
+ onRouteWorkSettingsClick = onShowRouteWorkSettings,
+ onScenarioSelected = { scenario ->
+ if (scenario != Nav3Scenario.PERFORMANCE && performanceState.performanceRunActive) {
+ performanceState.cancelPerformanceRun()
+ }
+ selectedScenario = scenario
+ performanceState.stopAutomaticWork()
+ backStack.openScenario(scenario)
+ if (scenario == Nav3Scenario.PERFORMANCE) {
+ performanceState.resetCounters()
+ performanceState.markNavigationMutation()
+ }
+ },
+ )
+ },
+ bottomBar = {
+ SentryControls(
+ onCaptureException = { captureSampleException("Nav3") },
+ onCrashApp = onShowCrashConfirmation,
+ )
+ },
+ ) { innerPadding ->
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .padding(innerPadding)
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ NavDisplay(
+ backStack = backStack,
+ modifier =
+ Modifier.fillMaxSize().drawWithContent {
+ drawContent()
+ performanceState.recordFirstDraw()
+ },
+ onBack = {
+ if (backStack.size > 1) {
+ backStack.removeLastOrNull()
+ } else {
+ activity?.finish()
+ }
+ },
+ sceneStrategies = listOf(dialogSceneStrategy, bottomSheetSceneStrategy),
+ transitionSpec = {
+ fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS))
+ .togetherWith(fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)))
+ },
+ popTransitionSpec = {
+ fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS))
+ .togetherWith(fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)))
+ },
+ entryProvider =
+ entryProvider {
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ SingleStackRoute(backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ CustomRoute(
+ mode = customTransactionMode,
+ onModeSelected = { customTransactionMode = it },
+ isAsyncBrowseProductsRunning = isAsyncBrowseProductsRunning,
+ onBrowseProducts = {
+ if (customTransactionMode == Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) {
+ if (!isAsyncBrowseProductsRunning) {
+ customTransactionController.startAsyncBrowseProductsTransaction()
+ isAsyncBrowseProductsRunning = true
+ asyncBrowseProductsJob = customTransactionsScope.launch {
+ val span =
+ io.sentry.Sentry.getSpan()
+ ?.startChild(
+ "test.navigation.async_browse_products",
+ "Nav3 Custom async browse products",
+ )
+ try {
+ delay(250)
+ backStack.add(Nav3Route.ProductList)
+ } finally {
+ span?.finish()
+ isAsyncBrowseProductsRunning = false
+ asyncBrowseProductsJob = null
+ }
+ }
+ }
+ } else {
+ backStack.add(Nav3Route.ProductList)
+ }
+ },
+ )
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ LandingRoute()
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ DeepLinkRoute(backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ProductListRoute(backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ProductDetailRoute(route, backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ CheckoutRoute(route, backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ConfirmationRoute(
+ route = route,
+ backStack = backStack,
+ rootRoute =
+ if (selectedScenario == Nav3Scenario.CUSTOM) {
+ Nav3Route.Custom
+ } else {
+ Nav3Route.SingleStack
+ },
+ )
+ }
+ }
+
+ entry(metadata = Nav3DialogSceneStrategy.dialog()) { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ PromoDialogRoute(
+ route = route,
+ backStack = backStack,
+ onCaptureException = { captureSampleException("Nav3") },
+ onCrashApp = onShowCrashConfirmation,
+ )
+ }
+ }
+ entry(metadata = Nav3BottomSheetSceneStrategy.bottomSheet()) {
+ route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ShareSheetRoute(
+ route = route,
+ backStack = backStack,
+ onCaptureException = { captureSampleException("Nav3") },
+ onCrashApp = onShowCrashConfirmation,
+ )
+ }
+ }
+
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ FutureRoute(routeName = "Multipane", scenario = "multipane")
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ FutureRoute(routeName = "Multiple Stacks", scenario = "multistack")
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ Nav3PerformanceRoute(
+ route = route,
+ backStack = backStack,
+ performanceState = performanceState,
+ maxCapturedBackStackEntries = maxCapturedBackStackEntries,
+ onMaxCapturedBackStackEntriesChange = { maxCapturedBackStackEntries = it },
+ onApplyPreset = applyPerformancePreset,
+ onRunPerformance = runPerformance,
+ )
+ }
+ }
+ },
+ )
+ }
+ }
+
+ if (showTransactionHistorySheet) {
+ NavigationTransactionHistorySheet(
+ sampleName = "Nav3",
+ transactions = transactions,
+ showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage,
+ onDismissRequest = onDismissTransactionHistorySheet,
+ onOpenTransaction = onOpenTransaction,
+ onDumpTransactionUrl = onDumpTransactionUrl,
+ onCopyTransactionUrl = onCopyTransactionUrl,
+ )
+ }
+
+ if (showCrashConfirmation) {
+ AlertDialog(
+ onDismissRequest = onDismissCrashConfirmation,
+ title = { Text("Crash app?") },
+ text = { Text("This will throw an uncaught exception and close the sample app.") },
+ dismissButton = {
+ TextButton(onClick = onDismissCrashConfirmation) { Text("Cancel") }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDismissCrashConfirmation()
+ crashSampleApp("Nav3")
+ }
+ ) {
+ Text("Crash")
+ }
+ },
+ )
+ }
+}
+
+private const val NAV3_TAG = "Nav3Activity"
+private const val COMPOSE_ROUTE_TRANSITION_MILLIS = 350
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt
new file mode 100644
index 0000000000..2ff41d9684
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt
@@ -0,0 +1,172 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.slideInVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavMetadataKey
+import androidx.navigation3.runtime.get
+import androidx.navigation3.runtime.metadata
+import androidx.navigation3.scene.OverlayScene
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.scene.SceneStrategy
+import androidx.navigation3.scene.SceneStrategyScope
+
+/**
+ * Sample-only scene strategies for rendering lightweight overlays inside the Nav3 content frame.
+ */
+internal data class Nav3OverlayScene(
+ override val key: T,
+ override val previousEntries: List>,
+ override val overlaidEntries: List>,
+ private val entry: NavEntry,
+ private val onBack: () -> Unit,
+ private val alignment: Alignment,
+ private val enterTransition: @Composable () -> androidx.compose.animation.EnterTransition,
+ private val cardModifier: Modifier,
+) : OverlayScene {
+
+ override val entries: List> = listOf(entry)
+
+ override val content: @Composable (() -> Unit) = {
+ val visibleState = rememberOverlayVisibleState()
+
+ // NavDisplay bases its system-back pop count on the non-overlay scene beneath this custom
+ // in-content overlay. Consume Back here so a single press removes only the overlay entry.
+ BackHandler(onBack = onBack)
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ AnimatedOverlayScrim(visibleState = visibleState, onBack = onBack)
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = enterTransition(),
+ modifier = Modifier.align(alignment),
+ ) {
+ Card(
+ modifier = cardModifier,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ ) {
+ entry.Content()
+ }
+ }
+ }
+ }
+}
+
+/** Displays entries with [bottomSheet] metadata as bottom-sheet overlays. */
+internal class Nav3BottomSheetSceneStrategy : SceneStrategy {
+
+ private val strategy =
+ Nav3OverlaySceneStrategy(
+ metadataKey = BottomSheetKey,
+ alignment = Alignment.BottomCenter,
+ enterTransition = {
+ fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)) + slideInVertically { it }
+ },
+ cardModifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
+ )
+
+ override fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+ return with(strategy) { calculateScene(entries) }
+ }
+
+ internal companion object {
+ fun bottomSheet() = metadata { put(BottomSheetKey, true) }
+
+ object BottomSheetKey : NavMetadataKey
+ }
+}
+
+/** Displays marked Nav3 entries as in-content dialog overlays. */
+@Composable
+private fun rememberOverlayVisibleState(): MutableTransitionState = remember {
+ MutableTransitionState(false).apply { targetState = true }
+}
+
+@Composable
+private fun AnimatedOverlayScrim(
+ visibleState: MutableTransitionState,
+ onBack: () -> Unit,
+) {
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)),
+ ) {
+ Box(
+ modifier =
+ Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.32f)).clickable { onBack() }
+ )
+ }
+}
+
+private const val OVERLAY_ENTER_MILLIS = 120
+
+private class Nav3OverlaySceneStrategy(
+ private val metadataKey: NavMetadataKey,
+ private val alignment: Alignment,
+ private val enterTransition: @Composable () -> androidx.compose.animation.EnterTransition,
+ private val cardModifier: Modifier,
+) {
+
+ fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+ val overlayEntry = entries.lastOrNull() ?: return null
+ overlayEntry.metadata[metadataKey] ?: return null
+ val baseEntries = entries.dropLast(1)
+ @Suppress("UNCHECKED_CAST") val sceneKey = overlayEntry.contentKey as T
+
+ return Nav3OverlayScene(
+ key = sceneKey,
+ previousEntries = baseEntries,
+ overlaidEntries = baseEntries,
+ entry = overlayEntry,
+ onBack = onBack,
+ alignment = alignment,
+ enterTransition = enterTransition,
+ cardModifier = cardModifier,
+ )
+ }
+}
+
+/** Displays entries with [dialog] metadata as dialog overlays inside the NavDisplay frame. */
+internal class Nav3DialogSceneStrategy : SceneStrategy {
+
+ private val strategy =
+ Nav3OverlaySceneStrategy(
+ metadataKey = DialogKey,
+ alignment = Alignment.Center,
+ enterTransition = {
+ fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)) + scaleIn(initialScale = 0.96f)
+ },
+ cardModifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp),
+ )
+
+ override fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+ return with(strategy) { calculateScene(entries) }
+ }
+
+ internal companion object {
+ fun dialog() = metadata { put(DialogKey, true) }
+
+ object DialogKey : NavMetadataKey
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt
new file mode 100644
index 0000000000..b12f5b75a8
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt
@@ -0,0 +1,220 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import io.sentry.ITransaction
+import io.sentry.Sentry
+import io.sentry.SpanStatus
+import io.sentry.TransactionOptions
+
+internal enum class Nav3CustomTransactionMode(
+ val label: String,
+ val description: String,
+) {
+ PER_SCREEN(
+ label = "Per Screen",
+ description =
+ "Starts a custom transaction for every destination so Nav3 route work runs under app-owned " +
+ "screen-level transactions.",
+ ),
+ WHOLE_FLOW(
+ label = "Whole Flow",
+ description =
+ "Keeps one custom transaction open for the whole shopping journey until the flow returns " +
+ "to the Custom home screen.",
+ ),
+ LINGERING(
+ label = "Lingering",
+ description =
+ "Starts one custom transaction and intentionally leaves it active across later routes to " +
+ "simulate a stale power-user transaction.",
+ ),
+ ASYNC_FROM_USER_ACTION(
+ label = "Async From User Action",
+ description =
+ "Starts a custom transaction from the Browse Products button, waits for async work, then " +
+ "navigates to Product List while the manual transaction stays active.",
+ ),
+}
+
+@Composable
+internal fun Nav3CustomTransactionEffect(
+ selectedScenario: Nav3Scenario,
+ mode: Nav3CustomTransactionMode,
+ backStack: List,
+ controller: Nav3CustomTransactionController,
+) {
+ val backStackSnapshot = backStack.toList()
+
+ DisposableEffect(selectedScenario, mode, backStackSnapshot) {
+ controller.onBackStackChanged(
+ isCustomScenario = selectedScenario == Nav3Scenario.CUSTOM,
+ mode = mode,
+ backStack = backStackSnapshot,
+ )
+ onDispose {}
+ }
+
+ DisposableEffect(controller) { onDispose { controller.cleanup() } }
+}
+
+internal class Nav3CustomTransactionController {
+
+ private var activeTransaction: ITransaction? = null
+ private var activeMode: Nav3CustomTransactionMode? = null
+ private var activeRouteName: String? = null
+
+ fun onBackStackChanged(
+ isCustomScenario: Boolean,
+ mode: Nav3CustomTransactionMode,
+ backStack: List,
+ ) {
+ if (!isCustomScenario) {
+ cleanup()
+ return
+ }
+
+ val currentRoute =
+ backStack.lastOrNull()
+ ?: run {
+ cleanup()
+ return
+ }
+
+ when (mode) {
+ Nav3CustomTransactionMode.PER_SCREEN -> handlePerScreen(currentRoute)
+ Nav3CustomTransactionMode.WHOLE_FLOW -> handleWholeFlow(currentRoute)
+ Nav3CustomTransactionMode.LINGERING -> handleLingering(currentRoute)
+ Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION -> handleAsyncFromUserAction(currentRoute)
+ }
+ }
+
+ fun startAsyncBrowseProductsTransaction() {
+ finishActiveTransaction()
+ activeTransaction =
+ startCustomTransaction(
+ name = "custom.tap_to_browse_products",
+ operation = "ui.action",
+ mode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION,
+ routeName = Nav3Route.Custom.routeName,
+ )
+ activeMode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION
+ activeRouteName = Nav3Route.Custom.routeName
+ activeTransaction?.setData("sample.async_trigger", "browse_products")
+ }
+
+ fun cleanup() {
+ finishActiveTransaction()
+ }
+
+ private fun handlePerScreen(currentRoute: Nav3Route) {
+ if (
+ activeMode == Nav3CustomTransactionMode.PER_SCREEN &&
+ activeRouteName == currentRoute.routeName
+ ) {
+ return
+ }
+
+ finishActiveTransaction()
+ activeTransaction =
+ startCustomTransaction(
+ name = "custom.${currentRoute.routeName.lowercase()}_screen",
+ operation = "ui.screen.manual",
+ mode = Nav3CustomTransactionMode.PER_SCREEN,
+ routeName = currentRoute.routeName,
+ )
+ activeMode = Nav3CustomTransactionMode.PER_SCREEN
+ activeRouteName = currentRoute.routeName
+ }
+
+ private fun handleWholeFlow(currentRoute: Nav3Route) {
+ if (currentRoute == Nav3Route.Custom) {
+ finishActiveTransaction()
+ return
+ }
+
+ if (activeMode != Nav3CustomTransactionMode.WHOLE_FLOW || activeTransaction == null) {
+ finishActiveTransaction()
+ activeTransaction =
+ startCustomTransaction(
+ name = "custom.checkout_flow",
+ operation = "ui.flow.manual",
+ mode = Nav3CustomTransactionMode.WHOLE_FLOW,
+ routeName = currentRoute.routeName,
+ )
+ activeMode = Nav3CustomTransactionMode.WHOLE_FLOW
+ }
+
+ activeRouteName = currentRoute.routeName
+ activeTransaction?.setData("sample.current_route", currentRoute.routeName)
+ }
+
+ private fun handleLingering(currentRoute: Nav3Route) {
+ if (activeMode == Nav3CustomTransactionMode.LINGERING && activeTransaction != null) {
+ activeTransaction?.setData("sample.current_route", currentRoute.routeName)
+ return
+ }
+
+ finishActiveTransaction()
+ activeTransaction =
+ startCustomTransaction(
+ name = "custom.lingering_navigation_transaction",
+ operation = "ui.flow.manual",
+ mode = Nav3CustomTransactionMode.LINGERING,
+ routeName = currentRoute.routeName,
+ )
+ activeMode = Nav3CustomTransactionMode.LINGERING
+ activeRouteName = currentRoute.routeName
+ }
+
+ private fun handleAsyncFromUserAction(currentRoute: Nav3Route) {
+ if (activeMode != Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) {
+ finishActiveTransaction()
+ return
+ }
+
+ val transaction = activeTransaction ?: return
+ activeMode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION
+ activeRouteName = currentRoute.routeName
+ transaction.setData("sample.current_route", currentRoute.routeName)
+
+ if (currentRoute != Nav3Route.Custom && currentRoute !is Nav3Route.ProductList) {
+ finishActiveTransaction()
+ }
+ }
+
+ private fun startCustomTransaction(
+ name: String,
+ operation: String,
+ mode: Nav3CustomTransactionMode,
+ routeName: String,
+ ): ITransaction {
+ val options =
+ TransactionOptions().also {
+ it.isBindToScope = true
+ it.isWaitForChildren = true
+ it.idleTimeout = null
+ }
+ return Sentry.startTransaction(name, operation, options).apply {
+ setTag("sample_nav3_scenario", Nav3Scenario.CUSTOM.label)
+ setTag("sample_nav3_custom_mode", mode.label)
+ setData("sample.custom_transaction", true)
+ setData("sample.current_route", routeName)
+ }
+ }
+
+ private fun finishActiveTransaction() {
+ val transaction = activeTransaction ?: return
+ transaction.finish(transaction.status ?: SpanStatus.OK)
+ Sentry.configureScope { scope ->
+ scope.withTransaction { current ->
+ if (current == transaction) {
+ scope.clearTransaction()
+ }
+ }
+ }
+ activeTransaction = null
+ activeMode = null
+ activeRouteName = null
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt
new file mode 100644
index 0000000000..62c181b5b4
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt
@@ -0,0 +1,283 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CancellationException
+
+@Composable
+internal fun Nav3PerformanceRoute(
+ route: Nav3Route.Performance,
+ backStack: SnapshotStateList,
+ performanceState: Nav3PerformanceState,
+ maxCapturedBackStackEntries: Int,
+ onMaxCapturedBackStackEntriesChange: (Int) -> Unit,
+ onApplyPreset: (Nav3PerformancePreset) -> Unit,
+ onRunPerformance: (Nav3PerformanceRun) -> Unit,
+) {
+ val performanceRunActive = performanceState.performanceRunActive
+ if (performanceRunActive) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Performance run", style = MaterialTheme.typography.headlineMedium)
+ Text(performanceState.performanceStatus, style = MaterialTheme.typography.bodyLarge)
+ Text("Diagnostics will be published when the run completes.")
+ }
+ return
+ }
+
+ LaunchedEffect(route) { performanceState.markDestinationChange() }
+
+ Nav3PerformancePanel(
+ title = "Performance",
+ description =
+ "Stress SentryNavEffect with deep stacks, unrelated recompositions, route extraction, and " +
+ "argument sanitization. Use Perfetto sections like SentryNavEffect.onBackStackChanged and " +
+ "the Nav3Stress markers to inspect hot paths.",
+ currentRoute = "/${route.previewName}",
+ backStack = nav3PerformanceBackStackPreview(backStack.map { entry -> "/${entry.previewName}" }),
+ state = performanceState,
+ showExtractorControls = true,
+ onBuildStack = {
+ traceNav3PerformanceSection("Nav3Stress.buildStack") {
+ performanceState.beginNavigationOperation()
+ backStack.openPerformanceStack(
+ depth = performanceState.stackDepth,
+ generation = performanceState.nextGeneration(),
+ )
+ performanceState.markNavigationMutation()
+ }
+ },
+ onMutateLowerEntry = {
+ traceNav3PerformanceSection("Nav3Stress.mutateLowerEntry") {
+ performanceState.beginNavigationOperation()
+ backStack.mutatePerformanceLowerEntry(performanceState.nextGeneration())
+ performanceState.markNavigationMutation()
+ }
+ },
+ onReplaceTop = {
+ traceNav3PerformanceSection("Nav3Stress.replaceTop") {
+ performanceState.beginNavigationOperation()
+ backStack.replacePerformanceTop(performanceState.nextGeneration())
+ performanceState.markNavigationMutation()
+ }
+ },
+ nav3Controls =
+ Nav3PerformancePanelControls(
+ actualStackEntries = backStack.size,
+ maxCapturedBackStackEntries = maxCapturedBackStackEntries,
+ onMaxCapturedBackStackEntriesChange = onMaxCapturedBackStackEntriesChange,
+ onApplyPreset = onApplyPreset,
+ onRunPerformance = onRunPerformance,
+ ),
+ )
+}
+
+internal suspend fun prepareNav3PerformancePreset(
+ preset: Nav3PerformancePreset,
+ state: Nav3PerformanceState,
+ backStack: SnapshotStateList,
+ onMaxCapturedBackStackEntriesChange: (Int) -> Unit,
+) {
+ state.startPerformanceRun("Preparing ${preset.label}")
+ try {
+ state.stackDepth = preset.stackDepth
+ state.extractorMode = preset.extractorMode
+ state.argumentMode = preset.argumentMode
+ state.updateIntegrationMode(preset.integrationMode)
+ onMaxCapturedBackStackEntriesChange(preset.maxCapturedBackStackEntries)
+ backStack.openPerformanceStack(preset.stackDepth, state.nextGeneration())
+ awaitNav3PerformanceFrames()
+ if (
+ !runNav3PerformanceIterations(
+ iterationCount = PERFORMANCE_WARM_UP_ITERATIONS,
+ run = Nav3PerformanceRun.TOP_REPLACEMENTS,
+ state = state,
+ backStack = backStack,
+ )
+ ) {
+ return
+ }
+ state.resetCounters()
+ state.suppressNextDestinationChange()
+ state.cancelPerformanceRun(status = "Ready: ${preset.label}")
+ } catch (e: CancellationException) {
+ state.cancelPerformanceRun()
+ throw e
+ }
+}
+
+internal suspend fun runNav3Performance(
+ run: Nav3PerformanceRun,
+ state: Nav3PerformanceState,
+ backStack: SnapshotStateList,
+ warmUpOnly: Boolean = false,
+ skipWarmUp: Boolean = false,
+) {
+ if (!skipWarmUp) {
+ state.startPerformanceRun("Warming up")
+ }
+ try {
+ if (run == Nav3PerformanceRun.AB_COMPARISON) {
+ runNav3PerformanceAbComparison(state, backStack)
+ return
+ }
+
+ if (
+ !skipWarmUp &&
+ !runNav3PerformanceIterations(
+ iterationCount = PERFORMANCE_WARM_UP_ITERATIONS,
+ run = run,
+ state = state,
+ backStack = backStack,
+ )
+ ) {
+ return
+ }
+ if (warmUpOnly) {
+ state.finishWarmUp()
+ return
+ }
+ state.startMeasuredIterations()
+ if (!skipWarmUp) {
+ state.updatePerformanceStatus("Measuring $PERFORMANCE_MEASURED_ITERATIONS iterations")
+ }
+ if (
+ !runNav3PerformanceIterations(
+ iterationCount = PERFORMANCE_MEASURED_ITERATIONS,
+ run = run,
+ state = state,
+ backStack = backStack,
+ )
+ ) {
+ return
+ }
+ state.finishPerformanceRun()
+ } catch (e: CancellationException) {
+ state.cancelPerformanceRun()
+ throw e
+ }
+}
+
+private suspend fun runNav3PerformanceAbComparison(
+ state: Nav3PerformanceState,
+ backStack: SnapshotStateList,
+) {
+ val originalMode = state.integrationMode
+ val modes =
+ if (state.nextAbComparisonRunsDisabledFirst()) {
+ listOf(
+ Nav3PerformanceIntegrationMode.DISABLED,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ )
+ } else {
+ listOf(
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceIntegrationMode.DISABLED,
+ )
+ }
+ val results = mutableListOf()
+
+ try {
+ modes.forEach { mode ->
+ state.updatePerformanceStatus("Warming up ${mode.label}")
+ state.updateIntegrationMode(mode)
+ awaitNav3PerformanceFrames()
+ if (
+ !runNav3PerformanceIterations(
+ iterationCount = PERFORMANCE_WARM_UP_ITERATIONS,
+ run = Nav3PerformanceRun.TOP_REPLACEMENTS,
+ state = state,
+ backStack = backStack,
+ )
+ ) {
+ return
+ }
+ state.startMeasuredIterations()
+ state.updatePerformanceStatus("Measuring ${mode.label}")
+ state.beginAbPhase(mode)
+ val completed =
+ try {
+ runNav3PerformanceIterations(
+ iterationCount = PERFORMANCE_MEASURED_ITERATIONS,
+ run = Nav3PerformanceRun.TOP_REPLACEMENTS,
+ state = state,
+ backStack = backStack,
+ )
+ } finally {
+ state.finishAbPhase()
+ }
+ if (!completed) {
+ return
+ }
+ state.stopCollectingMeasurements()
+ results += state.performanceSummary(mode.label)
+ }
+ } finally {
+ state.updateIntegrationMode(originalMode)
+ try {
+ awaitNav3PerformanceFrames()
+ } finally {
+ state.resetNavigationMeasurementGuards()
+ }
+ }
+
+ state.finishPerformanceRun(results.joinToString(" | "))
+}
+
+private suspend fun runNav3PerformanceIterations(
+ iterationCount: Int,
+ run: Nav3PerformanceRun,
+ state: Nav3PerformanceState,
+ backStack: SnapshotStateList,
+): Boolean {
+ repeat(iterationCount) {
+ if (!state.performanceRunActive) {
+ return false
+ }
+ performNav3PerformanceIteration(run, state, backStack)
+ awaitNav3PerformanceFrames()
+ }
+ return true
+}
+
+private fun performNav3PerformanceIteration(
+ run: Nav3PerformanceRun,
+ state: Nav3PerformanceState,
+ backStack: SnapshotStateList,
+) {
+ when (run) {
+ Nav3PerformanceRun.UNRELATED_RECOMPOSITIONS -> state.markRecompositionRequest()
+ Nav3PerformanceRun.TOP_REPLACEMENTS -> {
+ state.beginNavigationOperation()
+ backStack.replacePerformanceTop(state.nextGeneration())
+ state.markNavigationMutation()
+ state.markPerformanceDestinationChange()
+ }
+ Nav3PerformanceRun.LOWER_ENTRY_MUTATIONS -> {
+ state.beginNavigationOperation()
+ backStack.mutatePerformanceLowerEntry(state.nextGeneration())
+ state.markNavigationMutation()
+ }
+ Nav3PerformanceRun.AB_COMPARISON -> error("A/B comparison runs its own iterations")
+ }
+}
+
+internal suspend fun awaitNav3PerformanceFrames() {
+ withFrameNanos {}
+ withFrameNanos {}
+}
+
+private const val PERFORMANCE_WARM_UP_ITERATIONS = 5
+private const val PERFORMANCE_MEASURED_ITERATIONS = 20
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt
new file mode 100644
index 0000000000..90f90dd2b3
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt
@@ -0,0 +1,1272 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.os.Build
+import android.os.Trace
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import java.util.Locale
+import kotlin.math.ceil
+import kotlinx.coroutines.delay
+
+internal class Nav3PerformanceState(
+ private val measureRenderLatency: Boolean = false,
+ private val diagnosticsSurfaceName: String? = null,
+) {
+ var stackDepth by mutableIntStateOf(30)
+ var recompositionTick by mutableIntStateOf(0)
+ var displayRevision by mutableIntStateOf(0)
+ var autoRecompose by mutableStateOf(false)
+ var autoNavigate by mutableStateOf(false)
+ var extractorMode by mutableStateOf(Nav3PerformanceExtractorMode.NORMAL)
+ var argumentMode by mutableStateOf(Nav3PerformanceArgumentMode.FLAT)
+ var integrationMode by mutableStateOf(Nav3PerformanceIntegrationMode.FULL_STACK)
+ private set
+
+ var performanceStatus by mutableStateOf("Ready")
+ private set
+
+ var performanceRunActive by mutableStateOf(false)
+ private set
+
+ var comparisonResult by mutableStateOf(null)
+ private set
+
+ var generation = 0
+ private set
+
+ var recompositionRequests = 0
+ private set
+
+ var navigationMutations = 0
+ private set
+
+ var destinationChanges = 0
+ private set
+
+ var nameExtractorCalls = 0
+ private set
+
+ var argumentsExtractorCalls = 0
+ private set
+
+ var nameExtractorNanos = 0L
+ private set
+
+ var argumentsExtractorNanos = 0L
+ private set
+
+ var sentryNavEffectAttempts = 0
+ private set
+
+ var sentryNavEffectProcessedCalls = 0
+ private set
+
+ var capturedEntriesResolved = 0
+ private set
+
+ private val mutationToCompositionDurations = Nav3PerformanceDurations()
+ private val mutationToFirstDrawDurations = Nav3PerformanceDurations()
+ private var pendingOperation: PendingNav3PerformanceOperation? = null
+ private var pendingAbPhase: PendingNav3PerformanceTrace? = null
+ private var pendingMeasuredPhase: PendingNav3PerformanceTrace? = null
+ private var nextTraceCookie = 0
+ private var abComparisonCount = 0
+ private var collectMeasurements = true
+ private var discardNextSentryNavEffectMeasurement = false
+ private var suppressExtractorMeasurementsUntilNavigation = false
+ private var suppressNextDestinationChange = false
+ private var pendingProcessedNavigationWork = false
+
+ fun resetCounters() {
+ finishPendingOperation()
+ recompositionRequests = 0
+ navigationMutations = 0
+ destinationChanges = 0
+ nameExtractorCalls = 0
+ argumentsExtractorCalls = 0
+ nameExtractorNanos = 0L
+ argumentsExtractorNanos = 0L
+ sentryNavEffectAttempts = 0
+ sentryNavEffectProcessedCalls = 0
+ capturedEntriesResolved = 0
+ pendingProcessedNavigationWork = false
+ mutationToCompositionDurations.clear()
+ mutationToFirstDrawDurations.clear()
+ comparisonResult = null
+ displayRevision++
+ }
+
+ fun stopAutomaticWork() {
+ autoRecompose = false
+ autoNavigate = false
+ }
+
+ fun nextGeneration(): Int {
+ generation++
+ return generation
+ }
+
+ fun markRecompositionRequest() {
+ beginNavigationOperation(measureFirstDraw = false)
+ recompositionRequests++
+ pendingProcessedNavigationWork = false
+ recompositionTick++
+ if (!performanceRunActive) {
+ displayRevision++
+ }
+ }
+
+ fun markNavigationMutation() {
+ navigationMutations++
+ markNavigationWorkPending()
+ if (!performanceRunActive) {
+ displayRevision++
+ }
+ }
+
+ fun markDestinationChange() {
+ if (suppressNextDestinationChange) {
+ suppressNextDestinationChange = false
+ return
+ }
+ destinationChanges++
+ markNavigationWorkPending()
+ if (!performanceRunActive) {
+ displayRevision++
+ }
+ }
+
+ fun markPerformanceDestinationChange() {
+ destinationChanges++
+ markNavigationWorkPending()
+ }
+
+ fun suppressNextDestinationChange() {
+ suppressNextDestinationChange = true
+ }
+
+ fun updateIntegrationMode(mode: Nav3PerformanceIntegrationMode) {
+ if (integrationMode != mode) {
+ discardNextSentryNavEffectMeasurement = true
+ suppressExtractorMeasurementsUntilNavigation = true
+ pendingProcessedNavigationWork = false
+ integrationMode = mode
+ }
+ }
+
+ fun beginNavigationOperation(measureFirstDraw: Boolean = true) {
+ if (!measureRenderLatency) {
+ return
+ }
+
+ finishPendingOperation()
+ val cookie = ++nextTraceCookie
+ pendingOperation =
+ PendingNav3PerformanceOperation(
+ cookie = cookie,
+ startedAtNanos = System.nanoTime(),
+ collectMeasurement = collectMeasurements,
+ measureFirstDraw = measureFirstDraw,
+ )
+ beginAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, cookie)
+ if (measureFirstDraw) {
+ beginAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, cookie)
+ }
+ }
+
+ fun recordComposition() {
+ val operation = pendingOperation ?: return
+ if (operation.compositionRecorded) {
+ return
+ }
+
+ operation.compositionRecorded = true
+ endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie)
+ if (operation.collectMeasurement) {
+ mutationToCompositionDurations.add(System.nanoTime() - operation.startedAtNanos)
+ }
+ if (!operation.measureFirstDraw) {
+ pendingOperation = null
+ }
+ }
+
+ fun recordFirstDraw() {
+ val operation = pendingOperation ?: return
+ if (!operation.measureFirstDraw) {
+ return
+ }
+ endAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, operation.cookie)
+ if (!operation.compositionRecorded) {
+ endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie)
+ }
+ if (operation.collectMeasurement) {
+ mutationToFirstDrawDurations.add(System.nanoTime() - operation.startedAtNanos)
+ }
+ pendingOperation = null
+ }
+
+ fun recordSentryNavEffect(
+ processedCall: Boolean,
+ resolvedEntryCount: Int,
+ ) {
+ if (discardNextSentryNavEffectMeasurement) {
+ discardNextSentryNavEffectMeasurement = false
+ pendingProcessedNavigationWork = false
+ return
+ }
+ if (!collectMeasurements) {
+ return
+ }
+
+ sentryNavEffectAttempts++
+ val shouldCountAsProcessed = processedCall && pendingProcessedNavigationWork
+ if (shouldCountAsProcessed) {
+ sentryNavEffectProcessedCalls++
+ capturedEntriesResolved += resolvedEntryCount
+ }
+ pendingProcessedNavigationWork = false
+ }
+
+ fun startPerformanceRun(status: String) {
+ finishMeasuredPhase()
+ performanceRunActive = true
+ performanceStatus = status
+ comparisonResult = null
+ collectMeasurements = false
+ }
+
+ fun updatePerformanceStatus(status: String) {
+ performanceStatus = status
+ }
+
+ fun startMeasuredIterations() {
+ finishMeasuredPhase()
+ resetCounters()
+ collectMeasurements = true
+ val cookie = ++nextTraceCookie
+ pendingMeasuredPhase = PendingNav3PerformanceTrace(MEASURED_SECTION, cookie)
+ beginAsyncTraceSection(MEASURED_SECTION, cookie)
+ }
+
+ fun stopCollectingMeasurements() {
+ finishMeasuredPhase()
+ collectMeasurements = false
+ }
+
+ fun finishWarmUp() {
+ resetCounters()
+ cancelPerformanceRun(status = "Warm-up complete")
+ }
+
+ fun finishPerformanceRun(result: String? = null) {
+ finishMeasuredPhase()
+ finishAbPhase()
+ collectMeasurements = true
+ performanceRunActive = false
+ performanceStatus = "Complete"
+ comparisonResult = result
+ suppressNextDestinationChange = true
+ pendingProcessedNavigationWork = false
+ displayRevision++
+ emitDiagnosticsSummary()
+ }
+
+ fun cancelPerformanceRun(status: String = "Ready") {
+ finishMeasuredPhase()
+ finishAbPhase()
+ collectMeasurements = true
+ performanceRunActive = false
+ performanceStatus = status
+ pendingProcessedNavigationWork = false
+ finishPendingOperation()
+ }
+
+ fun performanceSummary(label: String): String =
+ "$label: first draw ${mutationToFirstDrawDurations.compactSummary()}"
+
+ fun diagnosticsSummary(currentRoute: String, backStack: String): String = buildString {
+ appendLine("status=$performanceStatus")
+ appendLine("route=$currentRoute")
+ appendLine("tracked_stack=$backStack")
+ appendLine("recomposition_requests=$recompositionRequests")
+ appendLine("navigation_mutations=$navigationMutations")
+ appendLine("destination_changes=$destinationChanges")
+ appendLine("sentry_nav_effect_attempts=$sentryNavEffectAttempts")
+ appendLine("processed_calls=$sentryNavEffectProcessedCalls")
+ appendLine("captured_entries_resolved=$capturedEntriesResolved")
+ appendLine("name_extractor_calls=$nameExtractorCalls")
+ appendLine("arguments_extractor_calls=$argumentsExtractorCalls")
+ appendLine("name_extractor_avg=${nameExtractorAverageMicros()}")
+ appendLine("arguments_extractor_avg=${argumentsExtractorAverageMicros()}")
+ appendLine("mutation_to_composition=${mutationToCompositionSummary()}")
+ appendLine("mutation_to_first_draw=${mutationToFirstDrawSummary()}")
+ appendLine("first_draws_over_8_3_ms=${firstDrawsOver8Millis()}")
+ appendLine("first_draws_over_16_7_ms=${firstDrawsOver16Millis()}")
+ comparisonResult?.let { append("ab_result=$it") }
+ }
+
+ private fun emitDiagnosticsSummary() {
+ val surface = diagnosticsSurfaceName ?: return
+ Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_START surface=$surface")
+ diagnosticsSummary(currentRoute = "", backStack = "")
+ .trimEnd()
+ .lineSequence()
+ .forEach { line -> Log.i(NAV_PERF_TAG, line) }
+ Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_END surface=$surface")
+ }
+
+ fun publishDiagnostics(currentRoute: String, backStack: String) {
+ val surface = diagnosticsSurfaceName ?: return
+ Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_START surface=$surface")
+ diagnosticsSummary(currentRoute = currentRoute, backStack = backStack)
+ .trimEnd()
+ .lineSequence()
+ .forEach { line -> Log.i(NAV_PERF_TAG, line) }
+ Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_END surface=$surface")
+ }
+
+ fun nextAbComparisonRunsDisabledFirst(): Boolean = abComparisonCount++ % 2 == 0
+
+ fun beginAbPhase(mode: Nav3PerformanceIntegrationMode) {
+ finishAbPhase()
+ val sectionName =
+ when (mode) {
+ Nav3PerformanceIntegrationMode.DISABLED -> AB_DISABLED_SECTION
+ Nav3PerformanceIntegrationMode.FULL_STACK -> AB_ENABLED_SECTION
+ else -> error("Unsupported A/B integration mode: $mode")
+ }
+ val cookie = ++nextTraceCookie
+ pendingAbPhase = PendingNav3PerformanceTrace(sectionName, cookie)
+ beginAsyncTraceSection(sectionName, cookie)
+ }
+
+ fun finishAbPhase() {
+ pendingAbPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) }
+ pendingAbPhase = null
+ }
+
+ fun resetNavigationMeasurementGuards() {
+ discardNextSentryNavEffectMeasurement = false
+ suppressExtractorMeasurementsUntilNavigation = false
+ pendingProcessedNavigationWork = false
+ }
+
+ private fun finishMeasuredPhase() {
+ pendingMeasuredPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) }
+ pendingMeasuredPhase = null
+ }
+
+ fun mutationToCompositionSummary(): String = mutationToCompositionDurations.summary()
+
+ fun mutationToFirstDrawSummary(): String = mutationToFirstDrawDurations.summary()
+
+ fun firstDrawsOver8Millis(): Int = mutationToFirstDrawDurations.countOver(8_300_000L)
+
+ fun firstDrawsOver16Millis(): Int = mutationToFirstDrawDurations.countOver(16_700_000L)
+
+ private fun finishPendingOperation() {
+ val operation = pendingOperation ?: return
+ if (!operation.compositionRecorded) {
+ endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie)
+ }
+ if (operation.measureFirstDraw) {
+ endAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, operation.cookie)
+ }
+ pendingOperation = null
+ }
+
+ fun recordNameExtraction(sectionName: String, block: () -> String): String {
+ val startedAt = System.nanoTime()
+ Trace.beginSection(sectionName)
+ try {
+ return block()
+ } finally {
+ Trace.endSection()
+ if (collectMeasurements && !suppressExtractorMeasurementsUntilNavigation) {
+ nameExtractorCalls++
+ nameExtractorNanos += System.nanoTime() - startedAt
+ }
+ }
+ }
+
+ fun recordArgumentExtraction(
+ sectionName: String,
+ block: () -> Map,
+ ): Map {
+ val startedAt = System.nanoTime()
+ Trace.beginSection(sectionName)
+ try {
+ return block()
+ } finally {
+ Trace.endSection()
+ if (collectMeasurements && !suppressExtractorMeasurementsUntilNavigation) {
+ argumentsExtractorCalls++
+ argumentsExtractorNanos += System.nanoTime() - startedAt
+ }
+ }
+ }
+
+ private fun markNavigationWorkPending() {
+ suppressExtractorMeasurementsUntilNavigation = false
+ pendingProcessedNavigationWork = true
+ }
+}
+
+private data class PendingNav3PerformanceOperation(
+ val cookie: Int,
+ val startedAtNanos: Long,
+ val collectMeasurement: Boolean,
+ val measureFirstDraw: Boolean,
+ var compositionRecorded: Boolean = false,
+)
+
+private data class PendingNav3PerformanceTrace(val sectionName: String, val cookie: Int)
+
+internal class Nav3PerformanceDurations {
+ private val values = mutableListOf()
+
+ val size: Int
+ get() = values.size
+
+ fun add(durationNanos: Long) {
+ values += durationNanos.coerceAtLeast(0L)
+ }
+
+ fun clear() {
+ values.clear()
+ }
+
+ fun countOver(thresholdNanos: Long): Int = values.count { it > thresholdNanos }
+
+ fun percentile(percentile: Int): Long {
+ require(percentile in 0..100)
+ if (values.isEmpty()) {
+ return 0L
+ }
+ val sortedValues = values.sorted()
+ val index = (ceil(percentile / 100.0 * sortedValues.size).toInt() - 1).coerceAtLeast(0)
+ return sortedValues[index]
+ }
+
+ fun summary(): String =
+ if (values.isEmpty()) {
+ "No samples"
+ } else {
+ "n=$size, p50=${formatNanos(percentile(50))}, p90=${formatNanos(percentile(90))}, " +
+ "max=${formatNanos(values.max())}"
+ }
+
+ fun compactSummary(): String =
+ if (values.isEmpty()) "disabled" else "p50=${formatNanos(percentile(50))}"
+
+ fun minus(other: Nav3PerformanceDurations): Nav3PerformanceDurations =
+ Nav3PerformanceDurations().also { result ->
+ values.forEachIndexed { index, value ->
+ result.add(value - other.values.getOrElse(index) { 0L })
+ }
+ }
+}
+
+internal enum class Nav3PerformanceExtractorMode(val label: String) {
+ NORMAL("Normal"),
+ HEAVY("Heavy"),
+}
+
+internal enum class Nav3PerformanceArgumentMode(val label: String) {
+ EMPTY("Empty"),
+ FLAT("Flat"),
+ NESTED("Nested"),
+ LARGE("Large"),
+}
+
+internal enum class Nav3PerformanceIntegrationMode(
+ val label: String,
+ val captureBackStack: Boolean,
+ val includeArguments: Boolean,
+) {
+ DISABLED("Disabled", captureBackStack = false, includeArguments = false),
+ TOP_ONLY("Top only", captureBackStack = false, includeArguments = true),
+ FULL_STACK("Full stack", captureBackStack = true, includeArguments = true),
+ FULL_STACK_WITHOUT_ARGUMENTS(
+ "No arguments",
+ captureBackStack = true,
+ includeArguments = false,
+ ),
+}
+
+internal enum class Nav3PerformanceRun(val label: String) {
+ UNRELATED_RECOMPOSITIONS("Run 20 recompositions"),
+ TOP_REPLACEMENTS("Run 20 top replacements"),
+ LOWER_ENTRY_MUTATIONS("Run 20 lower mutations"),
+ AB_COMPARISON("Run A/B comparison"),
+}
+
+internal enum class Nav3PerformancePreset(
+ val label: String,
+ val stackDepth: Int,
+ val maxCapturedBackStackEntries: Int,
+ val integrationMode: Nav3PerformanceIntegrationMode,
+ val extractorMode: Nav3PerformanceExtractorMode,
+ val argumentMode: Nav3PerformanceArgumentMode,
+) {
+ LIGHT(
+ "Light",
+ 1,
+ 1,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.EMPTY,
+ ),
+ NORMAL(
+ "Normal",
+ 20,
+ 20,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.FLAT,
+ ),
+ CAPTURE_1_OF_100(
+ "Capture 1 of 100",
+ 100,
+ 1,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.FLAT,
+ ),
+ CAPTURE_20_OF_100(
+ "Capture 20 of 100",
+ 100,
+ 20,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.FLAT,
+ ),
+ CAPTURE_100_OF_100(
+ "Capture 100 of 100",
+ 100,
+ 100,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.FLAT,
+ ),
+ HEAVY(
+ "Heavy",
+ 50,
+ 50,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.NESTED,
+ ),
+ SUPER_HEAVY(
+ "Super heavy",
+ 100,
+ 100,
+ Nav3PerformanceIntegrationMode.FULL_STACK,
+ Nav3PerformanceExtractorMode.HEAVY,
+ Nav3PerformanceArgumentMode.LARGE,
+ ),
+ NO_ARGUMENTS(
+ "No arguments",
+ 100,
+ 100,
+ Nav3PerformanceIntegrationMode.FULL_STACK_WITHOUT_ARGUMENTS,
+ Nav3PerformanceExtractorMode.HEAVY,
+ Nav3PerformanceArgumentMode.LARGE,
+ ),
+ TOP_ONLY(
+ "Top only",
+ 30,
+ 30,
+ Nav3PerformanceIntegrationMode.TOP_ONLY,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.FLAT,
+ ),
+ DISABLED_CONTROL(
+ "Disabled control",
+ 30,
+ 30,
+ Nav3PerformanceIntegrationMode.DISABLED,
+ Nav3PerformanceExtractorMode.NORMAL,
+ Nav3PerformanceArgumentMode.EMPTY,
+ ),
+}
+
+internal data class Nav3PerformancePanelControls(
+ val actualStackEntries: Int,
+ val maxCapturedBackStackEntries: Int,
+ val onMaxCapturedBackStackEntriesChange: (Int) -> Unit,
+ val onApplyPreset: (Nav3PerformancePreset) -> Unit,
+ val onRunPerformance: (Nav3PerformanceRun) -> Unit,
+)
+
+@Composable
+internal fun Nav3PerformancePanel(
+ title: String,
+ description: String,
+ currentRoute: String,
+ backStack: String,
+ state: Nav3PerformanceState,
+ showExtractorControls: Boolean,
+ onBuildStack: () -> Unit,
+ onMutateLowerEntry: (() -> Unit)? = null,
+ onReplaceTop: () -> Unit,
+ nav3Controls: Nav3PerformancePanelControls? = null,
+) {
+ @Suppress("UNUSED_EXPRESSION") state.displayRevision
+
+ val diagnosticsSummary =
+ state.diagnosticsSummary(currentRoute = currentRoute, backStack = backStack)
+
+ LaunchedEffect(state.autoRecompose) {
+ while (state.autoRecompose) {
+ delay(250)
+ traceNav3PerformanceSection("Nav3Stress.autoRecompose") {
+ state.markRecompositionRequest()
+ }
+ }
+ }
+
+ LaunchedEffect(state.autoNavigate) {
+ while (state.autoNavigate) {
+ delay(500)
+ traceNav3PerformanceSection("Nav3Stress.autoNavigate") { onReplaceTop() }
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier.fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ .testTag(navPerformanceTag("root")),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ CollapsiblePerfInfoRow(
+ label = "Performance status",
+ value = state.performanceStatus,
+ collapsedValue = state.performanceStatus,
+ tag = navPerformanceTag("performance_status"),
+ defaultExpanded = state.performanceRunActive,
+ )
+ if (!state.performanceRunActive) {
+ CollapsiblePerfInfoRow(
+ label = "Diagnostics summary",
+ value = diagnosticsSummary,
+ collapsedValue = diagnosticsSummary.lineSequence().firstOrNull() ?: diagnosticsSummary,
+ tag = navPerformanceTag("diagnostics_summary_top"),
+ )
+ }
+
+ PerfCard(title = "Current State", tag = navPerformanceTag("current_state_card")) {
+ PerfInfoRow("Current route", currentRoute, tag = navPerformanceTag("current_route"))
+ PerfInfoRow("Tracked stack", backStack, tag = navPerformanceTag("tracked_stack"))
+ nav3Controls?.let { controls ->
+ PerfInfoRow(
+ "Actual stack entries",
+ controls.actualStackEntries.toString(),
+ tag = navPerformanceTag("actual_stack_entries"),
+ )
+ PerfInfoRow(
+ "Requested stack depth",
+ state.stackDepth.toString(),
+ tag = navPerformanceTag("requested_stack_depth"),
+ )
+ PerfInfoRow(
+ "Integration mode",
+ state.integrationMode.label,
+ tag = navPerformanceTag("integration_mode"),
+ )
+ PerfInfoRow(
+ "Capture enabled",
+ if (state.integrationMode.captureBackStack) "Yes" else "No",
+ tag = navPerformanceTag("capture_enabled"),
+ )
+ PerfInfoRow(
+ "Capture limit",
+ controls.maxCapturedBackStackEntries.toString(),
+ tag = navPerformanceTag("capture_limit"),
+ )
+ PerfInfoRow(
+ "Effective captured entries",
+ if (state.integrationMode.captureBackStack) {
+ minOf(controls.actualStackEntries, controls.maxCapturedBackStackEntries).toString()
+ } else {
+ "0"
+ },
+ tag = navPerformanceTag("effective_captured_entries"),
+ )
+ }
+ }
+
+ nav3Controls?.let { controls ->
+ PerfCard(title = "Scenarios", tag = navPerformanceTag("scenarios_card")) {
+ Nav3PerformancePreset.entries.chunked(2).forEach { presets ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ presets.forEach { preset ->
+ OutlinedButton(
+ enabled = !state.performanceRunActive,
+ onClick = { controls.onApplyPreset(preset) },
+ modifier =
+ Modifier.weight(1f)
+ .testTag(navPerformanceTag("preset_${preset.name.lowercase()}")),
+ ) {
+ Text(preset.label)
+ }
+ }
+ if (presets.size == 1) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+
+ PerfCard(title = "Nav3 Integration", tag = navPerformanceTag("integration_card")) {
+ PerfModeRow(
+ label = "Mode",
+ selectedLabel = state.integrationMode.label,
+ enabled = !state.performanceRunActive,
+ options =
+ Nav3PerformanceIntegrationMode.entries.map { mode ->
+ mode.label to { state.updateIntegrationMode(mode) }
+ },
+ )
+ PerfStepper(
+ label = "Capture limit",
+ value = controls.maxCapturedBackStackEntries,
+ enabled = !state.performanceRunActive,
+ onDecrement = {
+ controls.onMaxCapturedBackStackEntriesChange(
+ (controls.maxCapturedBackStackEntries - 1).coerceAtLeast(0)
+ )
+ },
+ onIncrement = {
+ controls.onMaxCapturedBackStackEntriesChange(
+ (controls.maxCapturedBackStackEntries + 1).coerceAtMost(100)
+ )
+ },
+ )
+ }
+ }
+
+ PerfCard(title = "Stress Controls", tag = navPerformanceTag("stress_controls_card")) {
+ PerfStepper(
+ label = "Stack depth",
+ value = state.stackDepth,
+ enabled = !state.performanceRunActive,
+ onDecrement = { state.stackDepth = (state.stackDepth - 1).coerceAtLeast(1) },
+ onIncrement = { state.stackDepth = (state.stackDepth + 1).coerceAtMost(100) },
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = onBuildStack,
+ modifier = Modifier.weight(1f),
+ ) {
+ Text("Build Stack")
+ }
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = onReplaceTop,
+ modifier = Modifier.weight(1f),
+ ) {
+ Text("Replace Top")
+ }
+ }
+
+ onMutateLowerEntry?.let { mutateLowerEntry ->
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = mutateLowerEntry,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Mutate Lower Entry")
+ }
+ }
+
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = { state.markRecompositionRequest() },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Force Unrelated Recomposition")
+ }
+
+ if (nav3Controls == null) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ PerfToggleButton(
+ selected = state.autoRecompose,
+ label = if (state.autoRecompose) "Stop Recompose" else "Auto Recompose",
+ onClick = { state.autoRecompose = !state.autoRecompose },
+ modifier = Modifier.weight(1f),
+ )
+ PerfToggleButton(
+ selected = state.autoNavigate,
+ label = if (state.autoNavigate) "Stop Navigate" else "Auto Navigate",
+ onClick = { state.autoNavigate = !state.autoNavigate },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+
+ nav3Controls?.let { controls ->
+ PerfCard(title = "Fixed Runs", tag = navPerformanceTag("fixed_runs_card")) {
+ Nav3PerformanceRun.entries.forEach { run ->
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = { controls.onRunPerformance(run) },
+ modifier =
+ Modifier.fillMaxWidth().testTag(navPerformanceTag("run_${run.name.lowercase()}")),
+ ) {
+ Text(run.label)
+ }
+ }
+ state.comparisonResult?.let { result ->
+ PerfInfoRow("A/B result", result, tag = navPerformanceTag("ab_result"))
+ }
+ }
+ }
+
+ if (showExtractorControls) {
+ PerfCard(title = "Extractor Inputs") {
+ PerfModeRow(
+ label = "Extractor cost",
+ selectedLabel = state.extractorMode.label,
+ enabled = !state.performanceRunActive,
+ options =
+ Nav3PerformanceExtractorMode.entries.map { mode ->
+ mode.label to { state.extractorMode = mode }
+ },
+ )
+ PerfModeRow(
+ label = "Argument shape",
+ selectedLabel = state.argumentMode.label,
+ enabled = !state.performanceRunActive,
+ options =
+ Nav3PerformanceArgumentMode.entries.map { mode ->
+ mode.label to { state.argumentMode = mode }
+ },
+ )
+ }
+ }
+
+ PerfCard(title = "Counters", tag = navPerformanceTag("counters_card")) {
+ PerfInfoRow(
+ "Recomposition requests",
+ state.recompositionRequests.toString(),
+ tag = navPerformanceTag("recomposition_requests"),
+ )
+ PerfInfoRow(
+ "Navigation mutations",
+ state.navigationMutations.toString(),
+ tag = navPerformanceTag("navigation_mutations"),
+ )
+ PerfInfoRow(
+ "Destination changes",
+ state.destinationChanges.toString(),
+ tag = navPerformanceTag("destination_changes"),
+ )
+ if (showExtractorControls) {
+ if (state.performanceRunActive) {
+ Text("Metrics are published when the fixed run completes.")
+ } else {
+ PerfInfoRow(
+ "SentryNavEffect attempts",
+ state.sentryNavEffectAttempts.toString(),
+ tag = navPerformanceTag("sentry_nav_effect_attempts"),
+ )
+ PerfInfoRow(
+ "Calls with extractor work",
+ state.sentryNavEffectProcessedCalls.toString(),
+ tag = navPerformanceTag("calls_with_extractor_work"),
+ )
+ PerfInfoRow(
+ "Captured entries resolved",
+ state.capturedEntriesResolved.toString(),
+ tag = navPerformanceTag("captured_entries_resolved"),
+ )
+ PerfInfoRow(
+ "nameExtractor calls",
+ state.nameExtractorCalls.toString(),
+ tag = navPerformanceTag("name_extractor_calls"),
+ )
+ PerfInfoRow(
+ "argumentsExtractor calls",
+ state.argumentsExtractorCalls.toString(),
+ tag = navPerformanceTag("arguments_extractor_calls"),
+ )
+ PerfInfoRow(
+ "nameExtractor avg",
+ state.nameExtractorAverageMicros(),
+ tag = navPerformanceTag("name_extractor_avg"),
+ )
+ PerfInfoRow(
+ "argumentsExtractor avg",
+ state.argumentsExtractorAverageMicros(),
+ tag = navPerformanceTag("arguments_extractor_avg"),
+ )
+ PerfInfoRow(
+ "Mutation to composition",
+ state.mutationToCompositionSummary(),
+ tag = navPerformanceTag("mutation_to_composition"),
+ )
+ PerfInfoRow(
+ "Mutation to first draw",
+ state.mutationToFirstDrawSummary(),
+ tag = navPerformanceTag("mutation_to_first_draw"),
+ )
+ PerfInfoRow(
+ "First draws over 8.3 ms",
+ state.firstDrawsOver8Millis().toString(),
+ tag = navPerformanceTag("first_draws_over_8_3_ms"),
+ )
+ PerfInfoRow(
+ "First draws over 16.7 ms",
+ state.firstDrawsOver16Millis().toString(),
+ tag = navPerformanceTag("first_draws_over_16_7_ms"),
+ )
+ }
+ }
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = { state.resetCounters() },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Reset Counters")
+ }
+ Button(
+ enabled = !state.performanceRunActive,
+ onClick = { state.publishDiagnostics(currentRoute = currentRoute, backStack = backStack) },
+ modifier = Modifier.fillMaxWidth().testTag(navPerformanceTag("publish_diagnostics")),
+ ) {
+ Text("Publish diagnostics")
+ }
+ }
+ }
+}
+
+@Composable
+private fun PerfCard(
+ title: String,
+ tag: String? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PerfInfoRow(label: String, value: String, tag: String? = null) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ label,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier.weight(1f).then(if (tag != null) Modifier.testTag("${tag}_label") else Modifier),
+ )
+ Spacer(Modifier.size(12.dp))
+ Text(
+ value,
+ modifier =
+ Modifier.weight(1f).then(if (tag != null) Modifier.testTag("${tag}_value") else Modifier),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CollapsiblePerfInfoRow(
+ label: String,
+ value: String,
+ collapsedValue: String,
+ tag: String? = null,
+ defaultExpanded: Boolean = false,
+) {
+ var expanded by remember(label, value) { mutableStateOf(defaultExpanded) }
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier),
+ onClick = { expanded = !expanded },
+ ) {
+ Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ label,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier.weight(1f)
+ .then(if (tag != null) Modifier.testTag("${tag}_label") else Modifier),
+ )
+ TextButton(onClick = { expanded = !expanded }, contentPadding = PaddingValues(0.dp)) {
+ Text(if (expanded) "Collapse" else "Expand")
+ }
+ }
+ Text(
+ if (expanded) value else collapsedValue,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier =
+ Modifier.fillMaxWidth()
+ .then(if (tag != null) Modifier.testTag("${tag}_value") else Modifier),
+ )
+ }
+ }
+}
+
+private fun navPerformanceTag(name: String): String = "navigation_perf_$name"
+
+@Composable
+private fun PerfStepper(
+ label: String,
+ value: Int,
+ enabled: Boolean = true,
+ onDecrement: () -> Unit,
+ onIncrement: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, fontWeight = FontWeight.Bold)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedButton(
+ modifier = Modifier.size(44.dp),
+ contentPadding = PaddingValues(0.dp),
+ enabled = enabled,
+ onClick = onDecrement,
+ ) {
+ Text("-", style = MaterialTheme.typography.titleLarge)
+ }
+ Text(
+ value.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ OutlinedButton(
+ modifier = Modifier.size(44.dp),
+ contentPadding = PaddingValues(0.dp),
+ enabled = enabled,
+ onClick = onIncrement,
+ ) {
+ Text("+", style = MaterialTheme.typography.titleLarge)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PerfToggleButton(
+ selected: Boolean,
+ label: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (selected) {
+ Button(enabled = enabled, onClick = onClick, modifier = modifier) { Text(label) }
+ } else {
+ OutlinedButton(enabled = enabled, onClick = onClick, modifier = modifier) { Text(label) }
+ }
+}
+
+@Composable
+private fun PerfModeRow(
+ label: String,
+ selectedLabel: String,
+ enabled: Boolean = true,
+ options: List Unit>>,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("$label: $selectedLabel", fontWeight = FontWeight.Bold)
+ options.chunked(2).forEach { optionRow ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ optionRow.forEach { (optionLabel, onClick) ->
+ PerfToggleButton(
+ selected = optionLabel == selectedLabel,
+ label = optionLabel,
+ enabled = enabled,
+ onClick = onClick,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ if (optionRow.size == 1) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+internal fun traceNav3PerformanceSection(sectionName: String, block: () -> Unit) {
+ Trace.beginSection(sectionName)
+ try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
+}
+
+internal fun nav3PerformanceArguments(
+ mode: Nav3PerformanceArgumentMode,
+ index: Int,
+ generation: Int,
+): Map =
+ when (mode) {
+ Nav3PerformanceArgumentMode.EMPTY -> emptyMap()
+ Nav3PerformanceArgumentMode.FLAT ->
+ mapOf("index" to index, "generation" to generation, "label" to "route-$index")
+ Nav3PerformanceArgumentMode.NESTED ->
+ mapOf(
+ "route" to
+ mapOf(
+ "index" to index,
+ "generation" to generation,
+ "source" to "performance",
+ ),
+ "tags" to listOf("nav", "stress", "route-$index"),
+ )
+ Nav3PerformanceArgumentMode.LARGE ->
+ (0 until 20).associate { valueIndex ->
+ "key_$valueIndex" to
+ mapOf(
+ "index" to index,
+ "generation" to generation,
+ "value" to "payload-$index-$generation-$valueIndex",
+ "tags" to listOf("a", "b", "c", valueIndex.toString()),
+ )
+ }
+ }
+
+@Volatile private var nav3PerformanceExtractorBlackhole = 0
+
+internal fun consumeNav3PerformanceExtractorWork(
+ mode: Nav3PerformanceExtractorMode,
+ seed: Int,
+) {
+ if (mode == Nav3PerformanceExtractorMode.NORMAL) {
+ return
+ }
+
+ var checksum = seed
+ repeat(2_000) { index -> checksum = (checksum * 31) xor index }
+ nav3PerformanceExtractorBlackhole = checksum
+}
+
+internal fun nav3PerformanceBackStackPreview(entries: List): String {
+ if (entries.size <= 8) {
+ return entries.joinToString(" -> ")
+ }
+
+ return "${entries.size} entries: " +
+ entries.take(3).joinToString(" -> ") +
+ " -> ... -> " +
+ entries.takeLast(3).joinToString(" -> ")
+}
+
+private fun Nav3PerformanceState.nameExtractorAverageMicros(): String =
+ averageMicros(nameExtractorNanos, nameExtractorCalls)
+
+private fun Nav3PerformanceState.argumentsExtractorAverageMicros(): String =
+ averageMicros(argumentsExtractorNanos, argumentsExtractorCalls)
+
+private fun averageMicros(totalNanos: Long, count: Int): String =
+ if (count == 0) "0 us" else "${totalNanos / count / 1_000} us"
+
+private fun formatNanos(durationNanos: Long): String =
+ if (durationNanos < 1_000_000L) {
+ "${durationNanos / 1_000} us"
+ } else {
+ String.format(Locale.US, "%.2f ms", durationNanos / 1_000_000.0)
+ }
+
+private fun beginAsyncTraceSection(sectionName: String, cookie: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Trace.beginAsyncSection(sectionName, cookie)
+ }
+}
+
+private fun endAsyncTraceSection(sectionName: String, cookie: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Trace.endAsyncSection(sectionName, cookie)
+ }
+}
+
+private const val NAVIGATION_TO_COMPOSITION_SECTION = "Nav3Stress.navigationToComposition"
+private const val NAVIGATION_TO_FIRST_DRAW_SECTION = "Nav3Stress.navigationToFirstDraw"
+private const val AB_DISABLED_SECTION = "Nav3Stress.ab.disabled"
+private const val AB_ENABLED_SECTION = "Nav3Stress.ab.enabled"
+private const val MEASURED_SECTION = "Nav3Stress.measured"
+private const val NAV_PERF_TAG = "NavPerformance"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt
new file mode 100644
index 0000000000..1d4b933fdc
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt
@@ -0,0 +1,727 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.sentry.Sentry
+import io.sentry.compose.SentryModifier.sentryTag
+import io.sentry.compose.SentryTraced
+import io.sentry.samples.android.R
+import io.sentry.samples.android.navigation.common.RouteNames
+import io.sentry.samples.android.navigation.common.RouteSpec
+import io.sentry.samples.android.navigation.common.RouteSpecs
+import io.sentry.samples.android.navigation.common.RouteWorkApi
+import io.sentry.samples.android.navigation.common.RouteWorkOption
+import io.sentry.samples.android.navigation.common.displayArguments
+import java.io.IOException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import retrofit2.HttpException
+
+@ExperimentalComposeUiApi
+@Composable
+internal fun TracedNav3Route(
+ route: Nav3Route,
+ scenario: Nav3Scenario,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ tagCurrentNav3Scenario(scenario)
+ SentryTraced(
+ tag = "Nav3 /${route.routeName}",
+ enableUserInteractionTracing = false,
+ content = content,
+ )
+}
+
+private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) {
+ Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label)
+ tagCurrentNavigationSampleScenario(scenario.label)
+}
+
+@Composable
+internal fun Nav3RouteWorkEffect(
+ route: Nav3Route,
+ routeWorkOptions: Set,
+) {
+ val currentOptions = rememberUpdatedState(routeWorkOptions)
+
+ if (RouteWorkOption.MANUAL_CHILD_SPAN in currentOptions.value) {
+ // Keep this synchronous to verify that Nav3 route transactions are bound before destination
+ // composition runs, not merely before destination effects are launched.
+ runManualNav3RouteActivationSpan(route)
+ }
+
+ LaunchedEffect(route) {
+ runNav3RouteWork(
+ route = route,
+ options = currentOptions.value,
+ )
+ }
+}
+
+private suspend fun runNav3RouteWork(
+ route: Nav3Route,
+ options: Set,
+) {
+ RouteWorkOption.entries.forEach { option ->
+ if (option !in options || option == RouteWorkOption.MANUAL_CHILD_SPAN) {
+ return@forEach
+ }
+
+ tagNav3SampleAction(option.tagName, route)
+ when (option) {
+ RouteWorkOption.HTTP_REQUEST -> {
+ try {
+ RouteWorkApi.runRequest()
+ } catch (e: IOException) {
+ Sentry.captureException(e)
+ } catch (e: HttpException) {
+ Sentry.captureException(e)
+ } finally {
+ withContext(Dispatchers.IO) { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }
+ }
+ }
+ RouteWorkOption.MANUAL_CHILD_SPAN -> Unit
+ }
+ }
+}
+
+internal fun captureSampleException(navName: String) {
+ Sentry.captureException(RuntimeException("$navName sample capture exception button"))
+ Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start()
+}
+
+@Composable
+internal fun SentryControls(
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+) {
+ Surface(shadowElevation = 8.dp) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Nav3SentryButton(
+ label = "Capture Exception",
+ onClick = onCaptureException,
+ modifier = Modifier.weight(1f),
+ )
+ Nav3SentryButton(
+ label = "Crash App",
+ onClick = onCrashApp,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+internal fun Nav3SentryButton(
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionLabel: String = label,
+ testTag: String = nav3ButtonTag(label),
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier.sentryTag(nav3InteractionTag(interactionLabel)).testTag(testTag),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = colorResource(R.color.colorAccentSoft),
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(label)
+ }
+}
+
+internal fun crashSampleApp(navName: String): Nothing {
+ throw RuntimeException("Fatal $navName sample crash app button")
+}
+
+private fun runManualNav3RouteActivationSpan(route: Nav3Route) {
+ val span =
+ Sentry.getSpan()
+ ?.startChild(
+ "test.navigation.route_activation",
+ "Nav3 /${route.routeName} route activation",
+ )
+ span?.setData("sample.route_activation", true)
+ span?.finish()
+}
+
+private fun tagNav3SampleAction(action: String, route: Nav3Route) {
+ Sentry.setTag("sample_action", "nav3_$action")
+ Sentry.setTag("sample_nav3_route", route.routeName)
+}
+
+@Composable
+internal fun RouteScaffold(
+ routeSpec: RouteSpec,
+ cardContent: (@Composable ColumnScope.() -> Unit)? = null,
+ footerContent: (@Composable ColumnScope.() -> Unit)? = null,
+ content: (@Composable ColumnScope.() -> Unit)? = null,
+ testTagPrefix: String = nav3RouteTag(routeSpec.routeName),
+) {
+ Column(
+ modifier =
+ Modifier.fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(16.dp)
+ .testTag("${testTagPrefix}_screen")
+ ) {
+ Column(
+ modifier =
+ Modifier.weight(1f)
+ .verticalScroll(rememberScrollState())
+ .testTag("${testTagPrefix}_content"),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ routeSpec.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.testTag("${testTagPrefix}_title"),
+ )
+ routeSpec.description?.let {
+ Text(
+ it,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.testTag("${testTagPrefix}_description"),
+ )
+ }
+ if (cardContent != null) {
+ Card(
+ colors =
+ CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth().testTag("${testTagPrefix}_card"),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp).testTag("${testTagPrefix}_card_content"),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ cardContent()
+ }
+ }
+ }
+ content?.invoke(this)
+ }
+
+ if (footerContent != null) {
+ Spacer(Modifier.size(12.dp))
+ Column(
+ modifier = Modifier.testTag("${testTagPrefix}_footer"),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ footerContent()
+ }
+ }
+ }
+}
+
+@Composable
+internal fun RouteButton(
+ label: String,
+ onClick: () -> Unit,
+ testTag: String = nav3ButtonTag(label),
+) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth().sentryTag(nav3InteractionTag(label)).testTag(testTag),
+ ) {
+ Text(label)
+ }
+}
+
+@Composable
+internal fun RouteInfo(label: String, value: String, testTag: String = nav3InfoTag(label)) {
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp))
+ .padding(12.dp)
+ .testTag(testTag),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.size(12.dp))
+ Text(value, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+internal fun SingleStackRoute(backStack: SnapshotStateList) {
+ RouteScaffold(
+ routeSpec = RouteSpecs.home,
+ testTagPrefix = nav3RouteTag("single_stack"),
+ cardContent = {
+ RouteButton(
+ "Browse Products",
+ onClick = { backStack.add(Nav3Route.ProductList) },
+ testTag = nav3TestTag("single_stack_browse_products"),
+ )
+ },
+ )
+}
+
+@Composable
+internal fun CustomRoute(
+ mode: Nav3CustomTransactionMode,
+ onModeSelected: (Nav3CustomTransactionMode) -> Unit,
+ isAsyncBrowseProductsRunning: Boolean,
+ onBrowseProducts: () -> Unit,
+) {
+ val helperText =
+ when (mode) {
+ Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION ->
+ "This mode starts a manual transaction from the button tap, waits for async work, and " +
+ "then pushes Product List."
+ Nav3CustomTransactionMode.LINGERING ->
+ "The lingering transaction stays active until you leave the Custom tab."
+ else -> null
+ }
+
+ RouteScaffold(
+ routeSpec = Nav3Route.Custom.routeSpec(),
+ testTagPrefix = nav3RouteTag("custom"),
+ cardContent = {
+ Nav3CustomTransactionModeSelector(selected = mode, onSelected = onModeSelected)
+ Text(
+ mode.description,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.testTag(nav3TestTag("custom_mode_description")),
+ )
+ RouteButton(
+ label =
+ if (mode == Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) {
+ if (isAsyncBrowseProductsRunning) {
+ "Starting async custom transaction..."
+ } else {
+ "Browse Products"
+ }
+ } else {
+ "Browse Products"
+ },
+ onClick = onBrowseProducts,
+ testTag = nav3TestTag("custom_browse_products"),
+ )
+ },
+ content = {
+ helperText?.let {
+ Text(
+ it,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.testTag(nav3TestTag("custom_helper_text")),
+ )
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Nav3CustomTransactionModeSelector(
+ selected: Nav3CustomTransactionMode,
+ onSelected: (Nav3CustomTransactionMode) -> Unit,
+) {
+ val sentryPink = colorResource(R.color.colorAccent)
+
+ Text(
+ "Mode",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.testTag(nav3TestTag("custom_mode_label")),
+ )
+ SingleChoiceSegmentedButtonRow(
+ modifier =
+ Modifier.fillMaxWidth()
+ .testTag(nav3TestTag("custom_mode_selector"))
+ .testTag(nav3CustomModeSelectionTag(selected))
+ ) {
+ Nav3CustomTransactionMode.entries.forEachIndexed { index, mode ->
+ SegmentedButton(
+ modifier =
+ Modifier.weight(1f)
+ .defaultMinSize(minHeight = 72.dp)
+ .testTag(nav3TestTag("custom_mode_${mode.name.lowercase()}")),
+ shape =
+ SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = Nav3CustomTransactionMode.entries.size,
+ ),
+ onClick = { onSelected(mode) },
+ selected = selected == mode,
+ colors =
+ SegmentedButtonDefaults.colors(
+ activeContainerColor = sentryPink,
+ activeContentColor = Color.White,
+ ),
+ icon = {},
+ label = {
+ Text(
+ text = mode.label,
+ style = MaterialTheme.typography.labelSmall,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+internal fun LandingRoute() {
+ LaunchedEffect(Unit) { cancelCurrentActivityUiLoadTransaction() }
+ RouteScaffold(routeSpec = RouteSpecs.landing, testTagPrefix = nav3RouteTag("landing"))
+}
+
+@Composable
+internal fun DeepLinkRoute(backStack: SnapshotStateList) {
+ RouteScaffold(
+ routeSpec = Nav3Route.DeepLink.routeSpec(),
+ testTagPrefix = nav3RouteTag("deep_link"),
+ cardContent = {
+ RouteButton(
+ "Go to deep link destination",
+ onClick = { backStack.openSyntheticProductDeepLink() },
+ testTag = nav3TestTag("deep_link_open_destination"),
+ )
+ },
+ )
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+internal fun ProductListRoute(backStack: SnapshotStateList) {
+ var showProductItems by rememberSaveable { mutableStateOf(false) }
+
+ RouteScaffold(
+ routeSpec = RouteSpecs.productList,
+ testTagPrefix = nav3RouteTag("product_list"),
+ cardContent = {
+ SentryTraced(
+ tag = "product_list_actions",
+ modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_actions")),
+ enableUserInteractionTracing = false,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ RouteButton(
+ "Open Product 42",
+ onClick = {
+ backStack.add(
+ Nav3Route.ProductDetail(
+ productId = "42",
+ source = "product-list",
+ campaign = "summer-sale",
+ )
+ )
+ },
+ testTag = nav3TestTag("product_list_open_product_42"),
+ )
+ RouteButton(
+ "Open Product 7",
+ onClick = {
+ backStack.add(Nav3Route.ProductDetail(productId = "7", source = "product-list"))
+ },
+ testTag = nav3TestTag("product_list_open_product_7"),
+ )
+ }
+ }
+ },
+ content = {
+ if (showProductItems) {
+ SentryTraced(
+ tag = "product_list_items",
+ modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_items")),
+ enableUserInteractionTracing = false,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ repeat(PRODUCT_LIST_ITEM_COUNT) { index -> Nav3ProductListItem(index + 1) }
+ }
+ }
+ }
+ },
+ footerContent = {
+ RouteButton(
+ label = if (showProductItems) "Hide Product Items" else "Show Product Items",
+ onClick = { showProductItems = !showProductItems },
+ testTag = nav3TestTag("product_list_toggle_items"),
+ )
+ },
+ )
+}
+
+@Composable
+internal fun ProductDetailRoute(
+ route: Nav3Route.ProductDetail,
+ backStack: SnapshotStateList,
+) {
+ LaunchedEffect(route.productId, route.source, route.campaign) {
+ recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL)
+ }
+
+ RouteScaffold(
+ routeSpec = RouteSpecs.productDetail,
+ testTagPrefix = nav3RouteTag("product_detail"),
+ cardContent = {
+ RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) ->
+ RouteInfo(label, value)
+ }
+ RouteButton(
+ "Show Promo Dialog",
+ onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) },
+ testTag = nav3TestTag("product_detail_show_promo_dialog"),
+ )
+ RouteButton(
+ "Open Share Sheet",
+ onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) },
+ testTag = nav3TestTag("product_detail_open_share_sheet"),
+ )
+ RouteButton(
+ "Go to Checkout",
+ onClick = { backStack.add(Nav3Route.Checkout(route.productId)) },
+ testTag = nav3TestTag("product_detail_go_to_checkout"),
+ )
+ },
+ footerContent = {
+ RouteButton(
+ "Emit a span",
+ onClick = { emitSampleNavigationSpan(route.routeName) },
+ testTag = nav3TestTag("product_detail_emit_span"),
+ )
+ },
+ )
+}
+
+@Composable
+internal fun CheckoutRoute(
+ route: Nav3Route.Checkout,
+ backStack: SnapshotStateList,
+) {
+ RouteScaffold(
+ routeSpec = RouteSpecs.checkout,
+ testTagPrefix = nav3RouteTag("checkout"),
+ cardContent = {
+ RouteSpecs.checkout.displayArguments(route.arguments).forEach { (label, value) ->
+ RouteInfo(label, value)
+ }
+ RouteButton(
+ "Complete Order",
+ onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) },
+ testTag = nav3TestTag("checkout_complete_order"),
+ )
+ },
+ )
+}
+
+@Composable
+internal fun ConfirmationRoute(
+ route: Nav3Route.Confirmation,
+ backStack: SnapshotStateList,
+ rootRoute: Nav3Route,
+) {
+ RouteScaffold(
+ routeSpec = RouteSpecs.confirmation,
+ testTagPrefix = nav3RouteTag("confirmation"),
+ cardContent = {
+ RouteSpecs.confirmation.displayArguments(route.arguments).forEach { (label, value) ->
+ RouteInfo(label, value)
+ }
+ RouteButton(
+ "Reset Backstack",
+ onClick = { backStack.resetTo(rootRoute) },
+ testTag = nav3TestTag("confirmation_reset_backstack"),
+ )
+ },
+ )
+}
+
+@Composable
+internal fun PromoDialogRoute(
+ route: Nav3Route.PromoDialog,
+ backStack: SnapshotStateList,
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("promo_dialog_card")),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp).testTag(nav3TestTag("promo_dialog_content")),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ val routeSpec = RouteSpecs.promoDialog
+ Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall)
+ routeSpec.description?.let { Text(it) }
+ routeSpec.displayArguments(route.arguments).forEach { (label, value) ->
+ Text("$label=$value")
+ }
+ Nav3SentryButton(
+ label = "Capture Exception",
+ onClick = onCaptureException,
+ modifier = Modifier.fillMaxWidth(),
+ interactionLabel = "Promo Dialog Exception",
+ testTag = nav3TestTag("promo_dialog_capture_exception"),
+ )
+ Nav3SentryButton(
+ label = "Crash App",
+ onClick = onCrashApp,
+ modifier = Modifier.fillMaxWidth(),
+ interactionLabel = "Promo Dialog Crash App",
+ testTag = nav3TestTag("promo_dialog_crash_app"),
+ )
+ Button(
+ onClick = { backStack.removeLastOrNull() },
+ modifier =
+ Modifier.fillMaxWidth()
+ .sentryTag(nav3InteractionTag("Promo Dialog Dismiss"))
+ .testTag(nav3TestTag("promo_dialog_dismiss")),
+ ) {
+ Text("Dismiss")
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ShareSheetRoute(
+ route: Nav3Route.ShareSheet,
+ backStack: SnapshotStateList,
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 12.dp)
+ .testTag(nav3TestTag("share_sheet_content")),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ val routeSpec = RouteSpecs.shareSheet
+ Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall)
+ routeSpec.description?.let { Text(it) }
+ routeSpec.displayArguments(route.arguments).forEach { (label, value) -> Text("$label=$value") }
+ Nav3SentryButton(
+ label = "Capture Exception",
+ onClick = onCaptureException,
+ modifier = Modifier.fillMaxWidth(),
+ interactionLabel = "Share Sheet Exception",
+ testTag = nav3TestTag("share_sheet_capture_exception"),
+ )
+ Nav3SentryButton(
+ label = "Crash App",
+ onClick = onCrashApp,
+ modifier = Modifier.fillMaxWidth(),
+ interactionLabel = "Share Sheet Crash App",
+ testTag = nav3TestTag("share_sheet_crash_app"),
+ )
+ Button(
+ onClick = { backStack.removeLastOrNull() },
+ modifier =
+ Modifier.fillMaxWidth()
+ .sentryTag(nav3InteractionTag("Share Sheet Done"))
+ .testTag(nav3TestTag("share_sheet_done")),
+ ) {
+ Text("Done")
+ }
+ Spacer(Modifier.size(12.dp))
+ }
+}
+
+@Composable
+internal fun FutureRoute(routeName: String, scenario: String) {
+ RouteScaffold(
+ routeSpec =
+ RouteSpecs.get(routeName)
+ .copy(
+ title = "$routeName: WIP",
+ description =
+ "Reserved for a future milestone when SentryNavEffect supports $scenario navigation " +
+ "state.",
+ ),
+ testTagPrefix = nav3RouteTag(routeName),
+ )
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun Nav3ProductListItem(index: Int) {
+ SentryTraced(
+ tag = "product_list_item_$index",
+ modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_item_$index")),
+ enableUserInteractionTracing = false,
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("Product #$index", fontWeight = FontWeight.Bold)
+ Text("SKU-$index")
+ }
+ }
+ }
+}
+
+private const val PRODUCT_LIST_ITEM_COUNT = 20
+
+private fun nav3RouteTag(routeName: String): String =
+ nav3TestTag("route_${routeName.lowercase().replace(' ', '_').replace('/', '_')}")
+
+private fun nav3ButtonTag(label: String): String =
+ nav3TestTag(
+ "button_${label.lowercase().replace(' ', '_').replace('/', '_').replace('.', '_').replace('-', '_')}"
+ )
+
+private fun nav3InfoTag(label: String): String =
+ nav3TestTag(
+ "info_${label.lowercase().replace(' ', '_').replace('/', '_').replace('.', '_').replace('-', '_')}"
+ )
+
+private fun nav3CustomModeSelectionTag(mode: Nav3CustomTransactionMode): String =
+ nav3TestTag("custom_mode_selected_${mode.name.lowercase()}")
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt
new file mode 100644
index 0000000000..b9972a6cd7
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt
@@ -0,0 +1,300 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import io.sentry.samples.android.navigation.common.NavArgs
+import io.sentry.samples.android.navigation.common.RouteNames
+import io.sentry.samples.android.navigation.common.RouteSpec
+import io.sentry.samples.android.navigation.common.RouteSpecs
+import io.sentry.samples.android.navigation.common.displayRoute
+
+internal fun Nav3Route.displayRoute(): String = routeSpec().displayRoute(arguments)
+
+internal fun Nav3Route.routeSpec(): RouteSpec =
+ when (this) {
+ Nav3Route.Landing -> RouteSpecs.landing
+ Nav3Route.SingleStack -> RouteSpecs.home
+ Nav3Route.Custom ->
+ RouteSpecs.home.copy(
+ routeName = Nav3Route.Custom.routeName,
+ title = "Custom Transactions",
+ description =
+ "Reuses the single-stack shopping flow while simulating a power user who starts their " +
+ "own manual transactions.",
+ )
+ Nav3Route.DeepLink ->
+ RouteSpec(
+ routeName = Nav3Route.DeepLink.routeName,
+ title = "Deep Link",
+ description =
+ "Simulates opening a deep link that builds a synthetic backstack before landing on a " +
+ "detail destination.",
+ )
+ Nav3Route.ProductList -> RouteSpecs.productList
+ is Nav3Route.ProductDetail -> RouteSpecs.productDetail
+ is Nav3Route.Checkout -> RouteSpecs.checkout
+ is Nav3Route.Confirmation -> RouteSpecs.confirmation
+ is Nav3Route.PromoDialog -> RouteSpecs.promoDialog
+ is Nav3Route.ShareSheet -> RouteSpecs.shareSheet
+ Nav3Route.Multipane,
+ Nav3Route.Multistack,
+ is Nav3Route.Performance -> RouteSpec(routeName = routeName, title = routeName)
+ }
+
+@Composable
+internal fun rememberSaveableNav3BackStack(initialRoute: Nav3Route): SnapshotStateList {
+ return rememberSaveable(saver = nav3BackStackSaver()) { mutableStateListOf(initialRoute) }
+}
+
+private fun nav3BackStackSaver() =
+ listSaver, Bundle>(
+ save = { stack -> stack.map { route -> route.toSavedState() } },
+ restore = { savedRoutes ->
+ mutableStateListOf().apply {
+ addAll(savedRoutes.map { savedRoute -> savedRoute.toNav3Route() })
+ if (isEmpty()) {
+ add(Nav3Route.SingleStack)
+ }
+ }
+ },
+ )
+
+private fun Nav3Route.toSavedState(): Bundle =
+ Bundle().apply {
+ when (this@toSavedState) {
+ Nav3Route.Landing -> putString("type", "landing")
+ Nav3Route.SingleStack -> putString("type", "single_stack")
+ Nav3Route.Custom -> putString("type", "custom")
+ Nav3Route.DeepLink -> putString("type", "deep_link")
+ Nav3Route.ProductList -> putString("type", "product_list")
+ is Nav3Route.ProductDetail -> {
+ putString("type", "product_detail")
+ putString("product_id", productId)
+ putString("source", source)
+ putString("campaign", campaign)
+ }
+ is Nav3Route.Checkout -> {
+ putString("type", "checkout")
+ putString("product_id", productId)
+ }
+ is Nav3Route.Confirmation -> {
+ putString("type", "confirmation")
+ putString("order_id", orderId)
+ }
+ is Nav3Route.PromoDialog -> {
+ putString("type", "promo_dialog")
+ putString("promo_id", promoId)
+ }
+ is Nav3Route.ShareSheet -> {
+ putString("type", "share_sheet")
+ putString("product_id", productId)
+ }
+ Nav3Route.Multipane -> putString("type", "multipane")
+ Nav3Route.Multistack -> putString("type", "multistack")
+ is Nav3Route.Performance -> {
+ putString("type", "performance")
+ putInt("index", index)
+ putInt("generation", generation)
+ }
+ }
+ }
+
+private fun Bundle.toNav3Route(): Nav3Route {
+ return when (getString("type")) {
+ "landing" -> Nav3Route.Landing
+ "single_stack" -> Nav3Route.SingleStack
+ "custom" -> Nav3Route.Custom
+ "deep_link" -> Nav3Route.DeepLink
+ "product_list" -> Nav3Route.ProductList
+ "product_detail" -> {
+ val productId = getString("product_id") ?: return Nav3Route.SingleStack
+ val source = getString("source") ?: return Nav3Route.SingleStack
+ Nav3Route.ProductDetail(
+ productId = productId,
+ source = source,
+ campaign = getString("campaign"),
+ )
+ }
+ "checkout" ->
+ Nav3Route.Checkout(productId = getString("product_id") ?: return Nav3Route.SingleStack)
+ "confirmation" ->
+ Nav3Route.Confirmation(orderId = getString("order_id") ?: return Nav3Route.SingleStack)
+ "promo_dialog" ->
+ Nav3Route.PromoDialog(promoId = getString("promo_id") ?: return Nav3Route.SingleStack)
+ "share_sheet" ->
+ Nav3Route.ShareSheet(productId = getString("product_id") ?: return Nav3Route.SingleStack)
+ "multipane" -> Nav3Route.Multipane
+ "multistack" -> Nav3Route.Multistack
+ "performance" ->
+ Nav3Route.Performance(
+ index = getInt("index"),
+ generation = getInt("generation"),
+ )
+ else -> Nav3Route.SingleStack
+ }
+}
+
+internal fun SnapshotStateList.resetTo(route: Nav3Route) {
+ clear()
+ add(route)
+}
+
+internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) {
+ when (scenario) {
+ Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing)
+ Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack)
+ Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom)
+ Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink)
+ Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane)
+ Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack)
+ Nav3Scenario.PERFORMANCE -> resetTo(Nav3Route.Performance(index = 0, generation = 0))
+ }
+}
+
+internal fun SnapshotStateList.openSyntheticProductDeepLink() {
+ clear()
+ add(Nav3Route.SingleStack)
+ add(Nav3Route.ProductList)
+ add(Nav3Route.ProductDetail(productId = "42", source = "deep-link", campaign = "email"))
+}
+
+internal fun SnapshotStateList.openPerformanceStack(depth: Int, generation: Int) {
+ clear()
+ repeat(depth.coerceAtLeast(1)) { index ->
+ add(Nav3Route.Performance(index = index, generation = generation))
+ }
+}
+
+internal fun SnapshotStateList.mutatePerformanceLowerEntry(generation: Int) {
+ if (isEmpty()) {
+ add(Nav3Route.Performance(index = 0, generation = generation))
+ return
+ }
+
+ val index = if (size > 1) 0 else lastIndex
+ set(index, Nav3Route.Performance(index = index, generation = generation))
+}
+
+internal fun SnapshotStateList.replacePerformanceTop(generation: Int) {
+ if (isEmpty()) {
+ add(Nav3Route.Performance(index = 0, generation = generation))
+ return
+ }
+
+ set(lastIndex, Nav3Route.Performance(index = lastIndex, generation = generation))
+}
+
+internal sealed interface Nav3Route {
+ val routeName: String
+ val arguments: Map
+ get() = emptyMap()
+
+ val previewName: String
+ get() = routeName
+
+ val performanceSeed: Int
+ get() = hashCode()
+
+ data object Landing : Nav3Route {
+ override val routeName: String = RouteNames.LANDING
+ }
+
+ data object SingleStack : Nav3Route {
+ override val routeName: String = RouteNames.HOME
+ }
+
+ data object Custom : Nav3Route {
+ override val routeName: String = "Custom"
+ }
+
+ data object DeepLink : Nav3Route {
+ override val routeName: String = RouteNames.DEEP_LINK
+ }
+
+ data object ProductList : Nav3Route {
+ override val routeName: String = RouteNames.PRODUCT_LIST
+ }
+
+ data class ProductDetail(
+ val productId: String,
+ val source: String,
+ val campaign: String? = null,
+ ) : Nav3Route {
+ override val routeName: String = RouteNames.PRODUCT_DETAIL
+ override val arguments: Map =
+ mapOf(
+ NavArgs.PRODUCT_ID to productId,
+ NavArgs.SOURCE to source,
+ NavArgs.CAMPAIGN to campaign,
+ )
+ .filterValues { it != null }
+ override val previewName: String = "ProductDetail($productId)"
+ }
+
+ data class Checkout(val productId: String) : Nav3Route {
+ override val routeName: String = RouteNames.CHECKOUT
+ override val arguments: Map = mapOf(NavArgs.PRODUCT_ID to productId)
+ override val previewName: String = "Checkout($productId)"
+ }
+
+ data class Confirmation(val orderId: String) : Nav3Route {
+ override val routeName: String = RouteNames.CONFIRMATION
+ override val arguments: Map = mapOf(NavArgs.ORDER_ID to orderId)
+ override val previewName: String = "Confirmation($orderId)"
+ }
+
+ data class PromoDialog(val promoId: String) : Nav3Route {
+ override val routeName: String = RouteNames.PROMO_DIALOG
+ override val arguments: Map = mapOf(NavArgs.PROMO_ID to promoId)
+ override val previewName: String = "PromoDialog($promoId)"
+ }
+
+ data class ShareSheet(val productId: String) : Nav3Route {
+ override val routeName: String = RouteNames.SHARE_SHEET
+ override val arguments: Map = mapOf(NavArgs.PRODUCT_ID to productId)
+ override val previewName: String = "ShareSheet($productId)"
+ }
+
+ data object Multipane : Nav3Route {
+ override val routeName: String = "Multipane"
+ override val arguments: Map = mapOf("scenario" to "multipane")
+ }
+
+ data object Multistack : Nav3Route {
+ override val routeName: String = "Multistack"
+ override val arguments: Map = mapOf("scenario" to "multistack")
+ }
+
+ data class Performance(val index: Int, val generation: Int) : Nav3Route {
+ override val routeName: String = "Performance"
+ override val arguments: Map = mapOf("index" to index, "generation" to generation)
+ override val previewName: String = "Performance($index:$generation)"
+ override val performanceSeed: Int = 31 * index + generation
+ }
+}
+
+internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) {
+ LANDING(RouteNames.LANDING, showTab = false),
+ SINGLE_STACK("Single Stack"),
+ DEEP_LINK("Deep Link"),
+ CUSTOM("Custom"),
+ MULTIPANE("Multipane"),
+ MULTIPLE_STACKS("Multistack"),
+ PERFORMANCE("Performance"),
+}
+
+internal val Nav3Scenario.initialRoute: Nav3Route
+ get() =
+ when (this) {
+ Nav3Scenario.LANDING -> Nav3Route.Landing
+ Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack
+ Nav3Scenario.CUSTOM -> Nav3Route.Custom
+ Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink
+ Nav3Scenario.MULTIPANE -> Nav3Route.Multipane
+ Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack
+ Nav3Scenario.PERFORMANCE -> Nav3Route.Performance(index = 0, generation = 0)
+ }
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt
new file mode 100644
index 0000000000..45eadc367c
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt
@@ -0,0 +1,139 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import io.sentry.samples.android.navigation.common.NavigationSampleConfig
+import io.sentry.samples.android.navigation.common.NavigationSampleConfigSnapshot
+
+internal fun Intent.previousNav3SampleConfigSnapshot(
+ fallback: NavigationSampleConfigSnapshot
+): NavigationSampleConfigSnapshot =
+ NavigationSampleConfigSnapshot(
+ enableScreenTracking =
+ getBooleanExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, fallback.enableScreenTracking),
+ enableUserInteractionTransactions =
+ getBooleanExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ fallback.enableUserInteractionTransactions,
+ ),
+ enableUserInteractionBreadcrumbs =
+ getBooleanExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ fallback.enableUserInteractionBreadcrumbs,
+ ),
+ )
+
+internal fun Intent.nav3SampleConfig(): NavigationSampleConfig =
+ NavigationSampleConfig(
+ enableNavigationTransactions = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, true),
+ enableNavigationBreadcrumbs = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, true),
+ enableScreenTracking = getBooleanExtra(EXTRA_ENABLE_SCREEN_TRACKING, true),
+ enableActivityUiLoadTransaction =
+ getBooleanExtra(EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false),
+ enableUserInteractionTransactions =
+ getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS, false),
+ enableUserInteractionBreadcrumbs =
+ getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS, false),
+ captureBackStack = getBooleanExtra(EXTRA_CAPTURE_BACK_STACK, true),
+ maxCapturedBackStackEntries =
+ getIntExtra(EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES, 10).coerceAtLeast(1),
+ )
+
+internal fun Context.nav3LaunchIntent(
+ configuration: NavigationSampleConfig,
+ previousOptions: NavigationSampleConfigSnapshot,
+): Intent =
+ Intent(this, Nav3Activity::class.java)
+ .putExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions)
+ .putExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs)
+ .putExtra(EXTRA_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking)
+ .putExtra(
+ EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION,
+ configuration.enableActivityUiLoadTransaction,
+ )
+ .putExtra(
+ EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ configuration.enableUserInteractionTransactions,
+ )
+ .putExtra(
+ EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ configuration.enableUserInteractionBreadcrumbs,
+ )
+ .putExtra(EXTRA_CAPTURE_BACK_STACK, configuration.captureBackStack)
+ .putExtra(EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES, configuration.maxCapturedBackStackEntries)
+ .putExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, previousOptions.enableScreenTracking)
+ .putExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ previousOptions.enableUserInteractionTransactions,
+ )
+ .putExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ previousOptions.enableUserInteractionBreadcrumbs,
+ )
+
+internal fun Bundle.putNav3SampleConfiguration(configuration: NavigationSampleConfig) {
+ putBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions)
+ putBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs)
+ putBoolean(STATE_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking)
+ putBoolean(
+ STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION,
+ configuration.enableActivityUiLoadTransaction,
+ )
+ putBoolean(
+ STATE_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ configuration.enableUserInteractionTransactions,
+ )
+ putBoolean(
+ STATE_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ configuration.enableUserInteractionBreadcrumbs,
+ )
+ putBoolean(STATE_CAPTURE_BACK_STACK, configuration.captureBackStack)
+ putInt(STATE_MAX_CAPTURED_BACK_STACK_ENTRIES, configuration.maxCapturedBackStackEntries)
+}
+
+internal fun Bundle.nav3SampleConfiguration(): NavigationSampleConfig =
+ NavigationSampleConfig(
+ enableNavigationTransactions = getBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, true),
+ enableNavigationBreadcrumbs = getBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, true),
+ enableScreenTracking = getBoolean(STATE_ENABLE_SCREEN_TRACKING, true),
+ enableActivityUiLoadTransaction = getBoolean(STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false),
+ enableUserInteractionTransactions =
+ getBoolean(STATE_ENABLE_USER_INTERACTION_TRANSACTIONS, false),
+ enableUserInteractionBreadcrumbs = getBoolean(STATE_ENABLE_USER_INTERACTION_BREADCRUMBS, false),
+ captureBackStack = getBoolean(STATE_CAPTURE_BACK_STACK, true),
+ maxCapturedBackStackEntries =
+ getInt(STATE_MAX_CAPTURED_BACK_STACK_ENTRIES, 10).coerceAtLeast(1),
+ )
+
+private const val EXTRA_PREFIX = "io.sentry.samples.android.navigation.nav3"
+private const val EXTRA_ENABLE_NAVIGATION_TRANSACTIONS =
+ "$EXTRA_PREFIX.enable_navigation_transactions"
+private const val EXTRA_ENABLE_NAVIGATION_BREADCRUMBS =
+ "$EXTRA_PREFIX.enable_navigation_breadcrumbs"
+private const val EXTRA_ENABLE_SCREEN_TRACKING = "$EXTRA_PREFIX.enable_screen_tracking"
+private const val EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION =
+ "$EXTRA_PREFIX.enable_activity_ui_load_transaction"
+private const val EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS =
+ "$EXTRA_PREFIX.enable_user_interaction_transactions"
+private const val EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS =
+ "$EXTRA_PREFIX.enable_user_interaction_breadcrumbs"
+private const val EXTRA_CAPTURE_BACK_STACK = "$EXTRA_PREFIX.capture_back_stack"
+private const val EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES =
+ "$EXTRA_PREFIX.max_captured_back_stack_entries"
+private const val EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING =
+ "$EXTRA_PREFIX.previous_enable_screen_tracking"
+private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS =
+ "$EXTRA_PREFIX.previous_enable_user_interaction_transactions"
+private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS =
+ "$EXTRA_PREFIX.previous_enable_user_interaction_breadcrumbs"
+
+private const val STATE_ENABLE_NAVIGATION_TRANSACTIONS = "enable_navigation_transactions"
+private const val STATE_ENABLE_NAVIGATION_BREADCRUMBS = "enable_navigation_breadcrumbs"
+private const val STATE_ENABLE_SCREEN_TRACKING = "enable_screen_tracking"
+private const val STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION = "enable_activity_ui_load_transaction"
+private const val STATE_ENABLE_USER_INTERACTION_TRANSACTIONS =
+ "enable_user_interaction_transactions"
+private const val STATE_ENABLE_USER_INTERACTION_BREADCRUMBS = "enable_user_interaction_breadcrumbs"
+private const val STATE_CAPTURE_BACK_STACK = "capture_back_stack"
+private const val STATE_MAX_CAPTURED_BACK_STACK_ENTRIES = "max_captured_back_stack_entries"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt
new file mode 100644
index 0000000000..c24dbe707c
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt
@@ -0,0 +1,60 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import io.sentry.samples.android.R
+
+@Composable
+internal fun Nav3SampleTheme(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ val primaryColor = Color(ContextCompat.getColor(context, R.color.colorPrimary))
+ val accentColor = Color(ContextCompat.getColor(context, R.color.colorAccent))
+ val darkBackground = Color(0xFF333333)
+ val darkSurface = Color(0xFF333333)
+ val darkSurfaceVariant = Color(0xFF454545)
+ val darkSurfaceContainer = Color(0xFF66666A)
+ val darkOnBackground = Color(0xFFF2ECF7)
+ val darkOnSurface = Color(0xFFF2ECF7)
+ val darkOnSurfaceVariant = Color(0xFFD0CAD6)
+ val lightBackground = Color(0xFFF6F1F8)
+ val lightSurface = Color(0xFFF6F1F8)
+ val lightSurfaceVariant = Color(0xFFE4DEEA)
+ val lightOnBackground = Color(0xFF241F29)
+ val lightOnSurface = Color(0xFF241F29)
+ val lightOnSurfaceVariant = Color(0xFF5F5868)
+ val colorScheme =
+ if (isSystemInDarkTheme()) {
+ darkColorScheme(
+ primary = primaryColor,
+ secondary = accentColor,
+ tertiary = primaryColor,
+ background = darkBackground,
+ surface = darkSurface,
+ surfaceVariant = darkSurfaceVariant,
+ surfaceContainer = darkSurfaceContainer,
+ onBackground = darkOnBackground,
+ onSurface = darkOnSurface,
+ onSurfaceVariant = darkOnSurfaceVariant,
+ )
+ } else {
+ lightColorScheme(
+ primary = primaryColor,
+ secondary = accentColor,
+ tertiary = primaryColor,
+ background = lightBackground,
+ surface = lightSurface,
+ surfaceVariant = lightSurfaceVariant,
+ onBackground = lightOnBackground,
+ onSurface = lightOnSurface,
+ onSurfaceVariant = lightOnSurfaceVariant,
+ )
+ }
+
+ MaterialTheme(colorScheme = colorScheme, content = content)
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt
new file mode 100644
index 0000000000..4a9b77227b
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt
@@ -0,0 +1,79 @@
+package io.sentry.samples.android.navigation.nav3
+
+import io.sentry.Sentry
+import io.sentry.SpanStatus
+import io.sentry.protocol.SentryTransaction
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+internal fun tagCurrentNavigationSampleScenario(scenarioLabel: String) {
+ Sentry.getSpan()?.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenarioLabel)
+ Sentry.configureScope { scope ->
+ scope.withTransaction { transaction ->
+ transaction?.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenarioLabel)
+ }
+ }
+}
+
+internal fun SentryTransaction.navigationSampleScenarioLabel(): String =
+ getTag(NAVIGATION_SAMPLE_SCENARIO_TAG)
+ ?: getTag(NAV2_SCENARIO_TAG)
+ ?: getTag(NAV3_SCENARIO_TAG)
+ ?: UNKNOWN_NAVIGATION_SCENARIO_LABEL
+
+internal fun cancelCurrentActivityUiLoadTransaction() {
+ Sentry.configureScope { scope ->
+ scope.withTransaction { transaction ->
+ if (transaction?.operation == ACTIVITY_UI_LOAD_OP) {
+ transaction.forceFinish(SpanStatus.CANCELLED, false, null)
+ scope.clearTransaction()
+ }
+ }
+ }
+}
+
+internal suspend fun recordSimulatedBackgroundSpan(routeName: String) {
+ val parentSpan = Sentry.getSpan()
+ suspendCancellableCoroutine { continuation ->
+ val worker = Thread {
+ val span =
+ parentSpan?.startChild(
+ "test.navigation.background_work",
+ "Nav3 /$routeName background work",
+ )
+ span?.setData("sample.background_work", true)
+ try {
+ Thread.sleep(BACKGROUND_WORK_MILLIS)
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ } finally {
+ span?.finish()
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ }
+ continuation.invokeOnCancellation { worker.interrupt() }
+ worker.start()
+ }
+}
+
+internal fun emitSampleNavigationSpan(routeName: String) {
+ val span =
+ Sentry.getSpan()
+ ?.startChild(
+ "test.navigation.emit_span",
+ "Nav3 /$routeName emit a span",
+ )
+ span?.setData("sample.emit_span", true)
+ span?.finish()
+}
+
+internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L
+
+private const val BACKGROUND_WORK_MILLIS = 1000L
+private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario"
+private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario"
+private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario"
+private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown"
+private const val ACTIVITY_UI_LOAD_OP = "ui.load"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt
new file mode 100644
index 0000000000..db945d4519
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt
@@ -0,0 +1,49 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import io.sentry.samples.android.navigation.common.NavigationSampleConfig
+import io.sentry.samples.android.navigation.common.NavigationSetupScreen
+import io.sentry.samples.android.navigation.common.applyToCurrentOptions
+import io.sentry.samples.android.navigation.common.currentNavigationSampleConfigSnapshot
+
+/**
+ * Activity for configuring the developer's experience in the [Nav3Activity], matching the Nav2
+ * setup flow while exposing Nav3-only back stack capture options.
+ */
+class Nav3SetupActivity : AppCompatActivity() {
+
+ private var configuration by mutableStateOf(NavigationSampleConfig())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ configuration = savedInstanceState?.nav3SampleConfiguration() ?: configuration
+ setContent {
+ Nav3SampleTheme {
+ NavigationSetupScreen(
+ navName = "Nav3",
+ navVersion = "3",
+ configuration = configuration,
+ showBackStackControls = true,
+ onConfigurationChanged = { updatedConfiguration ->
+ configuration = updatedConfiguration
+ },
+ onLaunch = {
+ val previousOptions = currentNavigationSampleConfigSnapshot()
+ configuration.applyToCurrentOptions()
+ startActivity(nav3LaunchIntent(configuration, previousOptions))
+ },
+ )
+ }
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putNav3SampleConfiguration(configuration)
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt
new file mode 100644
index 0000000000..d2872f3c93
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt
@@ -0,0 +1,172 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountTree
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.sentry.compose.SentryModifier.sentryTag
+
+@Composable
+internal fun Nav3TopBar(
+ backStack: List,
+ selectedScenario: Nav3Scenario,
+ maxCapturedBackStackEntries: Int,
+ onTransactionHistoryClick: () -> Unit,
+ onRouteWorkSettingsClick: () -> Unit,
+ onScenarioSelected: (Nav3Scenario) -> Unit,
+) {
+ val currentRoute = backStack.lastOrNull() ?: Nav3Route.SingleStack
+ val currentRouteText = currentRoute.displayRoute()
+ val capturedBackStackEntries =
+ backStack.takeLast(maxCapturedBackStackEntries).map { route -> "/${route.previewName}" }
+ val capturedBackStack =
+ capturedBackStackEntries
+ .mapIndexed { index, route ->
+ if (index == 0 && backStack.size > maxCapturedBackStackEntries) {
+ "... $route"
+ } else {
+ route
+ }
+ }
+ .joinToString(" -> ")
+ val selectedTabColor = MaterialTheme.colorScheme.primary
+
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ shadowElevation = 4.dp,
+ modifier = Modifier.testTag(nav3TestTag("top_bar")),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 24.dp, top = 18.dp, end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "Navigation 3",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.weight(1f).testTag(nav3TestTag("title")),
+ )
+ IconButton(
+ onClick = onTransactionHistoryClick,
+ modifier =
+ Modifier.sentryTag(nav3InteractionTag("Recent Transactions"))
+ .testTag(nav3TestTag("top_bar_recent_transactions")),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.AccountTree,
+ contentDescription = "Recent transactions",
+ )
+ }
+ IconButton(
+ onClick = onRouteWorkSettingsClick,
+ modifier =
+ Modifier.sentryTag(nav3InteractionTag("Route Work Settings"))
+ .testTag(nav3TestTag("top_bar_route_work_settings")),
+ ) {
+ Icon(imageVector = Icons.Filled.Settings, contentDescription = "Route work settings")
+ }
+ }
+ Column(
+ modifier =
+ Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp)
+ .testTag(nav3TestTag("route_summary")),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = "Current route: $currentRouteText",
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier.horizontalScroll(rememberScrollState()).testTag(nav3TestTag("current_route")),
+ maxLines = 1,
+ )
+ Text(
+ text = "Nav3 back stack: $capturedBackStack",
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier.horizontalScroll(rememberScrollState()).testTag(nav3TestTag("back_stack")),
+ maxLines = 1,
+ )
+ }
+ ScenarioBar(
+ selectedScenario = selectedScenario,
+ selectedTabColor = selectedTabColor,
+ onScenarioSelected = onScenarioSelected,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ScenarioBar(
+ selectedScenario: Nav3Scenario,
+ selectedTabColor: Color,
+ onScenarioSelected: (Nav3Scenario) -> Unit,
+) {
+ val scenarios = Nav3Scenario.entries.filter { scenario -> scenario.showTab }
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(start = 24.dp, end = 24.dp)
+ .testTag(nav3TestTag("scenario_bar"))
+ ) {
+ scenarios.forEach { scenario ->
+ val selected = selectedScenario == scenario
+ Column(
+ modifier =
+ Modifier.defaultMinSize(minWidth = 120.dp)
+ .clickable { onScenarioSelected(scenario) }
+ .testTag(nav3ScenarioTabTag(scenario)),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = scenario.label,
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
+ color = if (selected) selectedTabColor else MaterialTheme.colorScheme.onBackground,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+ )
+ Box(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(4.dp)
+ .background(
+ if (selected) selectedTabColor else Color.Transparent,
+ RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp),
+ )
+ )
+ }
+ }
+ }
+}
+
+internal fun nav3InteractionTag(label: String): String = "Nav3 $label"
+
+internal fun nav3TestTag(name: String): String = "nav3_$name"
+
+internal fun nav3ScenarioTabTag(scenario: Nav3Scenario): String =
+ nav3TestTag("tab_${scenario.name.lowercase()}")
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt
new file mode 100644
index 0000000000..38cd6d2039
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt
@@ -0,0 +1,176 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.mutableStateListOf
+import io.sentry.Sentry
+import io.sentry.protocol.SentrySpan
+import io.sentry.protocol.SentryTransaction
+import io.sentry.samples.android.SampleBeforeSendTransactionHook
+
+/** State holder backing the navigation sample transaction history sheet. */
+internal class NavigationTransactionHistory(private val isActive: () -> Boolean) {
+
+ val transactions = mutableStateListOf()
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn ->
+ add(transaction, dsn)
+ }
+
+ fun install() {
+ val options = Sentry.getCurrentScopes().options
+ SampleBeforeSendTransactionHook.installIfNeeded(options)
+ SampleBeforeSendTransactionHook.addListener(transactionListener)
+ }
+
+ fun uninstall() {
+ clear()
+ SampleBeforeSendTransactionHook.removeListener(transactionListener)
+ }
+
+ fun clear() {
+ transactions.clear()
+ }
+
+ private fun add(transaction: SentryTransaction, dsn: String?) {
+ if (!isActive()) {
+ return
+ }
+
+ val trace = transaction.toNavigationTransactionTrace(dsn)
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ transactions.addMostRecent(trace)
+ } else {
+ mainHandler.post {
+ if (isActive()) {
+ transactions.addMostRecent(trace)
+ }
+ }
+ }
+ }
+}
+
+internal data class NavigationTransactionTrace(
+ val name: String,
+ val operation: String,
+ val eventId: String,
+ val traceId: String,
+ val status: String?,
+ val tab: String,
+ val durationMillis: Double,
+ val sentryUrl: String?,
+ val spans: List,
+)
+
+internal data class NavigationTraceSpan(
+ val spanId: String,
+ val parentSpanId: String?,
+ val operation: String,
+ val description: String?,
+ val startOffsetMillis: Double,
+ val durationMillis: Double,
+ val children: List = emptyList(),
+)
+
+private fun SentryTransaction.toNavigationTransactionTrace(
+ dsn: String?
+): NavigationTransactionTrace {
+ val trace = contexts.trace
+ val startTimestamp = startTimestamp
+ val endTimestamp =
+ timestamp ?: spans.mapNotNull { span -> span.timestamp }.maxOrNull() ?: startTimestamp
+ val durationMillis = ((endTimestamp - startTimestamp) * 1_000.0).coerceAtLeast(0.0)
+ val rootSpanId = trace?.spanId?.toString()
+ val traceId = trace?.traceId?.toString().orEmpty()
+ val eventId = eventId?.toString().orEmpty()
+ val rawSpans = spans.map { it.toNavigationTraceSpan(startTimestamp) }
+ val spanIds = rawSpans.map { it.spanId }.toSet()
+ val spansByParentId = rawSpans.groupBy { span -> span.parentSpanId }
+ val topLevelSpans =
+ rawSpans
+ .filter { span ->
+ span.parentSpanId == null ||
+ span.parentSpanId == rootSpanId ||
+ span.parentSpanId !in spanIds
+ }
+ .sortedBy { span -> span.startOffsetMillis }
+
+ return NavigationTransactionTrace(
+ name = transaction ?: "",
+ operation = trace?.operation ?: "transaction",
+ eventId = eventId,
+ traceId = traceId,
+ status = status?.name,
+ tab = navigationSampleScenarioLabel(),
+ durationMillis = durationMillis,
+ sentryUrl = sentryTransactionUrl(dsn, traceId, rootSpanId, eventId, endTimestamp),
+ spans = topLevelSpans.withChildren(spansByParentId),
+ )
+}
+
+private fun MutableList.addMostRecent(
+ transaction: NavigationTransactionTrace
+) {
+ add(0, transaction)
+ while (size > TRANSACTION_HISTORY_LIMIT) {
+ removeAt(lastIndex)
+ }
+}
+
+private fun SentrySpan.toNavigationTraceSpan(
+ transactionStartTimestamp: Double
+): NavigationTraceSpan =
+ NavigationTraceSpan(
+ spanId = spanId.toString(),
+ parentSpanId = parentSpanId?.toString(),
+ operation = op,
+ description = description,
+ startOffsetMillis = ((startTimestamp - transactionStartTimestamp) * 1_000.0).coerceAtLeast(0.0),
+ durationMillis =
+ (((timestamp ?: startTimestamp) - startTimestamp) * 1_000.0).coerceAtLeast(0.0),
+ )
+
+private fun List.withChildren(
+ spansByParentId: Map>
+): List = map { span -> span.withChildren(spansByParentId) }
+
+private fun NavigationTraceSpan.withChildren(
+ spansByParentId: Map>
+): NavigationTraceSpan =
+ copy(
+ children =
+ spansByParentId[spanId]
+ .orEmpty()
+ .sortedBy { span -> span.startOffsetMillis }
+ .withChildren(spansByParentId)
+ )
+
+private fun sentryTransactionUrl(
+ dsn: String?,
+ traceId: String,
+ rootSpanId: String?,
+ eventId: String,
+ timestampSeconds: Double,
+): String? {
+ val projectId = dsn?.projectIdFromDsn() ?: return null
+ if (traceId.isEmpty() || rootSpanId.isNullOrEmpty() || eventId.isEmpty()) {
+ return null
+ }
+ return "https://$SENTRY_SAMPLE_ORG_SLUG.sentry.io/explore/traces/trace/$traceId/" +
+ "?node=span-$rootSpanId" +
+ "&project=$projectId" +
+ "&source=traces" +
+ "&statsPeriod=14d" +
+ "&targetId=$eventId" +
+ "×tamp=${timestampSeconds.toLong()}"
+}
+
+private fun String.projectIdFromDsn(): String? =
+ substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/').takeIf { projectId
+ ->
+ projectId.isNotEmpty() && projectId.all { it.isDigit() }
+ }
+
+private const val TRANSACTION_HISTORY_LIMIT = 10
+private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt
new file mode 100644
index 0000000000..0e0038083e
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt
@@ -0,0 +1,368 @@
+package io.sentry.samples.android.navigation.nav3
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import java.util.Locale
+import kotlin.math.roundToInt
+
+/**
+ * Bottom sheet for displaying recent navigation sample transactions generated by the Sentry SDK.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun NavigationTransactionHistorySheet(
+ sampleName: String,
+ transactions: List,
+ showActivityUiLoadTransactionDelayMessage: Boolean,
+ onDismissRequest: () -> Unit,
+ onOpenTransaction: (String) -> Unit,
+ onDumpTransactionUrl: (String) -> Unit,
+ onCopyTransactionUrl: (String) -> Unit,
+) {
+ ModalBottomSheet(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 24.dp)
+ .testTag(historyTag(sampleName, "sheet")),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ "Recent transactions",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.testTag(historyTag(sampleName, "title")),
+ )
+ Text(
+ "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .testTag(historyTag(sampleName, "list")),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (transactions.isEmpty()) {
+ EmptyTransactionHistory(showActivityUiLoadTransactionDelayMessage, sampleName)
+ } else {
+ transactions.forEachIndexed { index, transaction ->
+ TransactionCard(
+ sampleName = sampleName,
+ index = index,
+ transaction = transaction,
+ onOpenTransaction = onOpenTransaction,
+ onDumpTransactionUrl = onDumpTransactionUrl,
+ onCopyTransactionUrl = onCopyTransactionUrl,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyTransactionHistory(
+ showActivityUiLoadTransactionDelayMessage: Boolean,
+ sampleName: String,
+) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth().testTag(historyTag(sampleName, "empty")),
+ ) {
+ Text(
+ if (showActivityUiLoadTransactionDelayMessage) {
+ ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE
+ } else {
+ EMPTY_HISTORY_MESSAGE
+ },
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Composable
+private fun TransactionCard(
+ sampleName: String,
+ index: Int,
+ transaction: NavigationTransactionTrace,
+ onOpenTransaction: (String) -> Unit,
+ onDumpTransactionUrl: (String) -> Unit,
+ onCopyTransactionUrl: (String) -> Unit,
+) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth().testTag(historyTag(sampleName, "card_$index")),
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(
+ modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary"))
+ ) {
+ Text(
+ transaction.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_name")),
+ )
+ Text(
+ listOfNotNull(transaction.operation, transaction.status).joinToString(" - "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")),
+ )
+ }
+ Text(
+ "Tab: ${transaction.tab}",
+ style = MaterialTheme.typography.labelLarge,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")),
+ )
+ }
+
+ TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall"))
+
+ if (transaction.sentryUrl == null) {
+ Text(
+ "Sentry URL unavailable for this DSN.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Row(
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_actions")),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(
+ onClick = { onOpenTransaction(transaction.sentryUrl) },
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_open")),
+ ) {
+ Text("Open in Sentry")
+ }
+ TextButton(
+ onClick = { onDumpTransactionUrl(transaction.sentryUrl) },
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_dump")),
+ ) {
+ Text("Dump URL")
+ }
+ TextButton(
+ onClick = { onCopyTransactionUrl(transaction.sentryUrl) },
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_copy")),
+ ) {
+ Text("Copy URL")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TransactionWaterfall(transaction: NavigationTransactionTrace, testTag: String) {
+ val rows = transaction.spans.flattenTraceRows()
+ val scrollState = rememberScrollState()
+ Column(modifier = Modifier.horizontalScroll(scrollState).testTag(testTag)) {
+ TimelineHeader(transaction.durationMillis)
+ TraceRow(
+ label = transaction.name,
+ operation = transaction.operation,
+ level = 0,
+ startOffsetMillis = 0.0,
+ durationMillis = transaction.durationMillis,
+ totalDurationMillis = transaction.durationMillis,
+ color = ROOT_SPAN_COLOR,
+ showBranch = false,
+ )
+ rows.forEachIndexed { index, row ->
+ TraceRow(
+ label = row.span.description ?: row.span.operation,
+ operation = row.span.operation,
+ level = row.level,
+ startOffsetMillis = row.span.startOffsetMillis,
+ durationMillis = row.span.durationMillis,
+ totalDurationMillis = transaction.durationMillis,
+ color = spanBarColor(index, row.span.operation),
+ showBranch = true,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineHeader(totalDurationMillis: Double) {
+ Row(modifier = Modifier.width(WATERFALL_WIDTH).height(28.dp)) {
+ Spacer(Modifier.width(LABEL_WIDTH))
+ Row(
+ modifier = Modifier.width(TIMELINE_WIDTH),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ listOf(0.0, 0.25, 0.5, 0.75, 1.0).forEach { fraction ->
+ Text(
+ (totalDurationMillis * fraction).formatMillis(),
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TraceRow(
+ label: String,
+ operation: String,
+ level: Int,
+ startOffsetMillis: Double,
+ durationMillis: Double,
+ totalDurationMillis: Double,
+ color: Color,
+ showBranch: Boolean,
+) {
+ Row(
+ modifier = Modifier.width(WATERFALL_WIDTH).height(34.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier.width(LABEL_WIDTH).padding(end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(Modifier.width((level * 16).dp))
+ if (showBranch) {
+ Text("|-", color = MaterialTheme.colorScheme.outline)
+ Spacer(Modifier.width(4.dp))
+ }
+ Text(
+ "$operation - $label",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = if (level == 0) FontWeight.Bold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ BoxWithConstraints(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) {
+ val safeTotalDuration = totalDurationMillis.coerceAtLeast(1.0)
+ val startFraction = (startOffsetMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat()
+ val widthFraction = (durationMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat()
+ val barX = maxWidth * startFraction
+ val barWidth = (maxWidth * widthFraction).coerceAtLeast(3.dp).coerceAtMost(maxWidth - barX)
+
+ TimelineGrid()
+ Box(
+ modifier =
+ Modifier.offset(x = barX)
+ .width(barWidth)
+ .height(12.dp)
+ .align(Alignment.CenterStart)
+ .clip(RoundedCornerShape(2.dp))
+ .background(color)
+ )
+ Text(
+ durationMillis.formatMillis(),
+ modifier = Modifier.offset(x = barX + barWidth + 4.dp).align(Alignment.CenterStart),
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineGrid() {
+ Row(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) {
+ repeat(4) {
+ Box(modifier = Modifier.weight(1f).height(24.dp)) {
+ Box(
+ modifier =
+ Modifier.width(1.dp)
+ .height(24.dp)
+ .align(Alignment.CenterEnd)
+ .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
+ )
+ }
+ }
+ }
+}
+
+private fun List.flattenTraceRows(level: Int = 1): List =
+ flatMap { span ->
+ listOf(TraceRowData(span = span, level = level)) + span.children.flattenTraceRows(level + 1)
+ }
+
+private fun spanBarColor(index: Int, operation: String): Color =
+ when {
+ operation.startsWith("ui.compose") -> COMPOSE_SPAN_COLOR
+ operation.startsWith("ui.render") -> RENDER_SPAN_COLOR
+ operation.startsWith("http") -> HTTP_SPAN_COLOR
+ else -> CHILD_SPAN_COLORS[index % CHILD_SPAN_COLORS.size]
+ }
+
+private fun Double.formatMillis(): String =
+ when {
+ this < 10.0 -> String.format(Locale.ROOT, "%.2fms", this)
+ this < 100.0 -> String.format(Locale.ROOT, "%.1fms", this)
+ else -> "${roundToInt()}ms"
+ }
+
+private data class TraceRowData(val span: NavigationTraceSpan, val level: Int)
+
+private const val EMPTY_HISTORY_MESSAGE =
+ "No finished transactions have been emitted yet. Transactions with child work appear here after their work finishes."
+private const val ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE =
+ "No transactions have been emitted yet. ui.load transactions can take up to 30 seconds to " +
+ "appear after the last span is produced."
+private val ROOT_SPAN_COLOR = Color(0xFFE95F5C)
+private val COMPOSE_SPAN_COLOR = Color(0xFF5B3DB6)
+private val RENDER_SPAN_COLOR = Color(0xFFBBD233)
+private val HTTP_SPAN_COLOR = Color(0xFF2F80ED)
+private val CHILD_SPAN_COLORS =
+ listOf(Color(0xFF7553D6), Color(0xFF9B51E0), Color(0xFF27AE60), Color(0xFFF2C94C))
+
+private val LABEL_WIDTH = 260.dp
+private val TIMELINE_WIDTH = 420.dp
+private val WATERFALL_WIDTH = LABEL_WIDTH + TIMELINE_WIDTH
+
+private fun historyTag(sampleName: String, suffix: String): String =
+ "${sampleName.lowercase(Locale.ROOT)}_transaction_history_$suffix"