Skip to content

Commit cef4eab

Browse files
runningcodeclaude
andauthored
fix(android): Measure debounce windows on the monotonic ticker (JAVA-729) (#6590)
* fix(android): Measure debounce windows on the monotonic ticker (JAVA-729) Debouncer read AndroidCurrentDateProvider, which is SystemClock.uptimeMillis() and stops in deep sleep. A window that expired while the device slept still looked open, so the first low-memory or battery breadcrumb after a long Doze could be dropped. Debouncer now measures its window with a Deadline on options.getMonotonicTicker(), which is CLOCK_BOOTTIME on Android. The screenshot, view hierarchy, system events and app components debouncers all use it. AndroidEnvelopeCache still uses AndroidCurrentDateProvider: it compares against TimeSpan's uptime start, so it can't move on its own. Refs JAVA-729 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * changelog --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb5a462 commit cef4eab

7 files changed

Lines changed: 52 additions & 59 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
> - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469).
66
> - The Android `minSdk` will be raised from 21 to 24. See [#5508](https://github.com/getsentry/sentry-java/issues/5508).
77

8+
## Unreleased
9+
10+
### Fixes
11+
12+
- Android: Count deep sleep in breadcrumb, screenshot and view hierarchy debounce windows, so the first low-memory or battery breadcrumb after a long Doze is no longer dropped ([#6590](https://github.com/getsentry/sentry-java/pull/6590))
13+
814
## 8.64.0
915

1016
### Breaking Changes

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import io.sentry.Integration;
1313
import io.sentry.SentryLevel;
1414
import io.sentry.SentryOptions;
15-
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1615
import io.sentry.android.core.internal.util.Debouncer;
1716
import io.sentry.android.core.internal.util.DeviceOrientations;
1817
import io.sentry.protocol.Device;
@@ -34,10 +33,7 @@ public final class AppComponentsBreadcrumbsIntegration
3433
private @Nullable IScopes scopes;
3534
private @Nullable SentryAndroidOptions options;
3635

37-
// TODO: JAVA-729
38-
@SuppressWarnings("deprecation")
39-
private final @NotNull Debouncer trimMemoryDebouncer =
40-
new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0);
36+
private volatile @Nullable Debouncer trimMemoryDebouncer;
4137

4238
public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) {
4339
this.context =
@@ -51,6 +47,8 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
5147
Objects.requireNonNull(
5248
(options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null,
5349
"SentryAndroidOptions is required");
50+
this.trimMemoryDebouncer =
51+
new Debouncer(this.options.getMonotonicTicker(), DEBOUNCE_WAIT_TIME_MS, 0);
5452

5553
this.options
5654
.getLogger()
@@ -121,7 +119,8 @@ public void onTrimMemory(final int level) {
121119
return;
122120
}
123121

124-
if (trimMemoryDebouncer.checkForDebounce()) {
122+
final @Nullable Debouncer trimMemoryDebouncer = this.trimMemoryDebouncer;
123+
if (trimMemoryDebouncer != null && trimMemoryDebouncer.checkForDebounce()) {
125124
// if we received trim_memory within 1 minute time, ignore this call
126125
return;
127126
}

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import io.sentry.Hint;
1010
import io.sentry.SentryEvent;
1111
import io.sentry.SentryLevel;
12-
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1312
import io.sentry.android.core.internal.util.Debouncer;
1413
import io.sentry.android.core.internal.util.ScreenshotUtils;
1514
import io.sentry.internal.eventprocessor.SentryEventProcessor;
@@ -39,8 +38,6 @@ public final class ScreenshotEventProcessor implements SentryEventProcessor {
3938
private final boolean isReplayAvailable;
4039
private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false);
4140

42-
// TODO: JAVA-729
43-
@SuppressWarnings("deprecation")
4441
public ScreenshotEventProcessor(
4542
final @NotNull SentryAndroidOptions options,
4643
final @NotNull BuildInfoProvider buildInfoProvider,
@@ -49,10 +46,7 @@ public ScreenshotEventProcessor(
4946
this.buildInfoProvider =
5047
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
5148
this.debouncer =
52-
new Debouncer(
53-
AndroidCurrentDateProvider.getInstance(),
54-
DEBOUNCE_WAIT_TIME_MS,
55-
DEBOUNCE_MAX_EXECUTIONS);
49+
new Debouncer(options.getMonotonicTicker(), DEBOUNCE_WAIT_TIME_MS, DEBOUNCE_MAX_EXECUTIONS);
5650

5751
this.isReplayAvailable = isReplayAvailable;
5852

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
import io.sentry.Integration;
3636
import io.sentry.SentryLevel;
3737
import io.sentry.SentryOptions;
38-
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
3938
import io.sentry.android.core.internal.util.Debouncer;
4039
import io.sentry.util.AutoClosableReentrantLock;
4140
import io.sentry.util.Objects;
@@ -300,15 +299,14 @@ final class SystemEventsBroadcastReceiver extends BroadcastReceiver {
300299
private final @NotNull IScopes scopes;
301300
private final @NotNull SentryAndroidOptions options;
302301

303-
// TODO: JAVA-729
304-
@SuppressWarnings("deprecation")
305-
private final @NotNull Debouncer batteryChangedDebouncer =
306-
new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0);
302+
private final @NotNull Debouncer batteryChangedDebouncer;
307303

308304
SystemEventsBroadcastReceiver(
309305
final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) {
310306
this.scopes = scopes;
311307
this.options = options;
308+
this.batteryChangedDebouncer =
309+
new Debouncer(options.getMonotonicTicker(), DEBOUNCE_WAIT_TIME_MS, 0);
312310
}
313311

314312
@Override

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import io.sentry.SentryEvent;
1414
import io.sentry.SentryLevel;
1515
import io.sentry.android.core.internal.gestures.ViewUtils;
16-
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1716
import io.sentry.android.core.internal.util.AndroidThreadChecker;
1817
import io.sentry.android.core.internal.util.ClassUtil;
1918
import io.sentry.android.core.internal.util.Debouncer;
@@ -47,15 +46,10 @@ public final class ViewHierarchyEventProcessor implements SentryEventProcessor {
4746
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
4847
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
4948

50-
// TODO: JAVA-729
51-
@SuppressWarnings("deprecation")
5249
public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) {
5350
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
5451
this.debouncer =
55-
new Debouncer(
56-
AndroidCurrentDateProvider.getInstance(),
57-
DEBOUNCE_WAIT_TIME_MS,
58-
DEBOUNCE_MAX_EXECUTIONS);
52+
new Debouncer(options.getMonotonicTicker(), DEBOUNCE_WAIT_TIME_MS, DEBOUNCE_MAX_EXECUTIONS);
5953

6054
if (options.isAttachViewHierarchy()) {
6155
addIntegrationToSdkVersion("ViewHierarchy");

‎sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Debouncer.java‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,43 @@
11
package io.sentry.android.core.internal.util;
22

3-
import io.sentry.transport.ICurrentDateProvider;
3+
import io.sentry.time.Deadline;
4+
import io.sentry.time.MonotonicTicker;
5+
import java.util.concurrent.TimeUnit;
46
import java.util.concurrent.atomic.AtomicInteger;
5-
import java.util.concurrent.atomic.AtomicLong;
67
import org.jetbrains.annotations.ApiStatus;
78
import org.jetbrains.annotations.NotNull;
89

9-
/** A simple time-based debouncing mechanism */
10+
/**
11+
* A simple time-based debouncing mechanism.
12+
*
13+
* <p>The window is measured on a {@link MonotonicTicker}, which on Android keeps counting through
14+
* deep sleep, so a window that expired while the device slept is treated as expired.
15+
*/
1016
@ApiStatus.Internal
1117
public final class Debouncer {
1218

19+
private final @NotNull MonotonicTicker ticker;
1320
private final long waitTimeMs;
14-
private final @NotNull ICurrentDateProvider timeProvider;
15-
private final @NotNull AtomicInteger executions = new AtomicInteger(0);
1621
private final int maxExecutions;
17-
18-
private final @NotNull AtomicLong lastExecutionTime = new AtomicLong(0);
22+
private final @NotNull AtomicInteger executions = new AtomicInteger(0);
23+
private volatile @NotNull Deadline windowEnd;
1924

2025
public Debouncer(
21-
final @NotNull ICurrentDateProvider timeProvider,
22-
final long waitTimeMs,
23-
final int maxExecutions) {
24-
this.timeProvider = timeProvider;
26+
final @NotNull MonotonicTicker ticker, final long waitTimeMs, final int maxExecutions) {
27+
this.ticker = ticker;
2528
this.waitTimeMs = waitTimeMs;
2629
this.maxExecutions = maxExecutions <= 0 ? 1 : maxExecutions;
30+
this.windowEnd = Deadline.passed(ticker);
2731
}
2832

2933
/**
3034
* Returns true if the execution should be debounced due to maxExecutions executions being made
3135
* within waitTimeMs, otherwise false.
3236
*/
3337
public boolean checkForDebounce() {
34-
final long now = timeProvider.getCurrentTimeMillis();
35-
if (lastExecutionTime.get() == 0 || (lastExecutionTime.get() + waitTimeMs) <= now) {
38+
if (windowEnd.hasPassed()) {
3639
executions.set(0);
37-
lastExecutionTime.set(now);
40+
windowEnd = Deadline.after(ticker, waitTimeMs, TimeUnit.MILLISECONDS);
3841
return false;
3942
}
4043
if (executions.incrementAndGet() < maxExecutions) {

‎sentry-android-core/src/test/java/io/sentry/android/core/internal/util/DebouncerTest.kt‎

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,23 @@
11
package io.sentry.android.core.internal.util
22

3-
import io.sentry.transport.ICurrentDateProvider
4-
import kotlin.test.BeforeTest
3+
import io.sentry.time.TestMonotonicTicker
4+
import java.util.concurrent.TimeUnit
55
import kotlin.test.Test
66
import org.junit.Assert.assertFalse
77
import org.junit.Assert.assertTrue
88

99
class DebouncerTest {
10-
private class Fixture : ICurrentDateProvider {
11-
var currentTimeMs: Long = 0
10+
private class Fixture {
11+
val ticker = TestMonotonicTicker()
1212

13-
override fun getCurrentTimeMillis(): Long = currentTimeMs
13+
fun advance(millis: Long) = ticker.advance(millis, TimeUnit.MILLISECONDS)
1414

1515
fun getDebouncer(waitTimeMs: Long = 3000, maxExecutions: Int = 1): Debouncer =
16-
Debouncer(this, waitTimeMs, maxExecutions)
16+
Debouncer(ticker, waitTimeMs, maxExecutions)
1717
}
1818

1919
private val fixture = Fixture()
2020

21-
@BeforeTest
22-
fun `set up`() {
23-
fixture.currentTimeMs = 0
24-
}
25-
2621
@Test
2722
fun `Debouncer should not debounce on the first check`() {
2823
val debouncer = fixture.getDebouncer()
@@ -39,35 +34,41 @@ class DebouncerTest {
3934

4035
@Test
4136
fun `Debouncer should signal debounce if the second invocation is too early`() {
42-
fixture.currentTimeMs = 1000
4337
val debouncer = fixture.getDebouncer(3000)
4438
assertFalse(debouncer.checkForDebounce())
4539

46-
fixture.currentTimeMs = 3999
40+
fixture.advance(2999)
4741
assertTrue(debouncer.checkForDebounce())
4842
}
4943

5044
@Test
5145
fun `Debouncer should not signal debounce if the second invocation is late enough`() {
52-
fixture.currentTimeMs = 1000
5346
val debouncer = fixture.getDebouncer(3000)
5447
assertFalse(debouncer.checkForDebounce())
5548

56-
fixture.currentTimeMs = 4000
49+
fixture.advance(3000)
50+
assertFalse(debouncer.checkForDebounce())
51+
}
52+
53+
@Test
54+
fun `Debouncer does not debounce after a window that expired during deep sleep`() {
55+
val debouncer = fixture.getDebouncer(60_000)
56+
assertFalse(debouncer.checkForDebounce())
57+
58+
// The ticker keeps counting while the device sleeps, so an hour asleep expires the window.
59+
fixture.advance(TimeUnit.HOURS.toMillis(1))
5760
assertFalse(debouncer.checkForDebounce())
5861
}
5962

6063
@Test
6164
fun `Debouncer maxExecutions is always greater than 0`() {
62-
fixture.currentTimeMs = 1000
6365
val debouncer = fixture.getDebouncer(3000, -1)
6466
assertFalse(debouncer.checkForDebounce())
6567
assertTrue(debouncer.checkForDebounce())
6668
}
6769

6870
@Test
6971
fun `Debouncer should signal debounce after maxExecutions calls`() {
70-
fixture.currentTimeMs = 1000
7172
val debouncer = fixture.getDebouncer(3000, 3)
7273
assertFalse(debouncer.checkForDebounce())
7374
assertFalse(debouncer.checkForDebounce())
@@ -77,13 +78,12 @@ class DebouncerTest {
7778

7879
@Test
7980
fun `Debouncer maxExecutions counter resets if the other invocation is late enough`() {
80-
fixture.currentTimeMs = 1000
8181
val debouncer = fixture.getDebouncer(3000, 3)
8282
assertFalse(debouncer.checkForDebounce())
8383
assertFalse(debouncer.checkForDebounce())
8484

8585
// After waitTimeMs passes, the maxExecutions counter is reset
86-
fixture.currentTimeMs = 4000
86+
fixture.advance(3000)
8787
assertFalse(debouncer.checkForDebounce())
8888
assertFalse(debouncer.checkForDebounce())
8989
assertFalse(debouncer.checkForDebounce())
@@ -92,7 +92,6 @@ class DebouncerTest {
9292

9393
@Test
9494
fun `Debouncer maxExecutions counter resets after maxExecutions`() {
95-
fixture.currentTimeMs = 1000
9695
val debouncer = fixture.getDebouncer(3000, 3)
9796
assertFalse(debouncer.checkForDebounce())
9897
assertFalse(debouncer.checkForDebounce())

0 commit comments

Comments
 (0)