From 98106dd4bc11d056c20427c3e65bf8abb9d4ccc3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 13 Jul 2026 14:30:44 +0200 Subject: [PATCH 001/102] fix(core): Prevent lost wakeups in batch processors (#5756) * fix(core): Prevent lost wakeups in batch processors Coordinate log and metric flush scheduling through an atomic state transition. This ensures events added while a flush completes always have a pending task instead of relying on the running Future's completion state. Fixes #5739 Co-Authored-By: Claude * changelog --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + .../sentry/logger/LoggerBatchProcessor.java | 56 +++++++----------- .../sentry/metrics/MetricsBatchProcessor.java | 58 ++++++++----------- .../sentry/logger/LoggerBatchProcessorTest.kt | 22 +++++++ .../metrics/MetricsBatchProcessorTest.kt | 23 ++++++++ 5 files changed, 92 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3baff8d6b2..a6600fda921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixes +- Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) ### Dependencies diff --git a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java index 81ae5b73c1a..71877c21dae 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java @@ -4,7 +4,6 @@ import io.sentry.DataCategory; import io.sentry.ISentryClient; import io.sentry.ISentryExecutorService; -import io.sentry.ISentryLifecycleToken; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryLogEvent; @@ -12,15 +11,14 @@ import io.sentry.SentryOptions; import io.sentry.clientreport.DiscardReason; import io.sentry.transport.ReusableCountLatch; -import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.JsonSerializationUtils; import java.util.ArrayList; import java.util.List; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -37,9 +35,7 @@ public class LoggerBatchProcessor implements ILoggerBatchProcessor { private final @NotNull ISentryClient client; private final @NotNull Queue queue; private final @NotNull ISentryExecutorService executorService; - private volatile @Nullable Future scheduledFlush; - private final @NotNull AutoClosableReentrantLock scheduleLock = new AutoClosableReentrantLock(); - private volatile boolean hasScheduled = false; + private final @NotNull AtomicBoolean hasScheduled = new AtomicBoolean(false); private volatile boolean isShuttingDown = false; private final @NotNull ReusableCountLatch pendingCount = new ReusableCountLatch(); @@ -79,7 +75,7 @@ public void add(final @NotNull SentryLogEvent logEvent) { } pendingCount.increment(); queue.offer(logEvent); - maybeSchedule(false, false); + maybeSchedule(false); } @SuppressWarnings("FutureReturnValueIgnored") @@ -87,7 +83,7 @@ public void add(final @NotNull SentryLogEvent logEvent) { public void close(final boolean isRestarting) { isShuttingDown = true; if (isRestarting) { - maybeSchedule(true, true); + maybeSchedule(true); executorService.submit(() -> executorService.close(options.getShutdownTimeoutMillis())); } else { executorService.close(options.getShutdownTimeoutMillis()); @@ -97,33 +93,28 @@ public void close(final boolean isRestarting) { } } - private void maybeSchedule(boolean forceSchedule, boolean immediately) { - if (hasScheduled && !forceSchedule) { + @SuppressWarnings("FutureReturnValueIgnored") + private void maybeSchedule(boolean immediately) { + if (immediately) { + // any already scheduled task may be far in the future, we want to schedule something that + // runs right away + hasScheduled.set(true); + } else if (!hasScheduled.compareAndSet(false, true)) { + // was already true, no need to schedule again return; } - try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { - final @Nullable Future latestScheduledFlush = scheduledFlush; - if (forceSchedule - || latestScheduledFlush == null - || latestScheduledFlush.isDone() - || latestScheduledFlush.isCancelled()) { - hasScheduled = true; - final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; - try { - scheduledFlush = executorService.schedule(new BatchRunnable(), flushAfterMs); - } catch (RejectedExecutionException e) { - hasScheduled = false; - options - .getLogger() - .log(SentryLevel.WARNING, "Logs batch processor flush task rejected", e); - } - } + final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; + try { + executorService.schedule(new BatchRunnable(), flushAfterMs); + } catch (RejectedExecutionException e) { + hasScheduled.set(false); + options.getLogger().log(SentryLevel.WARNING, "Logs batch processor flush task rejected", e); } } @Override public void flush(long timeoutMillis) { - maybeSchedule(true, true); + maybeSchedule(true); try { pendingCount.waitTillZero(timeoutMillis, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { @@ -134,12 +125,9 @@ public void flush(long timeoutMillis) { private void flush() { flushInternal(); - try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { - if (!queue.isEmpty()) { - maybeSchedule(true, false); - } else { - hasScheduled = false; - } + hasScheduled.set(false); + if (!queue.isEmpty()) { + maybeSchedule(false); } } diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java index 2d5c78e5e89..3c744dbe3c5 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java @@ -4,7 +4,6 @@ import io.sentry.DataCategory; import io.sentry.ISentryClient; import io.sentry.ISentryExecutorService; -import io.sentry.ISentryLifecycleToken; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryMetricsEvent; @@ -12,15 +11,14 @@ import io.sentry.SentryOptions; import io.sentry.clientreport.DiscardReason; import io.sentry.transport.ReusableCountLatch; -import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.JsonSerializationUtils; import java.util.ArrayList; import java.util.List; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -35,9 +33,7 @@ public class MetricsBatchProcessor implements IMetricsBatchProcessor { private final @NotNull ISentryClient client; private final @NotNull Queue queue; private final @NotNull ISentryExecutorService executorService; - private volatile @Nullable Future scheduledFlush; - private final @NotNull AutoClosableReentrantLock scheduleLock = new AutoClosableReentrantLock(); - private volatile boolean hasScheduled = false; + private final @NotNull AtomicBoolean hasScheduled = new AtomicBoolean(false); private volatile boolean isShuttingDown = false; private final @NotNull ReusableCountLatch pendingCount = new ReusableCountLatch(); @@ -69,7 +65,7 @@ public void add(final @NotNull SentryMetricsEvent metricsEvent) { } pendingCount.increment(); queue.offer(metricsEvent); - maybeSchedule(false, false); + maybeSchedule(false); } @SuppressWarnings("FutureReturnValueIgnored") @@ -77,7 +73,7 @@ public void add(final @NotNull SentryMetricsEvent metricsEvent) { public void close(final boolean isRestarting) { isShuttingDown = true; if (isRestarting) { - maybeSchedule(true, true); + maybeSchedule(true); executorService.submit(() -> executorService.close(options.getShutdownTimeoutMillis())); } else { executorService.close(options.getShutdownTimeoutMillis()); @@ -87,33 +83,30 @@ public void close(final boolean isRestarting) { } } - private void maybeSchedule(boolean forceSchedule, boolean immediately) { - if (hasScheduled && !forceSchedule) { + @SuppressWarnings("FutureReturnValueIgnored") + private void maybeSchedule(boolean immediately) { + if (immediately) { + // any already scheduled task may be far in the future, we want to schedule something that + // runs right away + hasScheduled.set(true); + } else if (!hasScheduled.compareAndSet(false, true)) { + // was already true, no need to schedule again return; } - try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { - final @Nullable Future latestScheduledFlush = scheduledFlush; - if (forceSchedule - || latestScheduledFlush == null - || latestScheduledFlush.isDone() - || latestScheduledFlush.isCancelled()) { - hasScheduled = true; - final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; - try { - scheduledFlush = executorService.schedule(new BatchRunnable(), flushAfterMs); - } catch (RejectedExecutionException e) { - hasScheduled = false; - options - .getLogger() - .log(SentryLevel.WARNING, "Metrics batch processor flush task rejected", e); - } - } + final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; + try { + executorService.schedule(new BatchRunnable(), flushAfterMs); + } catch (RejectedExecutionException e) { + hasScheduled.set(false); + options + .getLogger() + .log(SentryLevel.WARNING, "Metrics batch processor flush task rejected", e); } } @Override public void flush(long timeoutMillis) { - maybeSchedule(true, true); + maybeSchedule(true); try { pendingCount.waitTillZero(timeoutMillis, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { @@ -124,12 +117,9 @@ public void flush(long timeoutMillis) { private void flush() { flushInternal(); - try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { - if (!queue.isEmpty()) { - maybeSchedule(true, false); - } else { - hasScheduled = false; - } + hasScheduled.set(false); + if (!queue.isEmpty()) { + maybeSchedule(false); } } diff --git a/sentry/src/test/java/io/sentry/logger/LoggerBatchProcessorTest.kt b/sentry/src/test/java/io/sentry/logger/LoggerBatchProcessorTest.kt index 73f2f03ebc3..01b8afdb4a3 100644 --- a/sentry/src/test/java/io/sentry/logger/LoggerBatchProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/logger/LoggerBatchProcessorTest.kt @@ -1,5 +1,6 @@ package io.sentry.logger +import com.google.common.truth.Truth.assertThat import io.sentry.DataCategory import io.sentry.ISentryClient import io.sentry.SentryLogEvent @@ -21,9 +22,30 @@ import kotlin.test.assertTrue import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify class LoggerBatchProcessorTest { + @Test + fun `schedules another flush after previous flush has run`() { + val mockClient = mock() + val mockExecutor = DeferredExecutorService() + val processor = LoggerBatchProcessor(SentryOptions(), mockClient, mockExecutor) + + processor.add(SentryLogEvent(SentryId(), SentryNanotimeDate(), "first", SentryLogLevel.INFO)) + mockExecutor.runAll() + + processor.add(SentryLogEvent(SentryId(), SentryNanotimeDate(), "second", SentryLogLevel.INFO)) + assertThat(mockExecutor.hasScheduledRunnables()).isTrue() + mockExecutor.runAll() + + val captor = argumentCaptor() + verify(mockClient, times(2)).captureBatchedLogEvents(captor.capture()) + assertThat(captor.allValues.flatMap { it.items }.map { it.body }) + .containsExactly("first", "second") + .inOrder() + } + @Test fun `drops log events after reaching MAX_QUEUE_SIZE limit`() { // given diff --git a/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt index 5ca1d0fe87a..d8320d9b1a6 100644 --- a/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt @@ -1,5 +1,6 @@ package io.sentry.metrics +import com.google.common.truth.Truth.assertThat import io.sentry.DataCategory import io.sentry.ISentryClient import io.sentry.SentryMetricsEvent @@ -20,9 +21,31 @@ import kotlin.test.assertTrue import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify class MetricsBatchProcessorTest { + @Test + fun `schedules another flush after previous flush has run`() { + val mockClient = mock() + val mockExecutor = DeferredExecutorService() + val processor = MetricsBatchProcessor(SentryOptions(), mockClient) + processor.injectForField("executorService", mockExecutor) + + processor.add(SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "first", "gauge", 1.0)) + mockExecutor.runAll() + + processor.add(SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "second", "gauge", 2.0)) + assertThat(mockExecutor.hasScheduledRunnables()).isTrue() + mockExecutor.runAll() + + val captor = argumentCaptor() + verify(mockClient, times(2)).captureBatchedMetricsEvents(captor.capture()) + assertThat(captor.allValues.flatMap { it.items }.map { it.name }) + .containsExactly("first", "second") + .inOrder() + } + @Test fun `drops metrics events after reaching MAX_QUEUE_SIZE limit`() { // given From f232e4f470c4dfd4e436b29411d0946b0974a1cb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:09:32 +0200 Subject: [PATCH 002/102] docs: Improve PR template guidance (#5757) Co-Authored-By: roman Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .github/pull_request_template.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4a12165077..baa2dad44a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## :scroll: Description - + ## :bulb: Motivation and Context @@ -12,6 +12,10 @@ --> ## :green_heart: How did you test it? + ## :pencil: Checklist From 31c558ecbc0ceee6d01fe587848d7307f7c78de1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 13 Jul 2026 21:53:48 +0200 Subject: [PATCH 003/102] fix(replay): Preserve segment ID after buffer-to-session conversion (#5753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(replay): Preserve segment ID after buffer-to-session conversion After a buffer-to-session conversion, the replay type stays BUFFER but the segment counter reflects the real sequence. If the app crashes and finalizePreviousReplay recovers the last segment, fromDisk() was normalizing the segment ID to 0 for all BUFFER replays, creating a duplicate segment 0 that overwrites the original. Add a persisted isFlushed flag set when the buffer is successfully flushed. fromDisk() now only normalizes to 0 when the buffer was never flushed (no segments were ever sent to the server). Co-Authored-By: Claude Opus 4.6 (1M context) * changelog * Apply suggestion from @romtsn * Add comment explaining isFlushed flag purpose Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Use persisted timestamp for flushed buffer recovery After buffer-to-session conversion, use the persisted segmentTimestamp (which chains with previous segments) instead of the first frame timestamp, avoiding gaps in the recovered segment timeline. Co-Authored-By: Claude Opus 4.6 (1M context) * Revert timestamp normalization change The gap from using first-frame timestamp vs persisted segmentTimestamp is at most ~1s (1/frameRate) — negligible for crash recovery. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../io/sentry/android/replay/ReplayCache.kt | 8 +++-- .../android/replay/ReplayIntegration.kt | 1 + .../replay/capture/BaseCaptureStrategy.kt | 6 ++++ .../replay/capture/BufferCaptureStrategy.kt | 1 - .../android/replay/capture/CaptureStrategy.kt | 1 + .../sentry/android/replay/ReplayCacheTest.kt | 36 ++++++++++++++++++- 7 files changed, 50 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6600fda921..393f4f8da3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixes +- Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index b3b9edae055..b54177bca9b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -317,6 +317,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" internal const val SEGMENT_KEY_ID = "segment.id" + internal const val SEGMENT_KEY_FLUSHED = "replay.flushed" fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? = if (options.cacheDirPath.isNullOrEmpty()) { @@ -415,8 +416,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } cache.frames.sortBy { it.timestamp } - // TODO: this should be removed when we start sending buffered segments on next launch - val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val wasFlushed = lastSegment[SEGMENT_KEY_FLUSHED]?.toBooleanStrictOrNull() == true + // In buffer mode, if the buffer was never flushed (no error triggered captureReplay), + // no segments were ever sent, so we normalize to 0. After a flush + conversion to + // session mode, the persisted segmentId is the real sequence number. + val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = if (replayType == SESSION) { segmentTimestamp diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 612517438f6..bae0e411795 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -261,6 +261,7 @@ public class ReplayIntegration( onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 captureStrategy?.segmentTimestamp = newTimestamp + captureStrategy?.isFlushed = true }, ) captureStrategy = captureStrategy?.convert() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6bb58c5e2a2..fbc0ccfd4bc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -13,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -89,6 +90,11 @@ internal abstract class BaseCaptureStrategy( get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + // Tracks whether the buffer was flushed (segments sent to server). Used by fromDisk() + // to decide whether to normalize the segment ID to 0 on crash recovery: if never flushed, + // no segments reached the server, so the recovered segment must be 0. + override var isFlushed: Boolean by + persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() private val traceIdsLock = Any() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 0df8a642f63..f6c6f3997ae 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -105,7 +105,6 @@ internal class BufferCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(scopes) - // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 6dc391a15ec..096a93741b2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -29,6 +29,7 @@ internal interface CaptureStrategy { val replayCacheDir: File? var replayType: ReplayType var segmentTimestamp: Date? + var isFlushed: Boolean fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 257941a9114..8b64ca5caeb 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -9,6 +9,7 @@ import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -443,7 +444,7 @@ class ReplayCacheTest { } @Test - fun `sets segmentId to 0 for buffer mode`() { + fun `sets segmentId to 0 for buffer mode when not flushed`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } val replayId = SentryId() val replayCacheFolder = @@ -474,6 +475,39 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + @Test + fun `preserves segmentId for buffer mode when already flushed`() { + fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } + val replayId = SentryId() + val replayCacheFolder = + File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=5 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_FLUSHED=true + """ + .trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(5, lastSegment.id) + } + @Test fun `when screenshot is corrupted, deletes it immediately`() { ShadowBitmapFactory.setAllowInvalidImageData(false) From c94d7babf5205296c999b719495baddc70c00c63 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 13 Jul 2026 22:59:22 +0200 Subject: [PATCH 004/102] fix(core): Inject replayId into trace context for buffer mode errors (#5754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): Set replayId in DSC for buffer mode error events In buffer mode, the scope's replayId is empty until captureReplay() is called. The transaction's baggage is frozen before that, so the DSC in the envelope header has no replay_id — breaking error-to-replay linkage. After captureReplay() sets the replayId on scope, force-set it on the transaction's frozen baggage via Baggage.forceSetReplayId() so the envelope header carries the correct replay_id in the DSC. This matches the JS SDK's approach of updating the DSC after buffer-to- session conversion. Co-Authored-By: Claude Opus 4.6 (1M context) * Add comment explaining forceSetReplayId Co-Authored-By: Claude Opus 4.6 (1M context) * Apply suggestion from @romtsn * test(core): Verify replayId is force-set on frozen baggage Co-Authored-By: Claude Opus 4.6 (1M context) * fix(core): Read replayId from scope after captureReplay Read scope.getReplayId() which is only set when on-error sampling succeeds. Using ReplayController.getReplayId() would return the buffer's internal ID even when sampling rejected the replay. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/Baggage.java | 13 +++++++ .../src/main/java/io/sentry/SentryClient.java | 12 +++++++ .../test/java/io/sentry/SentryClientTest.kt | 34 +++++++++++++++++++ 5 files changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 393f4f8da3e..af29b22ccb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixes - Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) +- Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c0b6fec780c..00183bc9b30 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -37,6 +37,7 @@ public final class io/sentry/Baggage { public fun (Lio/sentry/Baggage;)V public fun (Lio/sentry/ILogger;)V public fun (Ljava/util/concurrent/ConcurrentHashMap;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/String;ZZLio/sentry/ILogger;)V + public fun forceSetReplayId (Lio/sentry/protocol/SentryId;)V public fun forceSetSampleRate (Ljava/lang/Double;)V public fun freeze ()V public static fun fromEvent (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/SentryOptions;)Lio/sentry/Baggage; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 4645df3f3a4..9f0753f8671 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -451,6 +451,19 @@ public void setReplayId(final @Nullable String replayId) { set(DSCKeys.REPLAY_ID, replayId); } + /** + * Sets replay_id on the baggage bypassing the freeze check. In buffer mode the replay_id is + * unknown when the baggage is frozen, so it must be injected after {@link + * ReplayController#captureReplay} sets it on scope. This mirrors the JS SDK's setReplayIdOnDynamicSamplingContext. + */ + @ApiStatus.Internal + public void forceSetReplayId(final @NotNull SentryId replayId) { + if (!SentryId.EMPTY_ID.equals(replayId)) { + keyValues.put(DSCKeys.REPLAY_ID, replayId.toString()); + } + } + @ApiStatus.Internal public @Nullable String getOrgId() { return get(DSCKeys.ORG_ID); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 262b3a93034..4889d1629ce 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -250,6 +250,18 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } if (shouldCaptureReplay) { options.getReplayController().captureReplay(event.isCrashed()); + if (scope != null) { + final @Nullable SentryId replayId = scope.getReplayId(); + if (replayId != null && !replayId.equals(SentryId.EMPTY_ID)) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + final @Nullable Baggage baggage = transaction.getSpanContext().getBaggage(); + if (baggage != null) { + baggage.forceSetReplayId(replayId); + } + } + } + } } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index ea64675570d..f9cf7de567c 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3331,6 +3331,40 @@ class SentryClientTest { assertTrue(terminated == true) } + @Test + fun `sets replayId on frozen transaction baggage after captureReplay for error events`() { + val replayId = SentryId() + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) {} + } + ) + val sut = fixture.getSut() + + val baggage = Baggage(fixture.sentryOptions.logger) + baggage.traceId = SentryId().toString() + baggage.freeze() + + val spanContext = SpanContext("op.load") + spanContext.baggage = baggage + val transaction = mock() + whenever(transaction.spanContext).thenReturn(spanContext) + whenever(transaction.traceContext()).thenReturn(baggage.toTraceContext()) + + val scope = mock() + whenever(scope.transaction).thenReturn(transaction) + whenever(scope.span).thenReturn(transaction) + whenever(scope.replayId).thenReturn(replayId) + whenever(scope.breadcrumbs).thenReturn(LinkedList()) + whenever(scope.extras).thenReturn(emptyMap()) + whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.propagationContext).thenReturn(PropagationContext()) + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }, scope) + + assertEquals(replayId.toString(), baggage.getReplayId()) + } + @Test fun `cleans up replay folder for Backfillable replay events`() { val dir = File(tmpDir.newFolder().absolutePath) From c971c0487bc898e7bae1f0b6692f728d7423f567 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 07:31:04 +0200 Subject: [PATCH 005/102] build: Add Java 8 API compatibility checks (#5745) * build: Add Java 8 API compatibility checks Apply Animal Sniffer to Java 8 JVM SDK modules and check them against the Codehaus Java 8 runtime signature. This catches references to newer JDK APIs while keeping the existing Android signature checks in place where they already apply. Refs GH-5741 * build: Extract Animal Sniffer convention plugins Move shared Java 8 API compatibility configuration into build-logic plugins. Apply the Android-compatible variant where modules also need gummy-bears API checks to reduce duplicated signature setup. Co-Authored-By: Claude * build: Use catalog aliases for Animal Sniffer signatures Move Java 8 signature coordinates into the version catalog and add a shared helper for registering Animal Sniffer signature dependencies. Co-Authored-By: Claude * build: Remove Animal Sniffer reflection Configure Animal Sniffer tasks with the plugin task type directly so ignored classes can be set without reflective method lookup. Co-Authored-By: Claude * build: Avoid afterEvaluate for Animal Sniffer config Configure Animal Sniffer task options directly from the convention extension so the build does not defer configuration with afterEvaluate. Co-Authored-By: Claude * build: Make Animal Sniffer extension config-only Move the task-wiring logic out of SentryAnimalSnifferExtension and into the plugin. The extension previously held a Project reference and reached into the animalsnifferMain task on every DSL call, mixing configuration with application logic. The extension now only declares intent via managed ListProperty values; the plugin reads them when configuring the task. This drops the Project reference (config-cache friendly) and needs no afterEvaluate, since the task-configuration action runs after the build script is evaluated. Co-Authored-By: Claude Opus 4.8 * build: Clarify java18Signature refers to Java 1.8 * build: Rename java18 signature alias to java8 for clarity --------- Co-authored-by: Claude Co-authored-by: Nelson Osacky --- build-logic/build.gradle.kts | 14 +++++ .../gradle/SentryAnimalSnifferPlugin.kt | 57 +++++++++++++++++++ gradle/libs.versions.toml | 6 +- sentry-apache-http-client-5/build.gradle.kts | 1 + sentry-apollo-3/build.gradle.kts | 7 +-- sentry-apollo-4/build.gradle.kts | 7 +-- sentry-apollo/build.gradle.kts | 7 +-- sentry-async-profiler/build.gradle.kts | 1 + sentry-graphql-22/build.gradle.kts | 1 + sentry-graphql-core/build.gradle.kts | 1 + sentry-graphql/build.gradle.kts | 1 + sentry-jcache/build.gradle.kts | 1 + sentry-jdbc/build.gradle.kts | 1 + sentry-jul/build.gradle.kts | 1 + sentry-kafka/build.gradle.kts | 1 + sentry-kotlin-extensions/build.gradle.kts | 7 +-- sentry-ktor-client/build.gradle.kts | 7 +-- sentry-launchdarkly-server/build.gradle.kts | 1 + sentry-log4j2/build.gradle.kts | 1 + sentry-logback/build.gradle.kts | 1 + sentry-okhttp/build.gradle.kts | 7 +-- sentry-openfeature/build.gradle.kts | 1 + sentry-openfeign/build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + sentry-quartz/build.gradle.kts | 1 + sentry-servlet-jakarta/build.gradle.kts | 1 + sentry-servlet/build.gradle.kts | 1 + sentry-spotlight/build.gradle.kts | 7 +-- sentry-spring-boot-starter/build.gradle.kts | 1 + sentry-spring-boot/build.gradle.kts | 1 + sentry-spring/build.gradle.kts | 1 + sentry/build.gradle.kts | 20 ++----- 39 files changed, 116 insertions(+), 58 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8abe9f55283..bba758f9b79 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -7,5 +7,19 @@ repositories { } dependencies { + implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) } + +gradlePlugin { + plugins { + register("sentryAnimalSniffer") { + id = "io.sentry.animalsniffer" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" + } + register("sentryAnimalSnifferAndroid") { + id = "io.sentry.animalsniffer.android" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" + } + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt new file mode 100644 index 00000000000..f1bc2bafcf7 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt @@ -0,0 +1,57 @@ +package io.sentry.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.ListProperty +import ru.vyarus.gradle.plugin.animalsniffer.AnimalSniffer + +abstract class SentryAnimalSnifferExtension { + abstract val ignoredClasses: ListProperty + abstract val excludedClasses: ListProperty + + fun ignoreClasses(vararg classes: String) { + ignoredClasses.addAll(*classes) + } + + fun mainExcludes(vararg excludes: String) { + excludedClasses.addAll(*excludes) + } +} + +class SentryAnimalSnifferPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("ru.vyarus.animalsniffer") + + val extension = + project.extensions.create("sentryAnimalSniffer", SentryAnimalSnifferExtension::class.java) + + project.addSignatureDependency("java8-signature") + + project.tasks.named("animalsnifferMain", AnimalSniffer::class.java).configure { + ignoreClasses = ignoreClasses + extension.ignoredClasses.get() + exclude(extension.excludedClasses.get()) + } + + project.tasks.named("check").configure { dependsOn("animalsnifferMain") } + } +} + +class SentryAnimalSnifferAndroidPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply(SentryAnimalSnifferPlugin::class.java) + + project.addSignatureDependency("gummy-bears-api21") + } +} + +private fun Project.addSignatureDependency(libraryName: String) { + val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + dependencies.add("signature", signatureNotation(libs.findLibrary(libraryName).get().get())) +} + +private fun signatureNotation(dependency: MinimalExternalModuleDependency): String { + val module = "${dependency.module.group}:${dependency.module.name}" + return "$module:${dependency.versionConstraint.requiredVersion}@signature" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a409707f3f..c2b473dbed5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" @@ -11,6 +12,7 @@ coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" gummyBears = "0.12.0" +java8Signature = "1.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.3.21" @@ -70,11 +72,12 @@ springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } -animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } +animalsniffer = { id = "ru.vyarus.animalsniffer", version.ref = "animalsniffer" } sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] +animalsniffer-gradle-plugin = { module = "ru.vyarus:gradle-animalsniffer-plugin", version.ref = "animalsniffer" } apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-support", version.ref = "apollo" } apollo2-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } @@ -226,6 +229,7 @@ timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature gummy-bears-api21 = { module = "com.toasttab.android:gummy-bears-api-21", version.ref = "gummyBears" } +java8-signature = { module = "org.codehaus.mojo.signature:java18", version.ref = "java8Signature" } # tomcat libraries tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0.108" } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 00916258b8f..984974bae9a 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index d70085e27bd..70f43d946ef 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -37,13 +37,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index abb7ccb760e..4f1276f0bf4 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,13 +39,8 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 0fc853886df..2da8d8b20c1 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,13 +38,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 17093fe6a09..17454baa662 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index 3c0667fd0d4..32db28fae8f 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index 62635ded34e..34f71ab9cfb 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 30000655079..d92dc52c6d7 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index 1cc3b6e0e3d..b388f35881f 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 1e86048053e..e2a7f573138 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 66c46bcee21..2eec61eb171 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index ef1ff252468..0d543bad270 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 8c4312641a8..101761b2a82 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -32,13 +32,8 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 647563cc1d1..fefcdbfebaf 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,13 +39,8 @@ dependencies { testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.java) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - buildConfig { useJavaOutput() packageName("io.sentry.ktorClient") diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 370252c2154..95aba9faaf5 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 1c5cf94e8eb..6e5250ece50 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 1c42a4e1c03..5fd6c975231 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index d547720c174..47b8bfe5b15 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,13 +38,8 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - buildConfig { useJavaOutput() packageName("io.sentry.okhttp") diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index fbabcb81aa5..b079ead1fc5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 9b1ac2bbc29..3baa85dee26 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index ef98d488bd1..054db790dc2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -2,6 +2,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.shadow) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 71f31ce2afb..087568d03ee 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts index c02ca0ca468..5a94dcd4422 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 43e87d53beb..72508d73737 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index d4bd1af9ede..b69ae1be7b4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 91ec023e178..7c92b8a87a5 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts index 1ff16cd0a31..8a5093a6570 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index d63c8a5c451..1792c852dc3 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 6e227abafe6..f4f0d9d07d2 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 3cdc4772f18..5762a4ae52b 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index 9f12d4ee177..c2e3fe8e52e 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index 71498aecd92..a8d538c2e2e 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -7,7 +7,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") alias(libs.plugins.buildconfig) } @@ -30,13 +30,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - buildConfig { useJavaOutput() packageName("io.sentry.spotlight") diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index f4da56179cb..cd05baecc4d 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 3ed6199fbc5..947eaf9b03a 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index fced2220f02..03176d7b867 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 9717cb176ae..6b98ff790c6 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -32,26 +32,16 @@ dependencies { testImplementation(libs.msgpack) testImplementation(libs.okio) testImplementation(projects.sentryTestSupport) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -animalsniffer { - ignore = - listOf( - // We manually check on Android if it's available (API 26+). - "java.time.Instant" - ) } -tasks.animalsnifferMain { +sentryAnimalSniffer { + // We manually check on Android if it's available (API 26+). + ignoreClasses("java.time.Instant") // Uses java.util.function.Supplier, but must be manually invoked. - exclude("**/io/sentry/SentryWrapper.class") + mainExcludes("**/io/sentry/SentryWrapper.class") } tasks { - check { dependsOn(animalsnifferMain) } test { // java.lang open is needed by tests that reflectively rewrite Class names; it was previously // provided implicitly by the jacoco test agent, which has been removed. From dd1eb0c44c101686b71aed0a1732f2452646348c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 07:31:36 +0200 Subject: [PATCH 006/102] build: Bump Spotless to 8.8.0 (#5720) * fix(spring): Support Spring Boot 4.1 Bump OpenTelemetry and Spring Boot dependencies to compatible versions so the Spring Boot 4 OpenTelemetry sample works with Spring Boot 4.1. Keep the Spring 7 sample on the repository Kotlin compiler version when importing the Spring Boot BOM, and add Spring Boot 4.1 to the system test matrix. Fixes GH-5561 Co-Authored-By: Claude * changelog * build: Bump Kotlin to 2.3.21 Align the global Kotlin version with Spring Boot 4.1 and remove the now-unneeded Spring 7 BOM override. Also remove unused Spring 7-specific Kotlin aliases from the version catalog. Co-Authored-By: Claude * build: Bump Spotless to 8.7.0 Update the Spotless Gradle plugin and apply the resulting formatting changes. Co-Authored-By: Claude * build: Bump Spotless to 8.8.0 Update the Spotless Gradle plugin to the latest available release. Co-Authored-By: Claude * Format code * Apply suggestion from @adinauer --------- Co-authored-by: Claude Co-authored-by: Sentry Github Bot --- gradle/libs.versions.toml | 2 +- .../core/ActivityLifecycleIntegrationTest.kt | 275 ++++++++---------- .../core/AndroidContinuousProfilerTest.kt | 11 +- .../core/AndroidLoggerBatchProcessorTest.kt | 11 +- .../core/AndroidMetricsBatchProcessorTest.kt | 11 +- .../ApplicationExitIntegrationTestBase.kt | 6 +- .../sentry/android/core/SentryAndroidTest.kt | 8 +- .../android/core/SentryLogcatAdapterTest.kt | 9 +- .../core/SentryPerformanceProviderTest.kt | 5 +- .../threaddump/ThreadDumpParserTest.kt | 10 +- .../core/internal/util/CpuInfoUtilsTest.kt | 15 +- .../io/sentry/uitest/android/ReplayTest.kt | 9 +- .../uitest/android/UserInteractionTests.kt | 9 +- .../java/io/sentry/android/replay/Windows.kt | 13 +- .../replay/gestures/ReplayGestureConverter.kt | 9 +- .../replay/AnrWithReplayIntegrationTest.kt | 9 +- .../DefaultReplayBreadcrumbConverterTest.kt | 5 +- .../android/replay/ReplayIntegrationTest.kt | 4 +- .../android/replay/ScreenshotRecorderTest.kt | 14 +- .../ComposeMaskingOptionsTest.kt | 7 +- .../sentry/android/timber/SentryTimberTree.kt | 5 +- ...yncProfilerToSentryProfileConverterTest.kt | 29 +- .../io/sentry/logback/SentryAppenderTest.kt | 4 +- .../sentry/openfeign/SentryFeignClientTest.kt | 22 +- .../io/sentry/CheckInSerializationTest.kt | 15 +- ...efaultCompositePerformanceCollectorTest.kt | 9 +- .../JsonReflectionObjectSerializerTest.kt | 3 +- .../io/sentry/JsonUnknownSerializationTest.kt | 5 +- sentry/src/test/java/io/sentry/ScopeTest.kt | 18 +- sentry/src/test/java/io/sentry/ScopesTest.kt | 10 +- .../test/java/io/sentry/SentryClientTest.kt | 261 +++++++++-------- sentry/src/test/java/io/sentry/SentryTest.kt | 28 +- .../test/java/io/sentry/SentryWrapperTest.kt | 27 +- ...UncaughtExceptionHandlerIntegrationTest.kt | 17 +- .../ClientReportMultiThreadingTest.kt | 7 +- .../transport/QueuedThreadPoolExecutorTest.kt | 9 +- 36 files changed, 440 insertions(+), 471 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2b473dbed5..1fd275d06f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ sagp = "6.13.0" sqlite = "2.6.2" sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" -spotless = "8.6.0" +spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index d198c8d975e..1d8fa06f3bb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -242,11 +242,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `Standalone app start transaction op is app start`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -257,8 +256,9 @@ class ActivityLifecycleIntegrationTest { verify(fixture.scopes, times(2)).startTransaction(any(), any()) val contexts = fixture.capturedContexts - val appStartContext = - contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + val appStartContext = contexts.single { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } assertEquals("App Start", appStartContext.name) assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) val appStartTransaction = @@ -278,11 +278,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Standalone app start transaction carries app start reason when available`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -304,11 +303,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `Standalone app start transaction has no app start reason when unavailable`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -325,11 +323,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -350,11 +347,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start continues the trace into ui load without a second app start transaction`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -381,11 +377,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start trace is not reused by a later activity`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -411,11 +406,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start screen is not overwritten by a later activity`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -436,11 +430,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -463,11 +456,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start transaction stays open until finishExtendedAppStart`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -489,11 +481,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start persists the app start end time`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -506,11 +497,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `finished eager extended app start persists the app start end time`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -525,11 +515,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity long after the eager extended app start finished starts a fresh trace`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) // the eager extension starts at launch and finishes before any activity exists @@ -557,11 +546,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start does not create a duplicate when the extension already finished`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -590,11 +578,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start transaction is owned by the extension and survives activity destroy`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -611,11 +598,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Headless standalone app start transaction carries app start reason when available`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) val startInfo = @@ -632,11 +618,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) @@ -674,11 +659,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `close clears HeadlessAppStartListener`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) sut.close() prepareHeadlessAppStart() @@ -690,11 +674,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -721,11 +704,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.M]) fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessSdkInitAppStart() @@ -748,11 +730,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.WARM) @@ -767,11 +748,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) AppStartMetrics.getInstance().appStartTimeSpan.reset() AppStartMetrics.getInstance().sdkInitTimeSpan.reset() @@ -1086,11 +1066,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -1462,11 +1441,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity emits ui load and standalone App Start sharing trace id`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) val firstFrameDate = SentryNanotimeDate(1499, 0) fixture.options.dateProvider = SentryDateProvider { firstFrameDate } @@ -1517,11 +1495,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) val appStartEndDate = SentryNanotimeDate(499, 0) setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) @@ -1550,11 +1527,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity following a headless start reuses trace id and does not emit second standalone`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) // headless start always stores the trace header alongside the trace id; the ui.load txn // continues that trace via continueTrace, sharing the trace id. @@ -1576,11 +1552,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity within a minute of the headless start continues the same trace`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value @@ -1600,11 +1575,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity more than a minute after the headless start starts a fresh trace`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value @@ -1626,11 +1600,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -1648,11 +1621,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() // the app-start sampling decision carries the sampleRand the whole trace should share @@ -1681,11 +1653,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) // 1) a headless start emits the standalone app.start and stores its trace headers diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 162e56c36e3..8837030608e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -539,13 +539,12 @@ class AndroidContinuousProfilerTest { @Test fun `profiler does not start when offline`() { - val profiler = - fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } + val profiler = fixture.getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) } + } // If the device is offline, the profiler should never start profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt index ab83671fa0e..369f7f6a148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt @@ -68,12 +68,11 @@ class AndroidLoggerBatchProcessorTest { @Test fun `onBackground handles executor exception gracefully`() { - val sut = - fixture.getSut { options -> - val rejectingExecutor = mock() - whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) - options.executorService = rejectingExecutor - } + val sut = fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } // Should not throw sut.onBackground() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt index fceb9ed3f4d..7d85502d149 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -67,12 +67,11 @@ class AndroidMetricsBatchProcessorTest { @Test fun `onBackground handles executor exception gracefully`() { - val sut = - fixture.getSut { options -> - val rejectingExecutor = mock() - whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) - options.executorService = rejectingExecutor - } + val sut = fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } // Should not throw sut.onBackground() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 649e14e413b..edb2ce1df24 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -369,7 +369,11 @@ abstract class ApplicationExitIntegrationTestBase { val hintAccessors: HintAccessors, val addExitInfo: ApplicationExitTestFixture.( - reason: Int?, timestamp: Long?, importance: Int?, addTrace: Boolean, addBadTrace: Boolean, + reason: Int?, + timestamp: Long?, + importance: Int?, + addTrace: Boolean, + addBadTrace: Boolean, ) -> Unit, val flushLogPrefix: String, ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 8524a1cc807..2bd26051c07 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -440,7 +440,9 @@ class SentryAndroidTest { // clean state for a new process. assertEquals( emptyList(), - options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java), + options + .findPersistingScopeObserver() + ?.read(options, BREADCRUMBS_FILENAME, List::class.java), ) assertEquals( SentryId.EMPTY_ID.toString(), @@ -463,7 +465,9 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java), + options + .findPersistingScopeObserver() + ?.read(options, TRANSACTION_FILENAME, String::class.java), ) assertEquals( "io.sentry.sample@1.1.0+220", diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1a84a1282da..0c0c03d71d2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -31,11 +31,10 @@ class SentryLogcatAdapterTest { Bundle().apply { putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") } val mockContext = ContextUtilsTestHelper.mockMetaData(metaData = metadata) initForTest(mockContext) { - it.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + it.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } it.logs.isEnabled = true it.logs.beforeSend = SentryOptions.Logs.BeforeSendLogCallback { logEvent -> diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 58dc56d1493..254571181a6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -257,8 +257,9 @@ class SentryPerformanceProviderTest { @Test fun `when provider is closed, profiler is stopped`() { - val provider = - fixture.getSut { config -> writeConfig(config, continuousProfilingEnabled = false) } + val provider = fixture.getSut { config -> + writeConfig(config, continuousProfilingEnabled = false) + } provider.shutdown() assertNotNull(AppStartMetrics.getInstance().appStartProfiler) assertFalse(AppStartMetrics.getInstance().appStartProfiler!!.isRunning) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index ec5dbd58902..c5798be2111 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -52,8 +52,9 @@ class ThreadDumpParserTest { assertEquals(SentryLockReason.SLEEPING, blockingThread.heldLocks!!["0x09228c2d"]!!.type) assertEquals(null, blockingThread.heldLocks!!["0x09228c2d"]!!.threadId) - val randomThread = - threads.find { it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" } + val randomThread = threads.find { + it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" + } assertEquals(19, randomThread!!.id) assertEquals("Native", randomThread.state) assertEquals(false, randomThread.isCrashed) @@ -155,8 +156,9 @@ class ThreadDumpParserTest { assertNull(deletedFrame.addrMode) val debugImages = parser.debugImages - val image = - debugImages.first { image -> image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" } + val image = debugImages.first { image -> + image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" + } assertNotNull(image) assertEquals("499d48ba-c085-17cf-3209-da67405662f9", image.debugId) assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt index a6611e17e9a..c3993b94efd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt @@ -14,14 +14,13 @@ class CpuInfoUtilsTest { private lateinit var cpuDirs: File private lateinit var ciu: CpuInfoUtils - private fun populateCpuFiles(values: List) = - values.mapIndexed { i, v -> - val cpuMaxFreqFile = - File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") - cpuMaxFreqFile.parentFile?.mkdirs() - cpuMaxFreqFile.writeText(v) - cpuMaxFreqFile - } + private fun populateCpuFiles(values: List) = values.mapIndexed { i, v -> + val cpuMaxFreqFile = + File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") + cpuMaxFreqFile.parentFile?.mkdirs() + cpuMaxFreqFile.writeText(v) + cpuMaxFreqFile + } @BeforeTest fun `set up`() { diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index 3827561e37c..9c53f0a022d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -70,11 +70,10 @@ class ReplayTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.beforeSendReplay = - SentryOptions.BeforeSendReplayCallback { event, _ -> - sent.set(true) - event - } + it.beforeSendReplay = SentryOptions.BeforeSendReplayCallback { event, _ -> + sent.set(true) + event + } } // wait until first segment is being sent diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index b76017aeb0b..2ea1905b761 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -89,11 +89,10 @@ class UserInteractionTests : BaseUiTest() { options.profilesSampleRate = 1.0 options.isEnableUserInteractionTracing = true options.isEnableUserInteractionBreadcrumbs = true - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 9b9d8f0157b..e81c815dc11 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -83,14 +83,13 @@ internal object WindowSpy { } } - fun pullWindow(maybeDecorView: View): Window? = - decorViewClass?.let { decorViewClass -> - if (decorViewClass.isInstance(maybeDecorView)) { - windowField?.let { windowField -> windowField[maybeDecorView] as Window } - } else { - null - } + fun pullWindow(maybeDecorView: View): Window? = decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> windowField[maybeDecorView] as Window } + } else { + null } + } } /** diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index 70d0988d3b0..cbedfc24cc2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -65,11 +65,10 @@ internal class ReplayGestureConverter(private val dateProvider: ICurrentDateProv moveEvents += RRWebInteractionMoveEvent().apply { this.timestamp = now - this.positions = - positions.map { pos -> - pos.timeOffset -= totalOffset - pos - } + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } this.pointerId = pointerId } currentPositions[pointerId]!!.clear() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index 1214c55c057..3df08b2af24 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -155,11 +155,10 @@ class AnrWithReplayIntegrationTest { it.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event - it.beforeSend = - SentryOptions.BeforeSendCallback { event, _ -> - assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } it.addEventProcessor( object : EventProcessor { override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 3da118190f0..749d3496698 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -368,8 +368,9 @@ class DefaultReplayBreadcrumbConverterTest { } // Set up options with a user callback that returns modified breadcrumb - val userBeforeBreadcrumbCallback = - SentryOptions.BeforeBreadcrumbCallback { _, _ -> userModifiedBreadcrumb } + val userBeforeBreadcrumbCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> + userModifiedBreadcrumb + } val options = SentryOptions.empty() options.beforeBreadcrumb = userBeforeBreadcrumbCallback diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 61b5213e76f..32b7f4e9285 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1043,7 +1043,9 @@ class ReplayIntegrationTest { replay.start() fixture.options.sessionReplay.frameObserver = - SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") } + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> + throw RuntimeException("test") + } val sourceBitmap = mock { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt index 0a5c73f8a5c..2818eeb3537 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -42,10 +42,9 @@ class ScreenshotRecorderTest { @Test fun `when config uses PIXEL_COPY strategy, ScreenshotRecorder creates PixelCopyStrategy`() { - val recorder = - fixture.getSut { options -> - options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY - } + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY + } val strategy = getStrategy(recorder) @@ -57,10 +56,9 @@ class ScreenshotRecorderTest { @Test fun `when config uses CANVAS strategy, ScreenshotRecorder creates CanvasStrategy`() { - val recorder = - fixture.getSut { options -> - options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS - } + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS + } val strategy = getStrategy(recorder) assertTrue( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index baf0a32a415..0accb9ed16d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -233,10 +233,9 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - val unmaskNode = - textNodes.first { - (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" - } + val unmaskNode = textNodes.first { + (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" + } assertTrue(unmaskNode.isVisible, "The unmasked node must be visible for the test to be valid") assertFalse(unmaskNode.shouldMask, "Node with sentryReplayUnmask() should not be masked") diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt index 9c87c8a461d..61b1f99fb16 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt @@ -248,8 +248,9 @@ public class SentryTimberTree( ) { // checks the log level if (isLoggable(sentryLogLevel, minLogLevel)) { - val attributes = - tag?.let { SentryAttributes.of(SentryAttribute.stringAttribute("timber.tag", tag)) } + val attributes = tag?.let { + SentryAttributes.of(SentryAttribute.stringAttribute("timber.tag", tag)) + } val params = SentryLogParameters.create(attributes) params.origin = "auto.log.timber" diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt index d565fd9d51d..26a10352176 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -218,13 +218,12 @@ class JfrAsyncProfilerToSentryProfileConverterTest { assertTrue(frames.isNotEmpty()) // Find frames with complete information - val completeFrames = - frames.filter { frame -> - frame.function != null && - frame.module != null && - frame.lineno != null && - frame.filename != null - } + val completeFrames = frames.filter { frame -> + frame.function != null && + frame.module != null && + frame.lineno != null && + frame.filename != null + } assertTrue(completeFrames.isNotEmpty(), "Should have frames with complete information") } @@ -238,15 +237,15 @@ class JfrAsyncProfilerToSentryProfileConverterTest { val frames = sentryProfile.frames // Verify system packages are marked as not in-app - val systemFrames = - frames.filter { frame -> - frame.module?.let { - it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") - } ?: false - } + val systemFrames = frames.filter { frame -> + frame.module?.let { + it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") + } ?: false + } - val inappSentryFrames = - frames.filter { frame -> frame.module?.startsWith("io.sentry.") ?: false } + val inappSentryFrames = frames.filter { frame -> + frame.module?.startsWith("io.sentry.") ?: false + } val emptyModuleFrames = frames.filter { it.module.isNullOrEmpty() } diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index e93d6ef2db1..877d2a23d75 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -121,7 +121,9 @@ class SentryAppenderTest { Fixture( startLater = true, options = - SentryOptions().also { it.setTag("only-present-if-logger-init-was-run", "another-value") }, + SentryOptions().also { + it.setTag("only-present-if-logger-init-was-run", "another-value") + }, ) initForTest { it.dsn = "http://key@localhost/proj" diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 571a2339326..c25a81f9501 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -286,11 +286,10 @@ class SentryFeignClientTest { @Test fun `customizer modifies span`() { - val sut = - fixture.getSut { span, _, _ -> - span.description = "overwritten description" - span - } + val sut = fixture.getSut { span, _, _ -> + span.description = "overwritten description" + span + } sut.getOk() assertEquals(1, fixture.sentryTracer.children.size) val httpClientSpan = fixture.sentryTracer.children.first() @@ -299,13 +298,12 @@ class SentryFeignClientTest { @Test fun `customizer receives request and response`() { - val sut = - fixture.getSut { span, request, response -> - assertEquals(request.url(), request.url()) - assertEquals(request.httpMethod().name, request.httpMethod().name) - assertNotNull(response) { assertEquals(201, it.status()) } - span - } + val sut = fixture.getSut { span, request, response -> + assertEquals(request.url(), request.url()) + assertEquals(request.httpMethod().name, request.httpMethod().name) + assertNotNull(response) { assertEquals(201, it.status()) } + span + } sut.getOk() } diff --git a/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt b/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt index 51d37764f4d..69c65a64c62 100644 --- a/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt @@ -42,14 +42,13 @@ class CheckInSerializationTest { } else { MonitorConfig(MonitorSchedule.interval(42, MonitorScheduleUnit.MINUTE)) } - monitorConfig = - monitorConfigTmp.apply { - checkinMargin = 8L - maxRuntime = 9L - timezone = ZoneId.of("Europe/Vienna").id - failureIssueThreshold = 10 - recoveryThreshold = 20 - } + monitorConfig = monitorConfigTmp.apply { + checkinMargin = 8L + maxRuntime = 9L + timezone = ZoneId.of("Europe/Vienna").id + failureIssueThreshold = 10 + recoveryThreshold = 20 + } } } diff --git a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt index f8e3a8f9f98..bd1e584c2d7 100644 --- a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt +++ b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt @@ -191,11 +191,10 @@ class DefaultCompositePerformanceCollectorTest { SentryNanotimeDate(TimeUnit.SECONDS.toMillis(131), TimeUnit.SECONDS.toNanos(131)), ) whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) - val collector = - fixture.getSut { - it.dateProvider = mockDateProvider - it.addPerformanceCollector(mockCollector) - } + val collector = fixture.getSut { + it.dateProvider = mockDateProvider + it.addPerformanceCollector(mockCollector) + } collector.start(fixture.transaction1) verify(fixture.mockTimer, never())!!.cancel() diff --git a/sentry/src/test/java/io/sentry/JsonReflectionObjectSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonReflectionObjectSerializerTest.kt index 3ef48dad5ef..bd1de8fcbc7 100644 --- a/sentry/src/test/java/io/sentry/JsonReflectionObjectSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonReflectionObjectSerializerTest.kt @@ -102,7 +102,8 @@ class JsonReflectionObjectSerializerTest { "child" to mapOf( "title" to "First Child", - "child" to mapOf("title" to "Second Child", "child" to "fixture-toString"), + "child" to + mapOf("title" to "Second Child", "child" to "fixture-toString"), ), ) val actual = fixture.getSut().serialize(root, fixture.logger) diff --git a/sentry/src/test/java/io/sentry/JsonUnknownSerializationTest.kt b/sentry/src/test/java/io/sentry/JsonUnknownSerializationTest.kt index 438d20a9c44..37bf1efef98 100644 --- a/sentry/src/test/java/io/sentry/JsonUnknownSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/JsonUnknownSerializationTest.kt @@ -145,8 +145,9 @@ class JsonUnknownSerializationTest( ) } - private fun givenJsonUnknown(jsonUnknown: T): T = - jsonUnknown.apply { unknown = mapOf("fixture-key" to "fixture-value") } + private fun givenJsonUnknown(jsonUnknown: T): T = jsonUnknown.apply { + unknown = mapOf("fixture-key" to "fixture-value") + } } @Test diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 4b0047fdc18..86aaf6f8f24 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -359,11 +359,10 @@ class ScopeTest { val options = SentryOptions().apply { maxBreadcrumbs = 0 - beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - called = true - breadcrumb - } + beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + called = true + breadcrumb + } } val scope = Scope(options) @@ -377,11 +376,10 @@ class ScopeTest { var called = false val options = SentryOptions().apply { - beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - called = true - breadcrumb - } + beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + called = true + breadcrumb + } } val scope = Scope(options) diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index a4c2c76845e..4b9b3095d53 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -187,8 +187,9 @@ class ScopesTest { fun `when beforeBreadcrumb returns null, crumb is dropped`() { val options = SentryOptions() options.cacheDirPath = file.absolutePath - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> null } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> + null + } options.dsn = "https://key@sentry.io/proj" options.setSerializer(mock()) val sut = createScopes(options) @@ -240,8 +241,9 @@ class ScopesTest { val options = SentryOptions() options.cacheDirPath = file.absolutePath - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> throw exception } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> + throw exception + } options.dsn = "https://key@sentry.io/proj" options.setSerializer(mock()) val sut = createScopes(options) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index f9cf7de567c..fff05933d4e 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -424,7 +424,11 @@ class SentryClientTest { fixture.sentryOptions.clientReportRecorder, listOf( DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetric.category, 1), - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetricByte.category, 120), + DiscardedEvent( + DiscardReason.BEFORE_SEND.reason, + DataCategory.TraceMetricByte.category, + 120, + ), ), ) } @@ -447,7 +451,11 @@ class SentryClientTest { fixture.sentryOptions.clientReportRecorder, listOf( DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetric.category, 1), - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetricByte.category, 120), + DiscardedEvent( + DiscardReason.BEFORE_SEND.reason, + DataCategory.TraceMetricByte.category, + 120, + ), ), ) } @@ -2341,10 +2349,9 @@ class SentryClientTest { @Test fun `dropping a captured error from beforeSend has no effect on session and does not send anything`() { - val sut = - fixture.getSut { options -> - options.beforeSend = SentryOptions.BeforeSendCallback { _, _ -> null } - } + val sut = fixture.getSut { options -> + options.beforeSend = SentryOptions.BeforeSendCallback { _, _ -> null } + } val scope = givenScopeWithStartedSession() sut.captureEvent(SentryEvent().apply { exceptions = createHandledException() }, scope) @@ -2355,8 +2362,9 @@ class SentryClientTest { @Test fun `dropping a captured error from eventProcessor has no effect on session and does not send anything`() { - val sut = - fixture.getSut { options -> options.addEventProcessor(DropEverythingEventProcessor()) } + val sut = fixture.getSut { options -> + options.addEventProcessor(DropEverythingEventProcessor()) + } val scope = givenScopeWithStartedSession() sut.captureEvent(SentryEvent().apply { exceptions = createHandledException() }, scope) @@ -2444,13 +2452,12 @@ class SentryClientTest { fixture.sentryOptions.onDiscard = onDiscardMock - val sut = - fixture.getSut { options -> - options.sampleRate = 0.000000000001 - options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) - options.beforeSend = beforeSendMock - options.addEventProcessor(globalEventProcessorMock) - } + val sut = fixture.getSut { options -> + options.sampleRate = 0.000000000001 + options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) + options.beforeSend = beforeSendMock + options.addEventProcessor(globalEventProcessorMock) + } val scope = givenScopeWithStartedSession() scope.addEventProcessor(scopedEventProcessorMock) @@ -2487,13 +2494,12 @@ class SentryClientTest { it.arguments.first() as SentryEvent } - val sut = - fixture.getSut { options -> - options.sampleRate = 0.000000000001 - options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) - options.beforeSend = beforeSendMock - options.addEventProcessor(globalEventProcessorMock) - } + val sut = fixture.getSut { options -> + options.sampleRate = 0.000000000001 + options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) + options.beforeSend = beforeSendMock + options.addEventProcessor(globalEventProcessorMock) + } val scope = givenScopeWithStartedSession() scope.addEventProcessor(scopedEventProcessorMock) @@ -2530,13 +2536,12 @@ class SentryClientTest { } whenever(beforeSendMock.execute(any(), anyOrNull())).thenReturn(null) - val sut = - fixture.getSut { options -> - options.sampleRate = 0.000000000001 - options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) - options.beforeSend = beforeSendMock - options.addEventProcessor(globalEventProcessorMock) - } + val sut = fixture.getSut { options -> + options.sampleRate = 0.000000000001 + options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) + options.beforeSend = beforeSendMock + options.addEventProcessor(globalEventProcessorMock) + } val scope = givenScopeWithStartedSession() scope.addEventProcessor(scopedEventProcessorMock) @@ -2569,13 +2574,12 @@ class SentryClientTest { whenever(globalEventProcessorMock.process(any(), anyOrNull())).thenReturn(null) whenever(beforeSendMock.execute(any(), anyOrNull())).thenReturn(null) - val sut = - fixture.getSut { options -> - options.sampleRate = 0.000000000001 - options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) - options.beforeSend = beforeSendMock - options.addEventProcessor(globalEventProcessorMock) - } + val sut = fixture.getSut { options -> + options.sampleRate = 0.000000000001 + options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) + options.beforeSend = beforeSendMock + options.addEventProcessor(globalEventProcessorMock) + } val scope = givenScopeWithStartedSession() scope.addEventProcessor(scopedEventProcessorMock) @@ -2610,13 +2614,12 @@ class SentryClientTest { whenever(globalEventProcessorMock.process(any(), anyOrNull())).thenReturn(null) whenever(beforeSendMock.execute(any(), anyOrNull())).thenReturn(null) - val sut = - fixture.getSut { options -> - options.sampleRate = 0.000000000001 - options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) - options.beforeSend = beforeSendMock - options.addEventProcessor(globalEventProcessorMock) - } + val sut = fixture.getSut { options -> + options.sampleRate = 0.000000000001 + options.addIgnoredExceptionForType(NegativeArraySizeException::class.java) + options.beforeSend = beforeSendMock + options.addEventProcessor(globalEventProcessorMock) + } val scope = givenScopeWithStartedSession() scope.addEventProcessor(scopedEventProcessorMock) @@ -2658,14 +2661,13 @@ class SentryClientTest { @Test fun `can add to attachments in beforeSend`() { - val sut = - fixture.getSut { options -> - options.setBeforeSend { event, hints -> - assertEquals(listOf(fixture.attachment, fixture.attachment2), hints.attachments) - hints.addAttachment(fixture.attachment3) - event - } + val sut = fixture.getSut { options -> + options.setBeforeSend { event, hints -> + assertEquals(listOf(fixture.attachment, fixture.attachment2), hints.attachments) + hints.addAttachment(fixture.attachment3) + event } + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2676,13 +2678,12 @@ class SentryClientTest { @Test fun `can replace attachments in beforeSend`() { - val sut = - fixture.getSut { options -> - options.setBeforeSend { event, hints -> - hints.replaceAttachments(listOf(fixture.attachment3)) - event - } + val sut = fixture.getSut { options -> + options.setBeforeSend { event, hints -> + hints.replaceAttachments(listOf(fixture.attachment3)) + event } + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2693,22 +2694,21 @@ class SentryClientTest { @Test fun `can add to attachments in eventProcessor`() { - val sut = - fixture.getSut { options -> - options.addEventProcessor( - object : EventProcessor { - override fun process(event: SentryEvent, hint: Hint): SentryEvent? { - assertEquals(listOf(fixture.attachment, fixture.attachment2), hint.attachments) - hint.addAttachment(fixture.attachment3) - return event - } + val sut = fixture.getSut { options -> + options.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent? { + assertEquals(listOf(fixture.attachment, fixture.attachment2), hint.attachments) + hint.addAttachment(fixture.attachment3) + return event + } - override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { - return transaction - } + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { + return transaction } - ) - } + } + ) + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2719,21 +2719,20 @@ class SentryClientTest { @Test fun `can replace attachments in eventProcessor`() { - val sut = - fixture.getSut { options -> - options.addEventProcessor( - object : EventProcessor { - override fun process(event: SentryEvent, hint: Hint): SentryEvent? { - hint.replaceAttachments(listOf(fixture.attachment3)) - return event - } + val sut = fixture.getSut { options -> + options.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent? { + hint.replaceAttachments(listOf(fixture.attachment3)) + return event + } - override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { - return transaction - } + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { + return transaction } - ) - } + } + ) + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2784,22 +2783,21 @@ class SentryClientTest { @Test fun `can add to attachments in eventProcessor for transactions`() { - val sut = - fixture.getSut { options -> - options.addEventProcessor( - object : EventProcessor { - override fun process(event: SentryEvent, hint: Hint): SentryEvent? { - return event - } + val sut = fixture.getSut { options -> + options.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent? { + return event + } - override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { - assertEquals(listOf(fixture.attachment, fixture.attachment2), hint.attachments) - hint.addAttachment(fixture.attachment3) - return transaction - } + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { + assertEquals(listOf(fixture.attachment, fixture.attachment2), hint.attachments) + hint.addAttachment(fixture.attachment3) + return transaction } - ) - } + } + ) + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2820,21 +2818,20 @@ class SentryClientTest { @Test fun `can replace attachments in eventProcessor for transactions`() { - val sut = - fixture.getSut { options -> - options.addEventProcessor( - object : EventProcessor { - override fun process(event: SentryEvent, hint: Hint): SentryEvent? { - return event - } + val sut = fixture.getSut { options -> + options.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent? { + return event + } - override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { - hint.replaceAttachments(listOf(fixture.attachment3)) - return transaction - } + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { + hint.replaceAttachments(listOf(fixture.attachment3)) + return transaction } - ) - } + } + ) + } val scope = givenScopeWithStartedSession() scope.addAttachment(fixture.attachment2) @@ -2855,8 +2852,9 @@ class SentryClientTest { @Test fun `passing attachments via hint into breadcrumb ignores them`() { - val sut = - fixture.getSut { options -> options.setBeforeBreadcrumb { breadcrumb, hints -> breadcrumb } } + val sut = fixture.getSut { options -> + options.setBeforeBreadcrumb { breadcrumb, hints -> breadcrumb } + } val scope = givenScopeWithStartedSession() scope.addBreadcrumb( @@ -2871,13 +2869,12 @@ class SentryClientTest { @Test fun `adding attachments in beforeBreadcrumb ignores them`() { - val sut = - fixture.getSut { options -> - options.setBeforeBreadcrumb { breadcrumb, hints -> - hints.addAttachment(fixture.attachment) - breadcrumb - } + val sut = fixture.getSut { options -> + options.setBeforeBreadcrumb { breadcrumb, hints -> + hints.addAttachment(fixture.attachment) + breadcrumb } + } val scope = givenScopeWithStartedSession() scope.addBreadcrumb(Breadcrumb.info("hello from breadcrumb")) @@ -3160,11 +3157,11 @@ class SentryClientTest { @Test fun `beforeEnvelopeCallback is executed`() { var beforeEnvelopeCalled = false - val sut = - fixture.getSut { options -> - options.beforeEnvelopeCallback = - SentryOptions.BeforeEnvelopeCallback { _, _ -> beforeEnvelopeCalled = true } + val sut = fixture.getSut { options -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + beforeEnvelopeCalled = true } + } sut.captureEvent(SentryEvent(), Hint()) @@ -3173,11 +3170,11 @@ class SentryClientTest { @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { - val sut = - fixture.getSut { options -> - options.beforeEnvelopeCallback = - SentryOptions.BeforeEnvelopeCallback { _, _ -> RuntimeException("hook failed") } + val sut = fixture.getSut { options -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + RuntimeException("hook failed") } + } sut.captureEvent(SentryEvent(), Hint()) verify(fixture.transport).send(anyOrNull(), anyOrNull()) @@ -3444,7 +3441,9 @@ class SentryClientTest { } ) fixture.sentryOptions.sessionReplay.beforeErrorSampling = - SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> false } + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + false + } val sut = fixture.getSut() sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) @@ -3462,7 +3461,9 @@ class SentryClientTest { } ) fixture.sentryOptions.sessionReplay.beforeErrorSampling = - SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> true } + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + true + } val sut = fixture.getSut() sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) @@ -3496,7 +3497,9 @@ class SentryClientTest { } ) fixture.sentryOptions.sessionReplay.beforeErrorSampling = - SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> throw RuntimeException("test") } + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + throw RuntimeException("test") + } val sut = fixture.getSut() sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 9da52c39b5a..8d05697fda4 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -509,11 +509,10 @@ class SentryTest { initForTest { it.dsn = dsn it.isDebug = true - it.beforeSend = - SentryOptions.BeforeSendCallback { event, hint -> - capturedEvents.add(event) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + capturedEvents.add(event) + event + } } } thread.start() @@ -533,8 +532,9 @@ class SentryTest { assertEquals(2, capturedEvents.size) val mainCloneEvent = capturedEvents.firstOrNull { it.message?.formatted == "messageMainClone" } - val currentScopesEvent = - capturedEvents.firstOrNull { it.message?.formatted == "messageCurrent" } + val currentScopesEvent = capturedEvents.firstOrNull { + it.message?.formatted == "messageCurrent" + } assertNotNull(mainCloneEvent) assertNotNull(mainCloneEvent.breadcrumbs?.firstOrNull { it.message == "breadcrumbMainClone" }) @@ -563,11 +563,10 @@ class SentryTest { { it.dsn = dsn it.isDebug = true - it.beforeSend = - SentryOptions.BeforeSendCallback { event, hint -> - capturedEvents.add(event) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + capturedEvents.add(event) + event + } }, true, ) @@ -589,8 +588,9 @@ class SentryTest { assertEquals(2, capturedEvents.size) val mainCloneEvent = capturedEvents.firstOrNull { it.message?.formatted == "messageMainClone" } - val currentScopesEvent = - capturedEvents.firstOrNull { it.message?.formatted == "messageCurrent" } + val currentScopesEvent = capturedEvents.firstOrNull { + it.message?.formatted == "messageCurrent" + } assertNotNull(mainCloneEvent) assertNotNull(mainCloneEvent.breadcrumbs?.firstOrNull { it.message == "breadcrumbMainClone" }) diff --git a/sentry/src/test/java/io/sentry/SentryWrapperTest.kt b/sentry/src/test/java/io/sentry/SentryWrapperTest.kt index 31c06fab5ad..0ed46d8fa38 100644 --- a/sentry/src/test/java/io/sentry/SentryWrapperTest.kt +++ b/sentry/src/test/java/io/sentry/SentryWrapperTest.kt @@ -66,11 +66,10 @@ class SentryWrapperTest { initForTest { it.dsn = dsn - it.beforeSend = - SentryOptions.BeforeSendCallback { event, hint -> - capturedEvents.add(event) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + capturedEvents.add(event) + event + } } Sentry.addBreadcrumb("MyOriginalBreadcrumbBefore") @@ -119,11 +118,10 @@ class SentryWrapperTest { initForTest { it.dsn = dsn - it.beforeSend = - SentryOptions.BeforeSendCallback { event, hint -> - capturedEvents.add(event) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + capturedEvents.add(event) + event + } } Sentry.addBreadcrumb("MyOriginalBreadcrumbBefore") @@ -201,11 +199,10 @@ class SentryWrapperTest { initForTest { it.dsn = dsn - it.beforeSend = - SentryOptions.BeforeSendCallback { event, hint -> - capturedEvents.add(event) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + capturedEvents.add(event) + event + } } Sentry.addBreadcrumb("MyOriginalBreadcrumbBefore") diff --git a/sentry/src/test/java/io/sentry/UncaughtExceptionHandlerIntegrationTest.kt b/sentry/src/test/java/io/sentry/UncaughtExceptionHandlerIntegrationTest.kt index 494b7f8a0bc..02da3b9a335 100644 --- a/sentry/src/test/java/io/sentry/UncaughtExceptionHandlerIntegrationTest.kt +++ b/sentry/src/test/java/io/sentry/UncaughtExceptionHandlerIntegrationTest.kt @@ -445,15 +445,14 @@ class UncaughtExceptionHandlerIntegrationTest { whenever(scopes4.globalScope).thenReturn(mock()) whenever(scopes5.globalScope).thenReturn(mock()) - val integrations = - scopesList.map { scope -> - CompletableFuture.supplyAsync( - { - UncaughtExceptionHandlerIntegration(handler).apply { register(scope, fixture.options) } - }, - executor, - ) - } + val integrations = scopesList.map { scope -> + CompletableFuture.supplyAsync( + { + UncaughtExceptionHandlerIntegration(handler).apply { register(scope, fixture.options) } + }, + executor, + ) + } CompletableFuture.allOf(*integrations.toTypedArray()).get() diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportMultiThreadingTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportMultiThreadingTest.kt index fb62ee4a454..7f14c987438 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportMultiThreadingTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportMultiThreadingTest.kt @@ -118,10 +118,9 @@ class ClientReportMultiThreadingTest { println("took ${t2 - t1}ms") clientReportRecorder.resetCountsAndGenerateClientReport()?.let { clientReports.add(it) } - val numberOfLostItems = - clientReports.sumOf { clientReport -> - clientReport.discardedEvents.sumOf { it.quantity.toInt() } - } + val numberOfLostItems = clientReports.sumOf { clientReport -> + clientReport.discardedEvents.sumOf { it.quantity.toInt() } + } assertEquals(numberOfIncrementThreads * numberOfIncrementsPerThread, numberOfLostItems) } diff --git a/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt b/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt index e8e97e95c81..0c22a88aa87 100644 --- a/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt +++ b/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt @@ -145,11 +145,10 @@ class QueuedThreadPoolExecutorTest { val jobBlocker2 = CountDownLatch(1) val sync2 = CountDownLatch(1) - f = - sut.submit { - sync2.countDown() - jobBlocker2.await() - } + f = sut.submit { + sync2.countDown() + jobBlocker2.await() + } assertFalse( f.isCancelled, "A task should be successfully enqueued after making a place in the queue", From bcd3e76a8033a30cf291fc84c726d40205b21db2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 15 Jul 2026 11:16:35 +0200 Subject: [PATCH 007/102] perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-596) (#5670) * perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-570) Transactions with an idle or deadline timeout each created a java.util.Timer, which spawns a thread synchronously on the calling thread (often the main thread on Android). At scale (screen loads, HTTP spans) this was the dominant source of SDK thread churn. Schedule the idle/deadline timeouts on a dedicated, shared ISentryExecutorService held by SentryOptions instead, so no thread is created per transaction. It is kept separate from the main executor so timeout callbacks (which finish transactions) don't contend with cached event sending, and it is not prewarmed: its single worker thread is spawned lazily on the first scheduled timeout and reused thereafter. The dedicated executor uses removeOnCancelPolicy so cancelled timeouts (idle timers are rescheduled per child span) don't accumulate in its queue. On finish only the scheduled futures are cancelled; the executor is closed with the SDK. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * fix(core): Keep timer executor alive across SDK restart (JAVA-570) The shared timer executor introduced for transaction idle/deadline timeouts was shut down on every Scopes.close(), including SDK restart. This cancelled the pending idle timeout of any transaction started before the restart (e.g. an in-flight activity transaction), so it never auto-finished and its envelope was never sent. Only close the timer executor on a full close, not on restart, matching the pre-existing per-transaction Timer behaviour. Enable core-thread timeout on the timer executor so the instance abandoned by a restart self-terminates once idle instead of leaking a thread. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(core): Look up shared timer executor on demand (JAVA-570) SentryTracer cached the shared timer executor in a field for the lifetime of the transaction. Replace that field with a boolean flag tracking whether timeouts may still be scheduled, and fetch the executor from the options each time one is scheduled. This ensures the tracer always uses the executor currently held by the options (e.g. the fresh one installed after an SDK restart) rather than a stale reference. Also make the timer executor's keep-alive duration a constructor argument backed by the named TIMER_KEEP_ALIVE_SECONDS constant, and raise it from 10s to 30s so the shared worker thread is less likely to be torn down and respawned between transactions under normal use. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + .../core/ActivityLifecycleIntegrationTest.kt | 3 + sentry/api/sentry.api | 2 + sentry/src/main/java/io/sentry/Scopes.java | 6 ++ sentry/src/main/java/io/sentry/Sentry.java | 6 ++ .../java/io/sentry/SentryExecutorService.java | 19 +++++ .../main/java/io/sentry/SentryOptions.java | 42 +++++++++++ .../src/main/java/io/sentry/SentryTracer.java | 75 +++++++++---------- sentry/src/test/java/io/sentry/ScopesTest.kt | 26 +++++++ .../io/sentry/SentryExecutorServiceTest.kt | 17 +++++ .../test/java/io/sentry/SentryTracerTest.kt | 44 +++++------ 11 files changed, 182 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af29b22ccb5..17642fb819f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) +### Performance + +- Schedule transaction idle/deadline timeouts on a shared, dedicated executor instead of spawning a `Timer` thread per transaction ([#5670](https://github.com/getsentry/sentry-java/pull/5670)) + ### Dependencies - Bump OpenTelemetry to support Spring Boot 4.1 ([#5573](https://github.com/getsentry/sentry-java/pull/5573)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 1d8fa06f3bb..166129f601b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -23,6 +23,7 @@ import io.sentry.Scopes import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider +import io.sentry.SentryExecutorService import io.sentry.SentryNanotimeDate import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -919,6 +920,8 @@ class ActivityLifecycleIntegrationTest { it.idleTimeout = 100 } ) + // the transaction idle timeout is scheduled on the dedicated timer executor + fixture.options.timerExecutorService = SentryExecutorService() sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 00183bc9b30..e0bf144178f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3719,6 +3719,7 @@ public class io/sentry/SentryOptions { public fun getSslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory; public fun getTags ()Ljava/util/Map; public fun getThreadChecker ()Lio/sentry/util/thread/IThreadChecker; + public fun getTimerExecutorService ()Lio/sentry/ISentryExecutorService; public fun getTracePropagationTargets ()Ljava/util/List; public fun getTracesSampleRate ()Ljava/lang/Double; public fun getTracesSampler ()Lio/sentry/SentryOptions$TracesSamplerCallback; @@ -3884,6 +3885,7 @@ public class io/sentry/SentryOptions { public fun setStrictTraceContinuation (Z)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setThreadChecker (Lio/sentry/util/thread/IThreadChecker;)V + public fun setTimerExecutorService (Lio/sentry/ISentryExecutorService;)V public fun setTraceOptionsRequests (Z)V public fun setTracePropagationTargets (Ljava/util/List;)V public fun setTraceSampling (Z)V diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 3b67b94916e..936a331e3d4 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -467,6 +467,12 @@ public void close(final boolean isRestarting) { getOptions().getContinuousProfiler().close(true); getOptions().getCompositePerformanceCollector().close(); getOptions().getConnectionStatusProvider().close(); + // On restart we intentionally leave the timer executor running so that pending idle/ + // deadline timeouts of transactions started before the restart still fire and finish + // those transactions. It self-terminates once idle (allowCoreThreadTimeOut). + if (!isRestarting) { + getOptions().getTimerExecutorService().close(getOptions().getShutdownTimeoutMillis()); + } final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService(); if (isRestarting) { try { diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 275e2a3c614..8bba9d92e4f 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -353,6 +353,12 @@ private static void init(final @NotNull SentryOptions options, final boolean glo options.setExecutorService(new SentryExecutorService(options)); } + if (options.getTimerExecutorService().isClosed()) { + options.setTimerExecutorService( + new SentryExecutorService( + options, true, SentryExecutorService.TIMER_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS)); + } + // load lazy fields of the options in a separate thread try { options.getExecutorService().submit(() -> options.loadLazyFields()); diff --git a/sentry/src/main/java/io/sentry/SentryExecutorService.java b/sentry/src/main/java/io/sentry/SentryExecutorService.java index 1936bb9c360..a469dca5853 100644 --- a/sentry/src/main/java/io/sentry/SentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/SentryExecutorService.java @@ -22,6 +22,12 @@ public final class SentryExecutorService implements ISentryExecutorService { */ private static final int MAX_QUEUE_SIZE = 271; + /** + * How long the timer executor's worker thread stays alive while idle before self-terminating, so + * an instance abandoned on SDK restart doesn't leak a live thread once its queue drains. + */ + static final long TIMER_KEEP_ALIVE_SECONDS = 30; + private final @NotNull ScheduledThreadPoolExecutor executorService; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @@ -39,6 +45,19 @@ public SentryExecutorService(final @Nullable SentryOptions options) { this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), options); } + SentryExecutorService( + final @Nullable SentryOptions options, + final boolean removeOnCancelPolicy, + final long keepAliveTime, + final @NotNull TimeUnit keepAliveTimeUnit) { + this(options); + // removes cancelled tasks from the work queue immediately instead of leaving them until their + // scheduled time; useful for executors that frequently reschedule (e.g. transaction timeouts) + executorService.setRemoveOnCancelPolicy(removeOnCancelPolicy); + executorService.setKeepAliveTime(keepAliveTime, keepAliveTimeUnit); + executorService.allowCoreThreadTimeOut(true); + } + public SentryExecutorService() { this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), null); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3c55f5e1cfa..cde0c37ba90 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -44,6 +44,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLSocketFactory; import org.jetbrains.annotations.ApiStatus; @@ -317,6 +318,14 @@ public class SentryOptions { /** Sentry Executor Service that sends cached events and envelopes on App. start. */ private @NotNull ISentryExecutorService executorService = NoOpSentryExecutorService.getInstance(); + /** + * Dedicated executor for scheduling transaction idle/deadline timeouts. Kept separate from {@link + * #executorService} so timeout callbacks (which finish transactions) don't contend with cached + * event sending. + */ + private @NotNull ISentryExecutorService timerExecutorService = + NoOpSentryExecutorService.getInstance(); + /** * Whether SpotlightIntegration has already been loaded via reflection. This prevents re-adding it * if the user removed it in their configuration callback and activate() is called again. @@ -683,6 +692,15 @@ public void activate() { executorService = new SentryExecutorService(this); } + if (timerExecutorService instanceof NoOpSentryExecutorService) { + // Not prewarmed: its single worker thread is spawned lazily on the first scheduled timeout + // and then reused across all transactions. removeOnCancelPolicy keeps the work queue from + // accumulating cancelled timeouts (idle timers are cancelled and rescheduled per child span). + timerExecutorService = + new SentryExecutorService( + this, true, SentryExecutorService.TIMER_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS); + } + // SpotlightIntegration is loaded via reflection to allow the sentry-spotlight module // to be excluded from release builds, preventing insecure HTTP URLs from appearing in APKs. // Only attempt once to avoid re-adding after user removal in their configuration callback. @@ -1570,6 +1588,30 @@ public void setExecutorService(final @NotNull ISentryExecutorService executorSer } } + /** + * Returns the dedicated executor used to schedule transaction idle/deadline timeouts. + * + * @return the timer executor service + */ + @ApiStatus.Internal + @NotNull + public ISentryExecutorService getTimerExecutorService() { + return timerExecutorService; + } + + /** + * Sets the dedicated executor used to schedule transaction idle/deadline timeouts. + * + * @param timerExecutorService the timer executor service + */ + @ApiStatus.Internal + @TestOnly + public void setTimerExecutorService(final @NotNull ISentryExecutorService timerExecutorService) { + if (timerExecutorService != null) { + this.timerExecutorService = timerExecutorService; + } + } + /** * Returns the connection timeout in milliseconds. * diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 9729ac406b1..723538b9924 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -12,9 +12,8 @@ import java.util.List; import java.util.ListIterator; import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; @@ -37,10 +36,12 @@ public final class SentryTracer implements ITransaction { */ private @NotNull FinishStatus finishStatus = FinishStatus.NOT_FINISHED; - private volatile @Nullable TimerTask idleTimeoutTask; - private volatile @Nullable TimerTask deadlineTimeoutTask; + private volatile @Nullable Future idleTimeoutFuture; + private volatile @Nullable Future deadlineTimeoutFuture; - private volatile @Nullable Timer timer = null; + // Whether timeout tasks may still be scheduled. Set to false once the tracer is finished. The + // executor itself is owned by the options (shared SDK-wide) and obtained from there when needed. + private volatile boolean timersEnabled = false; private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock tracerLock = new AutoClosableReentrantLock(); @@ -99,7 +100,7 @@ public SentryTracer( if (transactionOptions.getIdleTimeout() != null || transactionOptions.getDeadlineTimeout() != null) { - timer = new Timer(true); + timersEnabled = true; scheduleDeadlineTimeout(); scheduleFinish(); @@ -109,22 +110,19 @@ public SentryTracer( @Override public void scheduleFinish() { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timer != null) { + if (timersEnabled) { final @Nullable Long idleTimeout = transactionOptions.getIdleTimeout(); if (idleTimeout != null) { cancelIdleTimer(); isIdleFinishTimerRunning.set(true); - idleTimeoutTask = - new TimerTask() { - @Override - public void run() { - onIdleTimeoutReached(); - } - }; try { - timer.schedule(idleTimeoutTask, idleTimeout); + idleTimeoutFuture = + scopes + .getOptions() + .getTimerExecutorService() + .schedule(this::onIdleTimeoutReached, idleTimeout); } catch (Throwable e) { scopes .getOptions() @@ -265,13 +263,12 @@ public void finish( }); final SentryTransaction transaction = new SentryTransaction(this); - if (timer != null) { + if (timersEnabled) { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timer != null) { + if (timersEnabled) { cancelIdleTimer(); cancelDeadlineTimer(); - timer.cancel(); - timer = null; + timersEnabled = false; } } } @@ -295,10 +292,10 @@ public void finish( private void cancelIdleTimer() { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (idleTimeoutTask != null) { - idleTimeoutTask.cancel(); + if (idleTimeoutFuture != null) { + idleTimeoutFuture.cancel(false); isIdleFinishTimerRunning.set(false); - idleTimeoutTask = null; + idleTimeoutFuture = null; } } } @@ -307,18 +304,15 @@ private void scheduleDeadlineTimeout() { final @Nullable Long deadlineTimeOut = transactionOptions.getDeadlineTimeout(); if (deadlineTimeOut != null) { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timer != null) { + if (timersEnabled) { cancelDeadlineTimer(); isDeadlineTimerRunning.set(true); - deadlineTimeoutTask = - new TimerTask() { - @Override - public void run() { - onDeadlineTimeoutReached(); - } - }; try { - timer.schedule(deadlineTimeoutTask, deadlineTimeOut); + deadlineTimeoutFuture = + scopes + .getOptions() + .getTimerExecutorService() + .schedule(this::onDeadlineTimeoutReached, deadlineTimeOut); } catch (Throwable e) { scopes .getOptions() @@ -335,10 +329,10 @@ public void run() { private void cancelDeadlineTimer() { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (deadlineTimeoutTask != null) { - deadlineTimeoutTask.cancel(); + if (deadlineTimeoutFuture != null) { + deadlineTimeoutFuture.cancel(false); isDeadlineTimerRunning.set(false); - deadlineTimeoutTask = null; + deadlineTimeoutFuture = null; } } } @@ -973,20 +967,19 @@ Span getRoot() { @TestOnly @Nullable - TimerTask getIdleTimeoutTask() { - return idleTimeoutTask; + Future getIdleTimeoutFuture() { + return idleTimeoutFuture; } @TestOnly @Nullable - TimerTask getDeadlineTimeoutTask() { - return deadlineTimeoutTask; + Future getDeadlineTimeoutFuture() { + return deadlineTimeoutFuture; } @TestOnly - @Nullable - Timer getTimer() { - return timer; + boolean areTimersEnabled() { + return timersEnabled; } @TestOnly diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 4b9b3095d53..9d598aec885 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1946,6 +1946,32 @@ class ScopesTest { verify(executor).close(any()) } + @Test + fun `Scopes with isRestarting true should not close the timer executor`() { + val timerExecutor = mock() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setTimerExecutorService(timerExecutor) + } + val sut = createScopes(options) + sut.close(true) + verify(timerExecutor, never()).close(any()) + } + + @Test + fun `Scopes with isRestarting false should close the timer executor`() { + val timerExecutor = mock() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setTimerExecutorService(timerExecutor) + } + val sut = createScopes(options) + sut.close(false) + verify(timerExecutor).close(any()) + } + @Test fun `Scopes close should clear the scope`() { val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } diff --git a/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt b/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt index 57dfb578ee9..153feecb4a4 100644 --- a/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt +++ b/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.test.getProperty import java.util.concurrent.BlockingQueue import java.util.concurrent.Callable import java.util.concurrent.CancellationException @@ -93,6 +94,22 @@ class SentryExecutorServiceTest { sentryExecutor.close(15000) } + @Test + fun `SentryExecutorService enables removeOnCancelPolicy when requested`() { + val sentryExecutor = SentryExecutorService(null, true, 30, TimeUnit.SECONDS) + val executor = sentryExecutor.getProperty("executorService") + assertTrue(executor.removeOnCancelPolicy) + sentryExecutor.close(15000) + } + + @Test + fun `SentryExecutorService does not enable removeOnCancelPolicy by default`() { + val sentryExecutor = SentryExecutorService(null) + val executor = sentryExecutor.getProperty("executorService") + assertFalse(executor.removeOnCancelPolicy) + sentryExecutor.close(15000) + } + @Test fun `SentryExecutorService isClosed returns true if executor is shutdown`() { val executor = mock() diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 3b808dd2220..20eeafffe92 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -913,7 +914,7 @@ class SentryTracerTest { @Test fun `when initialized without deadlineTimeout, does not schedule finish timer`() { val transaction = fixture.getSut() - assertNull(transaction.deadlineTimeoutTask) + assertNull(transaction.deadlineTimeoutFuture) } @Test @@ -921,7 +922,7 @@ class SentryTracerTest { val transaction = fixture.getSut(deadlineTimeout = 50) assertTrue(transaction.isDeadlineTimerRunning.get()) - assertNotNull(transaction.deadlineTimeoutTask) + assertNotNull(transaction.deadlineTimeoutFuture) } @Test @@ -949,7 +950,7 @@ class SentryTracerTest { transaction.finish(SpanStatus.OK) assertEquals(transaction.isDeadlineTimerRunning.get(), false) - assertNull(transaction.deadlineTimeoutTask) + assertNull(transaction.deadlineTimeoutFuture) assertEquals(transaction.isFinished, true) assertEquals(SpanStatus.OK, transaction.status) assertEquals(SpanStatus.OK, span.status) @@ -958,26 +959,26 @@ class SentryTracerTest { @Test fun `when initialized with idleTimeout it has no influence on deadline timeout`() { val transaction = fixture.getSut(idleTimeout = 3000, deadlineTimeout = 20) - val deadlineTimeoutTask = transaction.deadlineTimeoutTask + val deadlineTimeoutFuture = transaction.deadlineTimeoutFuture val span = transaction.startChild("op") // when the span finishes, it re-schedules the idle task span.finish() // but the deadline timeout task should not be re-scheduled - assertEquals(deadlineTimeoutTask, transaction.deadlineTimeoutTask) + assertSame(deadlineTimeoutFuture, transaction.deadlineTimeoutFuture) } @Test fun `when initialized without idleTimeout, does not schedule finish timer`() { val transaction = fixture.getSut() - assertNull(transaction.idleTimeoutTask) + assertNull(transaction.idleTimeoutFuture) } @Test fun `when initialized with idleTimeout, schedules finish timer`() { val transaction = fixture.getSut(idleTimeout = 50) - assertNotNull(transaction.idleTimeoutTask) + assertNotNull(transaction.idleTimeoutFuture) } @Test @@ -1008,22 +1009,23 @@ class SentryTracerTest { transaction.startChild("op") - assertNull(transaction.idleTimeoutTask) + assertNull(transaction.idleTimeoutFuture) } @Test fun `when a child is finished and the transaction is idle, resets the timer`() { val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 3000) - val initialTime = transaction.idleTimeoutTask!!.scheduledExecutionTime() + val initialFuture = transaction.idleTimeoutFuture val span = transaction.startChild("op") - Thread.sleep(1) span.finish() - val timerAfterFinishingChild = transaction.idleTimeoutTask!!.scheduledExecutionTime() + // finishing the child re-schedules the idle timeout, replacing the pending future + val futureAfterFinishingChild = transaction.idleTimeoutFuture - assertTrue { timerAfterFinishingChild > initialTime } + assertNotNull(futureAfterFinishingChild) + assertNotSame(initialFuture, futureAfterFinishingChild) } @Test @@ -1035,7 +1037,7 @@ class SentryTracerTest { Thread.sleep(1) span.finish() - assertNull(transaction.idleTimeoutTask) + assertNull(transaction.idleTimeoutFuture) } @Test @@ -1080,7 +1082,7 @@ class SentryTracerTest { trimEnd = true, samplingDecision = TracesSamplingDecision(true), ) - assertNotNull(transaction.timer) + assertTrue(transaction.areTimersEnabled()) } @Test @@ -1092,7 +1094,7 @@ class SentryTracerTest { trimEnd = true, samplingDecision = TracesSamplingDecision(true), ) - assertNull(transaction.timer) + assertFalse(transaction.areTimersEnabled()) } @Test @@ -1104,9 +1106,9 @@ class SentryTracerTest { trimEnd = true, samplingDecision = TracesSamplingDecision(true), ) - assertNotNull(transaction.timer) + assertTrue(transaction.areTimersEnabled()) transaction.finish(SpanStatus.OK) - assertNull(transaction.timer) + assertFalse(transaction.areTimersEnabled()) } @Test @@ -1539,18 +1541,18 @@ class SentryTracerTest { } @Test - fun `when timer is cancelled, schedule finish does not crash`() { + fun `when timer executor is shut down, schedule finish does not crash`() { val tracer = fixture.getSut(idleTimeout = 50, deadlineTimeout = 100) - tracer.timer!!.cancel() + fixture.options.timerExecutorService.close(0) tracer.scheduleFinish() } @Test - fun `when timer is cancelled, schedule finish finishes the transaction immediately`() { + fun `when timer executor is shut down, schedule finish finishes the transaction immediately`() { val tracer = fixture.getSut(idleTimeout = 50) tracer.startChild("load").finish() - tracer.timer!!.cancel() + fixture.options.timerExecutorService.close(0) tracer.scheduleFinish() assertTrue(tracer.isFinished) From a212e8f702c5fcfae9a26e73afd82bbd8f3927c5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:01:18 +0200 Subject: [PATCH 008/102] feat(replay): Record segment names (transaction names) (#5763) * feat(replay): Record segment names Co-Authored-By: Gino Buenaflor * docs: Add replay segment names changelog entry * Format code * test(replay): Use realistic Android transaction name Co-Authored-By: Gino Buenaflor * build: Update replay API dumps * test(replay): Use realistic Android segment names Co-Authored-By: Gino Buenaflor * Format code * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Gino Buenaflor Co-authored-by: Sentry Github Bot Co-authored-by: Giancarlo Buenaflor Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 7 +++ .../replay/capture/BaseCaptureStrategy.kt | 36 ++++++++----- .../android/replay/capture/CaptureStrategy.kt | 6 +++ .../capture/SessionCaptureStrategyTest.kt | 54 +++++++++++++++++++ sentry/api/sentry.api | 5 ++ .../java/io/sentry/NoOpReplayController.java | 3 ++ .../main/java/io/sentry/ReplayController.java | 3 ++ .../src/main/java/io/sentry/SentryClient.java | 4 ++ .../java/io/sentry/SentryReplayEvent.java | 26 ++++++++- .../test/java/io/sentry/SentryClientTest.kt | 20 +++++++ .../SentryReplayEventSerializationTest.kt | 1 + .../resources/json/sentry_replay_event.json | 4 ++ 14 files changed, 157 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17642fb819f..cdc3e33452b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Session Replay: Record segment names (transaction names) ([#5763](https://github.com/getsentry/sentry-java/pull/5763)) + - Add `io.sentry:sentry-opentelemetry-bom` to align Sentry OpenTelemetry modules with tested OpenTelemetry dependencies ([#5629](https://github.com/getsentry/sentry-java/pull/5629)) - Spring Boot Gradle plugin: add the Sentry BOM to `dependencyManagement`; explicit imports are applied after Spring Boot's implicit BOM ```kotlin diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 12fe214176d..3efee26e37d 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -76,6 +76,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun onWindowSizeChanged (II)V public fun pause ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index bae0e411795..d9cd15d891a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -297,6 +297,13 @@ public class ReplayIntegration( captureStrategy?.registerTraceId(traceId) } + override fun registerSegmentName(segmentName: String) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerSegmentName(segmentName) + } + private fun pauseInternal() { lifecycleLock.acquire().use { if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc0ccfd4bc..f505d21a151 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -54,7 +54,7 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" // https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41 - private const val MAX_TRACE_IDS = 100 + private const val MAX_CONTEXT_VALUES = 100 } private val gestureConverter = ReplayGestureConverter(dateProvider) @@ -97,8 +97,9 @@ internal abstract class BaseCaptureStrategy( persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() - private val traceIdsLock = Any() - private val currentTraceIds: MutableList = mutableListOf() + private val replayContextLock = Any() + private val currentTraceIds: MutableSet = linkedSetOf() + private val currentSegmentNames: MutableSet = linkedSetOf() override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) { cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) @@ -139,11 +140,12 @@ internal abstract class BaseCaptureStrategy( breadcrumbs: List? = null, events: Deque = this.currentEvents, ): ReplaySegment { - val traceIds = - synchronized(traceIdsLock) { - val ids = currentTraceIds.toList() + val (traceIds, segmentNames) = + synchronized(replayContextLock) { + val context = currentTraceIds.toList() to currentSegmentNames.toList() currentTraceIds.clear() - ids + currentSegmentNames.clear() + context } return createSegment( scopes, @@ -162,6 +164,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbs, events, traceIds, + segmentNames, ) } @@ -180,12 +183,19 @@ internal abstract class BaseCaptureStrategy( override fun registerTraceId(traceId: SentryId) { if (traceId != SentryId.EMPTY_ID) { - synchronized(traceIdsLock) { - if (currentTraceIds.size < MAX_TRACE_IDS) { - val id = traceId.toString() - if (!currentTraceIds.contains(id)) { - currentTraceIds.add(id) - } + synchronized(replayContextLock) { + if (currentTraceIds.size < MAX_CONTEXT_VALUES) { + currentTraceIds.add(traceId.toString()) + } + } + } + } + + override fun registerSegmentName(segmentName: String) { + if (segmentName.isNotEmpty()) { + synchronized(replayContextLock) { + if (currentSegmentNames.size < MAX_CONTEXT_VALUES) { + currentSegmentNames.add(segmentName) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 096a93741b2..780cdd92481 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -56,6 +56,8 @@ internal interface CaptureStrategy { fun registerTraceId(traceId: SentryId) + fun registerSegmentName(segmentName: String) + companion object { private fun Breadcrumb?.isNetworkAvailable(): Boolean = this != null && @@ -88,6 +90,7 @@ internal interface CaptureStrategy { breadcrumbs: List?, events: Deque, traceIds: List = emptyList(), + segmentNames: List = emptyList(), ): ReplaySegment { val generatedVideo = cache?.createVideoOf( @@ -127,6 +130,7 @@ internal interface CaptureStrategy { replayBreadcrumbs, events, traceIds, + segmentNames, ) } @@ -147,6 +151,7 @@ internal interface CaptureStrategy { breadcrumbs: List, events: Deque, traceIds: List, + segmentNames: List, ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = @@ -159,6 +164,7 @@ internal interface CaptureStrategy { this.replayType = replayType this.videoFile = video this.traceIds = traceIds + this.segmentNames = segmentNames } val recordingPayload = mutableListOf() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dd9e6c6ce1d..fc2354eb1c0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -562,4 +562,58 @@ class SessionCaptureStrategyTest { any(), ) } + + @Test + fun `registerSegmentName includes unique segment names in next segment and clears them`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("ProductDetailsActivity") + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity") + }, + any(), + ) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerSegmentName ignores empty names and limits names to 100`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("") + repeat(101) { strategy.registerSegmentName("ProductActivity$it") } + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 }, + any(), + ) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e0bf144178f..0e5aad4826b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1715,6 +1715,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z public fun pause ()V + public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V @@ -2357,6 +2358,7 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp public abstract fun isDebugMaskingOverlayEnabled ()Z public abstract fun isRecording ()Z public abstract fun pause ()V + public abstract fun registerSegmentName (Ljava/lang/String;)V public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V public abstract fun resume ()V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V @@ -4023,6 +4025,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s public fun getReplayStartTimestamp ()Ljava/util/Date; public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType; public fun getSegmentId ()I + public fun getSegmentNames ()Ljava/util/List; public fun getTimestamp ()Ljava/util/Date; public fun getTraceIds ()Ljava/util/List; public fun getType ()Ljava/lang/String; @@ -4036,6 +4039,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s public fun setReplayStartTimestamp (Ljava/util/Date;)V public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V public fun setSegmentId (I)V + public fun setSegmentNames (Ljava/util/List;)V public fun setTimestamp (Ljava/util/Date;)V public fun setTraceIds (Ljava/util/List;)V public fun setType (Ljava/lang/String;)V @@ -4056,6 +4060,7 @@ public final class io/sentry/SentryReplayEvent$JsonKeys { public static final field REPLAY_START_TIMESTAMP Ljava/lang/String; public static final field REPLAY_TYPE Ljava/lang/String; public static final field SEGMENT_ID Ljava/lang/String; + public static final field SEGMENT_NAMES Ljava/lang/String; public static final field TIMESTAMP Ljava/lang/String; public static final field TRACE_IDS Ljava/lang/String; public static final field TYPE Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 2f6de9740d2..2b8a09cb1d9 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -60,4 +60,7 @@ public void disableDebugMaskingOverlay() {} @Override public void registerTraceId(@NotNull SentryId traceId) {} + + @Override + public void registerSegmentName(@NotNull String segmentName) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index f4baba40c9d..2fb7b1c83a5 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -36,4 +36,7 @@ public interface ReplayController extends IReplayApi { * @param traceId the trace ID to associate with the current replay */ void registerTraceId(@NotNull SentryId traceId); + + /** Registers a segment name to be associated with the current replay segment. */ + void registerSegmentName(@NotNull String segmentName); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 4889d1629ce..a0ff98a9af7 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1079,6 +1079,10 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint if (trace != null) { options.getReplayController().registerTraceId(trace.getTraceId()); } + final @Nullable String segmentName = transaction.getTransaction(); + if (segmentName != null && !segmentName.isEmpty()) { + options.getReplayController().registerSegmentName(segmentName); + } } return sentryId; diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 95623d2ff62..0ed9dbf72f3 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -49,6 +49,7 @@ public static final class Deserializer implements JsonDeserializer { private @Nullable List urls; private @Nullable List errorIds; private @Nullable List traceIds; + private @Nullable List segmentNames; private @Nullable Map unknown; public SentryReplayEvent() { @@ -58,6 +59,7 @@ public SentryReplayEvent() { this.replayType = ReplayType.SESSION; this.errorIds = new ArrayList<>(); this.traceIds = new ArrayList<>(); + this.segmentNames = new ArrayList<>(); this.urls = new ArrayList<>(); timestamp = DateUtils.getCurrentDateTime(); } @@ -142,6 +144,15 @@ public void setTraceIds(final @Nullable List traceIds) { this.traceIds = traceIds; } + @Nullable + public List getSegmentNames() { + return segmentNames; + } + + public void setSegmentNames(final @Nullable List segmentNames) { + this.segmentNames = segmentNames; + } + @NotNull public ReplayType getReplayType() { return replayType; @@ -162,12 +173,14 @@ public boolean equals(Object o) { && Objects.equals(replayId, that.replayId) && Objects.equals(urls, that.urls) && Objects.equals(errorIds, that.errorIds) - && Objects.equals(traceIds, that.traceIds); + && Objects.equals(traceIds, that.traceIds) + && Objects.equals(segmentNames, that.segmentNames); } @Override public int hashCode() { - return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds); + return Objects.hash( + type, replayType, replayId, segmentId, urls, errorIds, traceIds, segmentNames); } // region json @@ -181,6 +194,7 @@ public static final class JsonKeys { public static final String URLS = "urls"; public static final String ERROR_IDS = "error_ids"; public static final String TRACE_IDS = "trace_ids"; + public static final String SEGMENT_NAMES = "segment_names"; } @Override @@ -207,6 +221,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (traceIds != null) { writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds); } + if (segmentNames != null) { + writer.name(JsonKeys.SEGMENT_NAMES).value(logger, segmentNames); + } new SentryBaseEvent.Serializer().serialize(this, writer, logger); @@ -250,6 +267,7 @@ public static final class Deserializer implements JsonDeserializer urls = null; @Nullable List errorIds = null; @Nullable List traceIds = null; + @Nullable List segmentNames = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -282,6 +300,9 @@ public static final class Deserializer implements JsonDeserializer) reader.nextObjectOrNull(); break; + case JsonKeys.SEGMENT_NAMES: + segmentNames = (List) reader.nextObjectOrNull(); + break; default: if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { if (unknown == null) { @@ -311,6 +332,7 @@ public static final class Deserializer implements JsonDeserializer Date: Thu, 16 Jul 2026 11:12:01 +0200 Subject: [PATCH 009/102] feat(samples): Enable Sentry Logs in the Android sample app (#5766) The Android sample already emits structured logs via Sentry.logger() in MainActivity.onCreate, but they were silently dropped because the Logs feature is disabled by default. Enable it through the manifest so the sample demonstrates the logging feature out of the box. Co-authored-by: Claude Opus 4.8 (1M context) --- .../sentry-samples-android/src/main/AndroidManifest.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 61f4df5b8d9..9a079a26632 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -130,6 +130,11 @@ android:name="io.sentry.debug" android:value="${sentryDebug}" /> + + + Date: Thu, 16 Jul 2026 11:12:16 +0200 Subject: [PATCH 010/102] fix(samples): Use float literal for session-replay sample rate (#5764) The Android sample manifest set io.sentry.session-replay.session-sample-rate to the integer literal "1", which aapt stores as an int. ManifestMetadataReader reads it via Bundle.getFloat, which does not coerce an int-typed value and logs a framework warning before falling back to getInt on startup. Using the float literal "1.0" stores it as a float so getFloat succeeds and no warning is logged. Co-authored-by: Claude Opus 4.8 --- .../sentry-samples-android/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9a079a26632..79150b51c98 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -271,7 +271,7 @@ android:value="canvas" /> + android:value="1.0" /> From 6424ca98d72b1fc02e1109d1813b50258035bc34 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Thu, 16 Jul 2026 11:18:04 +0200 Subject: [PATCH 011/102] Use the same method to get url in all spring filters (#5656) * use the same method to get url in all spring filters * remove unnecessary system out message in DatabaseUtils * spotless * extract target url to match against tracePropagationTargets * inject headers if url cannot be extracted to be in line with other integrations * Format code * verify host matches requestingHost before returning authentication * format * remove applyToSpan to not introduce unexpected behaviour in a minor release * Changelog --------- Co-authored-by: Sentry Github Bot Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 4 + .../java/io/sentry/jdbc/DatabaseUtils.java | 1 - .../build.gradle.kts | 2 +- .../otlp/OpenTelemetryOtlpPropagator.java | 55 +++++++++++ .../test/kotlin/OtelSentryPropagatorTest.kt | 92 +++++++++++++++++++ .../SentrySpanClientWebRequestFilter.java | 4 +- .../mvc/SentrySpringIntegrationTest.kt | 5 +- .../SentrySpanClientWebRequestFilter.java | 4 +- .../mvc/SentrySpringIntegrationTest.kt | 5 +- .../io/sentry/transport/HttpConnection.java | 7 +- .../sentry/transport/ProxyAuthenticator.java | 7 +- .../io/sentry/transport/HttpConnectionTest.kt | 11 +++ .../transport/ProxyAuthenticatorTest.kt | 62 +++++++++++++ 13 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/transport/ProxyAuthenticatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc3e33452b..ff35eabe03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ - Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) +- Prevent malformed JDBC URLs, which may contain credentials, from being printed to stdout ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Restrict JVM-global proxy authentication credentials to challenges from the configured proxy host ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Sanitize Spring 7 and Spring Jakarta WebClient span descriptions to prevent embedded URL credentials from being sent to Sentry ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Respect `tracePropagationTargets` when injecting Sentry tracing headers through the OpenTelemetry OTLP propagator ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) ### Performance diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java index 6879723ebf9..4583dc9e6c1 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java @@ -131,7 +131,6 @@ public static DatabaseDetails parse(final @Nullable String databaseConnectionUrl String pathWithoutProperties = StringUtils.substringBefore(path, ";"); return new DatabaseDetails(dbSystem, pathWithoutProperties); } catch (Throwable t) { - System.out.println(t.getMessage()); // ignore } return new DatabaseDetails(dbSystem, null); diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index 1792c852dc3..ec240c681ae 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { api(libs.otel.extension.autoconfigure) api(libs.otel.exporter.otlp) compileOnly(libs.otel.extension.autoconfigure.spi) - // compileOnly(libs.otel.semconv) + implementation(libs.otel.semconv) // compileOnly(libs.otel.semconv.incubating) compileOnly(libs.jetbrains.annotations) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java index a4249b27ec0..4cdf1b0ed09 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java @@ -2,6 +2,7 @@ import static io.sentry.SentryTraceHeader.SENTRY_TRACE_HEADER; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; @@ -11,14 +12,20 @@ import io.opentelemetry.context.propagation.TextMapGetter; import io.opentelemetry.context.propagation.TextMapPropagator; import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.semconv.ServerAttributes; +import io.opentelemetry.semconv.UrlAttributes; import io.sentry.Baggage; import io.sentry.BaggageHeader; import io.sentry.IScopes; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; +import io.sentry.util.PropagationTargetsUtils; import io.sentry.util.TracingUtils; +import java.net.URL; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -61,6 +68,10 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } + if (!shouldInjectTracingHeaders(otelSpan)) { + return; + } + setter.set( carrier, SENTRY_TRACE_HEADER, @@ -76,6 +87,50 @@ public void inject(final Context context, final C carrier, final TextMapSett } } + private boolean shouldInjectTracingHeaders(final @NotNull Span otelSpan) { + final @NotNull SentryOptions options = scopes.getOptions(); + final @Nullable String url = extractUrl(otelSpan, options); + + return url == null + || PropagationTargetsUtils.contain(options.getTracePropagationTargets(), url); + } + + private @Nullable String extractUrl( + final @NotNull Span otelSpan, final @NotNull SentryOptions options) { + if (!(otelSpan instanceof ReadableSpan)) { + return null; + } + + final @NotNull Attributes attributes = ((ReadableSpan) otelSpan).getAttributes(); + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + return urlFull; + } + + final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); + final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); + final @Nullable Long serverPort = attributes.get(ServerAttributes.SERVER_PORT); + final @Nullable String path = attributes.get(UrlAttributes.URL_PATH); + + if (scheme == null || serverAddress == null) { + return null; + } + + try { + final @NotNull String pathToUse = path == null ? "" : path; + if (serverPort == null) { + return new URL(scheme, serverAddress, pathToUse).toString(); + } else { + return new URL(scheme, serverAddress, serverPort.intValue(), pathToUse).toString(); + } + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Unable to combine URL span attributes into one.", t); + return null; + } + } + @Override public Context extract( final Context context, final C carrier, final TextMapGetter getter) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt index 1d5d56c5bff..afa728c7ff0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -7,6 +7,7 @@ import io.opentelemetry.api.trace.TraceState import io.opentelemetry.context.Context import io.opentelemetry.context.propagation.TextMapGetter import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.sdk.trace.SdkTracerProvider import io.sentry.Baggage import io.sentry.Sentry import kotlin.test.AfterTest @@ -171,6 +172,97 @@ class OpenTelemetryOtlpPropagatorTest { ) } + @Test + fun `injects headers if URL in span attributes matches tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + val baggage = + Baggage.fromHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + + try { + val context = + Context.root().with(otelSpan).with(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY, baggage) + + propagator.inject(context, carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertEquals( + "${otelSpan.spanContext.traceId}-${otelSpan.spanContext.spanId}-1", + carrier["sentry-trace"], + ) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `does not inject headers if URL in span attributes does not match tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + + try { + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `injects headers if tracePropagationTargets is restricted and URL is unavailable`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + @Test fun `does not inject headers when no span in context`() { val propagator = OpenTelemetryOtlpPropagator() diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java index 6726302a83e..942cda241b7 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,8 +46,9 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt index 4b7e806730f..36e6af289fd 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt @@ -376,7 +376,8 @@ class SentrySpringIntegrationTest { assertThat(transaction.spans).hasSize(1) val span = transaction.spans.first() assertThat(span.op).isEqualTo("http.client") - assertThat(span.description).isEqualTo("GET http://localhost:$port/hello") + assertThat(span.description) + .isEqualTo("GET http://[Filtered]:[Filtered]@localhost:$port/hello") assertThat(span.data?.get(SpanDataConvention.HTTP_STATUS_CODE_KEY)).isEqualTo(200) assertThat(span.status).isEqualTo(SpanStatus.OK) }, @@ -519,7 +520,7 @@ class HelloController(private val webClient: WebClient, private val env: Environ fun webClient(): String? { return webClient .get() - .uri("http://localhost:${env.getProperty("local.server.port")}/hello") + .uri("http://user:password@localhost:${env.getProperty("local.server.port")}/hello") .retrieve() .bodyToMono(String::class.java) .block() diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java index 1189532c0c4..ec29b9c68a3 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,8 +46,9 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt index 6d399323f5f..f1837a08c1f 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt @@ -374,7 +374,8 @@ class SentrySpringIntegrationTest { assertThat(transaction.spans).hasSize(1) val span = transaction.spans.first() assertThat(span.op).isEqualTo("http.client") - assertThat(span.description).isEqualTo("GET http://localhost:$port/hello") + assertThat(span.description) + .isEqualTo("GET http://[Filtered]:[Filtered]@localhost:$port/hello") assertThat(span.data?.get(SpanDataConvention.HTTP_STATUS_CODE_KEY)).isEqualTo(200) assertThat(span.status).isEqualTo(SpanStatus.OK) }, @@ -517,7 +518,7 @@ class HelloController(private val webClient: WebClient, private val env: Environ fun webClient(): String? { return webClient .get() - .uri("http://localhost:${env.getProperty("local.server.port")}/hello") + .uri("http://user:password@localhost:${env.getProperty("local.server.port")}/hello") .retrieve() .bodyToMono(String::class.java) .block() diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index 71c3ebb15b2..e7935e76cf3 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -64,9 +64,10 @@ public HttpConnection( if (proxy != null && options.getProxy() != null) { final String proxyUser = options.getProxy().getUser(); final String proxyPassword = options.getProxy().getPass(); - - if (proxyUser != null && proxyPassword != null) { - authenticatorWrapper.setDefault(new ProxyAuthenticator(proxyUser, proxyPassword)); + final String proxyHost = options.getProxy().getHost(); + if (proxyUser != null && proxyPassword != null && proxyHost != null) { + authenticatorWrapper.setDefault( + new ProxyAuthenticator(proxyUser, proxyPassword, proxyHost)); } } } diff --git a/sentry/src/main/java/io/sentry/transport/ProxyAuthenticator.java b/sentry/src/main/java/io/sentry/transport/ProxyAuthenticator.java index 7aad9967f18..74b63566b37 100644 --- a/sentry/src/main/java/io/sentry/transport/ProxyAuthenticator.java +++ b/sentry/src/main/java/io/sentry/transport/ProxyAuthenticator.java @@ -9,6 +9,7 @@ final class ProxyAuthenticator extends Authenticator { private final @NotNull String user; private final @NotNull String password; + private final @NotNull String proxyHost; /** * Proxy authenticator. @@ -16,14 +17,16 @@ final class ProxyAuthenticator extends Authenticator { * @param user proxy username * @param password proxy password */ - ProxyAuthenticator(final @NotNull String user, final @NotNull String password) { + ProxyAuthenticator( + final @NotNull String user, final @NotNull String password, final @NotNull String proxyHost) { this.user = Objects.requireNonNull(user, "user is required"); this.password = Objects.requireNonNull(password, "password is required"); + this.proxyHost = Objects.requireNonNull(proxyHost, "proxyHost is required"); } @Override protected @Nullable PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() == RequestorType.PROXY) { + if (getRequestorType() == RequestorType.PROXY && proxyHost.equals(getRequestingHost())) { return new PasswordAuthentication(user, password.toCharArray()); } return null; diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 2a856ab5800..4c17c71fecc 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -259,6 +259,17 @@ class HttpConnectionTest { assertEquals(Type.SOCKS, transport.proxy!!.type()) } + @Test + fun `When Proxy username and password are given but host is missing, authenticator is not set`() { + fixture.proxy = Proxy(null, "8090", "some-user", "some-password") + val transport = fixture.getSUT() + + transport.send(createEnvelope()) + + assertNull(transport.proxy) + verify(fixture.authenticatorWrapper, never()).setDefault(any()) + } + @Test fun `sets common headers and on http connection`() { val transport = fixture.getSUT() diff --git a/sentry/src/test/java/io/sentry/transport/ProxyAuthenticatorTest.kt b/sentry/src/test/java/io/sentry/transport/ProxyAuthenticatorTest.kt new file mode 100644 index 00000000000..c3af7da226d --- /dev/null +++ b/sentry/src/test/java/io/sentry/transport/ProxyAuthenticatorTest.kt @@ -0,0 +1,62 @@ +package io.sentry.transport + +import java.net.Authenticator +import java.net.Authenticator.RequestorType +import java.net.PasswordAuthentication +import java.net.URL +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ProxyAuthenticatorTest { + @BeforeTest + @AfterTest + fun reset() { + Authenticator.setDefault(null) + } + + @Test + fun `returns authentication when proxy request host matches proxy host`() { + Authenticator.setDefault(ProxyAuthenticator("some-user", "some-password", "proxy.example.com")) + + val authentication = requestPasswordAuthentication("proxy.example.com", RequestorType.PROXY) + + assertEquals("some-user", authentication!!.userName) + assertEquals("some-password", String(authentication.password)) + } + + @Test + fun `returns null when requestor type is not proxy`() { + Authenticator.setDefault(ProxyAuthenticator("some-user", "some-password", "proxy.example.com")) + + val authentication = requestPasswordAuthentication("proxy.example.com", RequestorType.SERVER) + + assertNull(authentication) + } + + @Test + fun `returns null when proxy request host does not match proxy host`() { + Authenticator.setDefault(ProxyAuthenticator("some-user", "some-password", "proxy.example.com")) + + val authentication = requestPasswordAuthentication("other.example.com", RequestorType.PROXY) + + assertNull(authentication) + } + + private fun requestPasswordAuthentication( + host: String, + requestorType: RequestorType, + ): PasswordAuthentication? = + Authenticator.requestPasswordAuthentication( + host, + null, + 8080, + "https", + "prompt", + "basic", + URL("https://sentry.io"), + requestorType, + ) +} From 53ff4715b5f6b631079c7437461a2be0913710f6 Mon Sep 17 00:00:00 2001 From: romtsn <4999776+romtsn@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:19:39 +0000 Subject: [PATCH 012/102] release: 8.49.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff35eabe03a..c57c75fe6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.49.0 ### Features diff --git a/gradle.properties b/gradle.properties index 91122c1141f..aa5a7b1e28d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.48.0 +versionName=8.49.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 455eb6ed9adac5768a16757d85c99fec68caac67 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 16 Jul 2026 17:02:14 +0200 Subject: [PATCH 013/102] ci: Use non-deprecated sentry-cli snapshots upload command (#5774) The `sentry-cli build snapshots` command is deprecated in favor of `sentry-cli snapshots upload`. Rename both usages in CI so the upload steps stop emitting deprecation warnings and keep working once the old command is removed. Co-authored-by: Claude Opus 4.8 --- .github/workflows/build.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e396ef97175..a11da2987dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: # Skip on PRs from forks, which don't have access to the upload secret if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | - sentry-cli build snapshots ./sentry-android-core/build/test-snapshots \ + sentry-cli snapshots upload ./sentry-android-core/build/test-snapshots \ --app-id sentry-android-core env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index ed3a72ce7fc..a1f02d8512d 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -86,7 +86,7 @@ jobs: if [ ${#pngs[@]} -gt 0 ]; then mkdir -p replay-snapshots cp "${pngs[@]}" replay-snapshots/ - sentry-cli build snapshots ./replay-snapshots \ + sentry-cli snapshots upload ./replay-snapshots \ --app-id sentry-android-replay else echo "No replay snapshot files found, skipping upload" From 031ec336fb2ecc9c057d02fd60d4979eee095824 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 17 Jul 2026 12:43:31 +0200 Subject: [PATCH 014/102] fix(android): Backfill pre-init ANR and native crash metadata (#5762) * fix(android): Backfill exit options when app is unchanged Use current options when persisted values are missing and the app has not been updated since the exit. Avoid attributing historical crashes to a newer app version. * changelog * fix(android): Backfill app context for historical exits Populate app version and build for historical ANR and native crash events when the current package metadata is safe to use. Co-Authored-By: Codex * fix(android): Limit historical app metadata backfill Backfill only app version and build for historical exits. Avoid attaching current localized app names, identifiers, or split APK state to older events. Co-Authored-By: Codex * fix(android): Reject unknown exit timestamps Do not use current SDK options when an exit timestamp is unavailable because an intervening app update cannot be ruled out. Co-Authored-By: Codex * fix(android): Validate persisted options cache generation Persist the app update timestamp after writing the options snapshot. Trust cached release, environment, and dist only when the marker identifies the current app installation, preserving launch-specific values without leaking stale metadata across app updates. Co-Authored-By: Codex * fix(android): Validate launch-specific cached options Apply the app-generation marker when selecting option tags and the replay-on-error sample rate. Preserve values from the crashed launch within one app version while rejecting stale values after an update. Co-Authored-By: Codex * docs(android): Explain options cache marker ordering Document why the generation observer uses its release callback only after the options cache has been fully persisted. Co-Authored-By: Codex * fix(android): Validate exit option cache sources Reject option caches created after an exit and keep immutable build metadata aligned with the event's app generation. This prevents intermediate releases and stale ProGuard or SDK metadata from being attached to historical exits. Co-Authored-By: Codex * style(android): Annotate nullable app context Co-Authored-By: OpenAI Codex * docs(android): Explain options cache generation observer Clarify why the observer is ordered after option persistence and update the changelog to describe the generation-aware behavior. Co-Authored-By: OpenAI Codex * docs(android): Add cache generation example Document the same-build, account-specific options scenario that requires preferring a matching persisted snapshot. Co-Authored-By: OpenAI Codex * ref(android): Reuse cache utilities for generation marker Expose cache serialization helpers as internal API and use them for the Android options cache generation marker. Co-Authored-By: OpenAI Codex * docs(android): Explain exit option selection Document how launch options, build metadata, and cache generations are selected for application exit events. Co-Authored-By: OpenAI Codex * docs: Move changelog entry to Unreleased Keep PR #5762 out of the already released 8.49.0 section. Co-Authored-By: Codex --------- Co-authored-by: Codex --- CHANGELOG.md | 6 + .../core/AndroidOptionsInitializer.java | 5 + .../ApplicationExitInfoEventProcessor.java | 222 ++++++++++++----- ...sistingOptionsCacheGenerationObserver.java | 88 +++++++ .../core/AndroidOptionsInitializerTest.kt | 15 ++ .../ApplicationExitInfoEventProcessorTest.kt | 234 +++++++++++++++++- sentry/api/sentry.api | 5 + .../main/java/io/sentry/cache/CacheUtils.java | 10 +- 8 files changed, 522 insertions(+), 63 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a7..44231ff4eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) + ## 8.49.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 9cc5cb3df0f..434dfc73d3e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -182,6 +182,11 @@ static void initializeIntegrationsAndProcessors( if (options.getCacheDirPath() != null) { options.addScopeObserver(new PersistingScopeObserver(options)); options.addOptionsObserver(new PersistingOptionsObserver(options)); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + if (packageInfo != null && packageInfo.lastUpdateTime > 0) { + options.addOptionsObserver( + new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime)); + } } options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 2eca0e68b5b..f175db90488 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -51,6 +51,7 @@ import io.sentry.exception.ExceptionMechanismException; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; +import io.sentry.hints.NativeCrashExit; import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugImage; @@ -161,7 +162,13 @@ public ApplicationExitInfoEventProcessor( mergeOS(event); setDevice(event); + final OptionsSource optionsSource = getOptionsSource(backfillable); + if (!backfillable.shouldEnrich()) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setAppVersionAndBuild(event); options .getLogger() .log( @@ -170,9 +177,9 @@ public ApplicationExitInfoEventProcessor( return event; } - backfillScope(event); + backfillScope(event, optionsSource); - backfillOptions(event); + backfillOptions(event, optionsSource); setStaticValues(event); @@ -184,7 +191,8 @@ public ApplicationExitInfoEventProcessor( } // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event) { + private void backfillScope( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { setRequest(event); setUser(event); setScopeTags(event); @@ -195,19 +203,25 @@ private void backfillScope(final @NotNull SentryEvent event) { setFingerprints(event); setLevel(event); setTrace(event); - setReplayId(event); + setReplayId(event, optionsSource); } - private boolean sampleReplay(final @NotNull SentryEvent event) { + private boolean sampleReplay( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); final @Nullable String replayErrorSampleRate = - PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + getLaunchOption( + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String.class, + currentSampleRate == null ? null : currentSampleRate.toString(), + optionsSource); if (replayErrorSampleRate == null) { return false; } try { - // we have to sample here with the old sample rate, because it may change between app launches + // Sample with the rate from the relevant launch because it may change between launches. final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) { options @@ -226,7 +240,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { return true; } - private void setReplayId(final @NotNull SentryEvent event) { + private void setReplayId( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); @Nullable String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath == null) { @@ -234,7 +249,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); if (!replayFolder.exists()) { - if (!sampleReplay(event)) { + if (!sampleReplay(event, optionsSource)) { return; } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the @@ -393,14 +408,15 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // endregion // region options persisted values - private void backfillOptions(final @NotNull SentryEvent event) { - setRelease(event); - setEnvironment(event); - setDist(event); - setDebugMeta(event); - setSdk(event); + private void backfillOptions( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setDebugMeta(event, optionsSource); + setSdk(event, optionsSource); setApp(event); - setOptionsTags(event); + setOptionsTags(event, optionsSource); } private void setApp(final @NotNull SentryBaseEvent event) { @@ -415,25 +431,6 @@ private void setApp(final @NotNull SentryBaseEvent event) { app.setAppIdentifier(packageInfo.packageName); } - // backfill versionName and versionCode from the persisted release string - final String release = - event.getRelease() != null - ? event.getRelease() - : PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - if (release != null) { - try { - final String versionName = - release.substring(release.indexOf('@') + 1, release.indexOf('+')); - final String versionCode = release.substring(release.indexOf('+') + 1); - app.setAppVersion(versionName); - app.setAppBuild(versionCode); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); - } - } - try { final ContextUtils.SplitApksInfo splitApksInfo = DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); @@ -448,25 +445,50 @@ private void setApp(final @NotNull SentryBaseEvent event) { } event.getContexts().setApp(app); + setAppVersionAndBuild(event); + } + + private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { + final String release = event.getRelease(); + if (release != null) { + try { + @Nullable App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + } + final String versionName = + release.substring(release.indexOf('@') + 1, release.indexOf('+')); + final String versionCode = release.substring(release.indexOf('+') + 1); + app.setAppVersion(versionName); + app.setAppBuild(versionCode); + event.getContexts().setApp(app); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); + } + } } - private void setRelease(final @NotNull SentryBaseEvent event) { + private void setRelease( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getRelease() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - event.setRelease(release); + event.setRelease( + getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource)); } } - private void setEnvironment(final @NotNull SentryBaseEvent event) { + private void setEnvironment( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getEnvironment() == null) { - final String environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); - event.setEnvironment(environment != null ? environment : options.getEnvironment()); + event.setEnvironment( + getLaunchOption( + ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource)); } } - private void setDebugMeta(final @NotNull SentryBaseEvent event) { + private void setDebugMeta( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { DebugMeta debugMeta = event.getDebugMeta(); if (debugMeta == null) { @@ -478,7 +500,8 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { List images = debugMeta.getImages(); if (images != null) { final String proguardUuid = - PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class); + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); if (proguardUuid != null) { final DebugImage debugImage = new DebugImage(); @@ -490,15 +513,14 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { } } - private void setDist(final @NotNull SentryBaseEvent event) { + private void setDist( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getDist() == null) { - final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); - event.setDist(dist); + event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); } - // if there's no user-set dist, fall back to versionCode from the persisted release string + // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + final String release = event.getRelease(); if (release != null) { try { final String versionCode = release.substring(release.indexOf('+') + 1); @@ -512,20 +534,101 @@ private void setDist(final @NotNull SentryBaseEvent event) { } } - private void setSdk(final @NotNull SentryBaseEvent event) { + /** + * Resolves an option that may change between launches of the same build, such as environment or + * tags. A matching persisted value is preferred; the current value is used only when the source + * identifies the current app generation or permits a fallback for a missing persisted value. + */ + private @Nullable T getLaunchOption( + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + + final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz); + return persistedValue != null || optionsSource == OptionsSource.PERSISTED + ? persistedValue + : currentValue; + } + + /** + * Resolves metadata that cannot change between launches of the same build, such as the ProGuard + * UUID or SDK version. Current metadata is used for exits from the current app generation, while + * persisted metadata is reserved for historical exits. + */ + private @Nullable T getBuildOption( + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT + || optionsSource == OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + return PersistingOptionsObserver.read(options, fileName, clazz); + } + + /** + * Chooses the options snapshot that can safely describe an exit by comparing its timestamp with + * the current app update time and the persisted cache generation. A markerless legacy cache is + * accepted for compatibility; {@link OptionsSource#NONE} is returned when neither current nor + * persisted options can be matched to the exit. + */ + private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) { + final @Nullable Long timestamp; + if (hint instanceof AbnormalExit) { + timestamp = ((AbnormalExit) hint).timestamp(); + } else if (hint instanceof NativeCrashExit) { + timestamp = ((NativeCrashExit) hint).timestamp(); + } else { + timestamp = null; + } + final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + final long currentLastUpdateTime = packageInfo == null ? 0 : packageInfo.lastUpdateTime; + + if (timestamp != null && currentLastUpdateTime > 0 && currentLastUpdateTime <= timestamp) { + return cachedLastUpdateTime != null && cachedLastUpdateTime == currentLastUpdateTime + ? OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK + : OptionsSource.CURRENT; + } + if (cachedLastUpdateTime == null) { + return OptionsSource.PERSISTED; + } + // A cache generation created after the exit cannot describe that exit. + if (timestamp != null && cachedLastUpdateTime > 0 && cachedLastUpdateTime <= timestamp) { + return OptionsSource.PERSISTED; + } + return OptionsSource.NONE; + } + + private void setSdk( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getSdk() == null) { final SdkVersion sdkVersion = - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class); + getBuildOption( + SDK_VERSION_FILENAME, SdkVersion.class, options.getSdkVersion(), optionsSource); event.setSdk(sdkVersion); } } @SuppressWarnings("unchecked") - private void setOptionsTags(final @NotNull SentryBaseEvent event) { + private void setOptionsTags( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { final Map tags = (Map) - PersistingOptionsObserver.read( - options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); + getLaunchOption( + PersistingOptionsObserver.TAGS_FILENAME, + Map.class, + options.getTags(), + optionsSource); if (tags == null) { return; } @@ -542,6 +645,13 @@ private void setOptionsTags(final @NotNull SentryBaseEvent event) { // endregion + private enum OptionsSource { + CURRENT, + PERSISTED, + PERSISTED_WITH_CURRENT_FALLBACK, + NONE + } + @Override public @Nullable Long getOrder() { return 12000L; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java new file mode 100644 index 00000000000..9b4433e255e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,88 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; + +import io.sentry.IOptionsObserver; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.cache.CacheUtils; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Persists the app generation that produced the options cache. + * + *

{@link ApplicationExitInfoEventProcessor} compares the cached {@link + * android.content.pm.PackageInfo#lastUpdateTime} with an exit timestamp before reusing + * launch-specific options. This prevents options written by a later app update from being attached + * to an older ANR or native crash. + * + *

For example: + * + *

    + *
  1. The installed build launches for account A and persists account A's tags and replay + * sampling options. + *
  2. A later launch of the same build exits before SDK initialization, so it cannot persist a + * new options snapshot. + *
  3. The next launch initializes the SDK for account B and reports the previous exit. + *
  4. The matching generation marker lets the processor use account A's persisted options instead + * of account B's current options. + *
+ * + *

This observer must be registered after {@link PersistingOptionsObserver}. Options observers + * are notified one at a time, so the first callback to this observer writes the generation marker + * only after the preceding observer has persisted the complete options snapshot. + */ +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + private final @NotNull SentryOptions options; + private final long lastUpdateTime; + + PersistingOptionsCacheGenerationObserver( + final @NotNull SentryOptions options, final long lastUpdateTime) { + this.options = options; + this.lastUpdateTime = lastUpdateTime; + } + + @Override + public void setRelease(final @Nullable String release) { + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + final String value = + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Failed to read options cache generation."); + return null; + } + } + + @Override + public void setProguardUuid(final @Nullable String proguardUuid) {} + + @Override + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {} + + @Override + public void setEnvironment(final @Nullable String environment) {} + + @Override + public void setDist(final @Nullable String dist) {} + + @Override + public void setTags(final @NotNull Map tags) {} + + @Override + public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {} +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f8724d286f8..cbe42faa103 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -843,6 +843,21 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } } } + @Test + fun `options cache generation observer is set when app update time is valid`() { + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + ContextUtils.getPackageInfo(fixture.context, buildInfo)!!.lastUpdateTime = 1_000L + + fixture.initSut(useRealContext = true) + + assertTrue { + fixture.sentryOptions.optionsObservers.any { + it is PersistingOptionsCacheGenerationObserver + } + } + } + @Test fun `when cacheDir is not set, persisting observers are not set to options`() { fixture.initSut(configureOptions = { cacheDirPath = null }) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index e7583429910..d5b916d3b44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -27,6 +27,7 @@ import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME as OPTIONS_TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME @@ -152,7 +153,7 @@ class ApplicationExitInfoEventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(OPTIONS_TAGS_FILENAME, mapOf("option" to "tag")) replayErrorSampleRate?.let { persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) } @@ -198,6 +199,7 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { DeviceInfoUtil.resetInstance() + ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -390,14 +392,199 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `if environment is not persisted, uses environment from options`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + fun `if environment is not persisted and app was not updated, uses environment from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + setLastUpdateTime(1_000) val processed = processEvent(hint) assertEquals("release", processed.environment) } + @Test + fun `if release is not persisted and app was not updated, uses release from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + } + + @Test + fun `if release is not persisted and app was updated, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if exit timestamp is unknown, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if last update time is invalid, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(-1) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if dist is not persisted and app was not updated, uses version code from options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("232", processed.dist) + } + + @Test + fun `if app version is not persisted and app was not updated, uses options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) + } + + @Test + fun `historical event uses current options when app was not updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + fixture.options.environment = "production" + fixture.options.dist = "custom-dist" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + assertEquals("production", processed.environment) + assertEquals("custom-dist", processed.dist) + val app = processed.contexts.app!! + assertEquals("1.2.0", app.appVersion) + assertEquals("232", app.appBuild) + assertNull(app.appName) + assertNull(app.appIdentifier) + } + + @Test + fun `if options cache is from an older app update, uses current options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@2.0.0+300" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.proguardUuid = "current-uuid" + fixture.options.sdkVersion = SdkVersion("current-sdk", "2.0.0") + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") + fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("previous-sdk", "1.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@2.0.0+300", processed.release) + assertEquals("current-user", processed.environment) + assertEquals("current-dist", processed.dist) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) + assertEquals("current-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache is from current app update, uses persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.0.0+100" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") + fixture.persistOptions(DIST_FILENAME, "crashed-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "crashed-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.0.0+100", processed.release) + assertEquals("crashed-user", processed.environment) + assertEquals("crashed-dist", processed.dist) + assertEquals("crashed-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache was written after the exit, ignores persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@2.0.0+200") + fixture.persistOptions(ENVIRONMENT_FILENAME, "newer-user") + fixture.persistOptions(DIST_FILENAME, "newer-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "newer-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("newer-sdk", "2.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "newer-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 2_500L).setRelease(null) + setLastUpdateTime(3_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.environment) + assertNull(processed.dist) + assertTrue(processed.debugMeta!!.images!!.isEmpty()) + assertNull(processed.sdk) + assertNull(processed.tags?.get("account")) + } + + @Test + fun `historical event leaves release empty when app was updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.contexts.app) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -890,6 +1077,39 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.contexts[Contexts.REPLAY_ID]) } + @Test + fun `if options cache is current, uses persisted replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `if options cache is stale, uses current replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + val replayId = SentryId() + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + it.setLastModified(1_000) + } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + } + @Test fun `set replayId of the last modified folder`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -938,15 +1158,21 @@ class ApplicationExitInfoEventProcessorTest { return processor.process(original, hint)!! } + private fun setLastUpdateTime(lastUpdateTime: Long) { + ContextUtils.getPackageInfo(fixture.context, fixture.buildInfo)!!.lastUpdateTime = + lastUpdateTime + } + internal class AbnormalExitHint( val mechanism: String? = null, private val shouldEnrich: Boolean = true, + private val timestamp: Long? = null, ) : AbnormalExit, Backfillable { override fun mechanism(): String? = mechanism override fun ignoreCurrentThread(): Boolean = false - override fun timestamp(): Long? = null + override fun timestamp(): Long? = timestamp override fun shouldEnrich(): Boolean = shouldEnrich } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0e5aad4826b..d1aecf5ccfd 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4821,6 +4821,11 @@ public final class io/sentry/backpressure/NoOpBackpressureMonitor : io/sentry/ba public fun start ()V } +public final class io/sentry/cache/CacheUtils { + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)V +} + public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static final field CRASH_MARKER_FILE Ljava/lang/String; public static final field NATIVE_CRASH_MARKER_FILE Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java index 5f578191bd5..142b7c3fdab 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheUtils.java +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -18,15 +18,19 @@ import java.io.Reader; import java.io.Writer; import java.nio.charset.Charset; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -final class CacheUtils { +@ApiStatus.Internal +public final class CacheUtils { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); - static void store( + private CacheUtils() {} + + public static void store( final @NotNull SentryOptions options, final @NotNull T entity, final @NotNull String dirName, @@ -63,7 +67,7 @@ static void delete( } } - static @Nullable T read( + public static @Nullable T read( final @NotNull SentryOptions options, final @NotNull String dirName, final @NotNull String fileName, From 5ed4fea07737c2a1cf1dc8c04204d35e4bda7768 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:07:09 +0000 Subject: [PATCH 015/102] chore(deps): bump the github-actions group with 3 updates (#5780) Bumps the github-actions group with 3 updates: [actions/setup-java](https://github.com/actions/setup-java), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `actions/setup-java` from 5.5.0 to 5.6.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/0f481fcb613427c0f801b606911222b5b6f3083a...03ad4de0992f5dab5e18fcb136590ce7c4a0ac95) Updates `github/codeql-action/init` from 4.37.0 to 4.37.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a) Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.37.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.37.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 1d2a3bbb567..5251236fd54 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -33,7 +33,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a11da2987dd..4ab77129172 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0c4fda8cfd3..ef7e91d8cfb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # pin@v2 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # pin@v2 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 0604179edda..a5aee08bceb 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -14,7 +14,7 @@ jobs: uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index e860ef74f14..21b7373a361 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -13,7 +13,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index f6de0912fd4..e987a0eedbf 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -14,7 +14,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index a2728bc9694..ab6c3de9bcf 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -32,7 +32,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' @@ -82,7 +82,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index e8e0df16285..f72a221e00f 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: "temurin" java-version: "17" diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 6eeaf4e919d..fff6ef6fa38 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Java 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index a1f02d8512d..bd5995e57dd 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -27,7 +27,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index e1a334e48a2..4d213470aed 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,7 +20,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 2cee0e04441..2d0c0909526 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index e689bc4c0b7..ba44ea01b65 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index cf30cf0500f..e2fb0aeccee 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b26632f7f65..1f0f53c6eed 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -112,7 +112,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: 'temurin' java-version: '17' From 0ee65e9bc88b40b1a6739d4cb1d8a3a8d6754dfe Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 17 Jul 2026 16:10:57 +0200 Subject: [PATCH 016/102] feat(samples): Make Native Crash button fault inside app native code (#5773) The Native Crash button called raise(SIGSEGV), which faults inside libc and, for a JNI-originated crash, does not exercise symbolication of the app's own native code. Point it at a null-deref inside a named function (trigger_null_deref) instead, so the crashing frame resolves to a real symbol + source line and native symbolication can be verified from the sample. Ref JAVA-645 Claude-Session: https://claude.ai/code/session_01EmE8hdaj9H9K61opK2PZ6U Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/main/cpp/native-sample.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp index abac2bf58fe..6b9e6e89d87 100644 --- a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp +++ b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp @@ -1,15 +1,23 @@ #include #include #include -#include #define TAG "sentry-sample" extern "C" { +// Faults inside this named function so the crashing frame resolves to a real +// symbol + source line. A bare raise(SIGSEGV) would instead fault in libc and, +// for a JNI-originated crash, not exercise app-native symbolication. +[[gnu::noinline]] +static void trigger_null_deref() { + volatile int *ptr = nullptr; + *ptr = 42; +} + JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_crash(JNIEnv *env, jclass cls) { __android_log_print(ANDROID_LOG_WARN, TAG, "About to crash."); - raise(SIGSEGV); + trigger_null_deref(); } JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEnv *env, jclass cls) { From 47b5ffaf47c5194ed2ad10246e896cb349aa5842 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:50:40 +0200 Subject: [PATCH 017/102] chore(deps): bump actions/setup-python in the github-actions group (#5788) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 6.3.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 2d0c0909526..aa0c66f9562 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index ba44ea01b65..24e86afbc04 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index e2fb0aeccee..6a6dda337f1 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 1f0f53c6eed..feac0a4f3a7 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -102,7 +102,7 @@ jobs: with: submodules: 'recursive' - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' From eb1a94667b39a0dc8ae51955daafa26ec3504334 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jul 2026 13:12:44 +0200 Subject: [PATCH 018/102] fix(compose): Update isImportantForBounds to return true (#5789) * fix(compose): Update isImportantForBounds to return true As any inner node could be important for semantics. * Update CHANGELOG * Format code --------- Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../kotlin/io/sentry/compose/SentryModifier.kt | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44231ff4eed..f1d98e41ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) +- `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) ## 8.49.0 diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 787c66b3b0b..3c8fb48c35a 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -55,13 +55,11 @@ public object SentryModifier { } // SemanticsModifierNode.isImportantForBounds() was added as an abstract method in - // compose-ui 1.11. Classes compiled against earlier versions lack this method in + // compose-ui 1.11. Classes compiled against earlier versions lack this method in // their bytecode, which causes AbstractMethodError when the accessibility tree is - // traversed on 1.11+ runtimes. We can't use the `override` keyword here because - // the method doesn't exist in the compile-time dependency (compose-ui 1.6.x), but - // the JVM satisfies the abstract-method requirement at runtime via signature - // matching. SentryTagModifierNode only stores a semantic tag and has no visual - // effect on layout, so it is not important for bounds. - @Suppress("unused") fun isImportantForBounds(): Boolean = false + // traversed on 1.11+ runtimes. + // Returning true to match the default behavior + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt;l=69-83;drc=bd7809b4bc9205721c2f1bc681694dd348885849 + @Suppress("unused") fun isImportantForBounds(): Boolean = true } } From 747ff0be2ed03fbc866e99801ee7e0a70de0e70b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 14:17:10 +0200 Subject: [PATCH 019/102] fix(core): Prevent recursion when a callback triggers another capture (#5737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): Prevent recursion when a callback triggers another capture A user beforeSend/beforeBreadcrumb/beforeSendLog callback that itself captures — directly, or transitively through a logging integration that routes back into Sentry (e.g. Timber, or the Gradle plugin's logcat instrumentation) — recursed until a StackOverflowError, because the callbacks run synchronously on the caller thread with no re-entrancy protection. Add a shared, thread-local SentryCallbackReentrancyGuard that is set only while a callback's execute() runs. Capture entry points (captureEvent, captureTransaction, captureLog, Scope.addBreadcrumb) drop nested captures while the guard is active, breaking the loop for every capture type at once. Dropping (rather than sending) the nested capture is intentional: bypassing beforeSend would send unscrubbed data. The nested capture is dropped, not sent. This also makes the per-integration guard in SentryLogcatAdapter redundant. Co-Authored-By: Claude Opus 4.8 * docs(changelog): Add entry for callback re-entrancy guard Co-Authored-By: Claude Opus 4.8 * fix(core): Guard captureFeedback, captureReplayEvent, captureMetric too These three capture methods had their beforeSend* executors wrapped by the re-entrancy guard but no entry check, so they were never dropped when a callback was active. That broke the "callbacks never nest" invariant: a callback that captured feedback/replay/metric ran that executor, whose exit() cleared the shared flag mid-callback, re-enabling recursion for any captureEvent/captureLog that followed in the same callback. They also recursed directly (e.g. beforeSendFeedback -> captureFeedback). Add the same isActive() entry guard to all three so every executor-wrapped capture path also drops while a callback runs. Co-Authored-By: Claude Opus 4.8 * fix(core): Harden re-entrancy guard with depth counter, wrap remaining callbacks Two robustness fixes for the callback re-entrancy guard: Replace the boolean flag with a depth counter so a nested exit() cannot disarm the guard while an outer callback is still running. The boolean relied on the "callbacks never nest" invariant, which every capture entry point must uphold by convention - a future capture path added without an entry check would silently re-open the recursion hole. The counter makes that failure mode structurally impossible, and lets exit() remove() the thread-local entry instead of parking a stale value on pooled threads. Wrap beforeErrorSampling and beforeEnvelopeCallback, the two remaining user callbacks in SentryClient without enter()/exit(). A capture from within beforeErrorSampling recursed unguarded (captureEvent -> beforeErrorSampling -> captureEvent -> ...). Both regression tests were verified to fail with StackOverflowError without the wraps. Co-Authored-By: Claude Fable 5 * refactor(core): Hand out AutoCloseable token from re-entrancy guard Replace the manual enter()/finally-exit() pattern at every callback site with an ISentryLifecycleToken returned from enter(), used via try-with-resources. This removes nine copies of the finally boilerplate and the risk of forgetting the exit() call. The depth counter stays as the guard's state model - the token's close() just decrements it - so the nesting correctness guarantee is unchanged. The token is a shared static singleton, so no allocation happens per callback, matching the AutoClosableReentrantLock idiom already in the codebase. Co-Authored-By: Claude Opus 4.8 (1M context) * Format code * docs(changelog): Move re-entrancy entry to Unreleased The rebase folded the previous Unreleased section into the released 8.48.0, stranding the callback re-entrancy entry there. Move it back under Unreleased. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(core): Guard sendEnvelope against callback re-entrancy captureEnvelope and captureCheckIn route into sendEnvelope without the isActive() entry check every other capture path has. A beforeEnvelope callback that itself captured an envelope or check-in re-entered sendEnvelope, re-ran the callback, and recursed to a StackOverflowError - the same failure class the rest of this PR fixes. Check the guard at the top of sendEnvelope, the single choke point every send flows through, rather than adding a check to each public entry point. In normal flow the guard is already inactive there (the before* callback has exited), so an active guard can only mean a callback triggered the send, which is dropped. This also closes the hole for any future sender routed through sendEnvelope. Co-Authored-By: Claude Opus 4.8 (1M context) * test(core): Drop redundant check-in re-entrancy test captureCheckIn and captureEnvelope both funnel into sendEnvelope and hit the same isActive() guard, so the check-in test exercised no code path the envelope test did not already cover. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(core): Drop callback-recursion captures silently The re-entrancy guard dropped nested captures but logged a DEBUG line at seven of the eight drop sites. That log is itself routed back into Sentry by the same logging integrations this guard protects against (the Gradle plugin's logcat instrumentation feeds captureLog, whose own drop logs again), so logging on the drop path re-opens the very recursion the guard breaks. It only bites with SDK debug logging enabled, since the internal logger is otherwise a no-op, but dropping silently removes the failure mode outright. Drop without logging everywhere and document why in the guard's Javadoc and at each drop site. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(core): Document that captures inside before* callbacks are dropped A customer implementing beforeSend/beforeBreadcrumb/beforeSendLog/etc. has no way to know that capturing from within the callback — directly or through a logging integration — is silently dropped to prevent recursion. State the contract on each customer-facing callback interface. Co-Authored-By: Claude Opus 4.8 (1M context) * build(core): Update API dump for re-entrancy guard token The token refactor changed SentryCallbackReentrancyGuard.enter() to return an ISentryLifecycleToken and made exit() private, but the API dump was not regenerated at the time. The guard is @ApiStatus.Internal. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 2 + .../timber/SentryTimberIntegrationTest.kt | 44 +++++ sentry/api/sentry.api | 5 + sentry/src/main/java/io/sentry/Scope.java | 7 +- .../src/main/java/io/sentry/SentryClient.java | 55 +++++- .../main/java/io/sentry/SentryOptions.java | 24 +++ .../java/io/sentry/SentryReplayOptions.java | 4 + .../util/SentryCallbackReentrancyGuard.java | 77 +++++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 21 +++ .../test/java/io/sentry/SentryClientTest.kt | 158 ++++++++++++++++++ 10 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d98e41ebc..6d37da44d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. ## 8.49.0 diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 43a45da7bb3..7c21eca8ef0 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,10 +1,14 @@ package io.sentry.android.timber import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion +import io.sentry.transport.ITransport import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import timber.log.Timber class SentryTimberIntegrationTest { @@ -112,4 +117,43 @@ class SentryTimberIntegrationTest { assertTrue(fixture.options.sdkVersion!!.integrationSet.contains("Timber")) } + + @Test + fun `a beforeSend callback that logs via Timber does not recurse`() { + // End-to-end guard against SDK-CRASHES-JAVA-3T3H style recursion: with a real Sentry instance, + // a beforeSend callback that logs through the planted SentryTimberTree must not loop back into + // capture forever. + val transport = mock() + val transportFactory = mock() + whenever(transportFactory.create(any(), any())).thenReturn(transport) + + var beforeSendInvocations = 0 + Sentry.init { options -> + options.dsn = "https://key@sentry.io/123" + options.setTransportFactory(transportFactory) + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } + } + Timber.plant( + SentryTimberTree( + ScopesAdapter.getInstance(), + SentryLevel.ERROR, + SentryLevel.INFO, + SentryLogLevel.INFO, + ) + ) + + try { + Timber.e("outer error") + + // Without the core re-entrancy guard this recurses until a StackOverflowError. The nested + // Timber.e is dropped before its own beforeSend, so the callback runs exactly once. + assertEquals(1, beforeSendInvocations) + } finally { + Sentry.close() + } + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d1aecf5ccfd..c623e71d08f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7924,6 +7924,11 @@ public final class io/sentry/util/ScopesUtil { public static fun printScopesChain (Lio/sentry/IScopes;)V } +public final class io/sentry/util/SentryCallbackReentrancyGuard { + public static fun enter ()Lio/sentry/ISentryLifecycleToken; + public static fun isActive ()Z +} + public final class io/sentry/util/SentryRandom { public fun ()V public static fun current ()Lio/sentry/util/Random; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..195e5b5b05a 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -16,6 +16,7 @@ import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import io.sentry.util.Pair; +import io.sentry.util.SentryCallbackReentrancyGuard; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; @@ -464,7 +465,7 @@ public Queue getBreadcrumbs() { final @NotNull SentryOptions.BeforeBreadcrumbCallback callback, @NotNull Breadcrumb breadcrumb, final @NotNull Hint hint) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { breadcrumb = callback.execute(breadcrumb, hint); } catch (Throwable e) { options @@ -493,6 +494,10 @@ public void addBreadcrumb(@NotNull Breadcrumb breadcrumb, @Nullable Hint hint) { if (breadcrumb == null || breadcrumbs instanceof DisabledQueue) { return; } + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } SentryOptions.BeforeBreadcrumbCallback callback = options.getBeforeBreadcrumb(); if (callback != null) { if (hint == null) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a0ff98a9af7..a25aa9c79e1 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -107,6 +107,11 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @NotNull SentryEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SentryEvent is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -236,7 +241,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul final SentryReplayOptions.BeforeErrorSamplingCallback beforeErrorSampling = options.getSessionReplay().getBeforeErrorSampling(); if (beforeErrorSampling != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { shouldCaptureReplay = beforeErrorSampling.execute(event, hint); } catch (Throwable e) { options @@ -312,6 +317,11 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -937,10 +947,19 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint private @NotNull SentryId sendEnvelope( @NotNull final SentryEnvelope envelope, @Nullable final Hint hint) throws IOException { + // captureEnvelope and captureCheckIn have no entry-level guard, so a callback that captures + // one of those would recurse back into beforeEnvelopeCallback. In normal flow the guard is + // already inactive by the time we get here (the before* callback has exited), so an active + // guard means a callback triggered this send. Drop silently: a log here can re-enter through a + // logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + final @Nullable SentryOptions.BeforeEnvelopeCallback beforeEnvelopeCallback = options.getBeforeEnvelopeCallback(); if (beforeEnvelopeCallback != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { beforeEnvelopeCallback.execute(envelope, hint); } catch (Throwable e) { options @@ -969,6 +988,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint final @Nullable ProfilingTraceData profilingTraceData) { Objects.requireNonNull(transaction, "Transaction is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -1187,6 +1211,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @Override public @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, @Nullable Hint hint, final @NotNull IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + SentryEvent event = new SentryEvent(); event.getContexts().setFeedback(feedback); @@ -1297,6 +1326,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @ApiStatus.Experimental @Override public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } + if (logEvent != null && scope != null) { logEvent = processLogEvent(logEvent, scope.getEventProcessors()); if (logEvent == null) { @@ -1351,6 +1385,11 @@ public void captureMetric( @Nullable SentryMetricsEvent metricsEvent, final @Nullable IScope scope, @Nullable Hint hint) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } + if (hint == null) { hint = new Hint(); } @@ -1612,7 +1651,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSend = options.getBeforeSend(); if (beforeSend != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSend.execute(event, hint); } catch (Throwable e) { options @@ -1634,7 +1673,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendTransactionCallback beforeSendTransaction = options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { transaction = beforeSendTransaction.execute(transaction, hint); } catch (Throwable e) { options @@ -1655,7 +1694,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSendFeedback = options.getBeforeSendFeedback(); if (beforeSendFeedback != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendFeedback.execute(event, hint); } catch (Throwable e) { options @@ -1673,7 +1712,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryReplayEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendReplayCallback beforeSendReplay = options.getBeforeSendReplay(); if (beforeSendReplay != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendReplay.execute(event, hint); } catch (Throwable e) { options @@ -1694,7 +1733,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Logs.BeforeSendLogCallback beforeSendLog = options.getLogs().getBeforeSend(); if (beforeSendLog != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendLog.execute(event); } catch (Throwable e) { options @@ -1716,7 +1755,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Metrics.BeforeSendMetricCallback beforeSendMetric = options.getMetrics().getBeforeSend(); if (beforeSendMetric != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendMetric.execute(event, hint); } catch (Throwable e) { options diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index cde0c37ba90..f10f2aede05 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3327,6 +3327,10 @@ public interface BeforeSendCallback { /** * Mutates or drop an event before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @param hint the hints * @return the original event or the mutated event or null if event was dropped @@ -3341,6 +3345,10 @@ public interface BeforeSendTransactionCallback { /** * Mutates or drop a transaction before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param transaction the transaction * @param hint the hints * @return the original transaction or the mutated transaction or null if transaction was @@ -3358,6 +3366,10 @@ public interface BeforeSendReplayCallback { * for a single replay (i.e. segments), you can check {@link SentryReplayEvent#getReplayId()} to * identify that the segments belong to the same replay. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @param hint the hint, contains {@link ReplayRecording}, can be accessed via {@link * Hint#getReplayRecording()} @@ -3373,6 +3385,10 @@ public interface BeforeBreadcrumbCallback { /** * Mutates or drop a callback before being added * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param breadcrumb the breadcrumb * @param hint the hints, usually the source of the breadcrumb * @return the original breadcrumb or the mutated breadcrumb of null if breadcrumb was dropped @@ -3961,6 +3977,10 @@ public interface BeforeSendLogCallback { /** * Mutates or drop a log event before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @return the original log event or the mutated event or null if event was dropped */ @@ -4035,6 +4055,10 @@ public interface BeforeSendMetricCallback { /** * A callback which gets called right before a metric is about to be sent. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param metric the metric * @return the original metric, mutated metric or null if metric was dropped */ diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d1da6510cdb..a068e12f2a8 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -28,6 +28,10 @@ public interface BeforeErrorSamplingCallback { /** * Determines whether replay capture should proceed for the given error event. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the error event that triggered the replay capture * @param hint the hint associated with the event * @return {@code true} if the error sample rate should be checked, {@code false} to skip replay diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java new file mode 100644 index 00000000000..461e7daf18f --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -0,0 +1,77 @@ +package io.sentry.util; + +import io.sentry.ISentryLifecycleToken; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Thread-local re-entrancy guard that marks whether a user-supplied {@code before*} callback + * ({@code beforeSend}, {@code beforeBreadcrumb}, {@code beforeSendLog}, ...) is currently executing + * on the current thread. + * + *

A callback that itself triggers another SDK capture on the same thread — directly, or + * transitively through a logging integration that routes back into Sentry (e.g. Timber or the + * Gradle plugin's logcat instrumentation) — would otherwise recurse indefinitely and throw {@link + * StackOverflowError}. Capture entry points consult {@link #isActive()} and drop the nested capture + * while a callback is running. + * + *

The nested capture MUST be dropped silently — callers must not log while the guard is active. + * The same logging integration that routes logs back into Sentry also routes the SDK's own + * diagnostic logs, so a "dropped to prevent recursion" log line would be turned into another + * capture, whose drop would log again, and so on. The guard suppresses the capture but not the log, + * so logging on the drop path re-opens exactly the recursion the guard exists to break. (This only + * bites when SDK debug logging is enabled, since {@code options.getLogger()} is otherwise a no-op, + * but dropping silently removes the failure mode entirely.) + * + *

The guard is set ONLY around each callback's {@code execute(...)} invocation, never around the + * whole capture pipeline, so captures made by event processors (which run outside the callback) are + * not affected. + * + *

The guard is a depth counter rather than a boolean so that nested {@link #exit()} calls cannot + * clear it while an outer callback is still running. Capture entry points drop while a callback is + * active, so callbacks should never nest — but a capture path lacking an entry check must not + * silently disarm the guard for the rest of the outer callback. + * + *

{@link #enter()} returns an {@link ISentryLifecycleToken} so callers can use + * try-with-resources instead of a manual {@code finally exit()}. The token is a shared singleton + * (its {@code close()} just decrements the counter), so no allocation happens per callback. + */ +@ApiStatus.Internal +public final class SentryCallbackReentrancyGuard { + + private static final ThreadLocal depth = new ThreadLocal<>(); + + private static final ISentryLifecycleToken TOKEN = SentryCallbackReentrancyGuard::exit; + + private SentryCallbackReentrancyGuard() {} + + /** + * Whether a user callback is currently executing on this thread. When {@code true}, capture entry + * points must drop the capture and return without logging — see the class Javadoc for why logging + * on the drop path re-opens the recursion. + */ + public static boolean isActive() { + final @Nullable Integer current = depth.get(); + return current != null && current > 0; + } + + /** + * Marks that a user callback is starting to execute on this thread. Close the returned token (via + * try-with-resources) once the callback returns. + */ + public static @NotNull ISentryLifecycleToken enter() { + final @Nullable Integer current = depth.get(); + depth.set(current == null ? 1 : current + 1); + return TOKEN; + } + + private static void exit() { + final @Nullable Integer current = depth.get(); + if (current == null || current <= 1) { + depth.remove(); + } else { + depth.set(current - 1); + } + } +} diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 86aaf6f8f24..7af4f6ccdca 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -371,6 +371,27 @@ class ScopeTest { assertFalse(called) } + @Test + fun `when beforeBreadcrumb adds another breadcrumb, the nested breadcrumb is dropped and does not recurse`() { + var invocations = 0 + lateinit var scope: Scope + val options = + SentryOptions().apply { + beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + invocations++ + scope.addBreadcrumb(Breadcrumb()) + breadcrumb + } + } + + scope = Scope(options) + scope.addBreadcrumb(Breadcrumb()) + + // Callback runs only for the outer breadcrumb; the nested one is dropped before its callback. + assertEquals(1, invocations) + assertEquals(1, scope.breadcrumbs.count()) + } + @Test fun `when adding breadcrumb and maxBreadcrumb is not 0, beforeBreadcrumb is executed`() { var called = false diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5cfa4b6b619..fa37cb0b70b 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -284,6 +284,108 @@ class SentryClientTest { ) } + @Test + fun `when beforeSend captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // Callback runs only for the outer event; the nested capture is dropped before its callback. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSend captures a log, the nested log is dropped`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // The shared guard spans capture types: a log emitted from beforeSend is dropped too. + verify(fixture.loggerBatchProcessor, never()).add(any()) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendLog logs again, the nested log is dropped and does not recurse`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.logs.setBeforeSend { l -> + invocations++ + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + l + } + sut = fixture.getSut() + + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "outer", SentryLogLevel.WARN), + scope, + ) + + assertEquals(1, invocations) + verify(fixture.loggerBatchProcessor, times(1)).add(any()) + } + + @Test + fun `when beforeSend captures feedback before an event, the guard is not cleared prematurely`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + // Capturing feedback must not clear the re-entrancy guard for captures that follow it in the + // same callback, otherwise the captureEvent below would recurse. + sut.captureFeedback(Feedback("feedback"), null, scope) + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendFeedback captures feedback again, the nested capture is dropped and does not recurse`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSendFeedback { e, _ -> + invocations++ + sut.captureFeedback(Feedback("nested"), null, scope) + e + } + sut = fixture.getSut() + + sut.captureFeedback(Feedback("outer"), null, scope) + + assertEquals(1, invocations) + } + @Test fun `when beforeSendLog is set, callback is invoked`() { val scope = createScope() @@ -3188,6 +3290,44 @@ class SentryClientTest { assertTrue(beforeEnvelopeCalled) } + @Test + fun `when beforeEnvelopeCallback captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeEnvelopeCallback captures an envelope, the nested envelope is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEnvelope(SentryEnvelope(SentryId(UUID.randomUUID()), null, setOf())) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + // Callback runs only for the outer envelope; the nested captureEnvelope is dropped in + // sendEnvelope before its callback would run. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { val sut = fixture.getSut { options -> @@ -3450,6 +3590,24 @@ class SentryClientTest { assertFalse(called) } + @Test + fun `when beforeErrorSampling captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + true + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeErrorSampling returning false skips captureReplay`() { var called = false From 7f6421dfb157952bcce5e5e07189bd3b05aad8c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:55:29 +0200 Subject: [PATCH 020/102] chore(deps): bump actions/checkout in the github-actions group (#5794) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/changes-in-high-risk-code.yml | 2 +- .github/workflows/check-tombstone-proto-schema.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 5251236fd54..e3ec972ffe6 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ab77129172..04b5d26e6c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 864be8140ad..44d65924209 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -16,7 +16,7 @@ jobs: high_risk_code: ${{ steps.changes.outputs.high_risk_code }} high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get changed files id: changes uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index 3e30f97e45e..0190865250e 100644 --- a/.github/workflows/check-tombstone-proto-schema.yml +++ b/.github/workflows/check-tombstone-proto-schema.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for newer Tombstone proto schema run: ./scripts/check-tombstone-proto-schema.sh diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ef7e91d8cfb..919d09659b9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index a5aee08bceb..dcb0ca09dbe 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # TODO: remove this when upstream is fixed - name: Disable Gradle configuration cache (see https://github.com/fossas/fossa-cli/issues/872) diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 21b7373a361..c0ca07b7387 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index e987a0eedbf..72d7e9dd7c5 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index ab6c3de9bcf..6b0c074f9da 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' @@ -77,7 +77,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index f72a221e00f..835019d1f89 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index fff6ef6fa38..d858ef8c4b2 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 @@ -77,7 +77,7 @@ jobs: arch: x86_64 steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Enable KVM run: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index bd5995e57dd..da46b859a14 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 4d213470aed..32451c1a8cb 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51e9987cc19..85850dd578b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index aa0c66f9562..d41de896e24 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 24e86afbc04..539301099d7 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 6a6dda337f1..cd12cb80809 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index feac0a4f3a7..db1a29df14e 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -98,7 +98,7 @@ jobs: agent: "false" agent-auto-init: "true" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' From 0da3c81b10da505155ac17e2559677fb22edf442 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:58:05 +0200 Subject: [PATCH 021/102] chore: update scripts/update-sentry-native-ndk.sh to 0.15.4 (#5793) Co-authored-by: GitHub --- CHANGELOG.md | 6 ++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d37da44d4b..4bbee5bf70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. +### Dependencies + +- Bump Native SDK from v0.15.3 to v0.15.4 ([#5793](https://github.com/getsentry/sentry-java/pull/5793)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) + ## 8.49.0 ### Features diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fd275d06f8..cdbb4326e9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -169,7 +169,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.3" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.4" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From 0e948652b09b858c7c0c146c4f93657a495b0152 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 13:56:01 +0200 Subject: [PATCH 022/102] build: Bump AGP to 9.2.1 and migrate Android modules (JAVA-649) (#5779) Raise the minimum AGP to 9.0.0 (default fallback 9.2.1) and perform the AGP 9 migration: refresh the AGP-compat matrix (9.0.0/9.1.1/9.2.1), add the AGP 9 opt-outs (android.builtInKotlin/newDsl, lint 9.2.1), pin Java and Kotlin targets to 8 across the Android library modules, set testBuildType to release for unit tests (including sentry-compose), bump the uitest modules to JVM 11, and resolve the AGP 9.2 lint findings. Robolectric 4.15 caps at API 35, so pin the affected tests with @Config(sdk = [35]) to keep them green against targetSdk 36. lint 9.2.1 flags compileSdk 36 as outdated (37 is available); disable GradleDependency for the Android modules since the SDK bump lives in the API 37 PR. Split out from the API 37 bump (#5768) so the toolchain upgrade lands on its own. compileSdk/targetSdk stay at 36 here. Claude-Session: https://claude.ai/code/session_01EmE8hdaj9H9K61opK2PZ6U Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/agp-matrix.yml | 2 +- CHANGELOG.md | 4 +++ build.gradle.kts | 26 +++++++++++++++++++ buildSrc/src/main/java/Config.kt | 2 +- gradle.properties | 5 +++- sentry-android-core/build.gradle.kts | 7 ++++- .../util/SentryFrameMetricsCollector.java | 2 +- .../layout/sentry_dialog_user_feedback.xml | 3 +++ sentry-android-distribution/build.gradle.kts | 7 ++++- .../distribution/UpdateResponseParserTest.kt | 2 ++ sentry-android-fragment/build.gradle.kts | 12 ++++++--- .../build.gradle.kts | 5 ++-- .../build.gradle.kts | 3 ++- .../build.gradle.kts | 4 ++- .../sentry-uitest-android/build.gradle.kts | 6 +++-- sentry-android-navigation/build.gradle.kts | 12 ++++++--- sentry-android-ndk/build.gradle.kts | 4 +++ sentry-android-replay/build.gradle.kts | 17 +++++++----- .../android/replay/ScreenshotRecorderTest.kt | 2 ++ .../sentry/android/replay/util/ViewsTest.kt | 2 ++ sentry-android-sqlite/build.gradle.kts | 12 ++++++--- sentry-android-timber/build.gradle.kts | 12 ++++++--- sentry-apache-http-client-5/build.gradle.kts | 11 ++++---- sentry-compose/build.gradle.kts | 6 +++-- sentry-launchdarkly-android/build.gradle.kts | 4 +++ settings.gradle.kts | 1 + 26 files changed, 135 insertions(+), 38 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index e3ec972ffe6..513d879513f 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - agp: [ '8.7.0','8.8.0','8.9.0' ] + agp: [ '9.0.0', '9.1.1', '9.2.1' ] integrations: [ true, false ] name: AGP Matrix Release - AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbee5bf70a..df9e9e3c0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) +### Dependencies + +- The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) + ## 8.49.0 ### Features diff --git a/build.gradle.kts b/build.gradle.kts index 55b5a71a1e5..2e491d2a16c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -113,6 +113,20 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } + // AGP 9.2 bundles lint 9.2.1, which flags compileSdk 36 as outdated because 37 is available. + // We intentionally stay on compileSdk 36 until the API 37 bump (#5768), so silence that check + // for every Android module (library and application). + pluginManager.withPlugin("com.android.library") { + extensions.configure { + lintOptions { disable("GradleDependency") } + } + } + pluginManager.withPlugin("com.android.application") { + extensions.configure { + lintOptions { disable("GradleDependency") } + } + } + plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true @@ -168,6 +182,18 @@ subprojects { } } + // AGP 9 defaults Android modules to Java 11. Pin the published library modules back + // to Java 8 so their bytecode stays consumable by Java 8 projects, mirroring the + // java-library pin above. + plugins.withId("com.android.library") { + configure { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + } + } + apply() afterEvaluate { diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index f0e2e9baf86..7575670a38a 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,6 +1,6 @@ object Config { - val AGP = System.getenv("VERSION_AGP") ?: "8.13.1" + val AGP = System.getenv("VERSION_AGP") ?: "9.2.1" val kotlinStdLib = "stdlib-jdk8" val kotlinStdLibVersionAndroid = "1.9.24" val kotlinTestJunit = "test-junit" diff --git a/gradle.properties b/gradle.properties index aa5a7b1e28d..edb73d3fb78 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,10 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # AndroidX required by AGP >= 3.6.x android.useAndroidX=true -android.experimental.lint.version=8.13.1 +# AGP 9+ migration opt-outs until we remove kotlin-android plugin and adopt built-in Kotlin. +android.builtInKotlin=false +android.newDsl=false +android.experimental.lint.version=9.2.1 # Release information versionName=8.49.0 diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de486..f92876530fd 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -1,5 +1,6 @@ import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 plugins { id("com.android.library") @@ -33,7 +34,11 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = JVM_1_8 } testOptions { animationsDisabled = true diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 4f76a51e86f..4f6b486f3a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -91,7 +91,7 @@ public SentryFrameMetricsCollector( } @SuppressWarnings("deprecation") - @SuppressLint({"NewApi", "PrivateApi"}) + @SuppressLint({"NewApi", "PrivateApi", "DiscouragedPrivateApi"}) public SentryFrameMetricsCollector( final @NotNull Context context, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml index 722a0d5cf3d..370c37fa0e9 100644 --- a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml +++ b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml @@ -47,6 +47,7 @@ android:layout_height="wrap_content" android:hint="Your Name" android:inputType="textPersonName" + android:autofillHints="name" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_name" /> @@ -66,6 +67,7 @@ android:layout_height="wrap_content" android:hint="your.email@example.org" android:inputType="textEmailAddress" + android:autofillHints="emailAddress" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_email" /> @@ -85,6 +87,7 @@ android:layout_height="wrap_content" android:lines="6" android:inputType="textMultiLine" + android:importantForAutofill="no" android:gravity="top|left" android:hint="What's the bug? What did you expect?" android:background="@drawable/sentry_edit_text_border" diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 2d23bf3ab74..363bce003f4 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { @@ -12,6 +13,10 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { unitTests.apply { isReturnDefaultValues = true @@ -21,7 +26,7 @@ android { } kotlin { - jvmToolchain(17) + compilerOptions.jvmTarget = JVM_1_8 compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 explicitApi() } diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt index 3f1d083919c..f1817a39f34 100644 --- a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -8,8 +8,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class UpdateResponseParserTest { private lateinit var options: SentryOptions diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 1bd182d618c..197c0b05d6a 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index 459c1653fa9..04b5eaf6a23 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -64,12 +65,12 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36 + // Suppress OldTargetApi: lint 9.2.1 expects API 37 but we target 36 disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index 4b0cd68ca90..6f875e7a5e9 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -1,4 +1,5 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -31,7 +32,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts index 2d2aab48a1b..a00d76d6029 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + plugins { id("com.android.test") alias(libs.plugins.kotlin.android) @@ -30,7 +32,7 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } + kotlin { compilerOptions.jvmTarget = JVM_11 } targetProjectPath = ":sentry-samples:sentry-samples-android" // Run the test in its own process so it measures the target app cold, not itself. diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 1d725b0b595..6fbba814f83 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -56,18 +57,19 @@ android { buildTypes { getByName("release") { isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") // to be able to run release mode testProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36 + // Suppress OldTargetApi: lint 9.2.1 expects API 37 but we target 36 disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index eaa204b3860..5ac9842548f 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index c2d0a33d823..ba651da56ac 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -26,6 +26,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 8d0f63797aa..02f2dab3d4d 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { @@ -25,20 +27,21 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - useLiveLiterals = false - } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt index 2818eeb3537..00b58666669 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -14,8 +14,10 @@ import kotlin.test.Test import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ScreenshotRecorderTest { internal class Fixture() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index 2eaa8411cfe..3d5f6a9c506 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -20,8 +20,10 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ViewsTest { @BeforeTest diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 6e0275b29b8..e1e3bc68765 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index d8f8431bef1..e55b8b0e3c5 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -30,10 +32,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 984974bae9a..7502fb6c4b8 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -1,5 +1,6 @@ import net.ltgt.gradle.errorprone.errorprone -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` @@ -10,10 +11,10 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index c45a431b1b3..388bfe832bb 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -87,13 +87,15 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } - sourceSets["main"].apply { manifest.srcFile("src/androidMain/AndroidManifest.xml") } - buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { animationsDisabled = true unitTests.apply { diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index 427ec473676..32b3641203f 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -27,6 +27,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. CI disables the debug + // variant, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/settings.gradle.kts b/settings.gradle.kts index 7fb5c627912..82fad42aee3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ pluginManagement { } mavenCentral() gradlePluginPortal() + google() } } From 8d299fcb84b5afdbc618ece9b25b9a65b917e3eb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 14:29:45 +0200 Subject: [PATCH 023/102] perf(android): Init shake detector off the main thread (#5784) * perf(android): Init shake detector off the main thread (JAVA-618) FeedbackShakeIntegration.register() resolved the accelerometer via SensorManager synchronously on the calling thread, which under auto-init is the main thread. On a Pixel 10 this first SensorManager access measured ~1.75ms, making it the single most expensive integration in the Sentry.init register loop. Submit the pre-warm init() to the executor service instead. start() already re-runs the idempotent init() on demand, so shake detection still works if an activity resumes before the warm-up completes. SentryShakeDetector's lifecycle methods are now synchronized so the executor warm-up and a main-thread start() cannot race on the sensor fields. Co-Authored-By: Claude Opus 4.8 * docs: Add changelog for shake-detector init off main thread (JAVA-618) Co-Authored-By: Claude Opus 4.8 * fix(android): Guard shake detector against warm-up after close (JAVA-618) A warm-up init submitted to the executor could be drained after the integration's close() ran, since integrations shut down before the executor. That re-resolved the sensor and leaked a HandlerThread. Guard init()/start() with a closed latch so a late warm-up is a no-op. Co-Authored-By: Claude Opus 4.8 * fix(android): Re-arm shake detector on re-register (JAVA-618) The closed latch added to neutralize a warm-up drained after close() was permanent, so re-registering the same integration (e.g. a second Sentry.init reusing the same options) left shake detection off with no recovery. register() now re-arms the detector via reopen(), which the stale-warm-up path (init()) deliberately does not, preserving the drain-after-close guard. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../core/FeedbackShakeIntegration.java | 22 ++++++- .../android/core/SentryShakeDetector.java | 29 +++++++-- .../core/FeedbackShakeIntegrationTest.kt | 64 ++++++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df9e9e3c0c2..41b74388723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) - Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java index fc34f18152f..b059b0104da 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -44,11 +44,29 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions : null, "SentryAndroidOptions is required"); - if (!this.options.getFeedbackOptions().isUseShakeGesture()) { + final @NotNull SentryAndroidOptions options = this.options; + + if (!options.getFeedbackOptions().isUseShakeGesture()) { return; } - shakeDetector.init(application, options.getLogger()); + // Re-arm the detector in case this integration is being re-registered after a previous close() + // (e.g. a second Sentry.init reusing the same options), otherwise the closed latch would keep + // shake detection off permanently. + shakeDetector.reopen(); + + // Resolving the accelerometer is the most expensive part of init (the first SensorManager + // access), so warm it up off the main thread. start() re-runs init() on demand, so shake + // detection still works if an activity resumes before this completes. + try { + options + .getExecutorService() + .submit(() -> shakeDetector.init(application, options.getLogger())); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to submit shake detector initialization.", t); + } addIntegrationToSdkVersion("FeedbackShake"); application.registerActivityLifecycleCallbacks(this); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java index a4c4ae0c4f5..9f4f73d10f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -40,6 +40,7 @@ public final class SentryShakeDetector implements SensorEventListener { private @Nullable Handler handler; private volatile @Nullable Listener listener; private @NotNull ILogger logger; + private boolean closed; private final @NotNull SampleQueue queue = new SampleQueue(); @@ -51,16 +52,29 @@ public SentryShakeDetector(final @NotNull ILogger logger) { this.logger = logger; } + /** + * Re-arms the detector after a previous {@link #close()} so it can be reused when the owning + * integration is registered again (e.g. a second {@code Sentry.init}). + */ + synchronized void reopen() { + closed = false; + } + /** * Initializes the sensor manager and accelerometer sensor. This is separated from start() so the * values can be resolved once and reused across activity transitions. */ - void init(final @NotNull Context context, final @NotNull ILogger logger) { + synchronized void init(final @NotNull Context context, final @NotNull ILogger logger) { this.logger = logger; init(context); } - private void init(final @NotNull Context context) { + private synchronized void init(final @NotNull Context context) { + // A warm-up submitted to the executor can be drained after close() (integrations are closed + // before the executor shuts down), so bail out instead of spinning up a new HandlerThread. + if (closed) { + return; + } if (sensorManager == null) { sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); } @@ -74,7 +88,11 @@ private void init(final @NotNull Context context) { } } - public void start(final @NotNull Context context, final @NotNull Listener shakeListener) { + public synchronized void start( + final @NotNull Context context, final @NotNull Listener shakeListener) { + if (closed) { + return; + } this.listener = shakeListener; init(context); if (sensorManager == null) { @@ -89,7 +107,7 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); } - public void stop() { + public synchronized void stop() { listener = null; if (sensorManager != null) { sensorManager.unregisterListener(this); @@ -105,7 +123,8 @@ public void stop() { } /** Stops detection and releases the background thread. */ - public void close() { + public synchronized void close() { + closed = true; stop(); if (handlerThread != null) { // quitSafely drains pending messages (including the clear posted by stop) before exiting diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index bddc9395c0d..170211abf55 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -2,13 +2,18 @@ package io.sentry.android.core import android.app.Activity import android.app.Application +import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Scopes import io.sentry.SentryFeedbackOptions +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import kotlin.test.BeforeTest import kotlin.test.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -20,7 +25,11 @@ class FeedbackShakeIntegrationTest { private class Fixture { val application = mock() val scopes = mock() - val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + executorService = ImmediateExecutorService() + } val activity = mock() val formHandler = mock() @@ -49,6 +58,59 @@ class FeedbackShakeIntegrationTest { verify(fixture.application).registerActivityLifecycleCallbacks(any()) } + @Test + fun `resolves the accelerometer sensor off the main thread`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + // Callback registration stays synchronous, but the expensive SensorManager lookup is deferred. + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + + deferredExecutor.runAll() + + verify(fixture.application).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `warm-up drained after close does not resolve the sensor`() { + // Integrations are closed before the executor drains, so a queued warm-up can run after + // close(). It must be a no-op rather than resolving the sensor and spinning up a HandlerThread. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + + deferredExecutor.runAll() + + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `re-registering after close re-arms shake detection`() { + // A second Sentry.init reusing the same integration must revive shake detection rather than + // stay off because of the closed latch. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + sut.register(fixture.scopes, fixture.options) + + deferredExecutor.runAll() + + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + @Test fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { val sut = fixture.getSut(useShakeGesture = false) From 5e269decf0940fea3ca550fb708cdeaa556bd72b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 15:06:44 +0200 Subject: [PATCH 024/102] ci(android): Bump Maestro to 2.7.0 for UI critical tests (#5798) Update the pinned Maestro CLI version used by the UI critical integration tests from 2.1.0 to the latest 2.7.0. No flow changes are required; all commands used remain supported and the Java 17 requirement is already satisfied by the CI job. Co-authored-by: Claude Opus 4.8 --- .github/workflows/integration-tests-ui-critical.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index d858ef8c4b2..f1f8e29a3a9 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -15,7 +15,7 @@ env: BUILD_PATH: "build/outputs/apk/release" APK_NAME: "sentry-uitest-android-critical-release.apk" APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" - MAESTRO_VERSION: "2.1.0" + MAESTRO_VERSION: "2.7.0" jobs: build: From e82419da4ab49467d1cb6d41ebd9674123746230 Mon Sep 17 00:00:00 2001 From: Syed Arsalan Hasan Date: Wed, 22 Jul 2026 10:36:02 +0500 Subject: [PATCH 025/102] Replace deprecated ThrowableProxy with LogEvent#getThrown() in sentry-log4j2 (#5751) * Replace deprecated ThrowableProxy with LogEvent#getThrown() in sentry-log4j2 * Add @Nullable annotation and CHANGELOG entry --------- Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/log4j2/SentryAppender.java | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b74388723..88bc0c7e11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) - Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. +- Replace deprecated `ThrowableProxy` with `LogEvent#getThrown()` in `sentry-log4j2` ([#5751](https://github.com/getsentry/sentry-java/pull/5751)) ### Dependencies diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index df0f9eeb2d2..0218b53518d 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -41,7 +41,6 @@ import org.apache.logging.log4j.core.config.plugins.PluginAttribute; import org.apache.logging.log4j.core.config.plugins.PluginElement; import org.apache.logging.log4j.core.config.plugins.PluginFactory; -import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -274,13 +273,12 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { event.setLogger(loggingEvent.getLoggerName()); event.setLevel(formatLevel(loggingEvent.getLevel())); - final ThrowableProxy throwableInformation = loggingEvent.getThrownProxy(); - if (throwableInformation != null) { + final @Nullable Throwable thrown = loggingEvent.getThrown(); + if (thrown != null) { final Mechanism mechanism = new Mechanism(); mechanism.setType(MECHANISM_TYPE); final Throwable mechanismException = - new ExceptionMechanismException( - mechanism, throwableInformation.getThrowable(), Thread.currentThread()); + new ExceptionMechanismException(mechanism, thrown, Thread.currentThread()); event.setThrowable(mechanismException); } From ae3e4c77308222ce6a67789896e235d08036f638 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 11:14:43 +0200 Subject: [PATCH 026/102] ci: Add Android 37 emulator to critical UI test matrix (JAVA-647) (#5775) Add an API level 37 (Android 17) emulator to the critical UI test matrix. API 37 ships only as a minor-versioned image, so the api-level is "37.0" (the platform and system image packages are android-37.0, not android-37) and only as google_apis_ps16k; there is no plain google_apis image. The runner's preinstalled avdmanager is too old to parse the minor version and writes target=android-0 into the AVD config, so the emulator clamps to API 3 and boots misconfigured. Update cmdline-tools before creating the AVD, and key the AVD cache on the tools version to invalidate broken caches. Workaround from ReactiveCircus/android-emulator-runner#482. Co-authored-by: Claude Opus 4.8 --- .../integration-tests-ui-critical.yml | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index f1f8e29a3a9..1d054079f1d 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -75,6 +75,10 @@ jobs: target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + - api-level: "37.0" # Android 17; API 37 ships only as a minor-versioned image + target: google_apis_ps16k # API 37 has no plain google_apis image + channel: canary # Necessary for ATDs + arch: x86_64 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -85,6 +89,22 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + # The runner ships an outdated avdmanager that writes target=android-0 into the + # AVD config for minor-versioned packages (android-37.x), so the emulator clamps + # to API 3 and boots misconfigured. Update cmdline-tools so avdmanager parses it. + # See https://github.com/ReactiveCircus/android-emulator-runner/issues/482 + - name: Update SDK cmdline-tools + id: cmdline-tools + run: | + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install "cmdline-tools;latest" > /dev/null + # sdkmanager won't overwrite the preinstalled dir, so it installs to latest-2. + if [ -d "$SDK/cmdline-tools/latest-2" ]; then + rm -rf "$SDK/cmdline-tools/latest" + mv "$SDK/cmdline-tools/latest-2" "$SDK/cmdline-tools/latest" + fi + echo "version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)" >> "$GITHUB_OUTPUT" + - name: AVD cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache @@ -92,7 +112,9 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} + # Keyed on the cmdline-tools version so AVDs created by the old, broken + # avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-tools${{ steps.cmdline-tools.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' From 5f42ae8d2b9df4994b23fcfd34c28cee2c4f8dab Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 13:25:28 +0200 Subject: [PATCH 027/102] chore(changelog): Merge duplicate Dependencies subsections under Unreleased (#5815) The Unreleased section contained two separate "### Dependencies" subsections, one added by #5793 and one by #5779. Merge them into a single subsection, keeping both entries. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88bc0c7e11a..1a72ca9eea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,6 @@ - Bump Native SDK from v0.15.3 to v0.15.4 ([#5793](https://github.com/getsentry/sentry-java/pull/5793)) - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) - -### Dependencies - - The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) ## 8.49.0 From df6bed6ee22dd7e5f3970c570e9062657ffa8a67 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 15:28:52 +0200 Subject: [PATCH 028/102] build: Bump target and compile SDK to 37 (JAVA-648) (#5796) * build: Bump target and compile SDK to 37 Raise targetSdk and compileSdk from 36 to 37 and remove the temporary GradleDependency lint suppression that was in place only until this bump. API 37 makes MediaCodecInfo.getVideoCapabilities() nullable, so guard the access in SimpleVideoEncoder to keep it compiling. Co-Authored-By: Claude Opus 4.8 * changelog * build: Drop obsolete OldTargetApi lint suppression Now that the integration test modules target API 37, lint no longer flags OldTargetApi, so remove the temporary suppression added while they were on 36. Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + build.gradle.kts | 14 -------------- gradle/libs.versions.toml | 4 ++-- .../build.gradle.kts | 2 -- .../sentry-uitest-android/build.gradle.kts | 2 -- .../android/replay/video/SimpleVideoEncoder.kt | 2 +- 6 files changed, 4 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a72ca9eea1..96d23812cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) - The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) +- The SDK has been fully tested for compatibility with Android 17 and platform 37; it is now compiled and tested against it ([#5796](https://github.com/getsentry/sentry-java/pull/5796)) ## 8.49.0 diff --git a/build.gradle.kts b/build.gradle.kts index 2e491d2a16c..764667ecd65 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -113,20 +113,6 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } - // AGP 9.2 bundles lint 9.2.1, which flags compileSdk 36 as outdated because 37 is available. - // We intentionally stay on compileSdk 36 until the API 37 bump (#5768), so silence that check - // for every Android module (library and application). - pluginManager.withPlugin("com.android.library") { - extensions.configure { - lintOptions { disable("GradleDependency") } - } - } - pluginManager.withPlugin("com.android.application") { - extensions.configure { - lintOptions { disable("GradleDependency") } - } - } - plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cdbb4326e9c..89de8fce039 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,8 +47,8 @@ springboot4 = "4.1.0" sqldelight = "2.3.2" # Android -targetSdk = "36" -compileSdk = "36" +targetSdk = "37" +compileSdk = "37" minSdk = "21" [plugins] diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index 04b5eaf6a23..c3ca2379a76 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -70,8 +70,6 @@ android { lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 9.2.1 expects API 37 but we target 36 - disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. checkReleaseBuilds = false diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 6fbba814f83..52c17199e4d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -69,8 +69,6 @@ android { lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 9.2.1 expects API 37 but we target 36 - disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. checkReleaseBuilds = false diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index de14aadaaab..04d573d793f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -81,7 +81,7 @@ internal class SimpleVideoEncoder( val videoCapabilities = mediaCodec.codecInfo.getCapabilitiesForType(muxerConfig.mimeType).videoCapabilities - if (!videoCapabilities.bitrateRange.contains(bitRate)) { + if (videoCapabilities != null && !videoCapabilities.bitrateRange.contains(bitRate)) { options.logger.log( DEBUG, "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one", From aafebb3a66d9c7f9273dbbc8517f925b0837ed9c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 16:31:58 +0200 Subject: [PATCH 029/102] docs(changelog): Add dedicated Android 17 support section (#5820) Promote the Android 17 compatibility note out of the Dependencies subsection into its own highlighted section so the officially supported platform is more visible to users reading the changelog. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d23812cc6..b9f7fc811bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Android 17 support + +- We've put Android 17 through a set of rigorous tests. We're now officially giving it the Sentry stamp of compatibility .([#5796](https://github.com/getsentry/sentry-java/pull/5796)) + ### Fixes - Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) @@ -17,7 +21,6 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) - The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) -- The SDK has been fully tested for compatibility with Android 17 and platform 37; it is now compiled and tested against it ([#5796](https://github.com/getsentry/sentry-java/pull/5796)) ## 8.49.0 From c0eb80252d73405b2a6bd1f84a9d0882fc6bc773 Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:37:22 +0000 Subject: [PATCH 030/102] release: 8.50.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f7fc811bc..6accb560053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.50.0 ### Android 17 support diff --git a/gradle.properties b/gradle.properties index edb73d3fb78..669629ea17f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ android.newDsl=false android.experimental.lint.version=9.2.1 # Release information -versionName=8.49.0 +versionName=8.50.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 023ef00380c20af75e4205b5ed82b37a04f22950 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 18:00:41 +0200 Subject: [PATCH 031/102] perf: Schedule session end on shared executor (JAVA-653) (#5819) * perf: Schedule session end on shared executor (JAVA-653) LifecycleWatcher created a java.util.Timer whose thread was spawned on the first background transition and lived for the rest of the process. Schedule the session-end task on the shared timer executor instead, whose single worker thread is reused and self-terminates when idle. If scheduling fails (executor already shut down), the session is ended right away instead of leaking. Co-Authored-By: Claude Fable 5 * changelog --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 6 ++ .../sentry/android/core/LifecycleWatcher.java | 60 ++++++++++--------- .../android/core/LifecycleWatcherTest.kt | 15 ++--- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6accb560053..9e1561ebfa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Performance + +- Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) + ## 8.50.0 ### Android 17 support diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 3d4cedb1b53..de1c40c570c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -8,9 +8,7 @@ import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; -import java.util.Timer; -import java.util.TimerTask; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -22,9 +20,8 @@ final class LifecycleWatcher implements AppState.AppStateListener { private final long sessionIntervalMillis; - private @Nullable TimerTask timerTask; - private final @NotNull LazyEvaluator timer = new LazyEvaluator<>(() -> new Timer(true)); - private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); + private @Nullable Future endSessionFuture; + private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock(); private final @NotNull IScopes scopes; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; @@ -104,29 +101,40 @@ public void onBackground() { } private void scheduleEndSession() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { cancelTask(); - timerTask = - new TimerTask() { - @Override - public void run() { - if (enableSessionTracking) { - scopes.endSession(); - } - scopes.getOptions().getReplayController().stop(); - scopes.getOptions().getContinuousProfiler().close(false); + final @NotNull Runnable endSession = + () -> { + if (enableSessionTracking) { + scopes.endSession(); } + scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); }; - timer.getValue().schedule(timerTask, sessionIntervalMillis); + try { + endSessionFuture = + scopes + .getOptions() + .getTimerExecutorService() + .schedule(endSession, sessionIntervalMillis); + } catch (Throwable e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Failed to schedule end of session. Ending it now.", e); + // if we cannot re-check after the session interval, end the session right away instead of + // leaving it open forever + endSession.run(); + } } } private void cancelTask() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timerTask != null) { - timerTask.cancel(); - timerTask = null; + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { + if (endSessionFuture != null) { + endSessionFuture.cancel(false); + endSessionFuture = null; } } } @@ -144,13 +152,7 @@ private void addAppBreadcrumb(final @NotNull String state) { @TestOnly @Nullable - TimerTask getTimerTask() { - return timerTask; - } - - @TestOnly - @NotNull - Timer getTimer() { - return timer.getValue(); + Future getEndSessionFuture() { + return endSessionFuture; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 09c4fae8dc4..ce518eabb05 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -7,6 +7,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.ReplayController import io.sentry.ScopeCallback +import io.sentry.SentryExecutorService import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.Session @@ -32,7 +33,8 @@ class LifecycleWatcherTest { private class Fixture { val scopes = mock() val dateProvider = mock() - val options = SentryOptions() + // a real executor so scheduled end-session tasks actually run + val options = SentryOptions().apply { setTimerExecutorService(SentryExecutorService(this)) } val replayController = mock() val continuousProfiler = mock() @@ -115,10 +117,10 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onBackground() - assertNotNull(watcher.timerTask) + assertNotNull(watcher.endSessionFuture) watcher.onForeground() - assertNull(watcher.timerTask) + assertNull(watcher.endSessionFuture) verify(fixture.scopes, never()).endSession() verify(fixture.replayController, never()).stop() @@ -186,13 +188,6 @@ class LifecycleWatcherTest { verify(fixture.scopes, never()).addBreadcrumb(any()) } - @Test - fun `timer is created if session tracking is enabled`() { - val watcher = - fixture.getSUT(enableAutoSessionTracking = true, enableAppLifecycleBreadcrumbs = false) - assertNotNull(watcher.timer) - } - @Test fun `if the scopes has already a fresh session running, don't start new one`() { val watcher = From 4670d89b9bdbc4d8346b20edf9489b4cb97bffcc Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 10:46:54 +0200 Subject: [PATCH 032/102] build(android): Pin AAR minCompileSdk to minSdk (#5823) * build(android): Pin AAR minCompileSdk to minSdk AGP 9 changed the default so a published library's AAR metadata minCompileSdk mirrors its compileSdk, which we recently bumped to 37. That would force every consumer of the Android SDK onto compileSdk 37. Pin minCompileSdk to our minSdk for all published Android library modules so consumers stay free to compile against any SDK we support, preserving the pre-AGP-9 behavior. Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ build.gradle.kts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1561ebfa3..f46c77b306c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) + ### Performance - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) diff --git a/build.gradle.kts b/build.gradle.kts index 764667ecd65..7d8cfcb626e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -177,6 +177,13 @@ subprojects { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + + // AGP 9 defaults the AAR metadata minCompileSdk to the library's compileSdk, + // which would force every consumer onto that compile SDK. Pin it to our minSdk + // so consumers remain free to compile against any SDK we support, as before. + defaultConfig { + aarMetadata { minCompileSdk = libs.versions.minSdk.get().toInt() } + } } } From bbcf99a5d31eadda0ad23b3bef08bfda0db63756 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 16:00:50 +0200 Subject: [PATCH 033/102] fix(replay): Release MediaMuxer when the encoder fails to start (#5607) * fix(replay): Release MediaMuxer when the encoder fails to start The MediaMuxer is opened eagerly in SimpleVideoEncoder's constructor, but its release() was only reachable on paths that assume start() succeeded. Two cases leaked it: - createVideoOf constructed the encoder and called start() in one expression, so when start() threw the encoder was never assigned and release() could never run. - SimpleVideoEncoder.release() released the muxer as the last statement of the try block, after draining and stopping the codec. Draining a codec that never started throws, skipping the muxer release. Release the encoder if start() throws, and always release the muxer from a finally block so it is freed even when draining/stopping the codec fails. This surfaced as a CloseGuard "resource was acquired but never released" warning. Complements #5583. Co-Authored-By: Claude Opus 4.8 * changelog * fix(replay): Skip MediaMuxer stop when no samples were written MediaMuxer.stop() throws IllegalStateException when the muxer was started but no sample was ever written to its track. SimpleMp4FrameMuxer.release() only guarded against the never-started case, so a started-but-empty muxer made release() throw. Because release() runs from SimpleVideoEncoder's finally block, that throw propagates out to createVideoOf, which treats release() as safe cleanup; the encoder is left dangling and the orphan video file is never deleted. Only call stop() when at least one sample was written; muxer.release() on a started-but-not-stopped muxer is safe. * Format code * fix(replay): Guard each native release so cleanup never propagates The finally block in SimpleVideoEncoder.release() released the codec, surface, and muxer without guards. release() is treated by callers such as createVideoOf as safe cleanup, but MediaMuxer.stop() (reached via frameMuxer.release()) can still throw IllegalStateException on a genuine file-finalization failure even when samples were written. That would skip the remaining releases and propagate out of release(). Guard each release independently so failing to free one resource neither skips the others nor escapes to the caller. --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../io/sentry/android/replay/ReplayCache.kt | 11 ++++++++- .../replay/video/SimpleMp4FrameMuxer.kt | 7 +++--- .../replay/video/SimpleVideoEncoder.kt | 23 +++++++++++++++---- .../sentry/android/replay/ReplayCacheTest.kt | 23 +++++++++++++++++++ .../replay/util/ReplayShadowMediaCodec.kt | 4 ++++ 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f46c77b306c..edd2c8d174d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) +- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) ### Performance diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index b54177bca9b..55891fea1a7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -162,7 +162,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate = bitRate, ), ) - .also { it.start() } + .apply { + // the constructor already opened the MediaMuxer, so release it if start() fails, + // otherwise the encoder is never assigned and its resources leak (CloseGuard warning) + try { + start() + } catch (t: Throwable) { + release() + throw t + } + } } val step = 1000 / frameRate.toLong() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index e32af9bb44b..0063cf636e4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -67,9 +67,10 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - // stop() throws if the muxer was never started (e.g. no frame was ever muxed), so we guard it - // to ensure release() is always reached and the underlying resources are freed - if (started) { + // stop() throws unless the muxer was started AND at least one sample was written, so we guard + // it + // to ensure muxer.release() is always reached and the underlying resources are freed + if (started && videoFrames > 0) { muxer.stop() } muxer.release() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 04d573d793f..d0cafd9f1a9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -287,12 +287,25 @@ internal class SimpleVideoEncoder( onClose?.invoke() drainCodec(true) mediaCodec.stop() - mediaCodec.release() - surface?.release() - - frameMuxer.release() - } catch (e: Throwable) { + } catch (e: RuntimeException) { options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } finally { + // always release the native resources, even if draining/stopping the codec above threw (e.g. + // when the encoder failed to fully start), otherwise they leak (CloseGuard warning). guard + // each + // call so failing to release one resource neither skips the others nor propagates to callers, + // which treat release() as safe cleanup + releaseQuietly("MediaCodec") { mediaCodec.release() } + releaseQuietly("Surface") { surface?.release() } + releaseQuietly("MediaMuxer") { frameMuxer.release() } + } + } + + private inline fun releaseQuietly(name: String, block: () -> Unit) { + try { + block() + } catch (e: RuntimeException) { + options.logger.log(DEBUG, "Failed to release $name", e) } } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 8b64ca5caeb..23e8d60044f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -36,6 +37,7 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBitmapFactory +import org.robolectric.shadows.ShadowCloseGuard @RunWith(AndroidJUnit4::class) @Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class]) @@ -56,6 +58,7 @@ class ReplayCacheTest { @BeforeTest fun `set up`() { ReplayShadowMediaCodec.framesToEncode = 5 + ReplayShadowMediaCodec.throwOnStart = false ShadowBitmapFactory.setAllowInvalidImageData(true) } @@ -93,6 +96,26 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `releases the muxer when the encoder fails to start`() { + ReplayShadowMediaCodec.throwOnStart = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + ShadowCloseGuard.reset() + assertFailsWith { + replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + } + + val muxerLeaks = + ShadowCloseGuard.getErrors().filter { error -> + error.stackTrace.any { it.className.contains("MediaMuxer") } + } + assertTrue(muxerLeaks.isEmpty(), "MediaMuxer was not released: $muxerLeaks") + } + @Test fun `deletes frames after creating a video`() { ReplayShadowMediaCodec.framesToEncode = 3 diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt index f60c6688386..60ccb157747 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -15,12 +15,16 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() { companion object { var frameRate = 1 var framesToEncode = 5 + var throwOnStart = false } private val encoded = AtomicBoolean(false) @Implementation fun start() { + if (throwOnStart) { + throw IllegalStateException("Simulated codec start failure") + } super.native_start() } From 01572ad9a54c43d7cb53a1a7955a683b38ff521d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 16:01:34 +0200 Subject: [PATCH 034/102] perf(android): mini optimization: Guard manifest metadata debug logs behind isEnabled (#5790) * perf(android): Guard manifest metadata debug logs behind isEnabled (JAVA-614) The read helpers in ManifestMetadataReader built the debug message (key + " read: " + value) unconditionally at the call site, and DiagnosticLogger only filtered on options.isDebug() afterward. With debug=false (the default) that discarded ~100 StringBuilder/String allocations per init. Guard the six read helpers with logger.isEnabled(DEBUG) so the message is only constructed when debug logging is actually on. Behavior is unchanged; this is a pure allocation/GC-pressure reduction on the init path. Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ .../android/core/ManifestMetadataReader.java | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd2c8d174d..35f1f30820b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Improvements + +- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) + ### Fixes - Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 7a9cd8a4d13..469f15e3f3b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -779,7 +779,9 @@ private static boolean readBool( final @NotNull String key, final boolean defaultValue) { final boolean value = metadata.getBoolean(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -789,7 +791,9 @@ private static boolean readBool( final @NotNull String key, final @Nullable String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -799,14 +803,18 @@ private static boolean readBool( final @NotNull String key, final @NotNull String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } private static @Nullable List readList( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { final String value = metadata.getString(key); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } if (value != null) { return Arrays.asList(value.split(",", -1)); } else { @@ -821,7 +829,9 @@ private static double readDouble( if (value == -1) { value = ((Integer) metadata.getInt(key, -1)).doubleValue(); } - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -832,7 +842,9 @@ private static long readLong( final long defaultValue) { // manifest meta-data only reads int if the value is not big enough final long value = metadata.getInt(key, (int) defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } From 02e6bc88fab20573b7205ba54d08a46a63c9ec43 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 16:02:02 +0200 Subject: [PATCH 035/102] build: Disable Android debug variants outside the sample app (#5825) Unit tests already target the release variant (testBuildType = release) and CI disabled the debug variant. Disable the debug variant locally too so local builds match CI and skip building the unused variant. The sample app keeps both variants for manual runs and profiling. Update the contributor docs and test skill to reference testReleaseUnitTest instead of testDebugUnitTest. Co-authored-by: Claude Opus 4.8 --- .claude/skills/test/SKILL.md | 6 +++--- .cursor/rules/coding.mdc | 2 +- AGENTS.md | 4 ++-- buildSrc/src/main/java/Config.kt | 4 +++- sentry-android-core/build.gradle.kts | 6 +++--- sentry-android-distribution/build.gradle.kts | 4 ++-- sentry-android-fragment/build.gradle.kts | 4 ++-- sentry-android-navigation/build.gradle.kts | 4 ++-- sentry-android-ndk/build.gradle.kts | 4 ++-- sentry-android-replay/build.gradle.kts | 4 ++-- sentry-android-sqlite/build.gradle.kts | 4 ++-- sentry-android-timber/build.gradle.kts | 4 ++-- sentry-compose/build.gradle.kts | 4 ++-- sentry-launchdarkly-android/build.gradle.kts | 4 ++-- sentry-samples/sentry-samples-android/build.gradle.kts | 7 +++---- 15 files changed, 33 insertions(+), 32 deletions(-) diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index bdef12364f3..7e6ddd37294 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -40,9 +40,9 @@ Determine the Gradle test task: | Module Pattern | Test Task | |---------------|-----------| -| `sentry-android-*` | `testDebugUnitTest` | -| `sentry-compose*` | `testDebugUnitTest` | -| `*-android` | `testDebugUnitTest` | +| `sentry-android-*` | `testReleaseUnitTest` | +| `sentry-compose*` | `testReleaseUnitTest` | +| `*-android` | `testReleaseUnitTest` | | Everything else | `test` | **Interactive mode:** Before running, read the test class file and use AskUserQuestion to ask: diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc index fbcda27b120..70ac39e43a2 100644 --- a/.cursor/rules/coding.mdc +++ b/.cursor/rules/coding.mdc @@ -24,7 +24,7 @@ sentry-java is the Java and Android SDK for Sentry. This repository contains the ./gradlew check # Run unit tests for a specific file -./gradlew '::testDebugUnitTest' --tests="**" --info +./gradlew '::testReleaseUnitTest' --tests="**" --info ``` ## Contributing Guidelines diff --git a/AGENTS.md b/AGENTS.md index ec2ef62974c..de4e29376ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,13 +44,13 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ### Testing ```bash # Run unit tests for a specific file -./gradlew '::testDebugUnitTest' --tests="**" --info +./gradlew '::testReleaseUnitTest' --tests="**" --info # Run system tests (requires Python virtual env) make systemTest # Run specific test suites -./gradlew :sentry-android-core:testDebugUnitTest +./gradlew :sentry-android-core:testReleaseUnitTest ./gradlew :sentry:test ``` diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 7575670a38a..09d2869988b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -12,8 +12,10 @@ object Config { object Android { val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + // Debug variants are disabled everywhere. Unit tests run against the release + // variant, so building the debug variant would only add overhead. fun shouldSkipDebugVariant(name: String?): Boolean { - return System.getenv("CI")?.toBoolean() ?: false && name == "debug" + return name == "debug" } } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f92876530fd..23248d6dae4 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -34,8 +34,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { compilerOptions.jvmTarget = JVM_1_8 } @@ -83,7 +83,7 @@ tasks.withType().configureEach { // outputs so Gradle's build cache restores them on cache hits (otherwise the CLI upload step // finds an empty directory). tasks - .matching { it.name == "testDebugUnitTest" || it.name == "testReleaseUnitTest" } + .matching { it.name == "testReleaseUnitTest" } .configureEach { outputs.dir(layout.buildDirectory.dir("test-snapshots")) } dependencies { diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 363bce003f4..c699c364c3b 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -13,8 +13,8 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" testOptions { diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 197c0b05d6a..3ef1c1934f8 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -25,8 +25,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 5ac9842548f..6c1aa62a57d 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -25,8 +25,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index ba651da56ac..6867d964124 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -26,8 +26,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 02f2dab3d4d..45838cb1e1b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -34,8 +34,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index e1e3bc68765..9cf09fd76fd 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -25,8 +25,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index e55b8b0e3c5..3c8ac1ea1e4 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -32,8 +32,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 388bfe832bb..4ebd9349662 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -92,8 +92,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" testOptions { diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index 32b3641203f..f201c57b97d 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -27,8 +27,8 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - // AGP 9 only generates unit tests for the testBuildType. CI disables the debug - // variant, so unit tests must target release to run at all. + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. testBuildType = "release" kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index f994081450d..31009f6dbb9 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -114,6 +114,9 @@ android { // Suffix the id so debug and release builds can be installed side by side. applicationIdSuffix = ".debug" addManifestPlaceholders(mapOf("sentryDebug" to true, "sentryEnvironment" to "debug")) + // The SDK modules only publish a release variant, so fall back to it for the + // debug build of the sample. + matchingFallbacks += "release" } getByName("release") { isMinifyEnabled = true @@ -133,10 +136,6 @@ android { kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } - androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) - } - androidComponents.onVariants { variant -> variant.buildConfigFields?.put( "USE_SAGP", From cc65dda49f6371b5f1c3ddd455a5de84f2f4a5ab Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 24 Jul 2026 09:45:53 +0200 Subject: [PATCH 036/102] Fix Changelog (#5829) --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f1f30820b..cfd2e8d0f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,18 @@ ### Fixes -- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) ### Performance - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +## 8.50.1 + +### Fixes + +- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) + ## 8.50.0 ### Android 17 support From 6335e357adb957c9397e82c74c3ab20f5d9392f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:51:36 +0000 Subject: [PATCH 037/102] chore(deps): bump the github-actions group across 1 directory with 2 updates (#5809) Bumps the github-actions group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.1 to 4.37.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81) Updates `github/codeql-action/analyze` from 4.37.1 to 4.37.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.37.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 919d09659b9..33566af8602 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # pin@v2 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # pin@v2 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # pin@v2 From 7414e9b7c87593480d6b235d7bf14393bcf91b8a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Jul 2026 17:36:53 +0200 Subject: [PATCH 038/102] fix(replay): Prevent concurrent PixelCopy frame access (#5808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(replay): Prevent concurrent PixelCopy frame access Keep PixelCopy, masking, compositing, and cleanup from accessing the shared bitmap concurrently. Fixes GH-5340 Co-Authored-By: Codex * changelog * fix(android-replay): Preserve pending PixelCopy capture Re-arm the recorder's content-change gate when a capture is skipped because another frame is still in flight. This ensures the latest UI state is retried on the next capture interval. Refs GH-5340 Co-Authored-By: OpenAI Codex * fix(replay): Release PixelCopy frame gate and cleanup on failure paths Two bugbot findings on #5808: - Frame gate stuck on callback errors: viewhierarchy traversal or captureSurfaceViews throwing between the PixelCopy success check and the executor submit left frameInFlight = true forever, silently wedging all future captures. Wrap the post-success block in a try/finally that releases the gate unless work was successfully handed off. - Cleanup lost after executor shutdown: close() typically runs after ReplayIntegration has shut down the replay executor, so submit() returns null and the bitmap + maskRenderer were never released. Fall back to running cleanup inline in that case. * chore(replay): Drop redundant handedOff flag in capture callback The mask branch already knows via 'submitted == null' whether the executor took ownership; the surface-view branch always hands off. Flatten to explicit finishFrame() calls on the two failure paths (mask null-submit, outer catch) instead of tracking handoff state across a try/finally. * fix(replay): Distinguish inline execution from executor rejection ReplayExecutorService.submit previously returned null both when the caller was on the worker thread (task ran inline) and when the executor rejected the submission (task did NOT run). Callers had no way to tell them apart. Return a CompletedFuture sentinel for inline execution; null now means only rejection. Also narrow PixelCopyStrategy's frame-processing catch from Throwable to RuntimeException so OOM/LinkageError still propagate. * chore(replay): Drop redundant cleanupScheduled guard Cleanup body is already idempotent (screenshot.isRecycled check, MaskRenderer.close guards on isInitialized + isRecycled). A stray extra scheduleCleanup would just submit a no-op — not worth the AtomicBoolean. * test(replay): Fix recursive close and assert masking is skipped on close race The mock executor closed the strategy on every submit(); since close() itself submits the cleanup task, this recursed close() -> scheduleCleanup() -> submit() until the stack overflowed. Close only when the mask task is submitted. The prior "does not crash" assertion was also vacuous under the frameInFlight gate (passed even with the isClosed guard removed). Assert instead that no screenshot is emitted once close() races masking, which fails if the guard in applyMaskingAndNotify is removed. Co-Authored-By: Claude Opus 4.8 * fix(replay): Guard finishFrame cleanup with CAS to avoid recycling a bitmap a new capture is using finishFrame cleared frameInFlight before checking isClosed, so a new capture could take the gate and start PixelCopy into the shared screenshot while the old finishFrame went on to scheduleCleanup after close() flipped isClosed — recycling the bitmap mid-write. Re-take the gate with compareAndSet before cleaning up so the losing frame backs off. Co-Authored-By: Claude Opus 4.8 * fix(replay): Make close's idle cleanup claim the gate atomically close() checked !frameInFlight.get() non-atomically before scheduleCleanup, so a capture racing in after the check could take the gate, see isClosed, and have its finishFrame schedule a second cleanup. Extract the shared "claim the gate, then the winner cleans up once" invariant into cleanUpIfIdle() so close() and finishFrame() use the same CAS. Co-Authored-By: Claude Opus 4.8 * test(replay): Return CompletedFuture from inlineExecutor to match ReplayExecutorService inlineExecutor() returned null from submit, which under the executor's contract means "rejected" — making capture()'s null-fallback finishFrame run on top of the mask task's own finally, a double-release production never hits on the inline path. Return CompletedFuture so the fixture matches the real inline semantics. Co-Authored-By: Claude Opus 4.8 * fix(replay): Hold frame gate across emitLastScreenshot to prevent concurrent bitmap access emitLastScreenshot runs on the main thread, so the downstream consumer queues its bitmap read (JPEG compress) to the executor asynchronously. Without the gate, the next capture tick's PixelCopy.request writes into the shared screenshot while the queued read is still in flight. Submit the consumer call to the executor so it runs inline on the worker thread while the gate is held, same pattern as the masked capture path. Also fixes the close-race test mock to return CompletedFuture instead of null, matching the executor contract (same fix as inlineExecutor in cd3a7c0). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Codex Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../replay/screenshot/PixelCopyStrategy.kt | 208 ++++++++++---- .../replay/util/ReplayExecutorService.kt | 27 +- .../screenshot/PixelCopyStrategyTest.kt | 266 +++++++++++++++++- 4 files changed, 434 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd2e8d0f78..b8fc67b6b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) ### Performance diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 4b9618df6ec..14871b45d6d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -59,6 +59,7 @@ internal class PixelCopyStrategy( private val contentChanged = AtomicBoolean(false) private val unstableCaptures = AtomicInteger(0) private val isClosed = AtomicBoolean(false) + private val frameInFlight = AtomicBoolean(false) private val dstOverPaint by lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } @@ -77,8 +78,15 @@ internal class PixelCopyStrategy( return } + if (!frameInFlight.compareAndSet(false, true)) { + options.logger.log(DEBUG, "PixelCopyStrategy capture is already in flight, skipping") + markContentChanged() + return + } + if (isClosed.get()) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, not capturing screenshot") + finishFrame() return } @@ -90,6 +98,7 @@ internal class PixelCopyStrategy( { copyResult: Int -> if (isClosed.get()) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, ignoring capture result") + finishFrame() return@request } @@ -97,44 +106,64 @@ internal class PixelCopyStrategy( options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) unstableCaptures.set(0) lastCaptureSuccessful.set(false) + finishFrame() return@request } val changedDuringCapture = contentChanged.get() if (changedDuringCapture && shouldSkipUnstableCapture()) { + finishFrame() return@request } - // TODO: disableAllMasking here and dont traverse? - val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) - val surfaceViewNodes = - if (options.sessionReplay.isCaptureSurfaceViews) { - mutableListOf() - } else { - null - } - root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) - - if (surfaceViewNodes.isNullOrEmpty()) { - executor.submit( - ReplayRunnable("screenshot_recorder.mask") { - applyMaskingAndNotify( - root, - viewHierarchy, - resetUnstableCaptures = !changedDuringCapture, + // Release the frame gate if anything below throws before we hand work off to the + // executor — otherwise a single failure wedges captures forever. + try { + // TODO: disableAllMasking here and dont traverse? + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) + val surfaceViewNodes = + if (options.sessionReplay.isCaptureSurfaceViews) { + mutableListOf() + } else { + null + } + root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) + + if (surfaceViewNodes.isNullOrEmpty()) { + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { + try { + applyMaskingAndNotify( + root, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } finally { + finishFrame() + } + } ) + if (submitted == null) { + finishFrame() } - ) - } else { - // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger - // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. - markContentChanged() - captureSurfaceViews( - root, - surfaceViewNodes, - viewHierarchy, - resetUnstableCaptures = !changedDuringCapture, - ) + } else { + // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger + // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. + markContentChanged() + captureSurfaceViews( + root, + surfaceViewNodes, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } + } catch (e: RuntimeException) { + // OEM View subclasses have been observed throwing during hierarchy traversal + // (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame + // doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate. + options.logger.log(WARNING, "Failed to process replay frame", e) + finishFrame() } }, mainLooperHandler.handler, @@ -143,6 +172,7 @@ internal class PixelCopyStrategy( options.logger.log(WARNING, "Failed to capture replay recording", e) unstableCaptures.set(0) lastCaptureSuccessful.set(false) + finishFrame() } } @@ -272,37 +302,46 @@ internal class PixelCopyStrategy( windowY: Int, resetUnstableCaptures: Boolean, ) { - executor.submit( - ReplayRunnable("screenshot_recorder.composite") { - if (isClosed.get() || screenshot.isRecycled) { - options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") - recycleCaptures(captures) - return@ReplayRunnable - } + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.composite") { + try { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") + recycleCaptures(captures) + return@ReplayRunnable + } - for (capture in captures) { - if (capture == null) continue - if (capture.bitmap.isRecycled) continue - - compositeSurfaceViewInto( - screenshotCanvas, - dstOverPaint, - tmpSrcRect, - tmpDstRect, - capture.bitmap, - capture.x, - capture.y, - windowX, - windowY, - config.scaleFactorX, - config.scaleFactorY, - ) - capture.bitmap.recycle() - } + for (capture in captures) { + if (capture == null) continue + if (capture.bitmap.isRecycled) continue + + compositeSurfaceViewInto( + screenshotCanvas, + dstOverPaint, + tmpSrcRect, + tmpDstRect, + capture.bitmap, + capture.x, + capture.y, + windowX, + windowY, + config.scaleFactorX, + config.scaleFactorY, + ) + capture.bitmap.recycle() + } - applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) - } - ) + applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + recycleCaptures(captures) + finishFrame() + } } private fun recycleCaptures(captures: Array) { @@ -322,15 +361,57 @@ internal class PixelCopyStrategy( } override fun emitLastScreenshot() { - if (lastCaptureSuccessful() && !screenshot.isRecycled) { - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + if (!frameInFlight.compareAndSet(false, true)) { + return + } + if (!lastCaptureSuccessful() || screenshot.isRecycled) { + finishFrame() + return + } + // Submit to the executor so the downstream consumer's bitmap read (JPEG compress) runs inline + // on the worker thread while the gate is held, same as the masked capture path. + val submitted = + executor.submit( + ReplayRunnable("PixelCopyStrategy.emit") { + try { + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + finishFrame() } } override fun close() { isClosed.set(true) unstableCaptures.set(0) - executor.submit( + cleanUpIfIdle() + } + + private fun finishFrame() { + frameInFlight.set(false) + if (isClosed.get()) { + cleanUpIfIdle() + } + } + + /** + * Schedules cleanup only for the caller that owns the gate. Whoever wins [frameInFlight]'s CAS + * (close when no frame is running, or the finishFrame of the last in-flight frame after close) + * runs cleanup exactly once; a racing capture that took the gate loses the CAS and backs off, so + * we never recycle the shared screenshot while that capture is still using it. + */ + private fun cleanUpIfIdle() { + if (frameInFlight.compareAndSet(false, true)) { + scheduleCleanup() + } + } + + private fun scheduleCleanup() { + val cleanup = ReplayRunnable( "PixelCopyStrategy.close", { @@ -344,7 +425,12 @@ internal class PixelCopyStrategy( maskRenderer.close() }, ) - ) + // ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown); + // inline execution on the worker thread returns a completed future. Fall back to running + // cleanup here so the bitmap + mask renderer are freed even when the executor is dead. + if (executor.submit(cleanup) == null) { + cleanup.run() + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt index 9e9491f516f..5ba334f8029 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt @@ -4,6 +4,7 @@ import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import java.util.concurrent.Future import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.MILLISECONDS /** @@ -14,11 +15,20 @@ internal class ReplayExecutorService( private val delegate: ScheduledExecutorService, private val options: SentryOptions, ) : ScheduledExecutorService by delegate { + /** + * Submits [task] for execution and returns a [Future] describing what happened. The return value + * has three distinct outcomes callers can rely on: + * - [CompletedFuture] — the caller is already on the replay worker thread, so the task was run + * inline before this method returned. Skips the queue. + * - A regular [Future] from the underlying [ScheduledExecutorService] — the task was queued and + * will run asynchronously. + * - `null` — the underlying executor rejected the submission (typically because it has been shut + * down). The task did NOT run; callers that need cleanup must handle it themselves. + */ override fun submit(task: Runnable): Future<*>? { if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { - // we're already on the worker thread, no need to submit task.run() - return null + return CompletedFuture } return try { delegate.submit { @@ -68,3 +78,16 @@ internal class ReplayExecutorService( } internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate + +/** A Future that represents an already-completed inline execution — never used as null. */ +internal object CompletedFuture : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + + override fun isCancelled(): Boolean = false + + override fun isDone(): Boolean = true + + override fun get() {} + + override fun get(timeout: Long, unit: TimeUnit) {} +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index 779cf7d4311..0098ab24fc6 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -25,8 +25,11 @@ import io.sentry.SentryOptions import io.sentry.android.replay.ExecutorProvider import io.sentry.android.replay.ScreenshotRecorderCallback import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.CompletedFuture import io.sentry.android.replay.util.DebugOverlayDrawable import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.ReplayRunnable +import java.util.concurrent.Future import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -85,7 +88,10 @@ class PixelCopyStrategyTest { return mock { doAnswer { (it.arguments[0] as Runnable).run() - null // submit(Runnable) returns Future; returning Unit breaks the cast + // Mirror ReplayExecutorService's inline contract: a completed future, not null. Null + // means "rejected" and would make capture() run its null-fallback finishFrame on top of + // the task's own, a double-release production never does on the inline path. + CompletedFuture } .whenever(mock) .submit(any()) @@ -112,25 +118,30 @@ class PixelCopyStrategyTest { } @Test - fun `when close is called while executor task is running, does not crash with recycled bitmap`() { + fun `when close races the mask task, masking is skipped and no screenshot is emitted`() { val activity = buildActivity(SimpleActivity::class.java).setup() shadowOf(Looper.getMainLooper()).idle() var strategy: PixelCopyStrategy? = null val failure = AtomicReference() - // Custom executor that closes the strategy before executing tasks + // Custom executor that closes the strategy right before running the mask task, to simulate + // close() racing an in-flight mask task. We key off the mask task specifically (not "the first + // submit") because close() itself submits the cleanup task — closing again when that runs would + // recurse via close() -> scheduleCleanup() -> submit(), a loop no real code path can produce. val executorThatClosesFirst = mock() whenever(executorThatClosesFirst.submit(any())).doAnswer { val task = it.getArgument(0) - strategy?.close() + if ((task as? ReplayRunnable)?.taskName == "screenshot_recorder.mask") { + strategy?.close() + } try { task.run() } catch (e: Throwable) { // PixelCopyStrategy swallows the exception, so we have to capture it here and rethrow later failure.set(e) } - null + CompletedFuture } strategy = fixture.getSut(executor = executorThatClosesFirst) @@ -138,6 +149,251 @@ class PixelCopyStrategyTest { shadowOf(Looper.getMainLooper()).idle() if (failure.get() != null) throw failure.get() + // close() landed before masking ran, so applyMaskingAndNotify must bail out early and never + // hand a screenshot to the callback after the strategy is closed. + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while PixelCopy is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + + strategy.capture(root) + strategy.capture(root) + + assertTrue(fixture.contentChangedMarked.get()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback).onScreenshotRecorded(any()) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while masking is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks += it.getArgument(0) + mock>() + } + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + tasks.removeAt(0).run() + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot skips while frame is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureStableFrame(strategy, root) + + strategy.capture(root) + strategy.emitLastScreenshot() + + verify(fixture.callback).onScreenshotRecorded(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot holds the frame gate until the emit task drains`() { + // emit submits the consumer call to the executor so the bitmap read (JPEG compress) runs + // inline on the worker thread while the gate is held — same pattern as the masked capture path. + // Invariant: while the emit task is still queued (gate held), a racing capture is dropped. + // Without the gate (old `if (!frameInFlight.get())`) that capture proceeds -> extra frame. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks.add(it.arguments[0] as Runnable) + mock>() + } + val strategy = fixture.getSut(executor) + + // Set up a successful last capture: capture -> queued mask task -> drain releases the gate. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Emit takes the gate and queues the consumer task (still pending). + strategy.emitLastScreenshot() + // Callback hasn't fired yet — the task is queued, not drained. + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // A capture racing in before the emit task drains must be dropped (gate held). + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Drain the emit task -> callback fires, gate released -> captures resume. + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + captureStableFrame(strategy, root) + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(3)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close defers cleanup until PixelCopy completes`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() + + verify(executor, never()).submit(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close-triggered cleanup keeps the frame gate so a racing capture cannot double-clean up`() { + // Guards the CAS handoff in finishFrame(). The real race is a 3-thread interleave (a new + // capture takes the gate the instant finishFrame releases it, then the old finishFrame recycles + // the bitmap the new capture is writing) and isn't deterministically reproducible single- + // threaded. This exercises its observable invariant instead: when finishFrame cleans up on + // close, it must re-take the gate (frameInFlight stays held), so any later capture is dropped + // rather than sneaking through to schedule a *second* cleanup on the shared screenshot. + // Without the CAS (plain frameInFlight.set(false)) the gate is left free and the follow-up + // capture reaches the isClosed guard and schedules cleanup again -> 2 submits. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() // in-flight -> cleanup deferred, no submit yet + + // PixelCopy completes; the callback sees isClosed and runs finishFrame -> the one cleanup. + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // A capture racing in after close must be dropped (gate still held), not schedule cleanup + // again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `idle close claims the gate so a racing capture cannot schedule a second cleanup`() { + // Mirror of the finishFrame guard, but for close()'s idle path (no frame in flight). close() + // must atomically claim the gate before scheduling cleanup; otherwise a capture racing in right + // after the check can take the gate, see isClosed, run finishFrame and schedule cleanup a + // second + // time. Both cleanups are idempotent, but a single submit is the invariant we keep uniform. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.close() // idle -> claims gate, schedules the one cleanup + // A capture landing after close must be dropped (gate held), not schedule cleanup again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `frame gate is released when masking submit is rejected`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + // Simulate an already-shutdown executor: submit returns null. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // Gate must have been released; a follow-up capture should proceed rather than being dropped. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(2)).submit(any()) + } + + @Test + fun `close cleans up inline when executor is already shut down`() { + // submit returns null → previously the bitmap + maskRenderer would leak. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.close() + + // No crash and the submit was attempted exactly once (cleanup ran inline as fallback). + verify(executor).submit(any()) } @Test From a6214895f88fbf112bb7fd69d67b27cfebc92696 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:45:19 +0200 Subject: [PATCH 039/102] ci(warden): Drop anthropic model pin so CI inherits org default (#5839) getsentry Warden CI only provides WARDEN_OPENROUTER_API_KEY. Pinning anthropic/claude-sonnet-4-6 in repo warden.toml fails auth. Unset the model so runs inherit getsentry/.github openrouter/moonshotai/kimi-k3. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Roman Zavarnitsyn --- warden.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/warden.toml b/warden.toml index 3ce15f9a11f..f13067372ce 100644 --- a/warden.toml +++ b/warden.toml @@ -1,7 +1,10 @@ version = 1 [defaults] -model = "anthropic/claude-sonnet-4-6" +# Model intentionally unset so CI inherits the org base default from +# getsentry/.github/warden.toml (currently openrouter/moonshotai/kimi-k3). +# getsentry Warden CI only provides WARDEN_OPENROUTER_API_KEY; pinning an +# anthropic/* model fails auth. # Warden's schema does not support per-skill verification config; this is the only # placement available. Disabled for attribution policy checks: a second verifier From cb927487b3068f1fd6f99b7ceb8a2d47feea1120 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:00 +0200 Subject: [PATCH 040/102] ci(android): Increase API 37 emulator memory (#5840) * ci(android): Increase API 37 emulator memory Co-Authored-By: Roman Zavarnitsyn * ci: Include emulator memory in AVD cache key Co-Authored-By: Roman Zavarnitsyn --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Roman Zavarnitsyn --- .../workflows/integration-tests-ui-critical.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 1d054079f1d..2ee76141eb2 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -63,22 +63,27 @@ jobs: target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + memory: 4096 - api-level: 33 # Android 13 target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + memory: 4096 - api-level: 35 # Android 15 target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + memory: 4096 - api-level: 36 # Android 16 target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + memory: 4096 - api-level: "37.0" # Android 17; API 37 ships only as a minor-versioned image target: google_apis_ps16k # API 37 has no plain google_apis image channel: canary # Necessary for ATDs arch: x86_64 + memory: 8192 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -112,9 +117,9 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - # Keyed on the cmdline-tools version so AVDs created by the old, broken - # avdmanager are invalidated automatically. - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-tools${{ steps.cmdline-tools.outputs.version }} + # Keyed on memory and the cmdline-tools version so incompatible snapshots + # and AVDs created by the old, broken avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' @@ -127,7 +132,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -151,7 +156,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" From 3dd4c87e658f87d24d2e744b84a82ff6ae4c3d39 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 27 Jul 2026 13:07:14 +0200 Subject: [PATCH 041/102] perf(json): Avoid exceptions when typing JSON numbers (JAVA-536) (#5783) * perf(json): Avoid exceptions when typing JSON numbers (JAVA-536) JsonObjectDeserializer typed numbers by calling nextInt() and catching the NumberFormatException it throws for every non-integer value, then falling back to nextDouble(). For payloads full of floating-point values (timestamps, measurements) this threw and filled a stack trace on nearly every number, dominating the cost of deserializing arbitrary objects. Parse the value as a double once and narrow it back to an int only when it is integral and fits, which avoids the throws. Return types are unchanged (Integer for values that fit an int, Double otherwise), so callers that read integers out of the generic object tree are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../io/sentry/JsonObjectDeserializer.java | 19 ++++--- .../io/sentry/JsonObjectDeserializerTest.kt | 50 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fc67b6b53..c2e8119a422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Performance - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +- Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) ## 8.50.1 diff --git a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java index 0916f6e82d5..cd1d99d66fb 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java @@ -172,17 +172,16 @@ private boolean handlePrimitive(NextValue callback) throws IOException { } private Object nextNumber(JsonObjectReader reader) throws IOException { - try { - return reader.nextInt(); - } catch (Exception exception) { - // Need to try/fail as there are no int/double/long tokens. + // JSON has no int/double token distinction. Probing with reader.nextInt() and catching the + // NumberFormatException it throws for every non-integer (e.g. timestamps) dominated the cost of + // deserializing arbitrary objects. Read once as a double and narrow to an int only when the + // value is integral and fits, which reproduces the previous return types without any throws. + final double value = reader.nextDouble(); + final int intValue = (int) value; + if (intValue == value) { + return intValue; } - try { - return reader.nextDouble(); - } catch (Exception exception) { - // Need to try/fail as there are no int/double/long tokens. - } - return reader.nextLong(); + return value; } private @Nullable Token getCurrentToken() { diff --git a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt index 04e2aaceba0..f2bb1d79044 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt @@ -1,8 +1,10 @@ package io.sentry +import java.io.IOException import java.io.StringReader import java.lang.Exception import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.fail import org.junit.Test @@ -42,6 +44,54 @@ class JsonObjectDeserializerTest { assertEquals(1.1, actual) } + // A value that is integral and fits an int is typed as Integer (regardless of how it was + // written); anything else is a Double. This matches the behavior prior to removing the + // exception-based number typing. + + @Test + fun `deserialize negative int`() { + assertEquals(-5, deserialize("-5")) + } + + @Test + fun `deserialize negative double`() { + assertEquals(-3.14, deserialize("-3.14")) + } + + @Test + fun `deserialize integral exponent notation as int`() { + assertEquals(100, deserialize("1e2")) + assertEquals(100, deserialize("1E2")) + } + + @Test + fun `deserialize fractional exponent notation as double`() { + assertEquals(0.0025, deserialize("2.5e-3")) + } + + @Test + fun `deserialize whole-valued decimal as int`() { + assertEquals(1, deserialize("1.0")) + } + + @Test + fun `deserialize integer larger than int range as double`() { + assertEquals(1.0e10, deserialize("10000000000")) + assertEquals(2147483648.0, deserialize("2147483648")) + } + + @Test + fun `deserialize max int as int`() { + assertEquals(Int.MAX_VALUE, deserialize("2147483647")) + } + + @Test + fun `deserialize rejects literal overflowing to infinity`() { + // Strict JSON forbids non-finite numbers, so an out-of-range literal must fail rather than be + // stored as Infinity. + assertFailsWith { deserialize("1e400") } + } + @Test fun `deserialize array`() { val json = "[\"a\",\"b\"]" From d928adbde98c6da27c7836b1990411af5e1fb1b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:07:34 +0200 Subject: [PATCH 042/102] chore: update scripts/update-sentry-native-ndk.sh to 0.16.0 (#5845) Co-authored-by: GitHub --- CHANGELOG.md | 6 ++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e8119a422..8cb55637577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) - Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) +### Dependencies + +- Bump Native SDK from v0.15.4 to v0.16.0 ([#5845](https://github.com/getsentry/sentry-java/pull/5845)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0160) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.4...0.16.0) + ## 8.50.1 ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89de8fce039..32bf6b736d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -169,7 +169,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.4" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.16.0" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From a30048b0fc30280201fdba44a7ab4a91ad98c80a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Jul 2026 22:51:51 +0200 Subject: [PATCH 043/102] fix(replay): Post checkCanRecord to main thread to prevent deadlock (#5837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(replay): Post checkCanRecord to main thread to prevent deadlock onScreenshotRecorded can run on the replay executor thread (PixelCopy masked-capture and emit paths). checkCanRecord -> pauseInternal acquires lifecycleLock — if another thread holds that lock and submits to the same single-threaded executor, we deadlock. When not already on the main thread, post checkCanRecord to the main looper so it never runs on the executor. On main thread, call directly to preserve existing synchronous behavior. Co-Authored-By: Claude Opus 4.6 (1M context) * fix test * fix(replay): Move executor shutdown outside lifecycleLock in close() Holding lifecycleLock while calling awaitTermination() deadlocks if any executor task tries to acquire the same lock. Move shutdown after the lock is released to cut this edge. Ref: #5847 Co-Authored-By: Claude Opus 4.6 (1M context) * chore: Drop ponytail prefix from comment Co-Authored-By: Claude Opus 4.6 (1M context) * test(replay): Add deadlock test for close() with blocked executor task Verifies close() completes when an executor task is waiting on lifecycleLock, ensuring shutdown happens outside the lock. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../android/replay/ReplayIntegration.kt | 46 ++++++++++------ .../sentry/android/replay/ReplaySmokeTest.kt | 54 ++++++++++++++++++- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb55637577..ef4a9324457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) - Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d9cd15d891a..98333260c7d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build +import android.os.Looper import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All @@ -130,7 +131,7 @@ public class ReplayIntegration( private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null - private val lifecycleLock = AutoClosableReentrantLock() + internal val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() override fun register(scopes: IScopes, options: SentryOptions) { @@ -352,7 +353,7 @@ public class ReplayIntegration( } addFrame(bitmap, frameTimeStamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { @@ -375,7 +376,7 @@ public class ReplayIntegration( } addFrame(screenshot, frameTimestamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun close() { @@ -390,21 +391,23 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - if (lazyReplayExecutor.isInitialized()) { - if (options.threadChecker.isMainThread) { - replayExecutor.gracefulShutdown() - } else { - replayExecutor.shutdown() - } + lifecycle.currentState = CLOSED + } + // shutdown outside lock — awaiting termination while holding lifecycleLock deadlocks + // if any executor task tries to acquire the same lock + if (lazyReplayExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + replayExecutor.gracefulShutdown() + } else { + replayExecutor.shutdown() } - if (lazyPersistingExecutor.isInitialized()) { - if (options.threadChecker.isMainThread) { - persistingExecutor.gracefulShutdown() - } else { - persistingExecutor.shutdown() - } + } + if (lazyPersistingExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + persistingExecutor.gracefulShutdown() + } else { + persistingExecutor.shutdown() } - lifecycle.currentState = CLOSED } } @@ -444,6 +447,17 @@ public class ReplayIntegration( captureStrategy?.onTouchEvent(event) } + // Runs [block] on the main thread. If already there, executes inline; otherwise posts via + // the main looper handler. Prevents deadlocks when lifecycle-lock-acquiring code (e.g. + // checkCanRecord -> pauseInternal) is called from the replay executor thread. + private inline fun postOnMainThread(crossinline block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainLooperHandler.post { block() } + } + } + /** * Check if we're offline or rate-limited and pause for session mode to not overflow the envelope * cache. diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index c26e6be9c41..b5e15b5534f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -23,13 +23,16 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import java.time.Duration +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import org.awaitility.core.ConditionTimeoutException import org.awaitility.kotlin.await import org.junit.Rule @@ -41,6 +44,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -61,11 +65,17 @@ class ReplaySmokeTest { internal class Fixture { val options = SentryOptions() val scope = Scope(options) + val rateLimiter = + mock { + on { isActiveForCategory(any()) }.thenReturn(false) + } val scopes = mock { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + + on { rateLimiter }.doReturn(rateLimiter) } private class ImmediateHandler : @@ -91,7 +101,10 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + true + } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule( @@ -243,6 +256,45 @@ class ReplaySmokeTest { assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy) assertEquals(0, falseReplay.rootViewsSpy.listeners.size) } + + @Test + fun `close does not deadlock when executor task is waiting on lifecycleLock`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + + val taskBlocked = CountDownLatch(1) + val lockReleased = CountDownLatch(1) + + // hold lifecycleLock on this thread + val token = replay.lifecycleLock.acquire() + + // submit a task on the executor that tries to acquire the same lock — it will block + replay.replayExecutor.submit { + taskBlocked.countDown() + replay.lifecycleLock.acquire().use {} + } + + // wait for the executor task to actually be running and blocked + assertTrue(taskBlocked.await(2, TimeUnit.SECONDS)) + + // release the lock, then close — if shutdown were inside the lock this would deadlock + token.close() + + // close() must complete within a reasonable time + val closedInTime = AtomicBoolean(false) + val closeThread = Thread { + replay.close() + closedInTime.set(true) + } + closeThread.start() + closeThread.join(5000) + + assertTrue(closedInTime.get(), "close() deadlocked") + } } private class ExampleActivity : Activity() { From 02b196f8d027c6653ca4f1704741e91987f33fa9 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 10:36:08 +0200 Subject: [PATCH 044/102] fix(replay): Skip buffer-mode replay capture when rate-limited (DART-313) (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(replay): Skip buffer-mode replay capture when rate-limited (DART-313) In buffer (on-error) mode the recorder keeps running while rate-limited so the rolling buffer stays warm, but capturing on an error still encoded the current and buffered segments and handed them to the transport, which then dropped them. That wasted CPU, I/O, and MediaMuxer file descriptors on envelopes that could never be sent. Bail out of BufferCaptureStrategy.captureReplay when the Replay (or All) category is rate-limited, mirroring the guard session mode already applies. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * fix(replay): Record a lost replay event when buffer capture is rate-limited (DART-313) Skipping the encode when rate-limited meant the segments never reached the transport, so RateLimiter.filter never recorded them as lost. Replay drops in buffer mode silently vanished from client reports. Record a RATELIMIT_BACKOFF lost event for the Replay category at the bail-out, and move the rate-limit check below the sampling and isTerminating guards so we only report replays that would genuinely have been sent — a replay dropped by onErrorSampleRate is not a rate-limit loss, and a terminating one is deferred to the next launch rather than lost. Co-Authored-By: Claude Opus 5 (1M context) * changelog * fix(replay): Keep buffer mode while rate-limited (DART-313) Bailing out of BufferCaptureStrategy.captureReplay while rate-limited left isTerminating unset, so ReplayIntegration's unconditional convert() still swapped in a SessionCaptureStrategy. That discarded the rolling buffer, and the next recorded frame then hit checkCanRecord(), which pauses session mode when rate-limited and encodes a segment on the way out - exactly the work the bail-out was meant to avoid. It also left recording paused for the rest of the rate-limit window, contradicting onRateLimitChanged, which deliberately keeps buffer mode running. Stay in buffer mode while rate-limited so the buffer keeps rolling and the next error after the limit expires can send a complete replay. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 + .../replay/capture/BufferCaptureStrategy.kt | 27 +++++++ .../capture/BufferCaptureStrategyTest.kt | 75 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4a9324457..2aee94a20b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ ### Fixes +- Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) + - These skipped replays are now reported as `ratelimit_backoff` discarded events in client reports, so they no longer disappear from drop statistics. One event is recorded per buffer flush rather than per segment. + - Buffer mode is also kept while rate-limited instead of switching to session mode, so the rolling buffer stays warm and the next error after the rate limit expires can send a complete replay. - Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index f6c6f3997ae..1d7bd1c1126 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory.All +import io.sentry.DataCategory.Replay import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.SentryLevel.DEBUG @@ -17,6 +19,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.sample +import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -100,6 +103,18 @@ internal class BufferCaptureStrategy( return } + if (isReplayRateLimited()) { + // the segment envelopes would be dropped by the transport anyway, so don't waste resources + // encoding videos that will only be discarded + options.logger.log(INFO, "Replay is rate-limited, not capturing for event") + // one lost event per flush, not per segment: the transport would have counted the current + // segment plus every buffered one, but a flush only ever loses a single replay from the + // user's perspective. Under-reporting here is preferable to making replay look like it + // dropped data it never held. + options.clientReportRecorder.recordLostEvent(RATELIMIT_BACKOFF, Replay) + return + } + createCurrentSegment("capture_replay") { segment -> bufferedSegments.capture() @@ -151,6 +166,13 @@ internal class BufferCaptureStrategy( ) return this } + if (isReplayRateLimited()) { + // captureReplay skipped the flush, so there is nothing to continue in session mode. Staying + // in buffer mode keeps the rolling buffer warm, so the next error after the rate limit + // expires can send a complete replay starting at segment 0. + options.logger.log(DEBUG, "Not converting to session mode, because replay is rate-limited") + return this + } // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of // execution val captureStrategy = @@ -170,6 +192,11 @@ internal class BufferCaptureStrategy( rotateEvents(currentEvents, bufferLimit) } + private fun isReplayRateLimited(): Boolean = + scopes?.rateLimiter?.let { + it.isActiveForCategory(All) || it.isActiveForCategory(Replay) + } == true + private fun deleteFile(file: File?) { if (file == null) { return diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index b5048e856ff..fc1981a84b1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -2,9 +2,12 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeHeader import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.DefaultReplayBreadcrumbConverter @@ -17,9 +20,12 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import io.sentry.util.Random import java.io.File import kotlin.test.Test @@ -93,6 +99,16 @@ class BufferCaptureStrategyTest { bitRate = 20_000, ) + // client report counts are only readable by draining them onto an envelope + fun discardedEvents(): List = + options.clientReportRecorder + .attachReportToEnvelope(SentryEnvelope(SentryEnvelopeHeader(), emptyList())) + .items + .firstOrNull() + ?.getClientReport(options.serializer) + ?.discardedEvents + .orEmpty() + fun getSut( onErrorSampleRate: Double = 1.0, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -239,6 +255,19 @@ class BufferCaptureStrategyTest { assertTrue(converted is BufferCaptureStrategy) } + @Test + fun `convert stays in buffer mode when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + + strategy.captureReplay(false) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() @@ -336,6 +365,52 @@ class BufferCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) } + @Test + fun `captureReplay does not capture segments when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + // neither the current nor the buffered segment should be sent while rate-limited + verify(fixture.scopes, never()).captureReplay(any(), any()) + // the replayId is still set on the scope so the error that flushed the buffer stays linked to + // the replay that gets recorded once the rate limit lifts + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay records a lost replay event when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + val discarded = fixture.discardedEvents() + assertEquals(1, discarded.size) + assertEquals(DiscardReason.RATELIMIT_BACKOFF.reason, discarded.first().reason) + assertEquals(DataCategory.Replay.category, discarded.first().category) + } + + @Test + fun `captureReplay does not record a lost replay event when not rate-limited`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertTrue(fixture.discardedEvents().none { it.category == DataCategory.Replay.category }) + } + @Test fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false From 11666ec041600710cd261ca216cd7c53a538f7c0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 10:36:35 +0200 Subject: [PATCH 045/102] docs(replay): Document the two capture strategies (#5848) SessionCaptureStrategy and BufferCaptureStrategy differ only in when a recorded segment is sent, but nothing at the top of either class said so. Add a short KDoc to each pointing at the other, covering how the mode is selected, when segments are sent, and why ReplayIntegration pauses session mode while buffer mode keeps recording. Co-authored-by: Claude Opus 5 (1M context) --- .../android/replay/capture/BufferCaptureStrategy.kt | 12 ++++++++++++ .../android/replay/capture/SessionCaptureStrategy.kt | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 1d7bd1c1126..4d7bcd64cf4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -28,6 +28,18 @@ import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a rolling `errorReplayDuration` window: segments are encoded but held in memory, and + * frames and segments older than the window are dropped on every screenshot. Used when the session + * is not sampled by `sessionSampleRate` but `onErrorSampleRate` is set. + * + * Nothing is sent until [captureReplay] flushes the buffer for an error — sampled per error against + * `onErrorSampleRate`, unlike session mode which samples once at start. After a successful flush + * [convert] hands over to a [SessionCaptureStrategy] so the rest of the session is recorded live. + * + * Since nothing is in flight, `ReplayIntegration` deliberately keeps this strategy recording while + * rate-limited, so the buffer stays warm for when the limit expires. + */ @SuppressLint("UseRequiresApi") @TargetApi(26) internal class BufferCaptureStrategy( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index d62efb534cc..df6e09b5358 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -16,6 +16,17 @@ import io.sentry.util.FileUtils import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a full session: segments are encoded and sent continuously, one per + * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is + * sampled by `sessionSampleRate`. + * + * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is + * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses + * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * + * See [BufferCaptureStrategy] for the on-error counterpart. + */ internal class SessionCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, From d1f9ed43faaa55946372d9a0f2553c7315fa4509 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:20:31 +0200 Subject: [PATCH 046/102] chore(deps): bump the github-actions group across 1 directory with 3 updates (#5850) Bumps the github-actions group with 3 updates in the / directory: [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft), [saucelabs/saucectl-run-action](https://github.com/saucelabs/saucectl-run-action) and [getsentry/craft](https://github.com/getsentry/craft). Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.14 to 2.27.0 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/cdb657d4bbc70cd497876ad158984b4d345a48ae...667b5f5669552a35990a11ff6ac1131febb07446) Updates `saucelabs/saucectl-run-action` from 4.4.0 to 4.5.0 - [Release notes](https://github.com/saucelabs/saucectl-run-action/releases) - [Commits](https://github.com/saucelabs/saucectl-run-action/compare/bc81720eb01738d9c664b07fe42621bd0014283f...283660aa934c02723c497efa151d582a3acc5801) Updates `getsentry/craft` from 2.26.14 to 2.27.0 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/cdb657d4bbc70cd497876ad158984b4d345a48ae...667b5f5669552a35990a11ff6ac1131febb07446) --- updated-dependencies: - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.27.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: saucelabs/saucectl-run-action dependency-version: 4.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.27.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 27f1d0006e4..0bfa9bb2d03 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -15,5 +15,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@cdb657d4bbc70cd497876ad158984b4d345a48ae # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@667b5f5669552a35990a11ff6ac1131febb07446 # v2 secrets: inherit diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 6b0c074f9da..199d22122df 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -48,7 +48,7 @@ jobs: run: make assembleBenchmarks - name: Run All Tests in SauceLab - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name != 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} @@ -58,7 +58,7 @@ jobs: config-file: .sauce/sentry-uitest-android-benchmark.yml - name: Run one test in SauceLab - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name == 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index da46b859a14..0241cb07536 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -43,7 +43,7 @@ jobs: run: make assembleUiTests - name: Install SauceLabs CLI - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v4.4.0 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 env: GITHUB_TOKEN: ${{ github.token }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85850dd578b..704734bcbaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@cdb657d4bbc70cd497876ad158984b4d345a48ae # v2 + uses: getsentry/craft@667b5f5669552a35990a11ff6ac1131febb07446 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From c318acd31c312df97780dc68ba96671cd0ae3be2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 13:11:30 +0200 Subject: [PATCH 047/102] perf: Schedule rate-limit notifications on shared executor (JAVA-653) (#5814) * perf: Schedule rate-limit notifications on shared executor (JAVA-653) RateLimiter created a java.util.Timer whose thread stayed alive forever once the SDK got rate limited. Schedule the "rate limit lifted" observer notification on the shared timer executor instead, whose single worker thread is reused across all timeouts and self-terminates when idle. Pending notifications are cancelled on close(). Co-Authored-By: Claude Fable 5 * changelog --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + .../java/io/sentry/transport/RateLimiter.java | 57 +++++++++++-------- .../io/sentry/transport/RateLimiterTest.kt | 35 ++++++++---- 3 files changed, 58 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aee94a20b4..a964d4032c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Performance - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +- Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814)) - Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) ### Dependencies diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index a0cd96abba9..dfbc4cb2622 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -23,12 +23,13 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -42,8 +43,9 @@ public final class RateLimiter implements Closeable { private final @NotNull Map sentryRetryAfterLimit = new ConcurrentHashMap<>(); private final @NotNull List rateLimitObservers = new CopyOnWriteArrayList<>(); - private @Nullable Timer timer = null; - private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); + private final @NotNull List> notifyObserversFutures = new ArrayList<>(); + private final @NotNull AutoClosableReentrantLock notifyFuturesLock = + new AutoClosableReentrantLock(); public RateLimiter( final @NotNull ICurrentDateProvider currentDateProvider, @@ -278,11 +280,11 @@ public void updateRetryAfterLimits( continue; } - applyRetryAfterOnlyIfLonger(dataCategory, date); + applyRetryAfterOnlyIfLonger(dataCategory, date, retryAfterMillis); } } else { // if categories are empty, we should apply to "all" categories. - applyRetryAfterOnlyIfLonger(DataCategory.All, date); + applyRetryAfterOnlyIfLonger(DataCategory.All, date, retryAfterMillis); } } } @@ -291,7 +293,7 @@ public void updateRetryAfterLimits( final long retryAfterMillis = parseRetryAfterOrDefault(retryAfterHeader); // we dont care if Date is UTC as we just add the relative seconds final Date date = new Date(currentDateProvider.getCurrentTimeMillis() + retryAfterMillis); - applyRetryAfterOnlyIfLonger(DataCategory.All, date); + applyRetryAfterOnlyIfLonger(DataCategory.All, date, retryAfterMillis); } } @@ -300,10 +302,11 @@ public void updateRetryAfterLimits( * * @param dataCategory the DataCategory * @param date the Date to be applied + * @param delayMillis the millis until the rate limit is lifted */ @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) private void applyRetryAfterOnlyIfLonger( - final @NotNull DataCategory dataCategory, final @NotNull Date date) { + final @NotNull DataCategory dataCategory, final @NotNull Date date, final long delayMillis) { final Date oldDate = sentryRetryAfterLimit.get(dataCategory); // only overwrite its previous date if the limit is even longer @@ -312,19 +315,25 @@ private void applyRetryAfterOnlyIfLonger( notifyRateLimitObservers(); - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timer == null) { - timer = new Timer(true); + // notify observers again once the rate limit is lifted, using the shared timer executor + // instead of a dedicated Timer thread + try (final @NotNull ISentryLifecycleToken ignored = notifyFuturesLock.acquire()) { + final @NotNull Iterator> iterator = notifyObserversFutures.iterator(); + while (iterator.hasNext()) { + if (iterator.next().isDone()) { + iterator.remove(); + } + } + try { + notifyObserversFutures.add( + options + .getTimerExecutorService() + .schedule(this::notifyRateLimitObservers, delayMillis)); + } catch (RejectedExecutionException e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to schedule rate limit lifted notification.", e); } - - timer.schedule( - new TimerTask() { - @Override - public void run() { - notifyRateLimitObservers(); - } - }, - date); } } } @@ -364,11 +373,11 @@ public void removeRateLimitObserver(@NotNull final IRateLimitObserver observer) @Override public void close() throws IOException { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timer != null) { - timer.cancel(); - timer = null; + try (final @NotNull ISentryLifecycleToken ignored = notifyFuturesLock.acquire()) { + for (Future future : notifyObserversFutures) { + future.cancel(false); } + notifyObserversFutures.clear(); } rateLimitObservers.clear(); } diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 33cda17106f..36927df97dd 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -18,6 +18,7 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEnvelopeItem import io.sentry.SentryEvent +import io.sentry.SentryExecutorService import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents import io.sentry.SentryLogLevel @@ -37,12 +38,12 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User import io.sentry.test.getProperty -import io.sentry.test.injectForField import io.sentry.util.HintUtils import java.io.File -import java.util.Timer import java.util.UUID +import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -63,9 +64,14 @@ class RateLimiterTest { val currentDateProvider = mock() val clientReportRecorder = mock() val serializer = mock() + var executorService: SentryExecutorService? = null fun getSUT(): RateLimiter { val options = SentryOptions().apply { setLogger(NoOpLogger.getInstance()) } + // a real executor so scheduled rate-limit-lifted notifications actually run + val timerExecutorService = SentryExecutorService(options) + executorService = timerExecutorService + options.setTimerExecutorService(timerExecutorService) SentryOptionsManipulator.setClientReportRecorder(options, clientReportRecorder) @@ -75,6 +81,12 @@ class RateLimiterTest { private val fixture = Fixture() + @AfterTest + fun `tear down`() { + // the executor's core thread never times out, so it would stay parked for the whole test JVM + fixture.executorService?.close(0) + } + @Test fun `uses X-Sentry-Rate-Limit and allows sending if time has passed`() { val rateLimiter = fixture.getSUT() @@ -654,7 +666,7 @@ class RateLimiterTest { } @Test - fun `apply rate limits schedules a timer to notify observers of lifted limits`() { + fun `apply rate limits schedules a task to notify observers of lifted limits`() { val rateLimiter = fixture.getSUT() whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 1, 2001) @@ -667,18 +679,19 @@ class RateLimiterTest { } @Test - fun `close cancels the timer`() { + fun `close cancels pending notify tasks`() { val rateLimiter = fixture.getSUT() - val timer = mock() - rateLimiter.injectForField("timer", timer) + rateLimiter.updateRetryAfterLimits("60:replay:key", null, 1) + + val futures = rateLimiter.getProperty>>("notifyObserversFutures") + assertEquals(1, futures.size) + val future = futures.first() // When the rate limiter is closed rateLimiter.close() - // Then the timer is cancelled - verify(timer).cancel() - - // And is removed by the rateLimiter - assertNull(rateLimiter.getProperty("timer")) + // Then the pending notify task is cancelled and dropped + assertTrue(future.isCancelled) + assertTrue(rateLimiter.getProperty>>("notifyObserversFutures").isEmpty()) } } From 5e6844d5bf31f111114fff069ede6a7f12e87b3f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 13:24:22 +0200 Subject: [PATCH 048/102] perf(core): Create outbox and cache dirs lazily instead of during init (JAVA-613) (#5792) * perf(core): Create outbox and cache dirs lazily instead of during init (JAVA-613) Sentry.initConfigurations created the outbox and cache directories synchronously on the init thread, which on Android is the main thread. Create each directory lazily in its consumer instead: the cache dir on the first envelope write (transport thread), and the outbox dir in the file-observer integration (executor thread) and before writing the startup-crash marker. The native SDK already creates the outbox dir itself during sentry_init, so NDK crash writes are unaffected. Co-Authored-By: Claude Opus 4.8 * changelog Co-Authored-By: Claude Opus 4.8 * ref(core): Encapsulate lazy dir creation in a LazyDirectory value object (JAVA-613) Replace the duplicated "create the dir if it does not exist" idiom in the envelope cache, outbox file observer, and startup-crash-marker paths with a single LazyDirectory type that materializes the directory on first access. CacheStrategy now owns its directory as a LazyDirectory: write paths call getOrCreate(), while path-building and validity checks use getFile() so they do not create the directory as a side effect. Co-Authored-By: Claude Opus 4.8 * ref(core): Inject the cache LazyDirectory via the constructor (JAVA-613) Have the composition roots (EnvelopeCache.create and AndroidEnvelopeCache) build the LazyDirectory and pass it into CacheStrategy, so the cache no longer constructs its own directory collaborator from a path string. The public String constructor is kept and delegates, preserving binary compatibility. Co-Authored-By: Claude Opus 4.8 * fix(core): Create the outbox dir in external envelope writers (JAVA-613) Creating the outbox dir lazily moved the mkdirs() off the init thread onto the SDK executor, so the dir is no longer guaranteed to exist once Sentry.init returns. Writers that drop envelopes into the outbox themselves raced that executor task and could fail with ENOENT, which is what EnvelopeTests.sendsNativeTransaction hit on a slow emulator. Have both external writers create the dir before writing, and document on getOutboxPath that the directory is created lazily so hybrid SDKs writing envelopes directly know they have to do the same. Co-Authored-By: Claude Opus 5 (1M context) * fix(core): Create the cache dir before writing app-start config (JAVA-613) The removed init-time mkdirs() ran on the dsn-hashed cache path and created the un-hashed parent as a side effect. handleAppStartProfilingConfig writes app_start_profiling_config into that parent via createNewFile(), which fails with IOException when the parent is missing, and the surrounding catch swallows it into a log line. The next launch then finds no config and cannot start app-start profiling. This was masked because the profiling traces dir still calls mkdirs() on //profiling_traces, creating the un-hashed grandparent -- but only when profiling is enabled, which every existing test did. The new test leaves profiling off so nothing else materializes the dir. Co-Authored-By: Claude Opus 5 (1M context) * ref(core): Simplify the lazy directory creation helpers (JAVA-613) The LazyDirectory value object was doing two unrelated jobs: holding a directory that a long-lived cache creates on first write, and acting as a one-shot mkdirs() helper at three call sites that constructed it only to discard it immediately. Split those apart. FileUtils.createDirectory covers the one-shot case and returns whether the directory exists afterwards, so the callers log the failure instead of discarding mkdirs()' return value and failing later in an unrelated-looking write. LazyDirectory keeps only the cache use and gains resolve(), which creates the parent before returning the child, so write paths no longer depend on an earlier getOrCreate() call having run. Creation is deliberately not cached: on Android the cache dir lives under Context.getCacheDir(), which the system may wipe at any time, so each write re-checks and the directory heals itself. Also revert the LazyDirectory injection into CacheStrategy. Nothing injected a custom instance, so it only added a public EnvelopeCache constructor to the API surface for an internal change. * fix(core): Report success when losing the createDirectory race (JAVA-613) File.mkdirs() returns false both when it cannot create the directory and when another thread got there first, so createDirectory reported a failure for a directory that was present. Callers act on that by skipping their write: a startup crash would go unmarked and the app-start profiling config would not be written, even though the directory existed. Re-check for the directory when mkdirs() fails, which separates losing the race from a genuine failure such as missing permissions. The added test fails reliably without the fix, with most of the racing threads observing false. * fix(core): Don't recreate the cache dir when discarding (JAVA-613) getCurrentFile went through LazyDirectory.resolve, which creates the directory as a side effect. discard() calls it while deleting from the cache, so discarding an envelope could resurrect a cache dir that had just been removed. Compute the path without touching the filesystem instead, and drop resolve entirely: the remaining write paths already call getOrCreate once before writing. * docs(core): Note the lazy dir creation as a behavioral change (JAVA-613) Sentry.init used to mkdirs() the outbox and cache dirs synchronously, so both were guaranteed to exist once it returned. They are now created by whichever component first writes into them, off the init thread, which breaks anyone writing envelopes into the outbox path directly instead of going through the SDK -- notably the hybrid SDKs' captureEnvelope. Call that out under Behavioral Changes so hybrid maintainers see it; the existing Performance entry only describes the win, not the cost. * docs(core): Explain why LazyDirectory swallows a failed mkdirs (JAVA-613) getOrCreate() ignoring the return value of createDirectory() reads like an oversight next to the callers that do log it, so record that write paths already surface the failure via their own error handling. --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 7 +++ .../core/EnvelopeFileObserverIntegration.java | 8 +++ .../core/cache/AndroidEnvelopeCache.java | 11 +++- .../EnvelopeFileObserverIntegrationTest.kt | 17 +++++++ .../core/cache/AndroidEnvelopeCacheTest.kt | 14 ++++++ .../uitest/android/critical/MainActivity.kt | 3 ++ .../io/sentry/uitest/android/EnvelopeTests.kt | 7 ++- sentry/api/sentry.api | 7 +++ sentry/src/main/java/io/sentry/Sentry.java | 22 ++++---- .../main/java/io/sentry/SentryOptions.java | 6 ++- .../java/io/sentry/cache/CacheStrategy.java | 14 +++--- .../java/io/sentry/cache/EnvelopeCache.java | 17 +++++-- .../main/java/io/sentry/util/FileUtils.java | 16 ++++++ .../java/io/sentry/util/LazyDirectory.java | 38 ++++++++++++++ sentry/src/test/java/io/sentry/SentryTest.kt | 34 +++++++++---- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 31 +++++++++++- .../test/java/io/sentry/util/FileUtilsTest.kt | 50 +++++++++++++++++++ .../java/io/sentry/util/LazyDirectoryTest.kt | 45 +++++++++++++++++ 18 files changed, 311 insertions(+), 36 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/LazyDirectory.java create mode 100644 sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a964d4032c0..147172de532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Behavioral Changes + +- The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) + - They are now created lazily by whichever component first writes into them, off the init thread. As a result, the directories at `SentryOptions.getOutboxPath()` and `SentryOptions.getCacheDirPath()` are not guaranteed to exist once `Sentry.init` returns. + - If you write envelopes into the outbox path yourself instead of going through the SDK — as hybrid SDKs do for `captureEnvelope` — create the directory first, e.g. `new File(outboxPath).mkdirs()`. + ### Improvements - Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) @@ -14,6 +20,7 @@ ### Performance +- Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) - Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814)) - Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 482d90c6e6c..ab95ae32daa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,8 +10,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.FileUtils; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -67,6 +69,12 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { + // Create the outbox dir here (on the executor) so the observer can watch it for envelopes + // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + if (!FileUtils.createDirectory(new File(path))) { + options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path); + } + final OutboxSender outboxSender = new OutboxSender( scopes, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index e1590e47943..1ef02dfdd2c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -93,7 +93,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull @TestOnly public @NotNull File getDirectory() { - return directory; + return directory.getFile(); } private void writeStartupCrashMarkerFile() { @@ -106,7 +106,14 @@ private void writeStartupCrashMarkerFile() { .log(DEBUG, "Outbox path is null, the startup crash marker file will not be written"); return; } - final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); + // The outbox dir is no longer created during Sentry.init, so create it here in case the native + // SDK (which normally creates it) is disabled. + final File outboxDir = new File(outboxPath); + if (!FileUtils.createDirectory(outboxDir)) { + options.getLogger().log(ERROR, "Failed to create outbox dir %s", outboxPath); + return; + } + final File crashMarkerFile = new File(outboxDir, STARTUP_CRASH_MARKER_FILE); try { crashMarkerFile.createNewFile(); } catch (Throwable e) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 97276d67566..0b13f4ca4d8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -14,6 +14,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest { verify(fixture.logger) .log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) } + + @Test + fun `register creates the outbox dir when it does not exist yet`() { + val outboxDir = File(file, "outbox") + assertFalse(outboxDir.exists()) + + fixture.getSut { it.executorService = ImmediateExecutorService() } + val integration = + object : EnvelopeFileObserverIntegration() { + override fun getPath(options: SentryOptions): String = outboxDir.absolutePath + } + integration.register(fixture.scopes, fixture.scopes.options) + + assertTrue(outboxDir.isDirectory) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 09d3a779df0..a4063ccb148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -125,6 +125,20 @@ class AndroidEnvelopeCacheTest { assertTrue(fixture.startupCrashMarkerFile.exists()) } + @Test + fun `creates outbox dir when writing startup crash file and dir does not exist yet`() { + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) + + val outboxDir = File(fixture.options.outboxPath!!) + assertTrue(outboxDir.deleteRecursively()) + assertFalse(outboxDir.exists()) + + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) + cache.storeEnvelope(fixture.envelope, hints) + + assertTrue(fixture.startupCrashMarkerFile.exists()) + } + @Test fun `when no AnrV2 hint exists, does not write last anr report file`() { val cache = fixture.getSut(tmpDir) diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt index 46bfe7e44b7..7e0ff9d61c3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -66,6 +66,9 @@ class MainActivity : ComponentActivity() { Button(onClick = { Sentry.close() }) { Text("Close SDK") } Button( onClick = { + // The SDK creates the outbox dir lazily on its executor, so an external + // writer has to create it itself. + File(outboxPath).mkdirs() val file = File(outboxPath, "corrupted.envelope") val corruptedEnvelopeContent = """ diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index ade47363296..3fa2c904873 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -262,9 +262,14 @@ class EnvelopeTests : BaseUiTest() { optionsRef = options } + // The SDK creates the outbox dir lazily on its executor, so an external writer racing + // Sentry.init has to create it itself. + val outboxDir = File(optionsRef!!.outboxPath!!) + outboxDir.mkdirs() + // based on // https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131 - File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") + File(outboxDir, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") .writeText( """ {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c623e71d08f..14780d1a4b2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7692,6 +7692,7 @@ public final class io/sentry/util/ExceptionUtils { public final class io/sentry/util/FileUtils { public fun ()V + public static fun createDirectory (Ljava/io/File;)Z public static fun deleteRecursively (Ljava/io/File;)Z public static fun readBytesFromFile (Ljava/lang/String;J)[B public static fun readText (Ljava/io/File;)Ljava/lang/String; @@ -7757,6 +7758,12 @@ public final class io/sentry/util/JsonSerializationUtils { public static fun calendarToMap (Ljava/util/Calendar;)Ljava/util/Map; } +public final class io/sentry/util/LazyDirectory { + public fun (Ljava/lang/String;)V + public fun getFile ()Ljava/io/File; + public fun getOrCreate ()Ljava/io/File; +} + public final class io/sentry/util/LazyEvaluator { public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V public fun getValue ()Ljava/lang/Object; diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 8bba9d92e4f..266aa39e793 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -463,8 +463,9 @@ private static void handleAppStartProfilingConfig( () -> { final String cacheDirPath = options.getCacheDirPathWithoutDsn(); if (cacheDirPath != null) { + final @NotNull File cacheDir = new File(cacheDirPath); final @NotNull File appStartProfilingConfigFile = - new File(cacheDirPath, APP_START_PROFILING_CONFIG_FILE_NAME); + new File(cacheDir, APP_START_PROFILING_CONFIG_FILE_NAME); try { // We always delete the config file for app start profiling FileUtils.deleteRecursively(appStartProfilingConfigFile); @@ -481,6 +482,14 @@ private static void handleAppStartProfilingConfig( "Tracing is disabled and app start profiling will not start."); return; } + // The cache dir is no longer created during init, so create it here before writing: + // createNewFile() fails if the parent is missing. + if (!FileUtils.createDirectory(cacheDir)) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to create cache dir %s", cacheDirPath); + return; + } if (appStartProfilingConfigFile.createNewFile()) { // If old app start profiling is false, it means the transaction will not be // sampled, but we create the file anyway to allow continuous profiling on app @@ -616,19 +625,14 @@ private static void initConfigurations(final @NotNull SentryOptions options) { // TODO: read values from conf file, Build conf or system envs // eg release, distinctId, sentryClientName - // this should be after setting serializers - final String outboxPath = options.getOutboxPath(); - if (outboxPath != null) { - final File outboxDir = new File(outboxPath); - outboxDir.mkdirs(); - } else { + // The outbox and cache dirs are created lazily by their consumers (envelope cache, outbox file + // observer, native SDK) off the init thread, so we don't stat/mkdir them here. + if (options.getOutboxPath() == null) { logger.log(SentryLevel.INFO, "No outbox dir path is defined in options."); } final String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath != null) { - final File cacheDir = new File(cacheDirPath); - cacheDir.mkdirs(); final IEnvelopeCache envelopeCache = options.getEnvelopeDiskCache(); // only overwrite the cache impl if it's not already set if (envelopeCache instanceof NoOpEnvelopeCache) { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f10f2aede05..93104101f81 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1104,7 +1104,11 @@ String getCacheDirPathWithoutDsn() { } /** - * Returns the outbox path if cacheDirPath is set + * Returns the outbox path if cacheDirPath is set. + * + *

The directory is created lazily by the SDK on a background thread, so it is not guaranteed + * to exist when {@code Sentry.init} returns. Callers writing envelopes here directly (for example + * hybrid SDKs) must create it themselves, e.g. {@code new File(outboxPath).mkdirs()}. * * @return the outbox path or null if not set */ diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index 479c0e42eaf..5f3e605f2a1 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -10,6 +10,7 @@ import io.sentry.SentryOptions; import io.sentry.Session; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.LazyDirectory; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.BufferedInputStream; @@ -39,7 +40,7 @@ abstract class CacheStrategy { protected @NotNull SentryOptions options; protected final @NotNull LazyEvaluator serializer = new LazyEvaluator<>(() -> options.getSerializer()); - protected final @NotNull File directory; + protected final @NotNull LazyDirectory directory; private final int maxSize; CacheStrategy( @@ -48,9 +49,7 @@ abstract class CacheStrategy { final int maxSize) { Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - - this.directory = new File(directoryPath); - + this.directory = new LazyDirectory(directoryPath); this.maxSize = maxSize; } @@ -60,13 +59,12 @@ abstract class CacheStrategy { * @return true if valid and has permissions or false otherwise */ protected boolean isDirectoryValid() { - if (!directory.isDirectory() || !directory.canWrite() || !directory.canRead()) { + final File dir = directory.getFile(); + if (!dir.isDirectory() || !dir.canWrite() || !dir.canRead()) { options .getLogger() .log( - ERROR, - "The directory for caching files is inaccessible.: %s", - directory.getAbsolutePath()); + ERROR, "The directory for caching files is inaccessible.: %s", dir.getAbsolutePath()); return false; } return true; diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 475993bbc1d..618de655478 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -109,10 +109,13 @@ public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotN private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); + // Create the cache dir lazily on the first write so Sentry.init doesn't block on the mkdirs. + final String directoryPath = directory.getOrCreate().getAbsolutePath(); + rotateCacheIfNeeded(allEnvelopeFiles()); - final File currentSessionFile = getCurrentSessionFile(directory.getAbsolutePath()); - final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); + final File currentSessionFile = getCurrentSessionFile(directoryPath); + final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { if (!currentSessionFile.delete()) { @@ -199,7 +202,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not @SuppressWarnings("JavaUtilDate") private void tryEndPreviousSession(final @NotNull Hint hint) { final Object sdkHint = HintUtils.getSentrySdkHint(hint); - final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); + final File previousSessionFile = getPreviousSessionFile(directory.getFile().getAbsolutePath()); if (previousSessionFile.exists()) { options.getLogger().log(WARNING, "Previous session is not ended, we'd need to end it."); @@ -372,6 +375,10 @@ public void discard(final @NotNull SentryEnvelope envelope) { * Returns the envelope's file path. If the envelope wasn't added to the cache beforehand, a * random file name is assigned. * + *

This only computes a path and never creates the directory, so that {@link + * #discard(SentryEnvelope)} doesn't resurrect a cache dir it is only deleting from. Writers go + * through {@link #storeInternal}, which creates the directory up front. + * * @param envelope the SentryEnvelope object * @return the file */ @@ -385,7 +392,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { fileNameMap.put(envelope, fileName); } - return new File(directory.getAbsolutePath(), fileName); + return new File(directory.getFile(), fileName); } } @@ -431,7 +438,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { if (isDirectoryValid()) { // lets filter the session.json here final File[] files = - directory.listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); + directory.getFile().listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); if (files != null) { return files; } diff --git a/sentry/src/main/java/io/sentry/util/FileUtils.java b/sentry/src/main/java/io/sentry/util/FileUtils.java index 73b83713c77..337a9a8863d 100644 --- a/sentry/src/main/java/io/sentry/util/FileUtils.java +++ b/sentry/src/main/java/io/sentry/util/FileUtils.java @@ -8,6 +8,7 @@ import java.io.FileReader; import java.io.IOException; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal @@ -36,6 +37,21 @@ public static boolean deleteRecursively(@Nullable File file) { return file.delete(); } + /** + * Creates the directory and any missing parents, if it does not exist yet. + * + *

Callers are expected to log a failure: a missing directory otherwise surfaces later as an + * unrelated-looking write error. + * + * @param directory the directory to create + * @return true if the directory exists once this returns, false if it could not be created + */ + public static boolean createDirectory(final @NotNull File directory) { + // mkdirs() also returns false when another thread created the directory first, so re-check + // instead of reporting a failure the caller would act on by skipping its write. + return directory.isDirectory() || directory.mkdirs() || directory.isDirectory(); + } + /** * Reads the content of a File into a String. If the file does not exist or is not a file, null is * returned. Do not use with large files, as the String is kept in memory! diff --git a/sentry/src/main/java/io/sentry/util/LazyDirectory.java b/sentry/src/main/java/io/sentry/util/LazyDirectory.java new file mode 100644 index 00000000000..62334c7665d --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/LazyDirectory.java @@ -0,0 +1,38 @@ +package io.sentry.util; + +import java.io.File; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A filesystem directory that is created on demand rather than up front, so the (potentially + * blocking) {@code mkdirs()} runs on the thread that first writes into it instead of on the SDK + * init thread. + * + *

Read paths should use {@link #getFile()}, which never touches the filesystem. Write paths + * should call {@link #getOrCreate()} once before writing. Creation is not cached: on Android the + * cache dir lives under {@code Context.getCacheDir()}, which the system may wipe at any time, so + * each write re-checks. + */ +@ApiStatus.Internal +public final class LazyDirectory { + + private final @NotNull File file; + + public LazyDirectory(final @NotNull String path) { + this.file = new File(path); + } + + /** Returns the directory without touching the filesystem. */ + public @NotNull File getFile() { + return file; + } + + /** Returns the directory, creating it and any missing parents if it does not exist yet. */ + public @NotNull File getOrCreate() { + // A failed mkdirs is not reported here: callers are write paths, so the failure surfaces as the + // write error they already log and report. + FileUtils.createDirectory(file); + return file; + } +} diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 8d05697fda4..98cda8e9d82 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -183,7 +183,7 @@ class SentryTest { } @Test - fun `outboxPath should be created at initialization`() { + fun `outboxPath is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -191,13 +191,13 @@ class SentryTest { sentryOptions = it } + // The outbox dir is created lazily by its consumers (file observer, native SDK), not at init. val file = File(sentryOptions!!.outboxPath!!) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test - fun `cacheDirPath should be created at initialization`() { + fun `cacheDirPath is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -205,13 +205,13 @@ class SentryTest { sentryOptions = it } + // The cache dir is created lazily on the first envelope store, not at init. val file = File(sentryOptions!!.cacheDirPath!!) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test - fun `getCacheDirPathWithoutDsn should be created at initialization`() { + fun `cacheDirPathWithoutDsn is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -221,8 +221,7 @@ class SentryTest { val cacheDirPathWithoutDsn = sentryOptions!!.cacheDirPathWithoutDsn!! val file = File(cacheDirPathWithoutDsn) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test @@ -1317,6 +1316,23 @@ class SentryTest { assertTrue(appStartProfilingConfigFile.exists()) } + @Test + fun `init creates app start profiling config when the cache dir does not exist yet`() { + val path = getTempPath() + // Profiling is left disabled on purpose: it is the only other init-time consumer that creates + // the cache dir, so with it off nothing materializes the dir before the config is written. + initForTest { + it.dsn = dsn + it.cacheDirPath = path + it.isEnableAppStartProfiling = false + it.isStartProfilerOnAppStart = true + it.tracesSampleRate = 0.0 + it.profilesSampleRate = null + it.executorService = ImmediateExecutorService() + } + assertTrue(File(path, "app_start_profiling_config").exists()) + } + @Test fun `init saves SentryAppStartProfilingOptions to disk`() { var options = SentryOptions() diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 973070e0afe..dda06ee7e63 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -79,6 +79,22 @@ class EnvelopeCacheTest { file.deleteRecursively() } + @Test + fun `creates cache dir on store when it does not exist yet`() { + val cache = fixture.getSUT() + + val file = File(fixture.options.cacheDirPath!!) + assertTrue(file.deleteRecursively()) + assertFalse(file.exists()) + + cache.store(SentryEnvelope.from(fixture.options.serializer, createSession(), null)) + + assertTrue(file.exists()) + assertEquals(1, file.list()?.size) + + file.deleteRecursively() + } + @Test fun `tolerates discarding unknown envelope`() { val cache = fixture.getSUT() @@ -88,6 +104,19 @@ class EnvelopeCacheTest { // no exception thrown } + @Test + fun `does not create cache dir on discard`() { + val cache = fixture.getSUT() + + val file = File(fixture.options.cacheDirPath!!) + assertTrue(file.deleteRecursively()) + assertFalse(file.exists()) + + cache.discard(SentryEnvelope.from(fixture.options.serializer, createSession(), null)) + + assertFalse(file.exists()) + } + @Test fun `creates current file on session start`() { val cache = fixture.getSUT() @@ -450,7 +479,7 @@ class EnvelopeCacheTest { cache.store(envelopeA, Hint()) cache.store(envelopeB, Hint()) - assertEquals(2, cache.directory.list()?.size) + assertEquals(2, cache.directory.file.list()?.size) } @Test diff --git a/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt b/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt index 65745c0d3d2..60eb092e125 100644 --- a/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt @@ -1,7 +1,10 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat import java.io.File import java.nio.file.Files +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CyclicBarrier import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -73,4 +76,51 @@ class FileUtilsTest { f.writeText(text) assertEquals(text, FileUtils.readText(f)) } + + @Test + fun `createDirectory creates the directory and any missing parents`() { + val dir = File(Files.createTempDirectory("create-dir-test").toFile(), "nested/outbox") + + assertThat(FileUtils.createDirectory(dir)).isTrue() + assertThat(dir.isDirectory).isTrue() + } + + @Test + fun `createDirectory returns true when the directory already exists`() { + val dir = Files.createTempDirectory("create-dir-test").toFile() + + assertThat(FileUtils.createDirectory(dir)).isTrue() + } + + @Test + fun `createDirectory returns false when the directory cannot be created`() { + val file = Files.createTempFile("create-dir-test", "test").toFile() + + // a regular file already occupies the path, so it can never become a directory + assertThat(FileUtils.createDirectory(file)).isFalse() + } + + @Test + fun `createDirectory returns true for every caller when threads race to create it`() { + val threadCount = 8 + // mkdirs() returns false for the losers of the race, so every caller must still see success + repeat(50) { iteration -> + val dir = File(Files.createTempDirectory("create-dir-race").toFile(), "run$iteration/outbox") + val barrier = CyclicBarrier(threadCount) + val results = ConcurrentLinkedQueue() + + val threads = + (1..threadCount).map { + Thread { + barrier.await() + results.add(FileUtils.createDirectory(dir)) + } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + + assertThat(results).hasSize(threadCount) + assertThat(results).doesNotContain(false) + } + } } diff --git a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt new file mode 100644 index 00000000000..13e4df001e4 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt @@ -0,0 +1,45 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import kotlin.test.Test + +class LazyDirectoryTest { + @Test + fun `getFile does not create the directory`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.file.exists()).isFalse() + } + + @Test + fun `getOrCreate creates the directory and any missing parents`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("nested").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + val created = lazyDirectory.getOrCreate() + + assertThat(created.isDirectory).isTrue() + assertThat(created.absolutePath).isEqualTo(path.toFile().absolutePath) + } + + @Test + fun `getOrCreate is idempotent when the directory already exists`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + } + + @Test + fun `getOrCreate recreates the directory after it is deleted`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.getOrCreate().delete()).isTrue() + + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + } +} From 8f86119357fa53cee9b7cd48c29e6631811d7baf Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 29 Jul 2026 13:25:31 +0200 Subject: [PATCH 049/102] fix(anr-profiling): Properly bill ANR profiling under UI Profile Hours (#5836) Commit updates the platform used with ANR profiles from Java to Android so that we can properly bill ANR profiling under UI Profile Hours rather than Continuous Profile Hours. Depends on the updates made in [Relay #6183](https://github.com/getsentry/relay/pull/6183), [getsentry #118849](https://github.com/getsentry/sentry/pull/118849), [vroomrs #93](https://github.com/getsentry/vroomrs/pull/93), and [vroom #672](https://github.com/getsentry/vroom/pull/672). Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../sentry/android/core/ApplicationExitInfoEventProcessor.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 147172de532..a01233aebf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) - Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) +- Set the correct platform (`android` instead of `java`) on ANR profile chunks so they are billed as UI Profile Hours rather than Continuous Profile Hours ([#5836](https://github.com/getsentry/sentry-java/pull/5836)) ### Performance diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index f175db90488..ee07736123b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -1021,7 +1021,7 @@ private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfil null, new HashMap<>(0), anrTimestampMs / 1000.0d, - ProfileChunk.PLATFORM_JAVA, + ProfileChunk.PLATFORM_ANDROID, options); chunk.setSentryProfile(profile); From 91e71acbcd4c29fee08eeb9960d981215d7f2dfe Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 14:56:50 +0200 Subject: [PATCH 050/102] build: Upgrade Gradle to 9.6.1 (#5863) Keeps the build on the latest Gradle patch release. Co-authored-by: Claude Opus 5 (1M context) --- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 ++-- gradlew.bat | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad763d..a9db11550c6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f790..249efbb032c 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56f2d..a51ec4f5886 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel From 1b40080f664a4fe3b2b36a8faf6849902fcbc0ce Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 29 Jul 2026 16:35:22 +0200 Subject: [PATCH 051/102] fix(anr): Use Proguard ID from origination ANR process with ANR profie chunks (#5852) Prior to this commit, we'd always (rightly) bind originating process Proguard IDs to ANR events, but we'd (wrongly) bind current Proguard ID to ANR profile chunks. Usually the originating Proguard ID == the current Proguard ID, and so the discrepancy didn't matter. But the two differ in the situation when a user updates the host app after the ANR occurs but before it's reported. When that happens, deobfuscation for ANR profile chunks in the Sentry UI can break. This commit ensures both the ANR event and the ANR profile chunk receive the same originating Proguard ID by: 1. having the entity that determines the originating Proguard ID (ApplicationExitInfoEventProcessor) pass that ID to the profile chunk pipeline; and 2. updating our DebugMeta.buildDebugMeta(ProfileChunk, ...) method so that the profile chunk pipeline honors (rather than clobbers) the Proguard ID the event processor bakes into the DebugMeta owned by the incoming profile chunk. --- CHANGELOG.md | 1 + .../ApplicationExitInfoEventProcessor.java | 65 ++++++++-- .../ApplicationExitInfoEventProcessorTest.kt | 57 +++++++++ .../java/io/sentry/protocol/DebugMeta.java | 68 ++++++++-- .../test/java/io/sentry/SentryClientTest.kt | 63 +++++++++ .../java/io/sentry/protocol/DebugMetaTest.kt | 120 ++++++++++++++++++ 6 files changed, 353 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a01233aebf9..9aaa10b3482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Use the original app build's ProGuard UUID for ANR profile chunks ([#5852](https://github.com/getsentry/sentry-java/pull/5852)) - Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) - Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index ee07736123b..3182828a024 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -184,7 +184,7 @@ public ApplicationExitInfoEventProcessor( setStaticValues(event); if (hintEnricher != null) { - hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint); + hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint, optionsSource); } return event; @@ -504,10 +504,7 @@ private void setDebugMeta( PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); if (proguardUuid != null) { - final DebugImage debugImage = new DebugImage(); - debugImage.setType(DebugImage.PROGUARD); - debugImage.setUuid(proguardUuid); - images.add(debugImage); + images.add(createProguardDebugImage(proguardUuid)); } event.setDebugMeta(debugMeta); } @@ -790,7 +787,10 @@ void applyPreEnrichment( @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource); } private final class AnrHintEnricher implements HintEnricher { @@ -822,11 +822,14 @@ public void applyPreEnrichment( @Override public void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource) { final boolean isBackgroundAnr = isBackgroundAnr(rawHint); if (options.isAnrProfilingEnabled()) { - applyAnrProfile(event, hint, isBackgroundAnr); + applyAnrProfile(event, hint, isBackgroundAnr, optionsSource); } setDefaultAnrFingerprint(event, isBackgroundAnr); @@ -920,7 +923,10 @@ private void setAnrExceptions( } private void applyAnrProfile( - @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + boolean isBackgroundAnr, + @NotNull OptionsSource optionsSource) { // Skip background ANRs (as profiling only runs in foreground) if (isBackgroundAnr) { @@ -981,7 +987,8 @@ private void applyAnrProfile( } // Capture profile chunk - final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @Nullable SentryId profilerId = + captureAnrProfile(anrTimestamp, anrProfile, optionsSource); final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { @@ -1012,7 +1019,10 @@ private void applyAnrProfile( } @Nullable - private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfile anrProfile) { + private SentryId captureAnrProfile( + final long anrTimestampMs, + @NotNull AnrProfile anrProfile, + final @NotNull OptionsSource optionsSource) { final SentryProfile profile = StackTraceConverter.convert(anrProfile); final ProfileChunk chunk = new ProfileChunk( @@ -1024,6 +1034,7 @@ private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfil ProfileChunk.PLATFORM_ANDROID, options); chunk.setSentryProfile(profile); + chunk.setDebugMeta(createAnrProfileDebugMeta(optionsSource)); final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); if (SentryId.EMPTY_ID.equals(profilerId)) { @@ -1058,5 +1069,37 @@ private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { } return true; } + + /** + * Creates debug metadata for an ANR profile chunk using the build metadata selected for the ANR + * event. + * + *

ANR profile chunks are captured after app relaunch. If the app was updated between the ANR + * and the relaunch, the current options may contain the new build's ProGuard UUID. The provided + * {@link OptionsSource} lets us resolve the profile chunk and ANR event to the same originating + * build. + */ + private @Nullable DebugMeta createAnrProfileDebugMeta( + final @NotNull OptionsSource optionsSource) { + final String proguardUuid = + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); + if (proguardUuid == null) { + // If no historical UUID is available, let the generic profile chunk pipeline apply the + // current options UUID as its normal best-effort fallback. + return null; + } + + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(Collections.singletonList(createProguardDebugImage(proguardUuid))); + return debugMeta; + } + } + + private static @NotNull DebugImage createProguardDebugImage(final @NotNull String proguardUuid) { + final DebugImage debugImage = new DebugImage(); + debugImage.setType(DebugImage.PROGUARD); + debugImage.setUuid(proguardUuid); + return debugImage; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index d5b916d3b44..e80c738b5ea 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -11,6 +11,7 @@ import io.sentry.Hint import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.ProfileChunk import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent @@ -75,7 +76,9 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow @@ -1015,6 +1018,60 @@ class ApplicationExitInfoEventProcessorTest { } } + @Test + fun `uses persisted proguard uuid for ANR profile chunk after app update`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.proguardUuid = "current-uuid" + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + setLastUpdateTime(2_000) + + val hint = + HintUtils.createWithTypeCheckHint( + AbnormalExitHint(mechanism = "anr_foreground", timestamp = 1_000) + ) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + processor.process(SentryEvent(), hint) + + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val images = chunkCaptor.firstValue.debugMeta!!.images!! + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } + } + @Test fun `does not crash when ANR profiling is enabled but cache dir is null`() { fixture.options.anrProfilingSampleRate = 1.0 diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 45e5fda0603..9cfdb6f5f31 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -57,6 +57,19 @@ public void setSdkInfo(final @Nullable SdkInfo sdkInfo) { @ApiStatus.Internal public static @Nullable DebugMeta buildDebugMeta( final @Nullable DebugMeta eventDebugMeta, final @NotNull SentryOptions options) { + final @NotNull List optionDebugImages = createDebugImagesFromOptions(options); + + if (eventDebugMeta == null && optionDebugImages.isEmpty()) { + return null; + } + + final @NotNull DebugMeta debugMeta = eventDebugMeta != null ? eventDebugMeta : new DebugMeta(); + addMissingDebugImages(debugMeta, optionDebugImages); + return debugMeta; + } + + private static @NotNull List createDebugImagesFromOptions( + final @NotNull SentryOptions options) { final @NotNull List debugImages = new ArrayList<>(); if (options.getProguardUuid() != null) { @@ -73,21 +86,56 @@ public void setSdkInfo(final @Nullable SdkInfo sdkInfo) { debugImages.add(sourceBundleImage); } - if (!debugImages.isEmpty()) { - DebugMeta debugMeta = eventDebugMeta; + return debugImages; + } - if (debugMeta == null) { - debugMeta = new DebugMeta(); + private static void addMissingDebugImages( + final @NotNull DebugMeta debugMeta, final @NotNull List candidates) { + if (candidates.isEmpty()) { + return; + } + + if (debugMeta.getImages() == null) { + debugMeta.setImages(new ArrayList<>()); + } + + final @Nullable List images = debugMeta.getImages(); + if (images == null) { + return; + } + + for (final @NotNull DebugImage candidate : candidates) { + if (isMissingDebugImage(images, candidate)) { + images.add(candidate); } - if (debugMeta.getImages() == null) { - debugMeta.setImages(debugImages); - } else { - debugMeta.getImages().addAll(debugImages); + } + } + + private static boolean isMissingDebugImage( + final @NotNull List images, final @NotNull DebugImage candidate) { + for (final @NotNull DebugImage image : images) { + if (isMatchingDebugImage(image, candidate)) { + return false; } + } + return true; + } - return debugMeta; + private static boolean isMatchingDebugImage( + final @NotNull DebugImage image, final @NotNull DebugImage candidate) { + // There can only be one ProGuard mapping per payload, so an existing ProGuard image takes + // precedence over the option-derived default. + if (DebugImage.PROGUARD.equals(candidate.getType())) { + return DebugImage.PROGUARD.equals(image.getType()); } - return null; + + if (DebugImage.JVM.equals(candidate.getType())) { + return DebugImage.JVM.equals(image.getType()) + && candidate.getDebugId() != null + && candidate.getDebugId().equals(image.getDebugId()); + } + + return false; } // JsonKeys diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index fa37cb0b70b..02623556498 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -18,6 +18,8 @@ import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.metrics.IMetricsBatchProcessor import io.sentry.metrics.IMetricsBatchProcessorFactory import io.sentry.protocol.Contexts +import io.sentry.protocol.DebugImage +import io.sentry.protocol.DebugMeta import io.sentry.protocol.Feedback import io.sentry.protocol.Mechanism import io.sentry.protocol.Message @@ -1993,6 +1995,56 @@ class SentryClientTest { verifyProfileChunkInEnvelope(fixture.profileChunk.chunkId) } + @Test + fun `captureProfileChunk adds options proguard debug meta`() { + fixture.sentryOptions.proguardUuid = "current-uuid" + + val client = fixture.getSut() + client.captureProfileChunk(fixture.profileChunk, mock()) + + verify(fixture.transport) + .send( + check { actual -> + val profileChunk = getProfileChunkFromEnvelope(actual) + val images = profileChunk.debugMeta!!.images!! + + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("current-uuid", images[0].uuid) + } + ) + } + + @Test + fun `captureProfileChunk preserves existing proguard debug meta`() { + fixture.sentryOptions.proguardUuid = "current-uuid" + fixture.profileChunk.debugMeta = + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "previous-uuid" + } + ) + } + + val client = fixture.getSut() + client.captureProfileChunk(fixture.profileChunk, mock()) + + verify(fixture.transport) + .send( + check { actual -> + val profileChunk = getProfileChunkFromEnvelope(actual) + val images = profileChunk.debugMeta!!.images!! + + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } + ) + } + @Test fun `when captureProfileChunk with empty trace file, profile chunk is not sent`() { val client = fixture.getSut() @@ -4169,6 +4221,17 @@ class SentryClientTest { )!! } + private fun getProfileChunkFromData(data: ByteArray): ProfileChunk { + val inputStream = InputStreamReader(ByteArrayInputStream(data)) + return fixture.sentryOptions.serializer.deserialize(inputStream, ProfileChunk::class.java)!! + } + + private fun getProfileChunkFromEnvelope(envelope: SentryEnvelope): ProfileChunk { + val profileChunkItem = + envelope.items.first { item -> item.header.type == SentryItemType.ProfileChunk } + return getProfileChunkFromData(profileChunkItem.data) + } + private fun getReplayFromData(data: ByteArray): SentryReplayEvent? { val unpacker = MessagePack.newDefaultUnpacker(data) val mapSize = unpacker.unpackMapHeader() diff --git a/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt b/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt index 9cb8cf40946..a1c2f4edb05 100644 --- a/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt @@ -57,6 +57,98 @@ class DebugMetaTest { } } + @Test + fun `when debug meta already has proguard image, does not attach options proguard uuid`() { + val options = SentryOptions().apply { proguardUuid = "current-id" } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "existing-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(1, images.size) + assertEquals("existing-id", images[0].uuid) + assertEquals(DebugImage.PROGUARD, images[0].type) + } + } + } + + @Test + fun `when debug meta already has proguard image, still attaches missing bundle ids`() { + val options = + SentryOptions().apply { + proguardUuid = "current-id" + bundleIds.add("bundle-id") + } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "existing-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(2, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("existing-id", images[0].uuid) + assertEquals(DebugImage.JVM, images[1].type) + assertEquals("bundle-id", images[1].debugId) + } + } + } + + @Test + fun `when debug meta has unrelated debug image, attaches option debug information`() { + val options = + SentryOptions().apply { + proguardUuid = "proguard-id" + bundleIds.add("bundle-id") + } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = "elf" + debugId = "native-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(3, images.size) + assertEquals("elf", images[0].type) + assertEquals("native-id", images[0].debugId) + assertEquals(DebugImage.PROGUARD, images[1].type) + assertEquals("proguard-id", images[1].uuid) + assertEquals(DebugImage.JVM, images[2].type) + assertEquals("bundle-id", images[2].debugId) + } + } + } + @Test fun `when event has debug meta and bundle ids are set, attaches debug information`() { val options = SentryOptions().apply { bundleIds.addAll(listOf("id1", "id2")) } @@ -86,4 +178,32 @@ class DebugMetaTest { } } } + + @Test + fun `when debug meta already has jvm image, only attaches missing bundle ids`() { + val options = SentryOptions().apply { bundleIds.addAll(listOf("id1", "id2")) } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.JVM + debugId = "id1" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(2, images.size) + assertEquals("id1", images[0].debugId) + assertEquals(DebugImage.JVM, images[0].type) + assertEquals("id2", images[1].debugId) + assertEquals(DebugImage.JVM, images[1].type) + } + } + } } From 80c3e67a26874c5c5906dc0fe688d0399b553565 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 29 Jul 2026 17:54:19 +0200 Subject: [PATCH 052/102] build: Replace Gradle APIs removed in Gradle 10 (#5864) Gradle 9.6 deprecated four APIs our build scripts still used, all of which either fail or are removed in Gradle 10: - Project.getProperties, replaced with providers.gradleProperty - Project objects as dependency notation, replaced with project(path) - the 'val x by creating' configuration delegate, replaced with create() - the 'val x by getting' source set delegate, replaced with getByName() The remaining Gradle 10 warnings come from Detekt and AGP internals, not from our scripts, so they can only be resolved by upgrading those. Co-authored-by: Claude Opus 5 (1M context) --- .../src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts | 6 ++---- build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts | 2 +- build.gradle.kts | 2 +- buildSrc/src/main/java/Publication.kt | 7 +++++-- sentry-compose/build.gradle.kts | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts index e06cb677319..8fde556d751 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts @@ -1,11 +1,9 @@ import io.sentry.gradle.AggregateJavadoc import org.gradle.api.attributes.Category import org.gradle.api.attributes.LibraryElements -import org.gradle.kotlin.dsl.creating -import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.named -val javadocPublisher by configurations.creating { +val javadocPublisher = configurations.create("javadocPublisher") { isCanBeConsumed = false isCanBeResolved = true attributes { @@ -15,7 +13,7 @@ val javadocPublisher by configurations.creating { } subprojects { - javadocPublisher.dependencies.add(dependencies.create(this)) + javadocPublisher.dependencies.add(rootProject.dependencies.project(path)) } val javadocCollection = javadocPublisher.incoming.artifactView { lenient(true) }.files diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts index 7eb796a02ff..21f81fec36a 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts @@ -1,4 +1,4 @@ -val javadocConfig: Configuration by configurations.creating { +val javadocConfig: Configuration = configurations.create("javadocConfig") { isCanBeResolved = false isCanBeConsumed = true diff --git a/build.gradle.kts b/build.gradle.kts index 7d8cfcb626e..a663628b467 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,7 +92,7 @@ apiValidation { allprojects { group = Config.Sentry.group - version = properties[Config.Sentry.versionNameProp].toString() + version = providers.gradleProperty(Config.Sentry.versionNameProp).get() description = Config.Sentry.description tasks { withType().configureEach { diff --git a/buildSrc/src/main/java/Publication.kt b/buildSrc/src/main/java/Publication.kt index 0aa717a5630..d545e6e32dc 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -7,10 +7,13 @@ private object Consts { val taskRegex = Regex("(.*)DistZip") } +private fun Project.versionName(): String = + providers.gradleProperty("versionName").get() + // configure distZip tasks for multiplatform fun DistributionContainer.configureForMultiplatform(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.maybeCreate("android").contents { @@ -69,7 +72,7 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { fun DistributionContainer.configureForJvm(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.getByName("main").contents { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 4ebd9349662..8b835ba16fe 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -44,13 +44,13 @@ kotlin { } sourceSets { - val commonMain by getting { + getByName("commonMain") { compilerOptions { apiVersion.set(KotlinVersion.KOTLIN_1_9) languageVersion.set(KotlinVersion.KOTLIN_1_9) } } - val androidMain by getting { + getByName("androidMain") { dependencies { api(projects.sentry) api(projects.sentryAndroidNavigation) @@ -60,7 +60,7 @@ kotlin { implementation(libs.androidx.lifecycle.common.java8) } } - val androidUnitTest by getting { + getByName("androidUnitTest") { dependencies { implementation(libs.androidx.compose.ui.test.junit4) implementation(libs.androidx.navigation.compose) From 7055ed1d017c60459231faac78a2664c607467c5 Mon Sep 17 00:00:00 2001 From: Matthew Jay Williams <31186619+43jay@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:01:23 -0400 Subject: [PATCH 053/102] feat(profiling): Add Android ProfilingManager (Perfetto) support (#5251) --- CHANGELOG.md | 9 + .../api/sentry-android-core.api | 18 + .../core/AndroidContinuousProfiler.java | 5 + .../core/AndroidOptionsInitializer.java | 64 +- .../android/core/ManifestMetadataReader.java | 5 + .../core/PerfettoContinuousProfiler.java | 648 ++++++++++++++++++ .../sentry/android/core/PerfettoProfiler.java | 237 +++++++ .../core/SentryPerformanceProvider.java | 17 + .../core/AndroidContinuousProfilerTest.kt | 401 +++-------- .../core/AndroidOptionsInitializerTest.kt | 66 ++ .../core/ChunkMeasurementCollectorTest.kt | 146 ++++ .../core/ContinuousProfilerTestCases.kt | 194 ++++++ .../core/ManifestMetadataReaderTest.kt | 25 + .../core/PerfettoContinuousProfilerTest.kt | 223 ++++++ .../android/core/PerfettoProfilerTest.kt | 268 ++++++++ .../src/main/AndroidManifest.xml | 5 +- .../samples/android/ProfilingActivity.kt | 242 +++---- .../samples/android/ProfilingListAdapter.kt | 41 -- .../main/res/layout/activity_profiling.xml | 59 -- .../main/res/layout/profiling_item_list.xml | 15 - .../src/main/res/values/strings.xml | 13 +- sentry/api/sentry.api | 11 + .../DefaultCompositePerformanceCollector.java | 6 +- .../src/main/java/io/sentry/ProfileChunk.java | 31 +- .../SentryAppStartProfilingOptions.java | 19 + .../src/main/java/io/sentry/SentryClient.java | 13 +- .../java/io/sentry/SentryEnvelopeItem.java | 74 ++ .../io/sentry/SentryEnvelopeItemHeader.java | 107 ++- .../main/java/io/sentry/SentryOptions.java | 38 + .../test/java/io/sentry/JsonSerializerTest.kt | 3 +- .../java/io/sentry/SentryEnvelopeItemTest.kt | 99 +++ .../test/java/io/sentry/SentryOptionsTest.kt | 11 + 32 files changed, 2516 insertions(+), 597 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt delete mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt delete mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml delete mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/profiling_item_list.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaa10b3482..d05335e9aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Features + +- Use Android's `ProfilingManager` (Perfetto) for continuous profiling on API 35+ devices ([#5251](https://github.com/getsentry/sentry-java/pull/5251)) + - On API 35+ devices, continuous profiling now automatically uses Android's system `ProfilingManager` with Perfetto-based stack sampling, providing lower-overhead and more accurate profiles. No configuration change is required. + - Devices below API 35 keep using the legacy `Debug`-based profiler. + - Added an `enableLegacyProfiling` option (default `true`) to disable the legacy `Debug`-based profiler. Setting it to `false` disables continuous profiling on API < 35 devices as well as transaction-based profiling (`profilesSampleRate`/`profilesSampler`) on all devices, since transaction-based profiling is not supported by Perfetto. + - It can also be configured via the `io.sentry.profiling.enable-legacy-profiling` manifest flag. + - See the [Android profiling docs](https://docs.sentry.io/platforms/android/profiling/) for details. + ### Behavioral Changes - The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..da80a74e32c 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -362,6 +362,24 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { + public fun (Lio/sentry/ILogger;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Ljava/util/function/Supplier;)V + public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public class io/sentry/android/core/PerfettoProfiler { + public fun (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/ISentryExecutorService;)V + public fun endAndCollect (Ljava/util/function/Consumer;)V + public fun start (J)Z +} + public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V public fun getOrder ()Ljava/lang/Long; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 41362c9d93e..a1c0c097cb9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -38,6 +38,11 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; +/** + * Legacy Android implementation of {@link IContinuousProfiler}, using Android's {@code + * Debug.startMethodTracingSampling} See {@link PerfettoContinuousProfiler} for the new + * implementation using {@code ProfilingManager}, available on API 35+. + */ @ApiStatus.Internal public class AndroidContinuousProfiler implements IContinuousProfiler, RateLimiter.IRateLimitObserver { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 434dfc73d3e..a0547a78b34 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -2,6 +2,7 @@ import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME; +import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; @@ -299,6 +300,7 @@ static void initializeIntegrationsAndProcessors( } /** Setup the correct profiler (transaction or continuous) based on the options. */ + @SuppressLint("NewApi") private static void setupProfiler( final @NotNull SentryAndroidOptions options, final @NotNull Context context, @@ -308,6 +310,28 @@ private static void setupProfiler( final @NotNull CompositePerformanceCollector performanceCollector) { if (options.isProfilingEnabled() || options.getProfilesSampleRate() != null) { options.setContinuousProfiler(NoOpContinuousProfiler.getInstance()); + // Transaction-based profiling always relies on the legacy Debug-based profiler, so it is + // disabled together with legacy profiling. Perfetto profiling only supports continuous + // profiling. + if (!options.isEnableLegacyProfiling()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Transaction-based profiling (profilesSampleRate/profilesSampler) is disabled " + + "because enableLegacyProfiling is false. Transaction-based profiling always " + + "uses the legacy profiler and is not supported by Perfetto. No profiling " + + "data will be collected. Use profileSessionSampleRate for continuous " + + "profiling instead."); + options.setTransactionProfiler(NoOpTransactionProfiler.getInstance()); + if (appStartTransactionProfiler != null) { + appStartTransactionProfiler.close(); + } + if (appStartContinuousProfiler != null) { + appStartContinuousProfiler.close(true); + } + return; + } // This is a safeguard, but it should never happen, as the app start profiler should be the // continuous one. if (appStartContinuousProfiler != null) { @@ -341,16 +365,36 @@ private static void setupProfiler( performanceCollector.start(chunkId.toString()); } } else { - options.setContinuousProfiler( - new AndroidContinuousProfiler( - buildInfoProvider, - Objects.requireNonNull( - options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required"), - options.getLogger(), - options.getProfilingTracesDirPath(), - options.getProfilingTracesHz(), - () -> options.getExecutorService())); + final @NotNull SentryFrameMetricsCollector frameMetricsCollector = + Objects.requireNonNull( + options.getFrameMetricsCollector(), "options.getFrameMetricsCollector is required"); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + final @NotNull Context appContext = ContextUtils.getApplicationContext(context); + options.setContinuousProfiler( + new PerfettoContinuousProfiler( + options.getLogger(), + frameMetricsCollector, + () -> options.getExecutorService(), + () -> + new PerfettoProfiler( + appContext, options.getLogger(), options.getExecutorService()))); + } else if (options.isEnableLegacyProfiling()) { + options.setContinuousProfiler( + new AndroidContinuousProfiler( + buildInfoProvider, + frameMetricsCollector, + options.getLogger(), + options.getProfilingTracesDirPath(), + options.getProfilingTracesHz(), + () -> options.getExecutorService())); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "No profiling data will be collected."); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 469f15e3f3b..f21d4c801a3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -119,6 +119,8 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String ENABLE_LEGACY_PROFILING = "io.sentry.profiling.enable-legacy-profiling"; + static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; @@ -542,6 +544,9 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + options.setEnableLegacyProfiling( + readBool(metadata, logger, ENABLE_LEGACY_PROFILING, options.isEnableLegacyProfiling())); + options.setEnableScopePersistence( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java new file mode 100644 index 00000000000..d4260e93dd7 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -0,0 +1,648 @@ +package io.sentry.android.core; + +import static io.sentry.DataCategory.All; +import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED; + +import android.os.Build; +import android.os.SystemClock; +import androidx.annotation.RequiresApi; +import io.sentry.CompositePerformanceCollector; +import io.sentry.DataCategory; +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpScopes; +import io.sentry.PerformanceCollectionData; +import io.sentry.ProfileChunk; +import io.sentry.ProfileLifecycle; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.TracesSampler; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; +import io.sentry.protocol.SentryId; +import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; +import io.sentry.util.SentryRandom; +import java.io.File; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +/** + * Continuous profiler that uses Android's {@link android.os.ProfilingManager} (API 35+) to capture + * Perfetto stack-sampling traces. + * + *

This class is intentionally separate from {@link AndroidContinuousProfiler} to keep the two + * profiling backends independent. All ProfilingManager API usage is confined to this file and + * {@link PerfettoProfiler}. + * + *

Currently, this class doesn't do app-start profiling {@link SentryPerformanceProvider}. It is + * created during {@code Sentry.init()}. + * + *

Thread safety: all mutable state is guarded by a single {@link + * io.sentry.util.AutoClosableReentrantLock}. Public entry points ({@link #startProfiler}, {@link + * #stopProfiler}, {@link #close}, {@link #onRateLimitChanged}, {@link #reevaluateSampling}, and the + * getters) acquire the lock themselves and are thread-safe. Private methods {@code startInternal} + * and {@code stopInternal} require the caller to hold the lock. + */ +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoContinuousProfiler + implements IContinuousProfiler, RateLimiter.IRateLimitObserver { + private static final long MAX_CHUNK_DURATION_MILLIS = 60000; + + // Matches the thread name produced by SentryExecutorService's thread factory, used to detect + // when we are already running on the executor thread. + private static final String EXECUTOR_THREAD_NAME_PREFIX = "SentryExecutorServiceThreadFactory"; + + private final @NotNull ILogger logger; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; + private final @NotNull Supplier perfettoProfilerFactory; + + private @Nullable PerfettoProfiler perfettoProfiler = null; + private final @NotNull ChunkMeasurementCollector chunkMeasurements; + private boolean isRunning = false; + private @Nullable IScopes scopes; + private @Nullable CompositePerformanceCollector performanceCollector; + private @Nullable Future stopFuture; + private @NotNull SentryId profilerId = SentryId.EMPTY_ID; + private @NotNull SentryId chunkId = SentryId.EMPTY_ID; + private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); + private @NotNull SentryDate startProfileChunkTimestamp = new io.sentry.SentryNanotimeDate(); + private boolean shouldSample = true; + private boolean shouldStop = false; + private boolean isSampled = false; + private int activeTraceCount = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + public PerfettoContinuousProfiler( + final @NotNull ILogger logger, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier, + final @NotNull Supplier perfettoProfilerFactory) { + this.logger = logger; + this.chunkMeasurements = new ChunkMeasurementCollector(frameMetricsCollector); + this.executorServiceSupplier = executorServiceSupplier; + this.perfettoProfilerFactory = perfettoProfilerFactory; + } + + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + switch (profileLifecycle) { + case TRACE: + activeTraceCount = Math.max(0, activeTraceCount); // safety check. + activeTraceCount++; + break; + case MANUAL: + if (isRunning()) { + logger.log( + SentryLevel.WARNING, + "Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."); + return; + } + break; + } + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + shouldStop = false; + startInternal(); + } + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + activeTraceCount--; + activeTraceCount = Math.max(0, activeTraceCount); // safety check + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (activeTraceCount > 0) { + return; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } + } + } + + /** + * Stop the profiler as soon as we are rate limited, to avoid the performance overhead. + * + * @param rateLimiter the {@link RateLimiter} instance to check categories against + */ + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + } + } + // If we are not rate limited anymore, we don't do anything: the profile is broken, so it's + // useless to restart it automatically + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + activeTraceCount = 0; + shouldStop = true; + if (isTerminating) { + stopInternal(false); + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return profilerId; + } + } + + @Override + public @NotNull SentryId getChunkId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return chunkId; + } + } + + @Override + public boolean isRunning() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return isRunning; + } + } + + /** + * Resolves scopes on first call. Since PerfettoContinuousProfiler is created during Sentry.init() + * and never used for app-start profiling, scopes is guaranteed to be available by the time + * startProfiler is called. + * + *

Caller must hold {@link #lock}. + */ + private @NotNull IScopes resolveScopes() { + if (scopes != null && scopes != NoOpScopes.getInstance()) { + return scopes; + } + final @NotNull IScopes currentScopes = Sentry.getCurrentScopes(); + if (currentScopes == NoOpScopes.getInstance()) { + logger.log( + SentryLevel.ERROR, + "PerfettoContinuousProfiler: scopes not available. This is unexpected."); + return currentScopes; + } + this.scopes = currentScopes; + this.performanceCollector = currentScopes.getOptions().getCompositePerformanceCollector(); + final @Nullable RateLimiter rateLimiter = currentScopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); + } + return scopes; + } + + /** Caller must hold {@link #lock}. */ + private void startInternal() { + final @NotNull IScopes scopes = resolveScopes(); + + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi))) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + return; + } + + // If device is offline, we don't start the profiler, to avoid flooding the cache + if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) { + logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler."); + stopInternal(false); + return; + } + startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); + + perfettoProfiler = perfettoProfilerFactory.get(); + if (perfettoProfiler == null) { + return; + } + if (!perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS)) { + logger.log( + SentryLevel.ERROR, + "Failed to start Perfetto profiling. PerfettoProfiler.start() returned false."); + return; + } + + isRunning = true; + + if (profilerId.equals(SentryId.EMPTY_ID)) { + profilerId = new SentryId(); + } + + if (chunkId.equals(SentryId.EMPTY_ID)) { + chunkId = new SentryId(); + } + + chunkMeasurements.start(performanceCollector, chunkId.toString()); + + try { + stopFuture = + executorServiceSupplier + .evaluate() + .schedule( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + stopInternal(true); + } + }, + MAX_CHUNK_DURATION_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", + e); + shouldStop = true; + } + } + + /** Caller must hold {@link #lock}. */ + private void stopInternal(final boolean restartProfiler) { + final @Nullable PerfettoProfiler currentProfiler = perfettoProfiler; + + if (stopFuture != null) { + stopFuture.cancel(false); + } + + // Make sure perfetto was running + if (currentProfiler == null || !isRunning) { + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } + + final @NotNull IScopes scopes = resolveScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + + final @NotNull Map measurements = chunkMeasurements.stop(); + + // Capture state needed by the callback before clearing it + final @NotNull SentryId chunkProfilerId = profilerId; + final @NotNull SentryId chunkChunkId = chunkId; + final @NotNull SentryDate chunkTimestamp = startProfileChunkTimestamp; + + isRunning = false; + perfettoProfiler = null; + chunkId = SentryId.EMPTY_ID; + + if (!restartProfiler || shouldStop) { + profilerId = SentryId.EMPTY_ID; + } + + final boolean shouldRestart = restartProfiler && !shouldStop; + + // endAndCollect is non-blocking: the listener fires when the OS delivers the trace file. + // Synchronous: result already available — callback runs inline, lock is still held (re-entrant) + // Asynchronous: callback runs on an OS thread — acquires lock itself for restart + currentProfiler.endAndCollect( + traceFile -> + onChunkCollected( + traceFile, + chunkProfilerId, + chunkChunkId, + measurements, + chunkTimestamp, + shouldRestart, + scopes, + options)); + } + + private void onChunkCollected( + final @Nullable File traceFile, + final @NotNull SentryId chunkProfilerId, + final @NotNull SentryId chunkChunkId, + final @NotNull Map measurements, + final @NotNull SentryDate chunkTimestamp, + final boolean shouldRestart, + final @NotNull IScopes scopes, + final @NotNull SentryOptions options) { + if (traceFile == null) { + logger.log( + SentryLevel.ERROR, + "An error occurred while collecting a profile chunk, and it won't be sent."); + } else { + final ProfileChunk.Builder builder = + new ProfileChunk.Builder( + chunkProfilerId, + chunkChunkId, + measurements, + traceFile, + chunkTimestamp, + ProfileChunk.PLATFORM_ANDROID); + builder.setContentType(ProfileChunk.CONTENT_TYPE_PERFETTO); + sendChunk(builder, scopes, options); + } + + if (shouldRestart) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // shouldStop is re-checked here (not just at capture time) because a stopProfiler() or + // close() may have been requested while this async callback was pending. + if (isRunning || isClosed.get() || shouldStop) { + logger.log( + SentryLevel.DEBUG, + "Profile chunk finished, but profiler was already restarted, closed or stopped. Skipping."); + return; + } + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + startInternal(); + } + } else { + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } + + public void reevaluateSampling() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + shouldSample = true; + } + } + + private void sendChunk( + final @NotNull ProfileChunk.Builder builder, + final @NotNull IScopes scopes, + final @NotNull SentryOptions options) { + final @NotNull Runnable task = + () -> { + if (isClosed.get()) { + return; + } + scopes.captureProfileChunk(builder.build(options)); + }; + try { + // The chunk timer callback (stopInternal) already runs on the executor thread; submitting + // back into the same single-threaded executor from there can deadlock, so run inline instead. + if (Thread.currentThread().getName().startsWith(EXECUTOR_THREAD_NAME_PREFIX)) { + task.run(); + } else { + executorServiceSupplier.evaluate().submit(task); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunk.", e); + } + } + + /** + * Collects measurements for a single profiling chunk: frame metrics (slow/frozen frames, refresh + * rate) and performance data (CPU usage, memory footprint). + * + *

Frame metrics are delivered on the FrameMetrics HandlerThread. The deques use {@link + * ConcurrentLinkedDeque} because the HandlerThread writes and the executor thread reads. + * + *

Performance data is collected by the {@link CompositePerformanceCollector}'s Timer thread + * every 100ms and returned as a list on {@code stop()}. + */ + @VisibleForTesting + static class ChunkMeasurementCollector { + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private @Nullable String frameMetricsListenerId = null; + private @Nullable CompositePerformanceCollector performanceCollector = null; + private @Nullable String chunkId = null; + + private final @NotNull ConcurrentLinkedDeque + slowFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + frozenFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + screenFrameRateMeasurements = new ConcurrentLinkedDeque<>(); + + // Elapsed realtime when the measurement was started (nanosecond precision). + // Used to convert wall-time clock values into ns-since-chunk-start for the measurements + // payload. + private long profileStartElapsedRealtimeNanos = 0; + + ChunkMeasurementCollector(final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; + } + + void start( + final @Nullable CompositePerformanceCollector performanceCollector, + final @NotNull String chunkId) { + this.performanceCollector = performanceCollector; + this.chunkId = chunkId; + this.profileStartElapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos(); + + // Start frame metrics collection (runs on the FrameMetrics HandlerThread) + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + screenFrameRateMeasurements.clear(); + frameMetricsListenerId = + frameMetricsCollector.startCollection( + new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { + float lastRefreshRate = 0; + + @Override + public void onFrameMetricCollected( + final long frameStartNanos, + final long frameEndNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final float refreshRate) { + final long timestampNanos = new SentryNanotimeDate().nanoTimestamp(); + // Convert frameEndNanos (reported by FrameMetricsCollector using System.nanoTime + // / + // SystemClock.uptimeMillis), into the SystemClock.elapsedRealtime to report + // elapsed + // realtime nanos since chunk start + final long frameEndElapsedRealtimeNanos = + frameEndNanos - System.nanoTime() + SystemClock.elapsedRealtimeNanos(); + final long frameTimestampRelativeNanos = + frameEndElapsedRealtimeNanos - profileStartElapsedRealtimeNanos; + + // We don't allow negative relative timestamps, e.g. for a frame that started + // before the chunk did. This should never happen, but we check anyway. + if (frameTimestampRelativeNanos < 0) { + return; + } + if (isFrozen) { + frozenFrameRenderMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, durationNanos, timestampNanos)); + } else if (isSlow) { + slowFrameRenderMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, durationNanos, timestampNanos)); + } + if (refreshRate != lastRefreshRate) { + lastRefreshRate = refreshRate; + screenFrameRateMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, refreshRate, timestampNanos)); + } + } + }); + + // Start performance collection (runs on CompositePerformanceCollector's Timer thread) + if (performanceCollector != null) { + performanceCollector.start(chunkId); + } + } + + /** + * Stops all collection, builds and returns the combined measurements map containing frame + * metrics and performance data (CPU, memory). + */ + @NotNull + Map stop() { + final @NotNull Map measurements = new HashMap<>(); + // Stop frame metrics + frameMetricsCollector.stopCollection(frameMetricsListenerId); + frameMetricsListenerId = null; + addFrameDataToMeasurements(measurements); + + // Stop performance collection + @Nullable List performanceData = null; + if (performanceCollector != null && chunkId != null) { + performanceData = performanceCollector.stop(chunkId); + final long wallClockNowNanos = TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis()); + final long elapsedRealtimeNowNanos = SystemClock.elapsedRealtimeNanos(); + addPerformanceDataToMeasurements( + performanceData, + measurements, + wallClockNowNanos, + elapsedRealtimeNowNanos, + profileStartElapsedRealtimeNanos); + } + performanceCollector = null; + chunkId = null; + + return measurements; + } + + private void addFrameDataToMeasurements( + final @NotNull Map measurements) { + if (!slowFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, new ArrayList<>(slowFrameRenderMeasurements))); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, + new ArrayList<>(frozenFrameRenderMeasurements))); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement( + ProfileMeasurement.UNIT_HZ, new ArrayList<>(screenFrameRateMeasurements))); + } + } + + private static void addPerformanceDataToMeasurements( + final @Nullable List performanceData, + final @NotNull Map measurements, + final long wallClockNowNanos, + final long elapsedRealtimeNowNanos, + final long profileStartElapsedRealtimeNanos) { + if (performanceData == null || performanceData.isEmpty()) { + return; + } + final @NotNull ArrayDeque cpuUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque memoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque nativeMemoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + + // CompositePerformanceCollector.stop() hands back its live list, which its timer thread may + // still write to, so we synchronize on it while iterating, as AndroidProfiler does. + synchronized (performanceData) { + for (final @NotNull PerformanceCollectionData data : performanceData) { + // Convert sample timestamps (reported by CompositePerformanceCollector using + // System.currentTimeMillis), into the SystemClock.elapsedRealtime to report + // elapsed realtime nanos since chunk start + final long nanoTimestamp = data.getNanoTimestamp(); + final long nanosSinceSample = wallClockNowNanos - nanoTimestamp; + final long sampleElapsedRealtimeNanos = elapsedRealtimeNowNanos - nanosSinceSample; + final long relativeStartNs = + sampleElapsedRealtimeNanos - profileStartElapsedRealtimeNanos; + final @Nullable Double cpuUsagePercentage = data.getCpuUsagePercentage(); + final @Nullable Long usedHeapMemory = data.getUsedHeapMemory(); + final @Nullable Long usedNativeMemory = data.getUsedNativeMemory(); + + if (cpuUsagePercentage != null) { + cpuUsageMeasurements.addLast( + new ProfileMeasurementValue(relativeStartNs, cpuUsagePercentage, nanoTimestamp)); + } + if (usedHeapMemory != null) { + memoryUsageMeasurements.addLast( + new ProfileMeasurementValue(relativeStartNs, usedHeapMemory, nanoTimestamp)); + } + if (usedNativeMemory != null) { + nativeMemoryUsageMeasurements.addLast( + new ProfileMeasurementValue(relativeStartNs, usedNativeMemory, nanoTimestamp)); + } + } + } + + if (!cpuUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_CPU_USAGE, + new ProfileMeasurement(ProfileMeasurement.UNIT_PERCENT, cpuUsageMeasurements)); + } + if (!memoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, memoryUsageMeasurements)); + } + if (!nativeMemoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, nativeMemoryUsageMeasurements)); + } + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java new file mode 100644 index 00000000000..d09c7252694 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java @@ -0,0 +1,237 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ProfilingManager; +import android.os.ProfilingResult; +import androidx.annotation.RequiresApi; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.SentryLevel; +import java.io.File; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps Android's {@link ProfilingManager} API for a single Perfetto stack-sampling session. + * + *

Each instance is single-use: call {@link #start} once, then {@link #endAndCollect} once. For a + * new profiling session, create a new instance. + */ +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoProfiler { + + // Bundle keys matching ProfilingManager constants + private static final String KEY_DURATION_MS = "KEY_DURATION_MS"; + private static final String KEY_FREQUENCY_HZ = "KEY_FREQUENCY_HZ"; + + /** + * Fixed sampling frequency for Perfetto stack sampling. Not configurable by the developer. 101Hz + * (rather than 100Hz) to avoid lockstep sampling with the display refresh rate (e.g. 60/120fps), + * matching the legacy profiler's default sampling rate. + */ + private static final int PROFILING_FREQUENCY_HZ = 101; + + private static final long RESULT_TIMEOUT_MS = 5000; + + private final @NotNull ILogger logger; + private final @NotNull ISentryExecutorService executorService; + private final @Nullable ProfilingManager profilingManager; + private final @NotNull CancellationSignal cancellationSignal = new CancellationSignal(); + + private final @NotNull Object profilingResultLock = new Object(); + private volatile @Nullable ProfilingResult profilingResult = null; + + private @Nullable Consumer<@Nullable File> resultListener = null; + private volatile boolean started = false; + + @SuppressLint("WrongConstant") + public PerfettoProfiler( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull ISentryExecutorService executorService) { + this( + logger, + executorService, + (ProfilingManager) context.getSystemService(Context.PROFILING_SERVICE)); + } + + PerfettoProfiler( + final @NotNull ILogger logger, + final @NotNull ISentryExecutorService executorService, + final @Nullable ProfilingManager profilingManager) { + this.logger = logger; + this.executorService = executorService; + this.profilingManager = profilingManager; + } + + public boolean start(final long durationMs) { + if (started) { + logger.log(SentryLevel.WARNING, "PerfettoProfiler was already started."); + return false; + } + started = true; + + if (profilingManager == null) { + logger.log(SentryLevel.WARNING, "ProfilingManager is not available."); + return false; + } + + final Bundle params = new Bundle(); + params.putInt(KEY_DURATION_MS, (int) durationMs); + params.putInt(KEY_FREQUENCY_HZ, PROFILING_FREQUENCY_HZ); + + try { + profilingManager.requestProfiling( + ProfilingManager.PROFILING_TYPE_STACK_SAMPLING, + params, + "sentry-profiling", + cancellationSignal, + Runnable::run, + this::onProfilingResult); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to request Profiling.", e); + return false; + } + + return true; + } + + /** + * Cancels the current profiling session. The listener is called with the trace file (or null on + * error) once the OS delivers the result. The listener may be called synchronously if the result + * has already arrived, or asynchronously on an OS-managed thread otherwise. + */ + public void endAndCollect(final @NotNull Consumer<@Nullable File> listener) { + if (!started) { + logger.log(SentryLevel.WARNING, "PerfettoProfiler was never started"); + listener.accept(null); + return; + } + + cancellationSignal.cancel(); + + synchronized (profilingResultLock) { + final @Nullable ProfilingResult result = profilingResult; + if (result != null) { + listener.accept(processResult(result)); + return; + } + resultListener = listener; + } + + try { + executorService.schedule( + () -> { + synchronized (profilingResultLock) { + if (resultListener != null) { + logger.log(SentryLevel.WARNING, "Timed out waiting for Perfetto profiling result."); + resultListener.accept(null); + // Nobody consumes a late result anymore, so delete the trace file instead + resultListener = this::deleteTraceFile; + } + } + }, + RESULT_TIMEOUT_MS); + } catch (RejectedExecutionException e) { + logger.log(SentryLevel.DEBUG, "Failed to schedule profiling result timeout.", e); + } + } + + private void onProfilingResult(final @NotNull ProfilingResult result) { + logger.log( + SentryLevel.DEBUG, + "Perfetto ProfilingResult received: errorCode=%d, filePath=%s", + result.getErrorCode(), + result.getResultFilePath()); + + synchronized (profilingResultLock) { + profilingResult = result; + if (resultListener != null) { + resultListener.accept(processResult(result)); + resultListener = null; + } + } + } + + /** + * Deletes a trace file that nobody is going to consume. Called from {@link #onProfilingResult}, + * which the OS delivers on a binder thread, so deleting inline is fine. + */ + private void deleteTraceFile(final @Nullable File traceFile) { + if (traceFile == null) { + return; + } + if (!traceFile.delete()) { + logger.log( + SentryLevel.WARNING, "Failed to delete late Perfetto trace file %s", traceFile.getPath()); + } + } + + private @Nullable File processResult(final @NotNull ProfilingResult result) { + final int errorCode = result.getErrorCode(); + if (errorCode != ProfilingResult.ERROR_NONE) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + logger.log( + SentryLevel.INFO, + "Perfetto profiling failed: %s." + + " To disable during development run:" + + " adb shell device_config put profiling_testing rate_limiter.disabled true", + errorCodeToString(errorCode)); + break; + default: + logger.log( + SentryLevel.WARNING, + "Perfetto profiling failed with %s (error code %d): %s." + + " See https://developer.android.com/reference/android/os/ProfilingResult", + errorCodeToString(errorCode), + errorCode, + result.getErrorMessage()); + break; + } + return null; + } + + final @Nullable String resultFilePath = result.getResultFilePath(); + if (resultFilePath == null) { + logger.log(SentryLevel.WARNING, "Perfetto profiling result file path is null."); + return null; + } + + final File traceFile = new File(resultFilePath); + if (!traceFile.exists() || traceFile.length() == 0) { + logger.log(SentryLevel.WARNING, "Perfetto trace file does not exist or is empty."); + return null; + } + + return traceFile; + } + + private static @NotNull String errorCodeToString(final int errorCode) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + return "ERROR_FAILED_RATE_LIMIT_PROCESS"; + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + return "ERROR_FAILED_RATE_LIMIT_SYSTEM"; + case ProfilingResult.ERROR_FAILED_INVALID_REQUEST: + return "ERROR_FAILED_INVALID_REQUEST"; + case ProfilingResult.ERROR_FAILED_PROFILING_IN_PROGRESS: + return "ERROR_FAILED_PROFILING_IN_PROGRESS"; + case ProfilingResult.ERROR_FAILED_POST_PROCESSING: + return "ERROR_FAILED_POST_PROCESSING"; + case ProfilingResult.ERROR_UNKNOWN: + return "ERROR_UNKNOWN"; + default: + return "UNKNOWN_ERROR_CODE"; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 7e43d626b34..c85fe12674d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -129,6 +129,23 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri return; } + if (buildInfoProvider.getSdkInfoVersion() + >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { + logger.log( + SentryLevel.DEBUG, + "Device is API 35+. Skipping legacy app-start profiling — " + + "Perfetto ProfilingManager will be initialized after Sentry.init()."); + return; + } + + if (!profilingOptions.isEnableLegacyProfiling()) { + logger.log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "App start profiling will not start."); + return; + } + if (profilingOptions.isContinuousProfilingEnabled() && profilingOptions.isStartProfilerOnAppStart()) { createAndStartContinuousProfiler(context, profilingOptions, appStartMetrics); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 8837030608e..caaa30152a4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -5,7 +5,6 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CompositePerformanceCollector -import io.sentry.DataCategory import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes @@ -18,10 +17,8 @@ import io.sentry.TracesSampler import io.sentry.TransactionContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.profilemeasurements.ProfileMeasurement -import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import io.sentry.transport.RateLimiter import java.io.File import java.util.concurrent.Future import kotlin.test.AfterTest @@ -30,12 +27,10 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mockito import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -51,6 +46,7 @@ import org.mockito.kotlin.whenever class AndroidContinuousProfilerTest { private lateinit var context: Context private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks private class Fixture { private val mockDsn = "http://key@localhost/proj" @@ -143,6 +139,8 @@ class AndroidContinuousProfilerTest { Sentry.setCurrentScopes(fixture.scopes) fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) } @AfterTest @@ -151,110 +149,148 @@ class AndroidContinuousProfilerTest { fixture.mockedSentry.close() } + // -- TODO: Could be shared with PerfettoContinuousProfiler with some refactoring -- + @Test - fun `isRunning reflects profiler status`() { - val profiler = fixture.getSut() + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - assertFalse(profiler.isRunning) } @Test - fun `stopProfiler stops the profiler after chunk is finished`() { + fun `profiler starts performance collector on start`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector val profiler = fixture.getSut() + verify(performanceCollector, never()).start(any()) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + verify(performanceCollector).start(any()) + } + + @Test + fun `profiler stops performance collector on stop`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(performanceCollector, never()).stop(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) - assertTrue(profiler.isRunning) - assertNotEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertNotEquals(SentryId.EMPTY_ID, profiler.chunkId) - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart fixture.executor.runAll() - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) + verify(performanceCollector).stop(any()) } @Test - fun `profiler multiple starts are ignored in manual mode`() { + fun `profiler stops collecting frame metrics when it stops`() { val profiler = fixture.getSut() + val frameMetricsCollectorId = "id" + whenever(fixture.frameMetricsCollector.startCollection(any())) + .thenReturn(frameMetricsCollectorId) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(0, profiler.rootSpanCounter) + verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) } @Test - fun `profiler multiple starts are accepted in trace mode`() { - val profiler = fixture.getSut() + fun `profiler sends chunk with measurements`() { + val performanceCollector = mock() + val collectionData = PerformanceCollectionData(10) - // rootSpanCounter is incremented when the profiler starts in trace mode - assertEquals(0, profiler.rootSpanCounter) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(2, profiler.rootSpanCounter) + collectionData.usedHeapMemory = 2 + collectionData.usedNativeMemory = 3 + collectionData.cpuUsagePercentage = 3.0 + whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until - // rootSpanCounter is 0 - profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - - // only when rootSpanCounter is 0 the profiler stops - profiler.stopProfiler(ProfileLifecycle.TRACE) fixture.executor.runAll() - assertEquals(0, profiler.rootSpanCounter) - assertFalse(profiler.isRunning) + verify(fixture.scopes) + .captureProfileChunk( + check { + assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) + } + ) } + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + @Test - fun `profiler logs a warning on start if not sampled`() { - val profiler = fixture.getSut() - whenever(fixture.mockTracesSampler.sampleSessionProfile(any())).thenReturn(false) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) - } + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) @Test - fun `profiler evaluates sessionSampleRate only the first time`() { - val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, the sessionSampleRate is not evaluated again - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - } + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) @Test - fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) + + @Test + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + // -- Legacy-specific tests (AndroidContinuousProfiler only) -- + + @Test + fun `profiler multiple starts are ignored in manual mode`() { val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // When reevaluateSampling is called, the sessionSampleRate is not evaluated immediately - profiler.reevaluateSampling() - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, when the profiler starts again, the sessionSampleRate is reevaluated + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(0, profiler.rootSpanCounter) } @Test @@ -268,25 +304,14 @@ class AndroidContinuousProfilerTest { assertFalse(profiler.isRunning) } - @Test - fun `profiler ignores profilesSampleRate`() { - val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - } - @Test fun `profiler evaluates profilingTracesDirPath options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.cacheDirPath = null } verify(fixture.mockLogger, never()) .log( SentryLevel.WARNING, "Disabling profiling because no profiling traces dir path is defined in options.", ) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -298,13 +323,9 @@ class AndroidContinuousProfilerTest { @Test fun `profiler evaluates profilingTracesHz options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.profilingTracesHz = 0 } verify(fixture.mockLogger, never()) .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -338,47 +359,11 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - // We assert that no trace files are written assertTrue(File(fixture.options.profilingTracesDirPath!!).list()!!.isEmpty()) verify(fixture.mockLogger) .log(eq(SentryLevel.ERROR), eq("Error while stopping profiling: "), any()) } - @Test - fun `profiler starts performance collector on start`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - verify(performanceCollector, never()).start(any()) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector).start(any()) - } - - @Test - fun `profiler stops performance collector on stop`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector, never()).stop(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(performanceCollector).stop(any()) - } - - @Test - fun `profiler stops collecting frame metrics when it stops`() { - val profiler = fixture.getSut() - val frameMetricsCollectorId = "id" - whenever(fixture.frameMetricsCollector.startCollection(any())) - .thenReturn(frameMetricsCollectorId) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) - } - @Test fun `profiler stops profiling and clear scheduled job on close`() { val profiler = fixture.getSut() @@ -388,7 +373,6 @@ class AndroidContinuousProfilerTest { profiler.close(true) assertFalse(profiler.isRunning) - // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") val scheduledJob = androidProfiler?.getProperty?>("scheduledFinish") assertNull(scheduledJob) @@ -398,165 +382,8 @@ class AndroidContinuousProfilerTest { assertTrue(stopFuture.isCancelled || stopFuture.isDone) } - @Test - fun `profiler stops and restart for each chunk`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - val oldChunkId = profiler.chunkId - - fixture.executor.runAll() - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - - fixture.executor.runAll() - verify(fixture.mockLogger, times(2)) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - assertNotEquals(oldChunkId, profiler.chunkId) - } - - @Test - fun `profiler sends chunk on each restart`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - // Now the executor is used to send the chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `profiler sends chunk with measurements`() { - val performanceCollector = mock() - val collectionData = PerformanceCollectionData(10) - - collectionData.usedHeapMemory = 2 - collectionData.usedNativeMemory = 3 - collectionData.cpuUsagePercentage = 3.0 - whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We run the executor service to stop the profiler - fixture.executor.runAll() - // Then we run it again to send the profile chunk - fixture.executor.runAll() - verify(fixture.scopes) - .captureProfileChunk( - check { - assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) - } - ) - } - - @Test - fun `profiler sends another chunk on stop`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We stop the profiler, which should send a chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `close without terminating stops all profiles after chunk is finished`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running - profiler.close(false) - assertTrue(profiler.isRunning) - // However, close() already resets the rootSpanCounter - assertEquals(0, profiler.rootSpanCounter) - - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart - fixture.executor.runAll() - assertFalse(profiler.isRunning) - } - - @Test - fun `profiler does not send chunks after close`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // We close the profiler, which should prevent sending additional chunks - profiler.close(true) - - // The executor used to send the chunk doesn't do anything - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - } - - @Test - fun `profiler stops when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // If the SDK is rate limited, the profiler should stop - profiler.onRateLimitChanged(rateLimiter) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) - - // If the SDK is rate limited, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when offline`() { - val profiler = fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } - } - - // If the device is offline, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) - } - fun withMockScopes(closure: () -> Unit) = - Mockito.mockStatic(Sentry::class.java).use { + mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index cbe42faa103..6df1ed7167e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -376,6 +376,27 @@ class AndroidOptionsInitializerTest { assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) } + @Config(sdk = [35]) + @Test + fun `init on API 35+ always sets PerfettoContinuousProfiler`() { + fixture.initSut() + assertTrue(fixture.sentryOptions.continuousProfiler is PerfettoContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling true sets AndroidContinuousProfiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = true }) + assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling false noops profiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = false }) + assertTrue(fixture.sentryOptions.continuousProfiler is NoOpContinuousProfiler) + } + @Test fun `init with profilesSampleRate should set Android transaction profiler`() { fixture.initSut(configureOptions = { profilesSampleRate = 1.0 }) @@ -403,6 +424,51 @@ class AndroidOptionsInitializerTest { assertEquals(fixture.sentryOptions.continuousProfiler, NoOpContinuousProfiler.getInstance()) } + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampler and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampler = mock() + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false closes app start profiler`() { + val appStartProfiler = mock() + AppStartMetrics.getInstance().appStartProfiler = appStartProfiler + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + verify(appStartProfiler).close() + + // AppStartMetrics should be cleared + assertNull(AppStartMetrics.getInstance().appStartProfiler) + assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) + } + @Test fun `init reuses transaction profiler of appStartMetrics, if exists`() { val appStartProfiler = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt new file mode 100644 index 00000000000..ffc00907460 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt @@ -0,0 +1,146 @@ +package io.sentry.android.core + +import io.sentry.CompositePerformanceCollector +import io.sentry.PerformanceCollectionData +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.profilemeasurements.ProfileMeasurement +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ChunkMeasurementCollectorTest { + + /** + * Drives [PerfettoContinuousProfiler.ChunkMeasurementCollector] through two full `start -> + * collect -> stop` cycles to assert that the metrics collected are correct. + */ + @Test + fun `each start-stop cycle returns its own independent measurements`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val performanceCollector: CompositePerformanceCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + // Return distinct performance data for each stop() call. + whenever(performanceCollector.stop(any())) + .thenReturn( + // Cycle 1: 2 samples, both with cpu + heap, only first with native. + listOf( + perfData(nanos = 100L, cpu = 10.0, heap = 1_000L, native = 500L), + perfData(nanos = 200L, cpu = 20.0, heap = 2_000L, native = null), + ), + // Cycle 2: 3 samples, all with heap, only some with cpu/native. + listOf( + perfData(nanos = 1_000L, cpu = 30.0, heap = 3_000L, native = null), + perfData(nanos = 1_100L, cpu = null, heap = 4_000L, native = 800L), + perfData(nanos = 1_200L, cpu = 50.0, heap = 5_000L, native = 900L), + ), + ) + + // --- Cycle 1 --- + collector.start(performanceCollector, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + // frameEndNanos comes from System.nanoTime(), so it must be based on the current reading for + // the resulting chunk-relative timestamp to be non-negative. + var frameEnd = futureFrameEndNanos() + // onFrameMetricCollected(frameStart, frameEnd, duration, delay, isSlow, isFrozen, refreshRate) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 100L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 800L, 0L, false, true, 60.0f) // frozen + onFrameMetricCollected(0L, frameEnd, 50L, 0L, false, false, 90.0f) // refresh change + } + val chunk1 = collector.stop() + + // --- Cycle 2 --- + collector.start(performanceCollector, "chunk-2") + verify(frameMetricsCollector, times(2)).startCollection(listenerCaptor.capture()) + frameEnd = futureFrameEndNanos() + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 150L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 200L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 900L, 0L, false, true, 60.0f) // frozen + } + val chunk2 = collector.stop() + + // Cycle 1: 1 slow, 1 frozen; refresh rate goes 0 -> 60 -> 90 (2 changes recorded); + // 2 cpu samples, 2 heap samples, 1 native sample. + assertChunkCounts(chunk1, slow = 1, frozen = 1, refreshRate = 2, cpu = 2, heap = 2, native = 1) + // Cycle 2: 2 slow, 1 frozen; refresh rate goes 0 -> 60 (1 change recorded); + // 2 cpu samples (one was null), 3 heap samples, 2 native samples. + assertChunkCounts(chunk2, slow = 2, frozen = 1, refreshRate = 1, cpu = 2, heap = 3, native = 2) + } + + @Test + fun `frames ending before the chunk started are dropped`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + collector.start(null, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + + val staleFrameEnd = System.nanoTime() - TimeUnit.HOURS.toNanos(1) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, staleFrameEnd, 100L, 0L, true, false, 60.0f) + onFrameMetricCollected(0L, staleFrameEnd, 800L, 0L, false, true, 60.0f) + onFrameMetricCollected(0L, futureFrameEndNanos(), 150L, 0L, true, false, 60.0f) + } + + val measurements = collector.stop() + + assertChunkCounts( + measurements, + slow = 1, + frozen = 0, + refreshRate = 1, + cpu = 0, + heap = 0, + native = 0, + ) + } + + /** + * A frameEndNanos far enough ahead of the collector's own `System.nanoTime()` reading that the + * chunk-relative timestamp stays positive regardless of test execution timing. + */ + private fun futureFrameEndNanos() = System.nanoTime() + TimeUnit.MINUTES.toNanos(1) + + private fun perfData(nanos: Long, cpu: Double?, heap: Long?, native: Long?) = + PerformanceCollectionData(nanos).apply { + cpuUsagePercentage = cpu + usedHeapMemory = heap + usedNativeMemory = native + } + + private fun assertChunkCounts( + measurements: Map, + slow: Int, + frozen: Int, + refreshRate: Int, + cpu: Int, + heap: Int, + native: Int, + ) { + assertEquals(slow, measurements[ProfileMeasurement.ID_SLOW_FRAME_RENDERS]?.values?.size ?: 0) + assertEquals( + frozen, + measurements[ProfileMeasurement.ID_FROZEN_FRAME_RENDERS]?.values?.size ?: 0, + ) + assertEquals( + refreshRate, + measurements[ProfileMeasurement.ID_SCREEN_FRAME_RATES]?.values?.size ?: 0, + ) + assertEquals(cpu, measurements[ProfileMeasurement.ID_CPU_USAGE]?.values?.size ?: 0) + assertEquals(heap, measurements[ProfileMeasurement.ID_MEMORY_FOOTPRINT]?.values?.size ?: 0) + assertEquals( + native, + measurements[ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT]?.values?.size ?: 0, + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt new file mode 100644 index 00000000000..5e4e0504ddf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt @@ -0,0 +1,194 @@ +package io.sentry.android.core + +import io.sentry.DataCategory +import io.sentry.IContinuousProfiler +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.transport.RateLimiter +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Shared dependencies for profiler test cases. Each test class creates one from its own fixture. + */ +class ProfilerMocks( + val executor: DeferredExecutorService, + val tracesSampler: TracesSampler, + val logger: ILogger, + val scopes: IScopes, +) + +// -- Shared test cases as extension functions on IContinuousProfiler -- + +fun IContinuousProfiler.testIsRunningReflectsStatus(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testStopProfilerStopsAfterChunkFinished(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + assertTrue(isRunning) + assertNotEquals(SentryId.EMPTY_ID, profilerId) + assertNotEquals(SentryId.EMPTY_ID, chunkId) + mocks.executor.runAll() + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) +} + +fun IContinuousProfiler.testMultipleStartsAcceptedInTraceMode(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testLogsWarningIfNotSampled(mocks: ProfilerMocks) { + whenever(mocks.tracesSampler.sampleSessionProfile(any())).thenReturn(false) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + verify(mocks.logger) + .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) +} + +fun IContinuousProfiler.testEvaluatesSessionSampleRateOnlyOnce(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testReevaluateSamplingOnNextStart(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + reevaluateSampling() + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(2)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testStopsAndRestartsForEachChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + val oldChunkId = chunkId + + mocks.executor.runAll() + verify(mocks.logger).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + + mocks.executor.runAll() + verify(mocks.logger, times(2)) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + assertNotEquals(oldChunkId, chunkId) +} + +fun IContinuousProfiler.testSendsChunkOnRestart(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testSendsChunkOnStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testCloseWithoutTerminatingStopsAfterChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + close(false) + assertTrue(isRunning) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testDoesNotSendChunksAfterClose(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + close(true) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testStopsWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + (this as RateLimiter.IRateLimitObserver).onRateLimitChanged(rateLimiter) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + whenever(mocks.scopes.rateLimiter).thenReturn(rateLimiter) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenOffline(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) +} + +fun IContinuousProfiler.testCanBeStartedAgainAfterStopCycle(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) + + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + assertTrue(isRunning, "shouldStop must be reset on start") +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d0dbd1deb50..d67a869eff0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1649,6 +1649,31 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.isEnableAppStartProfiling) } + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_LEGACY_PROFILING to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableLegacyProfiling) + } + + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableLegacyProfiling) + } + @Test fun `applyMetadata reads enableScopePersistence flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt new file mode 100644 index 00000000000..2f76e73108f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,223 @@ +package io.sentry.android.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.test.DeferredExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PerfettoContinuousProfilerTest { + private lateinit var context: Context + private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockPerfettoProfiler = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val scopes: IScopes = mock() + + val options = + spy(SentryAndroidOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + val mockTraceFile = + java.io.File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + whenever(mockPerfettoProfiler.start(any())).thenReturn(true) + doAnswer { invocation -> + val listener = invocation.getArgument>(0) + listener.accept(mockTraceFile) + null + } + .whenever(mockPerfettoProfiler) + .endAndCollect(any()) + } + + fun getSut( + optionConfig: ((options: SentryAndroidOptions) -> Unit) = {} + ): PerfettoContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + return PerfettoContinuousProfiler( + mockLogger, + frameMetricsCollector, + { options.executorService }, + { mockPerfettoProfiler }, + ) + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + Sentry.setCurrentScopes(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) + } + + @AfterTest + fun clear() { + fixture.mockedSentry.close() + } + + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + + @Test + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + } + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) + + @Test + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() = + fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + + // -- Perfetto-specific tests -- + + @Test + fun `async chunk callback does not restart when stop requested while pending`() { + val profiler = fixture.getSut() + + // Defer the endAndCollect listener to simulate the OS delivering the trace asynchronously, + // after the chunk timer already captured the (then-true) restart decision. + var pendingListener: java.util.function.Consumer? = null + doAnswer { invocation -> + pendingListener = invocation.getArgument(0) + null + } + .whenever(fixture.mockPerfettoProfiler) + .endAndCollect(any()) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // Chunk timer fires: stopInternal(true) captures shouldRestart=true and calls endAndCollect, + // but the listener is held pending instead of firing inline. + fixture.executor.runAll() + assertFalse(profiler.isRunning) + assertNotNull(pendingListener) + + // A stop is requested while the async callback is still pending. + profiler.stopProfiler(ProfileLifecycle.MANUAL) + + // The OS now delivers the trace. The callback must honor the late stop and not restart. + pendingListener!!.accept(fixture.mockTraceFile) + fixture.executor.runAll() + assertFalse( + profiler.isRunning, + "profiler must not restart when a stop was requested while the callback was pending", + ) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt new file mode 100644 index 00000000000..0746d36dfff --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.content.Context +import android.os.ProfilingManager +import android.os.ProfilingResult +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.test.DeferredExecutorService +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PerfettoProfilerTest { + + private lateinit var context: Context + private val mockLogger = mock() + private val executor = DeferredExecutorService() + + private lateinit var capturedCallback: Consumer + + private val mockProfilingManager = + mock().also { manager -> + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + capturedCallback = invocation.getArgument(5) as Consumer + null + } + .whenever(manager) + .requestProfiling(any(), any(), any(), any(), any(), any()) + } + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + private fun getSut(profilingManager: ProfilingManager? = mockProfilingManager): PerfettoProfiler { + return PerfettoProfiler(mockLogger, executor, profilingManager) + } + + private fun createTraceFile(): File { + return File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + } + + private fun mockResult( + errorCode: Int = ProfilingResult.ERROR_NONE, + filePath: String? = null, + errorMessage: String? = null, + ): ProfilingResult { + return mock().also { + whenever(it.errorCode).thenReturn(errorCode) + whenever(it.resultFilePath).thenReturn(filePath) + whenever(it.errorMessage).thenReturn(errorMessage) + } + } + + @Test + fun `start returns true on first call`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + } + + @Test + fun `start returns false when already started`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + assertFalse(profiler.start(60000)) + } + + @Test + fun `start returns false when ProfilingManager is null`() { + val profiler = getSut(profilingManager = null) + assertFalse(profiler.start(60000)) + } + + @Test + fun `endAndCollect calls listener with null when never started`() { + val profiler = getSut() + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener synchronously when result already available`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener when result arrives later`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener with null on error result`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept( + mockResult(errorCode = ProfilingResult.ERROR_UNKNOWN, errorMessage = "unknown error") + ) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null on rate limit error`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `timeout fires listener with null when OS never responds`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + + assertEquals("sentinel", result.get()?.name) + + executor.runAll() + + assertNull(result.get()) + } + + @Test + fun `timeout is no-op when result already arrived`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val result = AtomicReference() + profiler.endAndCollect { + callCount.incrementAndGet() + result.set(it) + } + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `listener is called exactly once when result and endAndCollect race`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val latch = CountDownLatch(1) + + val resultThread = Thread { + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + latch.countDown() + } + + profiler.endAndCollect { callCount.incrementAndGet() } + resultThread.start() + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `trace file is deleted when result arrives after the timeout`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + profiler.endAndCollect { callCount.incrementAndGet() } + + executor.runAll() + assertEquals(1, callCount.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertFalse(traceFile.exists()) + } + + @Test + fun `endAndCollect calls listener with null when result file path is null`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = null)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null when trace file does not exist`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = "/non/existent/path.pftrace")) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 79150b51c98..ac53c538de5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -90,7 +90,8 @@ + android:exported="false" + android:theme="@style/AppTheme.Main" /> + android:value="false" /> diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 8626c12c6c8..e24822b3e42 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -1,161 +1,108 @@ package io.sentry.samples.android +import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.SeekBar import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import io.sentry.ITransaction -import io.sentry.ProfilingTraceData +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import io.sentry.Sentry -import io.sentry.SentryEnvelopeItem -import io.sentry.samples.android.databinding.ActivityProfilingBinding -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.UUID import java.util.concurrent.Executors -import java.util.zip.GZIPOutputStream -class ProfilingActivity : AppCompatActivity() { - private lateinit var binding: ActivityProfilingBinding +class ProfilingActivity : ComponentActivity() { + private val executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) private var profileFinished = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (profileFinished) { - isEnabled = false - onBackPressedDispatcher.onBackPressed() - } else { - Toast.makeText(this@ProfilingActivity, R.string.profiling_running, Toast.LENGTH_SHORT) - .show() - } - } - }, - ) - binding = ActivityProfilingBinding.inflate(layoutInflater) - - binding.profilingDurationSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - - binding.profilingThreadsSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingThreadsSeekbar.max = Runtime.getRuntime().availableProcessors() - 1 - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - - binding.profilingList.adapter = ProfilingListAdapter() - binding.profilingList.layoutManager = LinearLayoutManager(this) - - binding.profilingStart.setOnClickListener { - binding.profilingProgressBar.visibility = View.VISIBLE - profileFinished = false - val seconds = getProfileDuration() - val threads = getBackgroundThreads() - val t = Sentry.startTransaction("Profiling Test", "$seconds s - $threads threads") - repeat(threads) { executors.submit { runMathOperations() } } - executors.submit { swipeList() } - - Thread { - Thread.sleep((seconds * 1000).toLong()) - finishTransactionAndPrintResults(t) - binding.root.post { binding.profilingProgressBar.visibility = View.GONE } - } - .start() - } - setContentView(binding.root) - Sentry.reportFullyDisplayed() + setContent { MaterialTheme { ProfilingScreen() } } } - private fun finishTransactionAndPrintResults(t: ITransaction) { - t.finish() - profileFinished = true - val profilesDirPath = Sentry.getCurrentScopes().options.profilingTracesDirPath - if (profilesDirPath == null) { - Toast.makeText(this, R.string.profiling_no_dir_set, Toast.LENGTH_SHORT).show() - return - } - - // We have concurrent profiling now. We have to wait for all transactions to finish (e.g. button - // click) - // before reading the profile, otherwise it's empty and a crash occurs - if (Sentry.getSpan() != null) { - val timeout = Sentry.getCurrentScopes().options.idleTimeout ?: 0 - val duration = (getProfileDuration() * 1000).toLong() - Thread.sleep((timeout - duration).coerceAtLeast(0)) - } - - try { - // Get the last trace file, which is the current profile - val origProfileFile = File(profilesDirPath).listFiles()?.maxByOrNull { f -> f.lastModified() } - // Create a new profile file and copy the content of the original file into it - val profile = File(cacheDir, UUID.randomUUID().toString()) - origProfileFile?.copyTo(profile) - - val profileLength = profile.length() - val traceData = ProfilingTraceData(profile, t) - // Create envelope item from copied profile - val item = - SentryEnvelopeItem.fromProfilingTrace( - traceData, - Long.MAX_VALUE, - Sentry.getCurrentScopes().options.serializer, - ) - val itemData = item.data - - // Compress the envelope item using Gzip - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } - - binding.root.post { - binding.profilingResult.text = - getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun ProfilingScreen() { + val context = LocalContext.current + val options = remember { Sentry.getCurrentScopes().options } + val isPerfetto = remember { Build.VERSION.SDK_INT >= 35 } + val isContinuousEnabled = remember { options.isContinuousProfilingEnabled } + + var showProgress by remember { mutableStateOf(false) } + var manualActive by remember { mutableStateOf(false) } + + val statusText = + when { + !isContinuousEnabled -> stringResource(R.string.profiling_status_none) + isPerfetto -> stringResource(R.string.profiling_status_perfetto) + else -> stringResource(R.string.profiling_status_legacy) } - } catch (e: Exception) { - e.printStackTrace() - } - } - private fun swipeList() { - while (!profileFinished) { - if ( - (binding.profilingList.layoutManager as? LinearLayoutManager) - ?.findFirstVisibleItemPosition() == 0 + Scaffold(topBar = { TopAppBar(title = { Text("Profiling") }) }) { innerPadding -> + Column( + modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - binding.profilingList.smoothScrollToPosition(100) - } else { - binding.profilingList.smoothScrollToPosition(0) + Text(text = statusText, fontWeight = FontWeight.Bold) + + Text("profiling.enable-legacy-profiling: ${options.isEnableLegacyProfiling}") + Text("Build.VERSION.SDK_INT: ${Build.VERSION.SDK_INT}") + Text("traces.profiling.session-sample-rate: ${options.profileSessionSampleRate}") + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Button( + onClick = { + if (!manualActive) { + Sentry.startProfiler() + manualActive = true + profileFinished = false + showProgress = true + + val threads = 2 + repeat(threads) { executors.submit { runMathOperations() } } + + Toast.makeText(context, R.string.profiling_manual_started, Toast.LENGTH_SHORT).show() + } else { + Sentry.stopProfiler() + manualActive = false + profileFinished = true + showProgress = false + + Toast.makeText(context, R.string.profiling_manual_stopped, Toast.LENGTH_SHORT).show() + } + } + ) { + Text( + if (manualActive) stringResource(R.string.profiling_stop_manual) + else stringResource(R.string.profiling_start_manual) + ) + } + + if (showProgress) { + CircularProgressIndicator() + } } - Thread.sleep(3000) } } @@ -167,21 +114,8 @@ class ProfilingActivity : AppCompatActivity() { private fun fibonacci(n: Int): Int = when { - profileFinished -> n // If we destroy the activity we stop this function + profileFinished -> n n <= 1 -> 1 else -> fibonacci(n - 1) + fibonacci(n - 2) } - - private fun getProfileDuration(): Float { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingDurationSeekbar.progress / 10.0F + 0.1F - } - - private fun getBackgroundThreads(): Int { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingThreadsSeekbar.progress.coerceIn( - 0, - Runtime.getRuntime().availableProcessors() - 1, - ) - } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt deleted file mode 100644 index bf025118c80..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.sentry.samples.android - -import android.graphics.Bitmap -import android.graphics.Color -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.RecyclerView -import io.sentry.samples.android.databinding.ProfilingItemListBinding -import kotlin.random.Random - -class ProfilingListAdapter : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = - ProfilingItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.imageView.setImageBitmap(generateBitmap()) - } - - @Suppress("MagicNumber") - private fun generateBitmap(): Bitmap { - val bitmapSize = 128 - val colors = - (0 until (bitmapSize * bitmapSize)) - .map { Color.rgb(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) } - .toIntArray() - return Bitmap.createBitmap(colors, bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888) - } - - // Disables view recycling. - override fun getItemViewType(position: Int): Int = position - - override fun getItemCount(): Int = 200 -} - -class ViewHolder(binding: ProfilingItemListBinding) : RecyclerView.ViewHolder(binding.root) { - val imageView: ImageView = binding.benchmarkItemListImage -} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml deleted file mode 100644 index 8100834f78b..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - -