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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Fix missing Navigation 3 transactions for preloaded ViewPager2 pages and destinations reached while another Activity or Fragment owns navigation tracking ([#6400](https://github.com/getsentry/sentry-java/pull/6400))
- Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291))
- Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ import io.sentry.IScopes
* Finish txB if unfinished │
* ```
*
* **Nav transactions follow navigation, not the lease**
*
* A candidate gets a nav transaction for a destination it navigates to, never merely for acquiring
* the lease. A destination reached without the lease gets its transaction when the lease is next
* acquired, if it can start then; otherwise it gets none. On reacquiring the lease without having
* navigated in the meantime, a candidate only gets its unfinished transaction back, if any.
*
* Hence the asymmetry for, e.g., ViewPager2 pages: the first time the user swipes to a preloaded
* page, it gets the transaction for the destination it reached while offscreen, but swiping back to
* a page whose transaction has finished starts none.
*
* **Exception policy**
*
* Methods in this class with an `IScope` parameter can throw arbitrary exceptions†, which propagate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,11 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation"
* and publish their navigation data to the shared `IScope`. (The lease policy is implemented by
* [NavLeaseCoordinator].)
*
* `NavScopes` instances without a lease write to their own copy of [stagedNavData], and keep their
* unfinished nav transaction in [stagedNavTransaction], which lets them preserve their latest
* updates without overwriting another instance's transaction or published data. When an instance
* acquires the lease, it writes its staged data to the shared `IScope` and binds its unfinished
* transaction again (subject to any publishing conditions, e.g., the transaction stays unbound if
* another transaction is already bound to the shared `IScope`).
* `NavScopes` instances without a lease write to their own copy of [stagedNavTransaction] and
* [stagedNavData], which lets them preserve their latest updates without overwriting another
* instance's transaction or published data. When an instance acquires the lease, it writes its
* staged transaction and data to the shared `IScope` (subject to any publishing conditions, e.g.,
* staged transactions are dropped if another transaction is already bound to the shared `IScope`).
*
* **Usage**
*
Expand Down Expand Up @@ -151,6 +150,8 @@ internal class NavScopes(
private inner class NavStatePublisher : LeasedNavState {

override fun publishTo(scope: IScope) {
// Publishing the data first ensures a staged transaction is sampled against the rotated
// propagation context when it starts.
stagedNavData.publishTo(scope)
stagedNavTransaction.publishTo(scope)
}
Expand Down Expand Up @@ -219,13 +220,17 @@ internal class NavScopes(
}

/**
* Starts and binds a nav transaction with the nav lease (see [StagedNavTransaction]). Callers
* must [finish][finishNavTransaction] the previous nav transaction first.
* 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
* previous nav transaction first.
*
* Without the lease, starts none and defers the propagation context rotation a new transaction
* would have implied until the lease is acquired. A transaction started without the lease
* couldn't be bound to the scope, so its destination's spans would attach to the leaseholder's
* bound transaction instead, and the empty transaction would be dropped.
* **Deferred transactions may mis-parent spans generated before lease acquisition**
*
* A deferred transaction starts when the lease is acquired, which may be later than when its
* destination starts doing work (e.g., ViewPager2 can precompose offscreen pages, or an
* Activity that navigates from a result callback before it resumes). Spans created in the
* meantime attach to whichever transaction is bound to the scope at that time, usually the
* leaseholder's.
*/
override fun startNavTransaction(
transactionContext: TransactionContext,
Expand All @@ -235,7 +240,7 @@ internal class NavScopes(
stagedNavTransaction.start(delegate, transactionContext, transactionOptions)
} else {
stagedNavData.deferPropagationContextRotation()
null
stagedNavTransaction.stage(transactionContext, transactionOptions)
}
}

Expand Down Expand Up @@ -303,19 +308,26 @@ internal sealed interface INavScope {

/**
* Starts a nav transaction and binds it to the scope, unless another unfinished transaction is
* already bound to it or the nav lease isn't held. Returns `null` if no transaction was started.
* already bound to it. Returns `null` if no transaction was started.
*
* (Note: Callers must first [finish][finishNavTransaction] the nav transaction most recently
* started, if any. Otherwise, it blocks the new one while it's bound.)
* 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]).
*/
fun startNavTransaction(
transactionContext: TransactionContext,
transactionOptions: TransactionOptions,
): INavTransaction?

/**
* Finishes the nav transaction most recently started via [startNavTransaction] and unbinds it
* Finishes the nav transaction most recently requested via [startNavTransaction] and unbinds it
* from the shared [IScope].
*
* Alternatively, discards the most recently requested transaction if it's still deferred.
*/
fun finishNavTransaction()
}
Original file line number Diff line number Diff line change
@@ -1,51 +1,129 @@
package io.sentry.compose.navigation3.lifecycle

import io.sentry.IScopes
import io.sentry.ITransaction
import io.sentry.SpanStatus
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.protocol.App

/**
* An [INavTransaction] handle backed by a started [ITransaction].
* An [INavTransaction] handle backed by an [ITransaction] that's either started right away or, when
* the nav lease isn't held, [started later][start].
*
* A deferred handle records what's set on it and replays it onto the transaction once started.
* [Finishing][finish] a deferred handle before it starts discards it, so its transaction never
* starts.
*
* **Thread safety**
*
* This class is ***not*** thread-safe. Apply the thread safety policy from [NavScopes].
*/
internal class NavTransaction
private constructor(
/** The backing transaction. */
val transaction: ITransaction
val name: String,
private var state: State,
) : INavTransaction {

val name: String
get() = transaction.name
/**
* The backing transaction, once [started][State.Started]; `null` while [pending][State.Pending]
* or once [discarded][State.Discarded].
*/
val transaction: ITransaction?
get() = (state as? State.Started)?.transaction

override fun setData(key: String, value: Any) {
transaction.setData(key, value)
when (val state = state) {
is State.Pending -> state.data[key] = value
is State.Started -> state.transaction.setData(key, value)
State.Discarded -> Unit
}
}

override fun setContext(key: String, value: Any) {
transaction.setContext(key, value)
when (val state = state) {
is State.Pending -> state.contexts[key] = value
is State.Started -> state.transaction.setContext(key, value)
State.Discarded -> Unit
}
}

override fun setApp(app: App) {
transaction.contexts.setApp(app)
when (val state = state) {
is State.Pending -> state.app = app
is State.Started -> state.transaction.contexts.setApp(app)
State.Discarded -> Unit
}
}

/**
* Starts a deferred handle's transaction via [scopes] and replays what was set on the handle onto
* it.
*
* Returns the started transaction, or `null` if the handle was started already or discarded, or
* [scopes] declined to start one.
*/
fun start(scopes: IScopes): ITransaction? {
val pending = state as? State.Pending ?: return null
val transaction =
scopes.startTransaction(pending.context, pending.options).takeUnless { it.isNoOp }
?: return null

state = State.Started(transaction)
pending.data.forEach { (key, value) -> transaction.setData(key, value) }
pending.contexts.forEach { (key, value) -> transaction.setContext(key, value) }
pending.app?.let { transaction.contexts.setApp(it) }
return transaction
}

/**
* Finishes the backing transaction with its own status, or [OK][SpanStatus.OK] if it has none,
* and then passes it to [onFinished].
*
* A deferred handle that hasn't started is discarded instead, so it never starts, and
* [onFinished] isn't called.
*/
fun finish(onFinished: (ITransaction) -> Unit) {
if (!transaction.isFinished) {
transaction.finish(transaction.status ?: SpanStatus.OK)
when (val state = state) {
is State.Pending -> this.state = State.Discarded
is State.Started -> {
if (!state.transaction.isFinished) {
state.transaction.finish(state.transaction.status ?: SpanStatus.OK)
}
onFinished(state.transaction)
}
State.Discarded -> Unit
}
onFinished(transaction)
}

/** Lifecycle state for a [NavTransaction] handle. */
private sealed interface State {

/**
* Not started yet: the transaction will be started from [context] and [options], and what's set
* on the handle until then is recorded for replay.
*/
class Pending(val context: TransactionContext, val options: TransactionOptions) : State {
val data = linkedMapOf<String, Any>()
val contexts = linkedMapOf<String, Any>()
var app: App? = null
}

/** The backing [transaction] has been started. A terminal [State]. */
class Started(val transaction: ITransaction) : State

/** The handle was finished before its backing transaction was started. A terminal [State]. */
data object Discarded : State
}

companion object {
/** A handle backed by the already-started [transaction]. */
fun started(transaction: ITransaction): NavTransaction = NavTransaction(transaction)
fun started(transaction: ITransaction): NavTransaction =
NavTransaction(transaction.name, State.Started(transaction))

/** A handle whose transaction is started later, via [start]. */
fun deferred(context: TransactionContext, options: TransactionOptions): NavTransaction =
NavTransaction(context.name, State.Pending(context, options))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import io.sentry.TransactionOptions
/**
* Holds a nav transaction staged for publication to a shared [IScope].
*
* A started transaction that's [unpublished][unpublishFrom] is unbound but keeps running, so that
* it can be bound again if it's republished while unfinished. Once [finished][finish], a
* transaction is forgotten and never bound again.
* A transaction requested while publishing isn't permitted is [staged][stage] rather than started,
* and starts when it's next [published][publishTo]. That publication is its only chance to start:
* if it can't start then, it's discarded. A started transaction that's [unpublished][unpublishFrom]
* is unbound but keeps running, so that it can be bound again if it's republished while unfinished.
* Once [finished][finish], a transaction is forgotten and never bound again.
*
* Starting or publishing a nav transaction never displaces an unfinished transaction bound to the
* scope by someone else (e.g., the host app).
Expand All @@ -23,7 +25,9 @@ import io.sentry.TransactionOptions
*/
internal class StagedNavTransaction(private val scopes: IScopes) {

/** The nav transaction most recently started, unless it has been finished since then. */
/**
* The nav transaction most recently started or staged, unless it has been finished since then.
*/
private var navTransaction: NavTransaction? = null

/**
Expand Down Expand Up @@ -57,20 +61,46 @@ internal class StagedNavTransaction(private val scopes: IScopes) {
}

/**
* Binds the nav transaction to [scope]. A transaction that has finished in the meantime is
* forgotten and unbound instead.
* Stages a nav transaction that's started from [context] and [options] when it's
* [published][publishTo], and returns its handle, which records what's set on it in the meantime.
*
* **Warning**: [Finish][finish] the current nav transaction before calling this method, as this
* method replaces the current transaction without finishing it.
*/
fun stage(context: TransactionContext, options: TransactionOptions): NavTransaction =
NavTransaction.deferred(context, options).also { navTransaction = it }

/**
* Binds the nav transaction to [scope], starting it first if it's staged. A started transaction
* that has finished in the meantime is forgotten and unbound instead.
*
* If another unfinished transaction is bound to [scope], the transaction stays unbound, so it can
* be bound the next time it's published.
* If another unfinished transaction is bound to [scope], a started transaction stays unbound, so
* it can be bound the next time it's published. A staged transaction is discarded instead, as it
* is if [scopes] return a no-op transaction for it (see [start]).
*/
fun publishTo(scope: IScope) {
val navTransaction = navTransaction ?: return
val started = navTransaction.transaction
if (started.isFinished) {
this.navTransaction = null
unbind(scope, started)
} else if (scope.isFreeFor(navTransaction.name, started)) {
bind(scope, started)
when {
started?.isFinished == true -> {
this.navTransaction = null
unbind(scope, started)
}
started != null -> if (scope.isFreeFor(navTransaction.name, started)) bind(scope, started)
else -> {
val startedNow =
if (scope.isFreeFor(navTransaction.name, transaction = null)) {
navTransaction.start(scopes)
} else {
null
}
if (startedNow != null) {
bind(scope, startedNow)
} else {
this.navTransaction = null
navTransaction.finish(onFinished = {})
}
}
}
}

Expand All @@ -79,10 +109,13 @@ internal class StagedNavTransaction(private val scopes: IScopes) {
* [published][publishTo] again.
*/
fun unpublishFrom(scope: IScope) {
navTransaction?.let { unbind(scope, it.transaction) }
navTransaction?.transaction?.let { unbind(scope, it) }
}

/** Finishes the nav transaction and unbinds it from [scope], if it's bound. */
/**
* Finishes the nav transaction and unbinds it from [scope], if it's bound. A staged transaction
* is discarded instead.
*/
fun finish(scope: IScope) {
val navTransaction = navTransaction ?: return
// Forget the transaction before unbinding it: unbinding notifies the host app's scope
Expand All @@ -93,7 +126,7 @@ internal class StagedNavTransaction(private val scopes: IScopes) {

/**
* Forgets the nav transaction without finishing it, e.g., when there's no scope to unbind it
* from. It will be left to time out.
* from. If started, it will be left to time out.
*/
fun forget() {
navTransaction = null
Expand Down
Loading
Loading