-
-
Notifications
You must be signed in to change notification settings - Fork 478
fix(android): Decide session rotation on a monotonic clock (JAVA-573) #6096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
007a0ad
b5ad622
0853be3
9632113
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,53 +5,52 @@ | |
| import io.sentry.ISentryLifecycleToken; | ||
| import io.sentry.SentryLevel; | ||
| import io.sentry.Session; | ||
| import io.sentry.transport.CurrentDateProvider; | ||
| import io.sentry.transport.ICurrentDateProvider; | ||
| import io.sentry.time.Deadline; | ||
| import io.sentry.time.EpochClock; | ||
| import io.sentry.time.MonotonicTicker; | ||
| import io.sentry.util.AutoClosableReentrantLock; | ||
| import java.util.Date; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.atomic.AtomicLong; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
| import org.jetbrains.annotations.TestOnly; | ||
|
|
||
| final class LifecycleWatcher implements AppState.AppStateListener { | ||
|
|
||
| private final AtomicLong lastUpdatedSession = new AtomicLong(0L); | ||
|
|
||
| private final long sessionIntervalMillis; | ||
|
|
||
| /** | ||
| * When the session the app left behind stops being resumable, or null while in the foreground. | ||
| * | ||
| * <p>Only read or written while holding {@link #endSessionLock}, which is also what lets | ||
| * cancelling the pending task and taking this deadline happen as one step. | ||
| */ | ||
| private @Nullable Deadline sessionEnd; | ||
|
|
||
| private @Nullable Future<?> endSessionFuture; | ||
| private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock(); | ||
| private final @NotNull IScopes scopes; | ||
| private final boolean enableSessionTracking; | ||
| private final boolean enableAppLifecycleBreadcrumbs; | ||
|
|
||
| private final @NotNull ICurrentDateProvider currentDateProvider; | ||
|
|
||
| LifecycleWatcher( | ||
| final @NotNull IScopes scopes, | ||
| final long sessionIntervalMillis, | ||
| final boolean enableSessionTracking, | ||
| final boolean enableAppLifecycleBreadcrumbs) { | ||
| this( | ||
| scopes, | ||
| sessionIntervalMillis, | ||
| enableSessionTracking, | ||
| enableAppLifecycleBreadcrumbs, | ||
| CurrentDateProvider.getInstance()); | ||
| } | ||
| private final @NotNull MonotonicTicker ticker; | ||
| private final @NotNull EpochClock epochClock; | ||
|
|
||
| LifecycleWatcher( | ||
| final @NotNull IScopes scopes, | ||
| final long sessionIntervalMillis, | ||
| final boolean enableSessionTracking, | ||
| final boolean enableAppLifecycleBreadcrumbs, | ||
| final @NotNull ICurrentDateProvider currentDateProvider) { | ||
| final @NotNull MonotonicTicker ticker, | ||
| final @NotNull EpochClock epochClock) { | ||
| this.sessionIntervalMillis = sessionIntervalMillis; | ||
| this.enableSessionTracking = enableSessionTracking; | ||
| this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; | ||
| this.scopes = scopes; | ||
| this.currentDateProvider = currentDateProvider; | ||
| this.ticker = ticker; | ||
| this.epochClock = epochClock; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -61,40 +60,48 @@ public void onForeground() { | |
| } | ||
|
|
||
| private void startSession() { | ||
| cancelTask(); | ||
|
|
||
| final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); | ||
| final @Nullable Deadline sessionEnd = takeSessionEnd(); | ||
|
|
||
| scopes.configureScope( | ||
| scope -> { | ||
| if (lastUpdatedSession.get() == 0L) { | ||
| final @Nullable Session currentSession = scope.getSession(); | ||
| if (currentSession != null && currentSession.getStarted() != null) { | ||
| lastUpdatedSession.set(currentSession.getStarted().getTime()); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| final long lastUpdatedSession = this.lastUpdatedSession.get(); | ||
| final boolean startNewSession = | ||
| lastUpdatedSession == 0L | ||
| || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis; | ||
| sessionEnd != null ? sessionEnd.hasPassed() : isSessionOnScopeStale(); | ||
| if (startNewSession) { | ||
| if (enableSessionTracking) { | ||
| scopes.startSession(); | ||
| } | ||
| } | ||
| scopes.getOptions().getReplayController().onAppForegrounded(startNewSession); | ||
| this.lastUpdatedSession.set(currentTimeMillis); | ||
| } | ||
|
|
||
| /** | ||
| * Whether the session on the scope is too old to resume, so foregrounding should start a new one. | ||
| * | ||
| * <p>Used when no background window is pending, which means the session was started by SDK init | ||
| * rather than by leaving and returning to the app. Nothing captured a tick back then, and the | ||
| * only record of when the session started is {@link Session#getStarted()} — a wall-clock instant, | ||
| * because it is sent to Sentry. So this check stays on the wall clock, clock steps included. | ||
| * | ||
| * <p>TODO [MAJOR]: let a session remember the tick it started on, so this can use a {@link | ||
| * Deadline} too. That tick must not be serialized. | ||
| */ | ||
| private boolean isSessionOnScopeStale() { | ||
| final long nowMillis = TimeUnit.NANOSECONDS.toMillis(epochClock.now().epochNanos()); | ||
| // No session, or one that never recorded a start, leaves nothing to resume. | ||
| final @NotNull AtomicBoolean stale = new AtomicBoolean(true); | ||
| scopes.configureScope( | ||
| scope -> { | ||
| final @Nullable Session session = scope.getSession(); | ||
| final @Nullable Date started = session == null ? null : session.getStarted(); | ||
| if (started != null) { | ||
| stale.set(started.getTime() + sessionIntervalMillis <= nowMillis); | ||
| } | ||
| }); | ||
| return stale.get(); | ||
| } | ||
|
|
||
| // App went to background and triggered this callback after 700ms | ||
| // as no new screen was shown | ||
| @Override | ||
| public void onBackground() { | ||
| final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
All good, I see this is now handled via |
||
| this.lastUpdatedSession.set(currentTimeMillis); | ||
|
|
||
| scopes.getOptions().getReplayController().onAppBackgrounded(); | ||
| scheduleEndSession(); | ||
|
|
||
|
|
@@ -104,6 +111,9 @@ public void onBackground() { | |
| private void scheduleEndSession() { | ||
| try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { | ||
| cancelTask(); | ||
| final @NotNull Deadline sessionEnd = | ||
| Deadline.after(ticker, sessionIntervalMillis, TimeUnit.MILLISECONDS); | ||
| this.sessionEnd = sessionEnd; | ||
| final @NotNull Runnable endSession = | ||
| () -> { | ||
| if (enableSessionTracking) { | ||
|
|
@@ -114,11 +124,13 @@ private void scheduleEndSession() { | |
| }; | ||
|
|
||
| try { | ||
| // The executor's own delay stops while the device is suspended, while the deadline keeps | ||
| // counting, so this task can only run at or after the deadline. It needs no second check. | ||
| endSessionFuture = | ||
| scopes | ||
| .getOptions() | ||
| .getTimerExecutorService() | ||
| .schedule(endSession, sessionIntervalMillis); | ||
| .schedule(endSession, sessionEnd.remaining(TimeUnit.MILLISECONDS)); | ||
| } catch (Throwable e) { | ||
| scopes | ||
| .getOptions() | ||
|
|
@@ -131,6 +143,16 @@ private void scheduleEndSession() { | |
| } | ||
| } | ||
|
|
||
| /** Stops the pending end of session and hands back the deadline it was going to run at. */ | ||
| private @Nullable Deadline takeSessionEnd() { | ||
| try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { | ||
| cancelTask(); | ||
| final @Nullable Deadline sessionEnd = this.sessionEnd; | ||
| this.sessionEnd = null; | ||
| return sessionEnd; | ||
| } | ||
| } | ||
|
|
||
| private void cancelTask() { | ||
| try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { | ||
| if (endSessionFuture != null) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice refactor, this will keep future timing changes more isolated 👍