Skip to content

Commit 6fbcf31

Browse files
committed
feat(android-nav3): Track navigation state transitions
Add the observer layer that turns Navigation 3 back stack changes into breadcrumbs, scope state, and navigation transactions. This is also the point where the module starts carrying generated BuildConfig version metadata and its first non-empty API snapshot.
1 parent addea5d commit 6fbcf31

8 files changed

Lines changed: 944 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
public final class io/sentry/compose/navigation3/BuildConfig {
2+
public static final field BUILD_TYPE Ljava/lang/String;
3+
public static final field DEBUG Z
4+
public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
5+
public static final field VERSION_NAME Ljava/lang/String;
6+
public fun <init> ()V
7+
}
8+

‎sentry-android-navigation3/build.gradle.kts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ android {
1515

1616
defaultConfig {
1717
minSdk = 23 // Nav3 requires minSdk 23
18+
19+
// for AGP 4.1
20+
buildConfigField("String", "VERSION_NAME", "\"${project.version}\"")
1821
}
1922

2023
buildTypes {
@@ -32,6 +35,10 @@ android {
3235
compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9
3336
}
3437

38+
testOptions {
39+
unitTests.isReturnDefaultValues = true
40+
}
41+
3542
lint {
3643
warningsAsErrors = true
3744
checkDependencies = true
@@ -40,6 +47,8 @@ android {
4047
checkReleaseBuilds = false
4148
}
4249

50+
buildFeatures { buildConfig = true }
51+
4352
androidComponents.beforeVariants {
4453
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
4554
}
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
package io.sentry.compose.navigation3
2+
3+
import io.sentry.Breadcrumb
4+
import io.sentry.Hint
5+
import io.sentry.IScope
6+
import io.sentry.IScopes
7+
import io.sentry.ITransaction
8+
import io.sentry.PropagationContext
9+
import io.sentry.SentryIntegrationPackageStorage
10+
import io.sentry.SentryLevel.DEBUG
11+
import io.sentry.SentryLevel.ERROR
12+
import io.sentry.SentryLevel.INFO
13+
import io.sentry.SpanStatus
14+
import io.sentry.TransactionContext
15+
import io.sentry.TransactionOptions
16+
import io.sentry.TypeCheckHint
17+
import io.sentry.protocol.App
18+
import io.sentry.protocol.TransactionNameSource
19+
import io.sentry.util.ExceptionUtils
20+
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
21+
import java.lang.ref.WeakReference
22+
23+
/**
24+
* Observes the back stack managed by a single [SentryNavEffect] and records Sentry state as the
25+
* back stack is updated.
26+
*
27+
* **Warning!** This class is not thread-safe. Clients should serialize calls to
28+
* [onBackStackChanged] and [cleanup] (e.g., via invocation from an `*Effect` or another form of
29+
* thread confinement).
30+
*/
31+
internal class BackStackObserver<T : Any>(
32+
private val scopes: IScopes,
33+
private val options: SentryNavOptions,
34+
private val resolvers: () -> RouteResolvers<T>,
35+
) {
36+
37+
private val routeTranslator =
38+
RouteTranslator(resolvers, options.maxCapturedBackStackEntries, scopes.options.logger)
39+
40+
private var previousBackStackEntry: WeakReference<T>? = null
41+
private val screenTracker = ScreenTracker()
42+
43+
private val areNavigationTransactionsEnabled: Boolean
44+
get() = scopes.options.isTracingEnabled && options.enableNavigationTransactions
45+
46+
private val navTransactions = NavTransactionManager(scopes, NAVIGATION_OP, TRANSACTION_ORIGIN)
47+
48+
init {
49+
addIntegrationToSdkVersion("ComposeNavigation3")
50+
}
51+
52+
internal companion object {
53+
54+
private const val NAVIGATION_CONTEXT_KEY = "navigation"
55+
private const val NAVIGATION_OP: String = "navigation"
56+
private const val TRANSACTION_ORIGIN = "auto.navigation.nav3"
57+
58+
init {
59+
SentryIntegrationPackageStorage.getInstance()
60+
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
61+
}
62+
}
63+
64+
/**
65+
* Updates recorded Sentry data based on the provided [backStack].
66+
*
67+
* Note: This method is **not** idempotent. Callers should protect against repeat invocations with
68+
* the same back stack.
69+
*/
70+
internal fun onBackStackChanged(backStack: List<T>) {
71+
val updateWarningState = RouteTranslator.UpdateWarningState()
72+
73+
guard("onBackStackChanged") {
74+
scopes.configureScope { scope ->
75+
// Always update the recorded backstack, as any of its entries may have changed.
76+
scope.updateNavigationContext(backStack, options, updateWarningState)
77+
78+
// Return early if there's nowhere to go or if the top of the back stack hasn't changed...
79+
val previousTop: T? = previousBackStackEntry?.get()
80+
val currentTop: T? = backStack.lastOrNull()
81+
82+
if (currentTop == null) {
83+
handleEmptyBackStack(scope)
84+
return@configureScope
85+
}
86+
if (previousTop === currentTop) {
87+
return@configureScope
88+
}
89+
90+
// ...otherwise record data for the new nav destination.
91+
handleNewTop(scope, previousTop, currentTop, backStack, updateWarningState)
92+
}
93+
}
94+
}
95+
96+
internal fun cleanup() {
97+
previousBackStackEntry = null
98+
99+
scopes.configureScope { scope ->
100+
navTransactions.stop(scope)
101+
screenTracker.clear(scope)
102+
103+
if (options.captureBackStack) {
104+
// This observer owns the Nav3 navigation context while it's in the composition, and cleanup
105+
// removes it to avoid leaking stale back stack data after observation stops. If the host
106+
// app replaces one observer with another, there may be a brief gap where events lack
107+
// navigation context. Apps should keep the observer at the nav root so cleanup only runs
108+
// when the navigation session is ending, not during normal destination changes.
109+
scope.removeContexts(NAVIGATION_CONTEXT_KEY)
110+
}
111+
}
112+
}
113+
114+
private fun handleNewTop(
115+
scope: IScope,
116+
previousTop: T?,
117+
currentTop: T,
118+
backStack: List<T>,
119+
updateWarningState: RouteTranslator.UpdateWarningState,
120+
) {
121+
val routeName = routeTranslator.resolveRouteName(currentTop)
122+
val arguments = routeTranslator.resolveArguments(currentTop, updateWarningState)
123+
124+
if (scopes.options.isEnableScreenTracking) {
125+
screenTracker.track(scope, routeName)
126+
}
127+
128+
if (options.enableNavigationBreadcrumbs) {
129+
scopes.addNav3Breadcrumb(previousTop, currentTop, routeName, arguments, updateWarningState)
130+
}
131+
132+
navTransactions.stop(scope)
133+
134+
if (areNavigationTransactionsEnabled) {
135+
navTransactions.start(routeName, arguments) { transaction ->
136+
transaction.snapshotTrackedNavigationContext(routeName, backStack, updateWarningState)
137+
}
138+
} else {
139+
scope.rotatePropagationContext()
140+
}
141+
142+
previousBackStackEntry = WeakReference(currentTop)
143+
}
144+
145+
private fun handleEmptyBackStack(scope: IScope) {
146+
navTransactions.stop(scope)
147+
screenTracker.clear(scope)
148+
previousBackStackEntry = null
149+
}
150+
151+
private fun IScope.updateNavigationContext(
152+
backStack: List<T>,
153+
options: SentryNavOptions,
154+
updateWarningState: RouteTranslator.UpdateWarningState,
155+
) {
156+
if (!options.captureBackStack) {
157+
this.removeContexts(NAVIGATION_CONTEXT_KEY)
158+
return
159+
}
160+
161+
val entries = routeTranslator.toRouteEntries(backStack, updateWarningState)
162+
if (entries.isEmpty()) {
163+
this.removeContexts(NAVIGATION_CONTEXT_KEY)
164+
} else {
165+
this.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("backstack" to entries))
166+
}
167+
}
168+
169+
/**
170+
* Snapshots the current route and back stack onto the transaction itself so late-finishing child
171+
* spans cannot cause the event to inherit newer scope state from a later navigation update.
172+
*/
173+
private fun ITransaction.snapshotTrackedNavigationContext(
174+
routeName: String,
175+
backStack: List<T>,
176+
updateWarningState: RouteTranslator.UpdateWarningState,
177+
) {
178+
val appContext = contexts.app ?: App().also { contexts.setApp(it) }
179+
appContext.viewNames = listOf(routeName)
180+
181+
if (options.captureBackStack) {
182+
val entries = routeTranslator.toRouteEntries(backStack, updateWarningState)
183+
if (entries.isNotEmpty()) {
184+
setContext(NAVIGATION_CONTEXT_KEY, mapOf("backstack" to entries))
185+
}
186+
}
187+
}
188+
189+
private fun IScope.rotatePropagationContext() {
190+
withPropagationContext { setPropagationContext(PropagationContext()) }
191+
}
192+
193+
private fun IScopes.addNav3Breadcrumb(
194+
fromEntry: T?,
195+
toEntry: T,
196+
routeName: String,
197+
arguments: Map<String, Any?>,
198+
updateWarningState: RouteTranslator.UpdateWarningState,
199+
) {
200+
val breadcrumb =
201+
Breadcrumb().apply {
202+
type = NAVIGATION_OP
203+
category = NAVIGATION_OP
204+
205+
fromEntry?.let { prev ->
206+
data["from"] = routeTranslator.resolveRouteName(prev)
207+
val fromArgs = routeTranslator.resolveArguments(prev, updateWarningState)
208+
if (fromArgs.isNotEmpty()) {
209+
data["from_arguments"] = fromArgs
210+
}
211+
}
212+
213+
data["to"] = routeName
214+
if (arguments.isNotEmpty()) {
215+
data["to_arguments"] = arguments
216+
}
217+
218+
level = INFO
219+
}
220+
221+
val hint = Hint()
222+
hint.set(TypeCheckHint.NAV3_DESTINATION, toEntry)
223+
this.addBreadcrumb(breadcrumb, hint)
224+
}
225+
226+
@Suppress("TooGenericExceptionCaught")
227+
private inline fun guard(operation: String, body: () -> Unit) {
228+
try {
229+
body()
230+
} catch (t: Throwable) {
231+
ExceptionUtils.rethrowIfFatal(t)
232+
scopes.options.logger.log(
233+
ERROR,
234+
t,
235+
"Nav3 instrumentation failed during %s. Skipping this navigation update.",
236+
operation,
237+
)
238+
}
239+
}
240+
}
241+
242+
private class ScreenTracker {
243+
244+
private var lastScreenRouteName: String? = null
245+
246+
/** Tracks the provided [routeName] as the current visible screen. */
247+
fun track(scope: IScope, routeName: String) {
248+
scope.screen = routeName
249+
val app = scope.contexts.app ?: App().also { scope.contexts.setApp(it) }
250+
app.viewNames = listOf(routeName)
251+
lastScreenRouteName = routeName
252+
}
253+
254+
fun clear(scope: IScope) {
255+
val routeName = lastScreenRouteName ?: return
256+
if (scope.screen == routeName) {
257+
scope.screen = null
258+
}
259+
if (scope.contexts.app?.viewNames == listOf(routeName)) {
260+
scope.contexts.app?.viewNames = null
261+
}
262+
lastScreenRouteName = null
263+
}
264+
}
265+
266+
private class NavTransactionManager(
267+
private val scopes: IScopes,
268+
private val navigationOp: String,
269+
private val transactionOrigin: String,
270+
) {
271+
272+
private var activeNavTransaction: ITransaction? = null
273+
274+
/** Starts an idle navigation transaction, or no-ops if another span context is already active. */
275+
fun start(
276+
routeName: String,
277+
arguments: Map<String, Any?>,
278+
snapshot: (ITransaction) -> Unit,
279+
) {
280+
clearFinishedScopeTransaction()
281+
282+
if (scopes.span != null) {
283+
scopes.options.logger.log(
284+
DEBUG,
285+
"Nav3 transaction for route %s won't be created because another transaction or span is active.",
286+
routeName,
287+
)
288+
289+
return
290+
}
291+
292+
val transactionOptions =
293+
TransactionOptions().also {
294+
it.isWaitForChildren = true
295+
it.idleTimeout = scopes.options.idleTimeout
296+
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
297+
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
298+
it.isTrimEnd = true
299+
}
300+
301+
val transaction =
302+
scopes.startTransaction(
303+
TransactionContext(routeName, TransactionNameSource.ROUTE, navigationOp),
304+
transactionOptions,
305+
)
306+
307+
activeNavTransaction = transaction
308+
309+
transaction.apply {
310+
spanContext.origin = transactionOrigin
311+
if (arguments.isNotEmpty()) {
312+
setData("arguments", arguments)
313+
}
314+
snapshot(this)
315+
}
316+
317+
scopes.configureScope { scope ->
318+
scope.withTransaction { tx ->
319+
if (tx == null) {
320+
scope.transaction = transaction
321+
}
322+
}
323+
}
324+
}
325+
326+
/** Finishes and unsets the active navigation transaction, if one exists. */
327+
fun stop(scope: IScope) {
328+
val transaction = activeNavTransaction ?: return
329+
val status = transaction.status ?: SpanStatus.OK
330+
transaction.finish(status)
331+
332+
scope.withTransaction { tx ->
333+
if (tx == transaction) {
334+
scope.clearTransaction()
335+
}
336+
}
337+
338+
activeNavTransaction = null
339+
}
340+
341+
/** Clears a stale finished transaction that's still bound to the default scope. */
342+
private fun clearFinishedScopeTransaction() {
343+
scopes.configureScope { scope ->
344+
scope.withTransaction { tx ->
345+
if (tx?.isFinished == true) {
346+
scope.clearTransaction()
347+
}
348+
}
349+
}
350+
}
351+
}

0 commit comments

Comments
 (0)