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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216))
- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215))

### Behavioral Changes

- Nav3: Returning to an Activity or Fragment with its own `SentryNavEffect` within the app (e.g., closing the Activity that covered it, swiping back to its ViewPager2 page, or dismissing a dialog over it) now starts a new navigation transaction for the current destination if the previous one finished, so spans produced upon return can now be consistently captured ([#6482](https://github.com/getsentry/sentry-java/pull/6482))

## 8.63.0

### Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,18 @@ import io.sentry.Hint
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy
import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop
import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop
import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty
import io.sentry.compose.navigation3.lifecycle.INavScope
import io.sentry.compose.navigation3.lifecycle.INavScopes
import io.sentry.compose.navigation3.lifecycle.INavTransaction
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
import io.sentry.protocol.App
import io.sentry.protocol.TransactionNameSource
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_OP
import io.sentry.compose.navigation3.lifecycle.NavTransactionRequest
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
import java.lang.ref.WeakReference

private const val NAVIGATION_OP: String = "navigation"

/**
* Observes the back stack managed by a single [SentryNavEffect] and records Sentry state as the
* back stack is updated.
Expand Down Expand Up @@ -57,10 +52,9 @@ internal class BackStackObserver<T : Any>(

private val backStackConverter = BackStackConverter(entryMapper, scopes.options.logger)

private val navTransactions = NavTransactions(scopes)
private val navBreadcrumbs = NavBreadcrumbs(scopes)
private val navScreen = NavScreen()
private val navContext = NavContext(scopes, options)
private val navContext = NavContext(options)

// Safe because the host back stack retains the current top entry strongly between updates.
private var previousTop: WeakReference<T>? = null
Expand Down Expand Up @@ -192,16 +186,10 @@ internal class BackStackObserver<T : Any>(
navBreadcrumbs.emit(fromEntry = previousTop, toEntry = currentBackStack.topEntryNormalized)
}

navTransactions.stop(scope)
scope.finishNavTransaction()

if (areNavigationTransactionsEnabled) {
navTransactions
.start(
scope,
currentTop.name,
currentTop.argumentsWithMetadata(),
)
?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) }
scope.startNavTransaction(navTransactionRequestFor(currentBackStack))
} else {
scope.rotatePropagationContext()
}
Expand All @@ -211,6 +199,13 @@ internal class BackStackObserver<T : Any>(

private fun handleSameTop(scope: INavScope, backStack: BackStackData<T>) {
navContext.update(scope, backStack.capturedEntriesNormalized)

// Keeps the nav context of transactions started later for the same destination (e.g., when the
// user returns to the nav root) in step with the back stack.
if (areNavigationTransactionsEnabled) {
scope.updateNavTransactionRequest(navTransactionRequestFor(backStack))
}

storePreviousTop(backStack)
}

Expand All @@ -228,8 +223,20 @@ internal class BackStackObserver<T : Any>(
previousTopNormalized = null
}

private fun navTransactionRequestFor(backStack: BackStackData<T>): NavTransactionRequest {
val top = backStack.topEntryNormalized

return NavTransactionRequest.forDestination(
name = top.name,
arguments = top.argumentsWithMetadata(),
contexts = navContext.forTransaction(backStack.capturedEntriesNormalized),
viewNames = if (scopes.options.isEnableScreenTracking) listOf(top.name) else null,
options = scopes.options,
)
}

private fun tearDown(scope: INavScope) {
navTransactions.stop(scope)
scope.finishNavTransaction()
navContext.clear(scope)
navScreen.clear(scope)
clearPreviousTop()
Expand Down Expand Up @@ -274,58 +281,8 @@ private data class BackStackData<T>(
val capturedEntriesNormalized: List<NormalizedSentryBackStackEntry>,
)

/** A helper class for managing nav transactions. */
private class NavTransactions(private val scopes: INavScopes) {

private companion object {
private const val TRANSACTION_ORIGIN = "auto.navigation.nav3"
}

/**
* Starts an idle navigation transaction, or no-ops if another transaction is already active.
*
* [Stop][stop] the current navigation transaction first. See [INavScope.startNavTransaction].
*/
fun start(
scope: INavScope,
name: String,
arguments: Map<String, Any?>,
): INavTransaction? {
val transactionOptions =
TransactionOptions().also {
it.isWaitForChildren = true
it.idleTimeout = scopes.options.idleTimeout
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
it.isTrimEnd = true
it.origin = TRANSACTION_ORIGIN
}

val transaction =
scope.startNavTransaction(
TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP),
transactionOptions,
) ?: return null

if (arguments.isNotEmpty()) {
transaction.setData("arguments", arguments)
}

return transaction
}

/**
* Finishes and unsets the active navigation transaction, if one exists.
*
* See [INavScope.finishNavTransaction].
*/
fun stop(scope: INavScope) {
scope.finishNavTransaction()
}
}

/** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */
private class NavContext(private val scopes: INavScopes, private val options: SentryNavOptions) {
private class NavContext(private val options: SentryNavOptions) {

private companion object {
private const val BACKSTACK_KEY = "Back Stack"
Expand All @@ -345,33 +302,21 @@ private class NavContext(private val scopes: INavScopes, private val options: Se
}

/**
* Updates the transaction with the provided navigation info.
* The nav context to set on the nav transaction for a destination whose back stack is
* [capturedEntries], if any.
*
* Needed because transactions inherit base scope context on a per-key basis unless transactions
* have their own values for those keys. In our case, we need to keep fresh back stack and route
* values in the base context for purposes of crash reporting. But those values will often advance
* past what's relevant to a given transaction. This method prevents misassociation by binding
* proper values to the transaction context instead.
* have their own values for those keys. In our case, we need to keep fresh back stack values in
* the base context for purposes of crash reporting. But those values will often advance past
* what's relevant to a given transaction. Setting the destination's own back stack on its
* transaction prevents misassociation.
*/
fun <T : Any> updateTransaction(
transaction: INavTransaction,
scope: INavScope,
backStack: BackStackData<T>,
) {
if (scopes.options.isEnableScreenTracking) {
val appContext = scope.app ?: App()

appContext.viewNames = listOf(backStack.topEntryNormalized.name)
transaction.setApp(appContext)
}

if (options.captureBackStack && backStack.capturedEntriesNormalized.isNotEmpty()) {
transaction.setContext(
NAVIGATION_CONTEXT_KEY,
backStack.capturedEntriesNormalized.toBackStackMap(),
)
fun forTransaction(capturedEntries: List<NormalizedSentryBackStackEntry>): Map<String, Any> =
if (options.captureBackStack && capturedEntries.isNotEmpty()) {
mapOf(NAVIGATION_CONTEXT_KEY to capturedEntries.toBackStackMap())
} else {
emptyMap()
}
}

/** Builds the `{"backstack": [...]}` map bound under [NAVIGATION_CONTEXT_KEY]. */
private fun List<NormalizedSentryBackStackEntry>.toBackStackMap(): Map<String, Any?> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import io.sentry.IScopes
import io.sentry.PropagationContext
import io.sentry.SentryLevel
import io.sentry.SentryOptions
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.protocol.App
import io.sentry.protocol.Contexts

/** The key under which nav context (i.e., the captured back stack) is stored in a scope. */
internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation"
Expand Down Expand Up @@ -194,15 +190,6 @@ internal class NavScopes(
}
}

/**
* Copies the whole of [IScope.getContexts] in order to return a copy of its [App].
*
* [App]'s copy constructor isn't public, so an `App` can't be copied on its own. Going through
* [Contexts] deep-copies every context on the scope and discards all of it but the `App`.
*/
override val app: App?
get() = Contexts(delegate.contexts).app

private val hasLease: Boolean
get() = registration?.hasLease == true

Expand All @@ -221,6 +208,10 @@ internal class NavScopes(
}
}

override fun updateNavTransactionRequest(request: NavTransactionRequest) {
stagedNavTransaction.updateRequest(request)
}

/**
* Starts and binds a nav transaction with the nav lease, or defers it until the lease is
* acquired (see [StagedNavTransaction]). Callers must [finish][finishNavTransaction] the
Expand All @@ -234,15 +225,12 @@ internal class NavScopes(
* meantime attach to whichever transaction is bound to the scope at that time, usually the
* leaseholder's.
*/
override fun startNavTransaction(
transactionContext: TransactionContext,
transactionOptions: TransactionOptions,
): INavTransaction? {
return if (hasLease) {
stagedNavTransaction.start(delegate, transactionContext, transactionOptions)
override fun startNavTransaction(request: NavTransactionRequest) {
stagedNavTransaction.stage(request)
if (hasLease) {
stagedNavTransaction.publishTo(delegate)
} else {
stagedNavData.deferPropagationContextRotation()
stagedNavTransaction.stage(transactionContext, transactionOptions)
}
}

Expand Down Expand Up @@ -286,17 +274,6 @@ internal sealed interface INavScope {

var screen: String?

/**
* A copy of the scope's app context, if any.
*
* A copy rather than the live instance, because callers mutate the result before binding it to a
* transaction, and mutating the scope's own [App] would corrupt shared scope state.
*
* Implementations may have to copy more than the [App] to produce it, so treat this as expensive:
* read it once where it's needed rather than per frame or in a loop.
*/
val app: App?

/**
* Sets or, given `null`, clears the value stored under [NAVIGATION_CONTEXT_KEY].
*
Expand All @@ -309,21 +286,29 @@ internal sealed interface INavScope {
fun rotatePropagationContext()

/**
* Starts a nav transaction and binds it to the scope, unless another unfinished transaction is
* already bound to it. Returns `null` if no transaction was started.
* Replaces the request kept from the most recent [startNavTransaction] with [request] (e.g., to
* reflect a back stack whose entries below the top changed), so that transactions started for the
* same destination later are started from it. A transaction that has already started keeps what
* it was started with.
*
* Does nothing if no request is kept, i.e., none was made since the last [finishNavTransaction].
*/
fun updateNavTransactionRequest(request: NavTransactionRequest)

/**
* Starts a nav transaction for [request] and binds it to the scope, unless another unfinished
* transaction is already bound to it, in which case none is started.
*
* (Note: Callers must first [finish][finishNavTransaction] the nav transaction most recently
* requested, if any. Otherwise, with the nav lease, it blocks the new one while it's bound;
* without the lease, it's replaced without being finished.)
*
* The start may be deferred until the nav lease is acquired, in which case writes to the returned
* transaction are staged in the meantime, and whether another transaction blocks it is decided
* then (see [StagedNavTransaction.publishTo]).
* The start may be deferred until the nav lease is acquired, in which case whether another
* transaction blocks it is decided then (see [StagedNavTransaction.publishTo]). Either way, the
* request is kept until the next [finishNavTransaction], so that a new transaction can be started
* from it if the user returns to the nav root after this one finished.
*/
fun startNavTransaction(
transactionContext: TransactionContext,
transactionOptions: TransactionOptions,
): INavTransaction?
fun startNavTransaction(request: NavTransactionRequest)

/**
* Finishes the nav transaction most recently requested via [startNavTransaction] and unbinds it
Expand Down
Loading
Loading