Skip to content

Commit 2430316

Browse files
runningcodeclaude
andcommitted
fix(android): Decide session rotation on a monotonic clock (JAVA-573)
Whether foregrounding rotates the session was a comparison between two System.currentTimeMillis() readings taken up to 30 seconds apart. The gap between two wall-clock reads is not the time that passed — the device syncs its clock and the interval comes out too long, too short, or negative — so a step forward started a session that should have been resumed, and a step back resumed one that should have ended. Android steps the wall clock most often in the first seconds after boot, which is exactly where a cold start's background window lives. The background window is a duration, so it now lives on a Deadline over options.getMonotonicTicker(), which on Android is CLOCK_BOOTTIME and so keeps counting through deep sleep. One deadline serves both halves of the window: the end-session task is scheduled for its remaining(), and a foreground arriving first asks whether it hasPassed(). Measuring one window in two places is what allowed two readings to disagree. The staleness of a session already on the scope stays on the wall clock and is now named for it. That path is only reached before the app has been backgrounded in this process, and the only record of when that session started is Session.getStarted() — a serialized epoch instant, which no tick can be compared against. A TODO [MAJOR] marks the real fix: a session that records the tick it started on. Passing the clocks in from AppLifecycleIntegration, which already holds the options being registered, removes the second constructor rather than adding a third parameter to it. Two tests were pinning the old behavior: `if last started session is before interval` stubbed the clock with (2, 1) and passed only because the wall clock ran backwards. They become a real background-foreground cycle on a TestMonotonicTicker, with one regression test per step direction. This leaves the other half of JAVA-573 open: a session that slept through its window is still stamped when the SDK noticed rather than when the window fell due, so its duration is inflated by the suspend. That value is serialized, and #6091 is the template for fixing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 768ca8e commit 2430316

3 files changed

Lines changed: 129 additions & 53 deletions

File tree

‎sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
5656
scopes,
5757
this.options.getSessionTrackingIntervalMillis(),
5858
this.options.isEnableAutoSessionTracking(),
59-
this.options.isEnableAppLifecycleBreadcrumbs());
59+
this.options.isEnableAppLifecycleBreadcrumbs(),
60+
this.options.getMonotonicTicker(),
61+
this.options.getEpochClock());
6062

6163
AppState.getInstance().addAppStateListener(watcher);
6264
}

‎sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java‎

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,53 @@
55
import io.sentry.ISentryLifecycleToken;
66
import io.sentry.SentryLevel;
77
import io.sentry.Session;
8-
import io.sentry.transport.CurrentDateProvider;
9-
import io.sentry.transport.ICurrentDateProvider;
8+
import io.sentry.time.Deadline;
9+
import io.sentry.time.EpochClock;
10+
import io.sentry.time.MonotonicTicker;
1011
import io.sentry.util.AutoClosableReentrantLock;
12+
import java.util.Date;
1113
import java.util.concurrent.Future;
12-
import java.util.concurrent.atomic.AtomicLong;
14+
import java.util.concurrent.TimeUnit;
15+
import java.util.concurrent.atomic.AtomicBoolean;
1316
import org.jetbrains.annotations.NotNull;
1417
import org.jetbrains.annotations.Nullable;
1518
import org.jetbrains.annotations.TestOnly;
1619

1720
final class LifecycleWatcher implements AppState.AppStateListener {
1821

19-
private final AtomicLong lastUpdatedSession = new AtomicLong(0L);
20-
2122
private final long sessionIntervalMillis;
2223

24+
/**
25+
* When the session the app left behind stops being resumable, or null while in the foreground.
26+
*
27+
* <p>One deadline decides both halves of the background window: when the scheduled task ends the
28+
* session, and whether a foreground arriving first is soon enough to keep it. Measuring that one
29+
* window in two places used to mean two clock readings, which a clock step could make disagree.
30+
*/
31+
private @Nullable Deadline sessionEnd;
32+
2333
private @Nullable Future<?> endSessionFuture;
2434
private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock();
2535
private final @NotNull IScopes scopes;
2636
private final boolean enableSessionTracking;
2737
private final boolean enableAppLifecycleBreadcrumbs;
2838

29-
private final @NotNull ICurrentDateProvider currentDateProvider;
30-
31-
LifecycleWatcher(
32-
final @NotNull IScopes scopes,
33-
final long sessionIntervalMillis,
34-
final boolean enableSessionTracking,
35-
final boolean enableAppLifecycleBreadcrumbs) {
36-
this(
37-
scopes,
38-
sessionIntervalMillis,
39-
enableSessionTracking,
40-
enableAppLifecycleBreadcrumbs,
41-
CurrentDateProvider.getInstance());
42-
}
39+
private final @NotNull MonotonicTicker ticker;
40+
private final @NotNull EpochClock epochClock;
4341

4442
LifecycleWatcher(
4543
final @NotNull IScopes scopes,
4644
final long sessionIntervalMillis,
4745
final boolean enableSessionTracking,
4846
final boolean enableAppLifecycleBreadcrumbs,
49-
final @NotNull ICurrentDateProvider currentDateProvider) {
47+
final @NotNull MonotonicTicker ticker,
48+
final @NotNull EpochClock epochClock) {
5049
this.sessionIntervalMillis = sessionIntervalMillis;
5150
this.enableSessionTracking = enableSessionTracking;
5251
this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs;
5352
this.scopes = scopes;
54-
this.currentDateProvider = currentDateProvider;
53+
this.ticker = ticker;
54+
this.epochClock = epochClock;
5555
}
5656

5757
@Override
@@ -61,40 +61,50 @@ public void onForeground() {
6161
}
6262

6363
private void startSession() {
64-
cancelTask();
65-
66-
final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();
64+
final @Nullable Deadline sessionEnd = takeSessionEnd();
6765

68-
scopes.configureScope(
69-
scope -> {
70-
if (lastUpdatedSession.get() == 0L) {
71-
final @Nullable Session currentSession = scope.getSession();
72-
if (currentSession != null && currentSession.getStarted() != null) {
73-
lastUpdatedSession.set(currentSession.getStarted().getTime());
74-
}
75-
}
76-
});
77-
78-
final long lastUpdatedSession = this.lastUpdatedSession.get();
7966
final boolean startNewSession =
80-
lastUpdatedSession == 0L
81-
|| (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis;
67+
sessionEnd != null ? sessionEnd.hasPassed() : isSessionOnScopeStale();
8268
if (startNewSession) {
8369
if (enableSessionTracking) {
8470
scopes.startSession();
8571
}
8672
}
8773
scopes.getOptions().getReplayController().onAppForegrounded(startNewSession);
88-
this.lastUpdatedSession.set(currentTimeMillis);
74+
}
75+
76+
/**
77+
* Whether the session already bound to the scope is old enough that foregrounding should rotate
78+
* it.
79+
*
80+
* <p>Only reached before the app has been backgrounded in this process — a session started during
81+
* SDK init, most often. There is no tick to compare against at that point: the only record of
82+
* when that session started is {@link Session#getStarted()}, which is a wall-clock instant
83+
* because it is serialized and sent, so this comparison has to be a wall-clock one and inherits
84+
* the clock steps that come with it.
85+
*
86+
* <p>TODO [MAJOR]: have a session record the tick it started on, so this can be a {@link
87+
* Deadline} like the background window is. That tick would have to stay out of the payload.
88+
*/
89+
private boolean isSessionOnScopeStale() {
90+
final long nowMillis = TimeUnit.NANOSECONDS.toMillis(epochClock.now().epochNanos());
91+
// No session, or one that never recorded a start, leaves nothing to resume.
92+
final @NotNull AtomicBoolean stale = new AtomicBoolean(true);
93+
scopes.configureScope(
94+
scope -> {
95+
final @Nullable Session session = scope.getSession();
96+
final @Nullable Date started = session == null ? null : session.getStarted();
97+
if (started != null) {
98+
stale.set(started.getTime() + sessionIntervalMillis <= nowMillis);
99+
}
100+
});
101+
return stale.get();
89102
}
90103

91104
// App went to background and triggered this callback after 700ms
92105
// as no new screen was shown
93106
@Override
94107
public void onBackground() {
95-
final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();
96-
this.lastUpdatedSession.set(currentTimeMillis);
97-
98108
scopes.getOptions().getReplayController().onAppBackgrounded();
99109
scheduleEndSession();
100110

@@ -104,6 +114,9 @@ public void onBackground() {
104114
private void scheduleEndSession() {
105115
try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) {
106116
cancelTask();
117+
final @NotNull Deadline sessionEnd =
118+
Deadline.after(ticker, sessionIntervalMillis, TimeUnit.MILLISECONDS);
119+
this.sessionEnd = sessionEnd;
107120
final @NotNull Runnable endSession =
108121
() -> {
109122
if (enableSessionTracking) {
@@ -114,11 +127,13 @@ private void scheduleEndSession() {
114127
};
115128

116129
try {
130+
// The executor's own delay stops while the device is suspended, while the deadline keeps
131+
// counting, so this task can only run at or after the deadline. It needs no second check.
117132
endSessionFuture =
118133
scopes
119134
.getOptions()
120135
.getTimerExecutorService()
121-
.schedule(endSession, sessionIntervalMillis);
136+
.schedule(endSession, sessionEnd.remaining(TimeUnit.MILLISECONDS));
122137
} catch (Throwable e) {
123138
scopes
124139
.getOptions()
@@ -131,6 +146,16 @@ private void scheduleEndSession() {
131146
}
132147
}
133148

149+
/** Stops the pending end of session and hands back the deadline it was going to run at. */
150+
private @Nullable Deadline takeSessionEnd() {
151+
try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) {
152+
cancelTask();
153+
final @Nullable Deadline sessionEnd = this.sessionEnd;
154+
this.sessionEnd = null;
155+
return sessionEnd;
156+
}
157+
}
158+
134159
private void cancelTask() {
135160
try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) {
136161
if (endSessionFuture != null) {

‎sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt‎

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import io.sentry.SentryLevel
1212
import io.sentry.SentryOptions
1313
import io.sentry.Session
1414
import io.sentry.Session.State
15-
import io.sentry.transport.ICurrentDateProvider
15+
import io.sentry.time.EpochClock
16+
import io.sentry.time.TestMonotonicTicker
17+
import io.sentry.time.Timestamp
18+
import java.util.concurrent.TimeUnit.HOURS
19+
import java.util.concurrent.TimeUnit.MILLISECONDS
20+
import java.util.concurrent.atomic.AtomicLong
1621
import kotlin.test.BeforeTest
1722
import kotlin.test.Test
1823
import kotlin.test.assertEquals
@@ -32,7 +37,10 @@ import org.mockito.kotlin.whenever
3237
class LifecycleWatcherTest {
3338
private class Fixture {
3439
val scopes = mock<IScopes>()
35-
val dateProvider = mock<ICurrentDateProvider>()
40+
val ticker = TestMonotonicTicker()
41+
// the wall clock, which only the staleness of a session already on the scope depends on
42+
val nowMillis = AtomicLong(0L)
43+
val epochClock = EpochClock { Timestamp.ofEpochNanos(MILLISECONDS.toNanos(nowMillis.get())) }
3644
// a real executor so scheduled end-session tasks actually run
3745
val options = SentryOptions().apply { setTimerExecutorService(SentryExecutorService(this)) }
3846
val replayController = mock<ReplayController>()
@@ -60,7 +68,8 @@ class LifecycleWatcherTest {
6068
sessionIntervalMillis,
6169
enableAutoSessionTracking,
6270
enableAppLifecycleBreadcrumbs,
63-
dateProvider,
71+
ticker,
72+
epochClock,
6473
)
6574
}
6675
}
@@ -81,26 +90,67 @@ class LifecycleWatcherTest {
8190
}
8291

8392
@Test
84-
fun `if last started session is after interval, start new session`() {
85-
val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false)
86-
whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L, 2L)
93+
fun `if the background window has elapsed, start new session`() {
94+
val watcher =
95+
fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false)
8796
watcher.onForeground()
97+
watcher.onBackground()
98+
fixture.ticker.advance(30000, MILLISECONDS)
99+
88100
watcher.onForeground()
101+
89102
verify(fixture.scopes, times(2)).startSession()
90103
verify(fixture.replayController, times(2)).onAppForegrounded(true)
91104
}
92105

93106
@Test
94-
fun `if last started session is before interval, it should not start a new session`() {
95-
val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false)
96-
whenever(fixture.dateProvider.currentTimeMillis).thenReturn(2L, 1L)
107+
fun `if the app returns within the background window, it should not start a new session`() {
108+
val watcher =
109+
fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false)
97110
watcher.onForeground()
111+
watcher.onBackground()
112+
fixture.ticker.advance(29999, MILLISECONDS)
113+
98114
watcher.onForeground()
115+
99116
verify(fixture.scopes).startSession()
100117
verify(fixture.replayController).onAppForegrounded(true)
101118
verify(fixture.replayController).onAppForegrounded(false)
102119
}
103120

121+
@Test
122+
fun `a wall clock stepping forward during the background window does not rotate the session`() {
123+
val watcher =
124+
fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false)
125+
watcher.onForeground()
126+
watcher.onBackground()
127+
128+
// the device syncs its clock an hour forward, which two wall-clock reads used to report as an
129+
// hour spent in the background
130+
fixture.nowMillis.addAndGet(HOURS.toMillis(1))
131+
fixture.ticker.advance(1, MILLISECONDS)
132+
watcher.onForeground()
133+
134+
verify(fixture.scopes).startSession()
135+
verify(fixture.replayController).onAppForegrounded(false)
136+
}
137+
138+
@Test
139+
fun `a wall clock stepping backwards during the background window still rotates the session`() {
140+
val watcher =
141+
fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false)
142+
fixture.nowMillis.set(HOURS.toMillis(1))
143+
watcher.onForeground()
144+
watcher.onBackground()
145+
146+
fixture.nowMillis.set(0L)
147+
fixture.ticker.advance(30000, MILLISECONDS)
148+
watcher.onForeground()
149+
150+
verify(fixture.scopes, times(2)).startSession()
151+
verify(fixture.replayController, times(2)).onAppForegrounded(true)
152+
}
153+
104154
@Test
105155
fun `if app goes to background, end session after interval`() {
106156
val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false)
@@ -249,7 +299,6 @@ class LifecycleWatcherTest {
249299

250300
@Test
251301
fun `background-foreground replay`() {
252-
whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L)
253302
val watcher =
254303
fixture.getSUT(sessionIntervalMillis = 500L, enableAppLifecycleBreadcrumbs = false)
255304
watcher.onForeground()

0 commit comments

Comments
 (0)