From 952b180c6fffa6995fddbb3b638f0074d60a9500 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 22 Apr 2026 15:22:22 +0200 Subject: [PATCH 001/276] fix(gestures): Prevent duplicate ui.click breadcrumbs from buried window callbacks (#5300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(user-interaction): Restore window callbacks on close and dedup instrumentation via WeakHashMap Track wrapped windows in a thread-safe WeakHashMap so close() can restore each window's original callback chain, preventing an orphaned SentryWindowCallback from persisting after Sentry.close(). Also handle the case where another wrapper (e.g. Session Replay) has been installed on top of ours — we skip chain mutation but still invoke stopTracking() to release resources. Guards against racing lifecycle callbacks (main thread) and close() (possibly bg thread). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(replay): Track wrapped windows and inert buried recorders on stop Track wrapped windows in a WeakHashMap so GestureRecorder skips re-wrapping already-instrumented windows and can locate its own recorder even when another wrapper (e.g. UserInteractionIntegration) has been installed on top of it. When our wrapper is buried in the callback chain, inert() it instead of mutating the chain so unrelated instrumentation isn't broken; the next replay session wraps on top with a fresh active recorder. Co-Authored-By: Claude Opus 4.7 (1M context) * changelog * fix(user-interaction): Inert buried SentryWindowCallback and drop its cache entry on stop Two follow-ups to the buried-wrapper path: - stopTracking() now sets an inert flag that short-circuits handleTouchEvent, so a SentryWindowCallback that can't be cut out of the chain stops forwarding events to its gesture detector and listener. Without this, the "stopped" wrapper kept emitting ui.click breadcrumbs, so as soon as a fresh wrapper was installed on top the duplicates came back. - unwrapWindow removes the wrapped window from the tracking map in the buried path too. Previously only the top-of-chain path cleared it, which meant the next startTracking() found a stale (but alive, since the inert wrapper is still referenced by the chain) entry and returned early, permanently losing gesture tracking for that window. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../core/UserInteractionIntegration.java | 67 +++++++++++++++++-- .../gestures/SentryWindowCallback.java | 8 +++ .../core/UserInteractionIntegrationTest.kt | 64 ++++++++++++++++-- .../gestures/SentryWindowCallbackTest.kt | 14 ++++ .../replay/gestures/GestureRecorder.kt | 43 ++++++++++-- .../replay/gestures/GestureRecorderTest.kt | 44 ++++++++++-- 7 files changed, 217 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f27480154..f71dedaba95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### Fixes - Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138)) +- Fix duplicate `ui.click` breadcrumbs when another `Window.Callback` wraps `SentryWindowCallback` ([#5300](https://github.com/getsentry/sentry-java/pull/5300)) ### Internal diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index c0dd3f9eb71..0d77625c718 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -18,6 +18,9 @@ import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.WeakHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,6 +33,16 @@ public final class UserInteractionIntegration private final boolean isAndroidxLifecycleAvailable; + // WeakReference value, because the callback chain strongly references the wrapper — a strong + // value would prevent the window from ever being GC'd. + // + // All access must be guarded by wrappedWindowsLock — lifecycle callbacks fire on the main + // thread, but close() may be called from a background thread (e.g. Sentry.close()). + private final @NotNull WeakHashMap> wrappedWindows = + new WeakHashMap<>(); + + private final @NotNull Object wrappedWindowsLock = new Object(); + public UserInteractionIntegration( final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -47,19 +60,26 @@ private void startTracking(final @NotNull Activity activity) { } if (scopes != null && options != null) { + synchronized (wrappedWindowsLock) { + final @Nullable WeakReference cached = wrappedWindows.get(window); + if (cached != null && cached.get() != null) { + return; + } + } + Window.Callback delegate = window.getCallback(); if (delegate == null) { delegate = new NoOpWindowCallback(); } - if (delegate instanceof SentryWindowCallback) { - // already instrumented - return; - } - final SentryGestureListener gestureListener = new SentryGestureListener(activity, scopes, options); - window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); + final SentryWindowCallback wrapper = + new SentryWindowCallback(delegate, activity, gestureListener, options); + window.setCallback(wrapper); + synchronized (wrappedWindowsLock) { + wrappedWindows.put(window, new WeakReference<>(wrapper)); + } } } @@ -71,7 +91,10 @@ private void stopTracking(final @NotNull Activity activity) { } return; } + unwrapWindow(window); + } + private void unwrapWindow(final @NotNull Window window) { final Window.Callback current = window.getCallback(); if (current instanceof SentryWindowCallback) { ((SentryWindowCallback) current).stopTracking(); @@ -80,6 +103,23 @@ private void stopTracking(final @NotNull Activity activity) { } else { window.setCallback(((SentryWindowCallback) current).getDelegate()); } + synchronized (wrappedWindowsLock) { + wrappedWindows.remove(window); + } + return; + } + + // Another wrapper (e.g. Session Replay) sits on top of ours — cutting it out of the chain + // would break its instrumentation, so we leave the chain alone and just call stopTracking() + // to release our resources. The upstream wrapper holds a reference to ours, so it'll be + // GC'd whenever that upstream holder is (typically when the window is destroyed). + final @Nullable SentryWindowCallback ours; + synchronized (wrappedWindowsLock) { + final @Nullable WeakReference cached = wrappedWindows.remove(window); + ours = cached != null ? cached.get() : null; + } + if (ours != null) { + ours.stopTracking(); } } @@ -146,6 +186,21 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + // Restore original callbacks so a subsequent Sentry.init() starts from a clean chain instead + // of wrapping on top of our orphaned callback. + final ArrayList snapshot; + synchronized (wrappedWindowsLock) { + snapshot = new ArrayList<>(wrappedWindows.keySet()); + } + for (final Window window : snapshot) { + if (window != null) { + unwrapWindow(window); + } + } + synchronized (wrappedWindowsLock) { + wrappedWindows.clear(); + } + if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration removed."); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java index 557cd4e7a29..e69756e506a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java @@ -19,6 +19,10 @@ public final class SentryWindowCallback extends WindowCallbackAdapter { private final @Nullable SentryOptions options; private final @NotNull MotionEventObtainer motionEventObtainer; + // When we can't be removed from the callback chain (see UserInteractionIntegration), + // stopTracking() flips this so handleTouchEvent short-circuits. + private volatile boolean inert; + public SentryWindowCallback( final @NotNull Window.Callback delegate, final @NotNull Context context, @@ -64,6 +68,9 @@ public boolean dispatchTouchEvent(final @Nullable MotionEvent motionEvent) { } private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { + if (inert) { + return; + } gestureDetector.onTouchEvent(motionEvent); int action = motionEvent.getActionMasked(); if (action == MotionEvent.ACTION_UP) { @@ -72,6 +79,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { } public void stopTracking() { + inert = true; gestureListener.stopTracing(SpanStatus.CANCELLED); gestureDetector.release(); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index f558841e6f5..8f20dbb1539 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -14,7 +14,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertIs import kotlin.test.assertIsNot -import kotlin.test.assertNotEquals +import kotlin.test.assertNotSame import kotlin.test.assertSame import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -149,15 +149,63 @@ class UserInteractionIntegrationTest { } @Test - fun `does not instrument if the callback is already ours`() { - val existingCallback = - SentryWindowCallback(NoOpWindowCallback(), fixture.activity, mock(), mock()) - val sut = fixture.getSut(existingCallback) + fun `resume after buried pause installs a fresh wrapper on top`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + sut.onActivityResumed(fixture.activity) + val originalSentryCallback = fixture.window.callback + assertIs(originalSentryCallback) + + // Third-party wraps on top of us mid-activity. + val outerWrapper = WrapperCallback(originalSentryCallback) + fixture.window.callback = outerWrapper + + sut.onActivityPaused(fixture.activity) + sut.onActivityResumed(fixture.activity) + + val newTop = fixture.window.callback + assertIs(newTop) + assertNotSame(originalSentryCallback, newTop) + assertSame(outerWrapper, newTop.delegate) + } + + @Test + fun `close unwraps windows so re-init does not double-wrap`() { + val mockCallback = mock() + fixture.window.callback = mockCallback + val sutA = fixture.getSut() + sutA.register(fixture.scopes, fixture.options) + sutA.onActivityResumed(fixture.activity) + assertIs(fixture.window.callback) + + sutA.close() + assertSame(mockCallback, fixture.window.callback) + + val sutB = UserInteractionIntegration(fixture.application, fixture.loadClass) + sutB.register(fixture.scopes, fixture.options) + sutB.onActivityResumed(fixture.activity) + + val newWrapper = fixture.window.callback + assertIs(newWrapper) + assertSame(mockCallback, newWrapper.delegate) + } + + @Test + fun `paused with another wrapper on top does not cut it out of the chain`() { + val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) + sut.onActivityResumed(fixture.activity) + val sentryCallback = fixture.window.callback as SentryWindowCallback + + val outerWrapper = WrapperCallback(sentryCallback) + fixture.window.callback = outerWrapper - assertNotEquals(existingCallback, (fixture.window.callback as SentryWindowCallback).delegate) + sut.onActivityPaused(fixture.activity) + + assertSame(outerWrapper, fixture.window.callback) } @Test @@ -205,3 +253,7 @@ class UserInteractionIntegrationTest { private class EmptyActivity : Activity(), LifecycleOwner { override val lifecycle: Lifecycle = mock() } + +/** Simulates a third-party callback wrapper (e.g. Session Replay's FixedWindowCallback). */ +private open class WrapperCallback(@JvmField val delegate: Window.Callback) : + Window.Callback by delegate diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt index 8afc1b39304..be0438d9345 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt @@ -82,4 +82,18 @@ class SentryWindowCallbackTest { verify(fixture.gestureDetector, never()).onTouchEvent(any()) } + + @Test + fun `after stopTracking does not forward touches to detector or listener`() { + val event = mock { whenever(it.actionMasked).thenReturn(MotionEvent.ACTION_UP) } + val sut = fixture.getSut() + + sut.stopTracking() + sut.dispatchTouchEvent(event) + + verify(fixture.gestureDetector, never()).onTouchEvent(any()) + verify(fixture.gestureListener, never()).onUp(any()) + // super.dispatchTouchEvent still delegates to the wrapped delegate so the chain keeps working. + verify(fixture.delegate).dispatchTouchEvent(event) + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt index 945a0be5156..cee75fb06c0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt @@ -11,6 +11,7 @@ import io.sentry.android.replay.phoneWindow import io.sentry.android.replay.util.FixedWindowCallback import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference +import java.util.WeakHashMap internal class GestureRecorder( private val options: SentryOptions, @@ -19,6 +20,11 @@ internal class GestureRecorder( private val rootViews = ArrayList>() private val rootViewsLock = AutoClosableReentrantLock() + // WeakReference value, because the callback chain strongly references the wrapper — a strong + // value would prevent the window from ever being GC'd. + private val wrappedWindows = WeakHashMap>() + private val wrappedWindowsLock = AutoClosableReentrantLock() + override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { @@ -45,10 +51,16 @@ internal class GestureRecorder( return } - val delegate = window.callback - if (delegate !is SentryReplayGestureRecorder) { - window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + wrappedWindowsLock.acquire().use { + if (wrappedWindows[window]?.get() != null) { + return + } } + + val delegate = window.callback + val wrapper = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + window.callback = wrapper + wrappedWindowsLock.acquire().use { wrappedWindows[window] = WeakReference(wrapper) } } private fun View.stopGestureTracking() { @@ -60,14 +72,25 @@ internal class GestureRecorder( val callback = window.callback if (callback is SentryReplayGestureRecorder) { - val delegate = callback.delegate - window.callback = delegate + window.callback = callback.delegate + wrappedWindowsLock.acquire().use { wrappedWindows.remove(window) } + return + } + + // Another wrapper (e.g. UserInteractionIntegration) sits on top of ours — cutting it out of + // the chain would break its instrumentation, so we inert our buried wrapper instead. The + // next replay session will then wrap on top with a fresh active instance. + val ours: SentryReplayGestureRecorder? + wrappedWindowsLock.acquire().use { + ours = wrappedWindows[window]?.get() + wrappedWindows.remove(window) } + ours?.inert() } internal class SentryReplayGestureRecorder( private val options: SentryOptions, - private val touchRecorderCallback: TouchRecorderCallback?, + @Volatile private var touchRecorderCallback: TouchRecorderCallback?, delegate: Window.Callback?, ) : FixedWindowCallback(delegate) { override fun dispatchTouchEvent(event: MotionEvent?): Boolean { @@ -83,6 +106,14 @@ internal class GestureRecorder( } return super.dispatchTouchEvent(event) } + + /** + * Turns this wrapper into a passthrough when it can't be removed from the chain (another + * wrapper sits on top). Subsequent dispatches only delegate, skipping the recorder callback. + */ + fun inert() { + touchRecorderCallback = null + } } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt index bf3f9cb8443..6f5a02b54c7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.os.Bundle import android.view.MotionEvent import android.view.View +import android.view.Window import android.widget.LinearLayout import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions @@ -14,6 +15,7 @@ import io.sentry.android.replay.phoneWindow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -37,17 +39,44 @@ class GestureRecorderTest { } @Test - fun `when new window added and window callback is already wrapped, does not wrap it again`() { + fun `does not double-wrap when root is added twice and another callback wraps on top`() { val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() val gestureRecorder = fixture.getSut() - activity.root.phoneWindow?.callback = SentryReplayGestureRecorder(fixture.options, null, null) gestureRecorder.onRootViewsChanged(activity.root, true) + val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder - assertFalse( - (activity.root.phoneWindow?.callback as SentryReplayGestureRecorder).delegate - is SentryReplayGestureRecorder - ) + val outer = WrapperCallback(ourWrapper) + activity.root.phoneWindow?.callback = outer + + gestureRecorder.onRootViewsChanged(activity.root, true) + + assertSame(outer, activity.root.phoneWindow?.callback) + } + + @Test + fun `when stopped with another wrapper on top, inerts the buried recorder`() { + var called = false + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = + fixture.getSut( + touchRecorderCallback = + object : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) { + called = true + } + } + ) + + gestureRecorder.onRootViewsChanged(activity.root, true) + val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder + activity.root.phoneWindow?.callback = WrapperCallback(ourWrapper) + + gestureRecorder.onRootViewsChanged(activity.root, false) + + val motionEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + ourWrapper.dispatchTouchEvent(motionEvent) + assertFalse(called) } @Test @@ -109,6 +138,9 @@ class GestureRecorderTest { } } +private open class WrapperCallback(@JvmField val delegate: Window.Callback) : + Window.Callback by delegate + private class TestActivity : Activity() { lateinit var root: View From 2fcda643c58bb758682531d41331b5d6f18ba610 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 22 Apr 2026 16:14:16 +0200 Subject: [PATCH 002/276] fix(gestures): Thread-safe SentryGestureDetector with per-gesture VelocityTracker recycle (#5301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gestures): Recycle VelocityTracker per gesture and guard state with a lock Recycle VelocityTracker on every ACTION_UP/ACTION_CANCEL instead of only when the detector is torn down, so the pooled native tracker isn't held across gestures (matches Android's framework GestureDetector behavior). Merges endGesture() and release() into a single recycle() method. Guard onTouchEvent and recycle with an AutoClosableReentrantLock — SentryWindowCallback.stopTracking() can be invoked from a bg thread via Sentry.close(), which would otherwise race with the UI thread's touch dispatch and cause use-after-recycle on the native MotionEvent/VelocityTracker pools. recycle() captures the native handles under the lock and performs the JNI recycle() calls outside it to keep the bg thread's critical section to a pointer swap. Co-Authored-By: Claude Opus 4.7 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +- .../gestures/SentryGestureDetector.java | 173 +++++++++--------- .../gestures/SentryWindowCallback.java | 2 +- .../gestures/SentryGestureDetectorTest.kt | 28 +++ 4 files changed, 119 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f71dedaba95..9ae294f72e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Fix `NoSuchMethodError` for `LayoutCoordinates.localBoundingBoxOf$default` on Compose touch dispatch with AGP 8.13 and `minSdk < 24` ([#5302](https://github.com/getsentry/sentry-java/pull/5302)) - Fix reporting OkHttp's synthetic 504 "Unsatisfiable Request" responses as errors for `CacheControl.FORCE_CACHE` cache misses ([#5299](https://github.com/getsentry/sentry-java/pull/5299)) +- Make `SentryGestureDetector` thread-safe and recycle `VelocityTracker` per gesture ([#5301](https://github.com/getsentry/sentry-java/pull/5301)) +- Fix duplicate `ui.click` breadcrumbs when another `Window.Callback` wraps `SentryWindowCallback` ([#5300](https://github.com/getsentry/sentry-java/pull/5300)) ### Dependencies @@ -27,7 +29,6 @@ ### Fixes - Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138)) -- Fix duplicate `ui.click` breadcrumbs when another `Window.Callback` wraps `SentryWindowCallback` ([#5300](https://github.com/getsentry/sentry-java/pull/5300)) ### Internal diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java index 3196ae0189e..002938e9d60 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java @@ -5,6 +5,8 @@ import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.ViewConfiguration; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -35,6 +37,8 @@ public final class SentryGestureDetector { private @Nullable MotionEvent currentDownEvent; private @Nullable VelocityTracker velocityTracker; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + SentryGestureDetector( final @NotNull Context context, final @NotNull GestureDetector.OnGestureListener listener) { this.listener = listener; @@ -46,102 +50,101 @@ public final class SentryGestureDetector { } void onTouchEvent(final @NotNull MotionEvent event) { - final int action = event.getActionMasked(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final int action = event.getActionMasked(); + + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + + switch (action) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + lastX = downX; + lastY = downY; + isInTapRegion = true; + ignoreUpEvent = false; + + if (currentDownEvent != null) { + currentDownEvent.recycle(); + } + currentDownEvent = MotionEvent.obtain(event); - if (velocityTracker == null) { - velocityTracker = VelocityTracker.obtain(); - } + listener.onDown(event); + break; - if (action == MotionEvent.ACTION_DOWN) { - velocityTracker.clear(); - } - velocityTracker.addMovement(event); - - switch (action) { - case MotionEvent.ACTION_DOWN: - downX = event.getX(); - downY = event.getY(); - lastX = downX; - lastY = downY; - isInTapRegion = true; - ignoreUpEvent = false; - - if (currentDownEvent != null) { - currentDownEvent.recycle(); - } - currentDownEvent = MotionEvent.obtain(event); - - listener.onDown(event); - break; - - case MotionEvent.ACTION_MOVE: - { - final float x = event.getX(); - final float y = event.getY(); - final float dx = x - downX; - final float dy = y - downY; - final float distanceSquare = (dx * dx) + (dy * dy); - - if (distanceSquare > touchSlopSquare) { - final float scrollX = lastX - x; - final float scrollY = lastY - y; - listener.onScroll(currentDownEvent, event, scrollX, scrollY); - isInTapRegion = false; - lastX = x; - lastY = y; + case MotionEvent.ACTION_MOVE: + { + final float x = event.getX(); + final float y = event.getY(); + final float dx = x - downX; + final float dy = y - downY; + final float distanceSquare = (dx * dx) + (dy * dy); + + if (distanceSquare > touchSlopSquare) { + final float scrollX = lastX - x; + final float scrollY = lastY - y; + listener.onScroll(currentDownEvent, event, scrollX, scrollY); + isInTapRegion = false; + lastX = x; + lastY = y; + } + break; } + + case MotionEvent.ACTION_POINTER_DOWN: + // A second finger means this is not a single tap (e.g. pinch-to-zoom). + // Also suppress the UP handler to avoid spurious fling detection when the + // last finger lifts quickly after a pinch — mirrors GestureDetector's + // mIgnoreNextUpEvent / cancelTaps() behavior. + isInTapRegion = false; + ignoreUpEvent = true; break; - } - - case MotionEvent.ACTION_POINTER_DOWN: - // A second finger means this is not a single tap (e.g. pinch-to-zoom). - // Also suppress the UP handler to avoid spurious fling detection when the - // last finger lifts quickly after a pinch — mirrors GestureDetector's - // mIgnoreNextUpEvent / cancelTaps() behavior. - isInTapRegion = false; - ignoreUpEvent = true; - break; - - case MotionEvent.ACTION_UP: - if (ignoreUpEvent) { - endGesture(); - break; - } - if (isInTapRegion) { - listener.onSingleTapUp(event); - } else { - final int pointerId = event.getPointerId(0); - velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); - final float velocityX = velocityTracker.getXVelocity(pointerId); - final float velocityY = velocityTracker.getYVelocity(pointerId); - - if (Math.abs(velocityX) > minimumFlingVelocity - || Math.abs(velocityY) > minimumFlingVelocity) { - listener.onFling(currentDownEvent, event, velocityX, velocityY); + + case MotionEvent.ACTION_UP: + if (ignoreUpEvent) { + recycle(); + break; } - } - endGesture(); - break; + if (isInTapRegion) { + listener.onSingleTapUp(event); + } else { + final int pointerId = event.getPointerId(0); + velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); + final float velocityX = velocityTracker.getXVelocity(pointerId); + final float velocityY = velocityTracker.getYVelocity(pointerId); + + if (Math.abs(velocityX) > minimumFlingVelocity + || Math.abs(velocityY) > minimumFlingVelocity) { + listener.onFling(currentDownEvent, event, velocityX, velocityY); + } + } + recycle(); + break; - case MotionEvent.ACTION_CANCEL: - endGesture(); - break; + case MotionEvent.ACTION_CANCEL: + recycle(); + break; + } } } - /** Releases native resources. Call when the detector is no longer needed. */ - void release() { - endGesture(); - if (velocityTracker != null) { - velocityTracker.recycle(); + void recycle() { + final @Nullable MotionEvent capturedDownEvent; + final @Nullable VelocityTracker capturedVelocityTracker; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + capturedDownEvent = currentDownEvent; + currentDownEvent = null; + capturedVelocityTracker = velocityTracker; velocityTracker = null; } - } - - private void endGesture() { - if (currentDownEvent != null) { - currentDownEvent.recycle(); - currentDownEvent = null; + if (capturedDownEvent != null) { + capturedDownEvent.recycle(); + } + if (capturedVelocityTracker != null) { + capturedVelocityTracker.recycle(); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java index e69756e506a..612eb97946e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java @@ -81,7 +81,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { public void stopTracking() { inert = true; gestureListener.stopTracing(SpanStatus.CANCELLED); - gestureDetector.release(); + gestureDetector.recycle(); } public @NotNull Window.Callback getDelegate() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt index be15f9c578b..7967c4a3f0c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt @@ -322,6 +322,34 @@ class SentryGestureDetectorTest { up2.recycle() } + @Test + fun `recycle mid-gesture - subsequent gesture still fires onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + // Start a gesture, then simulate stopTracking() racing in mid-gesture. + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + sut.onTouchEvent(down1) + sut.recycle() + + verify(fixture.listener).onDown(down1) + + // New gesture after recycle — velocityTracker and currentDownEvent should be re-obtained + // lazily and the tap path should work as normal. + val downTime2 = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime2, downTime2, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val up2 = MotionEvent.obtain(downTime2, downTime2 + 50, MotionEvent.ACTION_UP, 200f, 200f, 0) + + sut.onTouchEvent(down2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onSingleTapUp(up2) + + down1.recycle() + down2.recycle() + up2.recycle() + } + @Test fun `sequential gestures - state resets between tap and scroll`() { val sut = fixture.getSut() From 2f670da8b19d00934e13602998c127e66e2e0874 Mon Sep 17 00:00:00 2001 From: markushi <1411808+markushi@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:47:33 +0000 Subject: [PATCH 003/276] release: 8.40.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae294f72e1..6dabfab7294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.40.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index aee4b497d0e..38ad043eee8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.39.1 +versionName=8.40.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 6b019b757adad61364e3f2fb04fb10060b4b5f44 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 23 Apr 2026 14:09:12 +0200 Subject: [PATCH 004/276] chore(deps): bump camerax to 1.4.0 for Android 16KB page size compatibility (#5329) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c62935b805..71f433d176c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ compileSdk = "36" minSdk = "21" spotless = "7.0.4" gummyBears = "0.12.0" -camerax = "1.3.0" +camerax = "1.4.0" openfeature = "1.18.2" [plugins] From b28a5d505430e7102bc539d7b6fe9303ca9dfbc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:08:17 +0200 Subject: [PATCH 005/276] build(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.4 to 2.26.2 (#5335) Bumps [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) from 2.25.4 to 2.26.2. - [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/97d0c4286f32a80d09c8b89366d762fecc3e27b6...3dc647fee3586e57c7c31eb900fdec7cbb44f23f) --- updated-dependencies: - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index d8ea91d129a..64e68738b2e 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@97d0c4286f32a80d09c8b89366d762fecc3e27b6 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 secrets: inherit From c3602770e7ca4635530daa547a884c27d6fc84ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:30:43 +0000 Subject: [PATCH 006/276] chore(deps): update Native SDK to v0.13.8 (#5334) Co-authored-by: GitHub --- CHANGELOG.md | 8 ++++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dabfab7294..81b5b7d1686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Dependencies + +- Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0138) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.7...0.13.8) + ## 8.40.0 ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71f433d176c..c04ab824c86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,7 +151,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.13.7" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.13.8" } 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 52feca702cd5b95903209091fb3511d296be7275 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:31:50 +0000 Subject: [PATCH 007/276] build(deps): bump getsentry/craft from 2.25.4 to 2.26.2 (#5336) Bumps [getsentry/craft](https://github.com/getsentry/craft) from 2.25.4 to 2.26.2. - [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/97d0c4286f32a80d09c8b89366d762fecc3e27b6...3dc647fee3586e57c7c31eb900fdec7cbb44f23f) --- updated-dependencies: - dependency-name: getsentry/craft dependency-version: 2.26.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 177e8810a1b..66776935d9e 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@97d0c4286f32a80d09c8b89366d762fecc3e27b6 # v2 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 61659b6eb9cf705591958eb6e4b1ce03c0a49ce3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Apr 2026 09:49:56 +0200 Subject: [PATCH 008/276] feat(android): Add queryable getFramesDelay API to SentryFrameMetricsCollector (#5248) * feat(android): Add queryable getFramesDelay API to SpanFrameMetricsCollector Expose a getFramesDelay(startNanos, endNanos) method that allows external consumers (e.g. React Native SDK) to query frame delay for arbitrary time ranges without registering a duplicate frame listener. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/sentry-android-core.api | 6 + .../android/core/SentryFramesDelayResult.java | 31 ++++ .../util/SentryFrameMetricsCollector.java | 102 ++++++++++++ .../util/SentryFrameMetricsCollectorTest.kt | 152 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..8af0182bb45 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -435,6 +435,12 @@ public abstract interface class io/sentry/android/core/SentryAndroidOptions$Befo public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z } +public final class io/sentry/android/core/SentryFramesDelayResult { + public fun (DI)V + public fun getDelaySeconds ()D + public fun getFramesContributingToDelayCount ()I +} + public final class io/sentry/android/core/SentryInitProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java new file mode 100644 index 00000000000..724d8446ea8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java @@ -0,0 +1,31 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; + +/** Result of querying frame delay for a given time range. */ +@ApiStatus.Internal +public final class SentryFramesDelayResult { + + private final double delaySeconds; + private final int framesContributingToDelayCount; + + public SentryFramesDelayResult( + final double delaySeconds, final int framesContributingToDelayCount) { + this.delaySeconds = delaySeconds; + this.framesContributingToDelayCount = framesContributingToDelayCount; + } + + /** + * @return the total frame delay in seconds, or -1 if incalculable (e.g. no frame data available) + */ + public double getDelaySeconds() { + return delaySeconds; + } + + /** + * @return the number of frames that contributed to the delay (slow + frozen frames) + */ + public int getFramesContributingToDelayCount() { + return framesContributingToDelayCount; + } +} 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 55342c0e4c0..241ab1e4cca 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 @@ -19,12 +19,15 @@ import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.android.core.SentryFramesDelayResult; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; +import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -35,6 +38,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks { private static final long oneSecondInNanos = TimeUnit.SECONDS.toNanos(1); private static final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700); + private static final int MAX_FRAMES_COUNT = 3600; + private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); @@ -53,6 +58,10 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private long lastFrameStartNanos = 0; private long lastFrameEndNanos = 0; + // frame buffer for getFramesDelay queries, sorted by frame end time + private final @NotNull ConcurrentSkipListSet delayedFrames = + new ConcurrentSkipListSet<>(); + @SuppressLint("NewApi") public SentryFrameMetricsCollector( final @NotNull Context context, @@ -177,6 +186,16 @@ public SentryFrameMetricsCollector( isSlow(cpuDuration, (long) ((float) oneSecondInNanos / (refreshRate - 1.0f))); final boolean isFrozen = isSlow && isFrozen(cpuDuration); + final long frameStartTime = startTime; + + // store frames with delay for getFramesDelay queries + if (delayNanos > 0) { + pruneOldFrames(lastFrameEndNanos); + if (delayedFrames.size() < MAX_FRAMES_COUNT) { + delayedFrames.add(new DelayedFrame(frameStartTime, lastFrameEndNanos, delayNanos)); + } + } + for (FrameMetricsCollectorListener l : listenerMap.values()) { l.onFrameMetricCollected( startTime, @@ -354,6 +373,89 @@ public long getLastKnownFrameStartTimeNanos() { return -1; } + /** + * Queries the frame delay for a given time range. + * + *

This is useful for external consumers (e.g. React Native SDK) that need to query frame delay + * for an arbitrary time range without registering their own frame listener. + * + * @param startSystemNanos start of the time range in {@link System#nanoTime()} units + * @param endSystemNanos end of the time range in {@link System#nanoTime()} units + * @return a {@link SentryFramesDelayResult} with the delay in seconds and the number of frames + * contributing to delay, or a result with delaySeconds=-1 if incalculable + */ + public @NotNull SentryFramesDelayResult getFramesDelay( + final long startSystemNanos, final long endSystemNanos) { + if (!isAvailable) { + return new SentryFramesDelayResult(-1, 0); + } + + if (endSystemNanos <= startSystemNanos) { + return new SentryFramesDelayResult(-1, 0); + } + + long totalDelayNanos = 0; + int delayFrameCount = 0; + + if (!delayedFrames.isEmpty()) { + final Iterator iterator = + delayedFrames.tailSet(new DelayedFrame(startSystemNanos)).iterator(); + + while (iterator.hasNext()) { + final @NotNull DelayedFrame frame = iterator.next(); + + if (frame.startNanos >= endSystemNanos) { + break; + } + + // The delay portion of a frame is at the end: [frameEnd - delay, frameEnd] + final long delayStart = frame.endNanos - frame.delayNanos; + final long delayEnd = frame.endNanos; + + // Intersect the delay interval with the query range + final long overlapStart = Math.max(delayStart, startSystemNanos); + final long overlapEnd = Math.min(delayEnd, endSystemNanos); + + if (overlapEnd > overlapStart) { + totalDelayNanos += (overlapEnd - overlapStart); + delayFrameCount++; + } + } + } + + final double delaySeconds = totalDelayNanos / 1e9d; + return new SentryFramesDelayResult(delaySeconds, delayFrameCount); + } + + private void pruneOldFrames(final long currentNanos) { + final long cutoff = currentNanos - MAX_FRAME_AGE_NANOS; + delayedFrames.headSet(new DelayedFrame(cutoff)).clear(); + } + + private static class DelayedFrame implements Comparable { + final long startNanos; + final long endNanos; + final long delayNanos; + + /** Sentinel constructor for set range queries (tailSet/headSet). */ + DelayedFrame(final long timestampNanos) { + this(timestampNanos, timestampNanos, 0); + } + + DelayedFrame(final long startNanos, final long endNanos, final long delayNanos) { + this.startNanos = startNanos; + this.endNanos = endNanos; + this.delayNanos = delayNanos; + } + + @Override + public int compareTo(final @NotNull DelayedFrame o) { + int cmp = Long.compare(this.endNanos, o.endNanos); + if (cmp != 0) return cmp; + return Long.compare(this.startNanos, o.startNanos); + } + } + @ApiStatus.Internal public interface FrameMetricsCollectorListener { /** diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index b3b018e87b2..02f65665a9e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -577,6 +577,158 @@ class SentryFrameMetricsCollectorTest { assertEquals(0, collector.getProperty>("trackedWindows").size) } + @Test + fun `getFramesDelay returns -1 when not available`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) } + val collector = fixture.getSut(context, buildInfo) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns -1 for invalid time range`() { + val collector = fixture.getSut(context) + + val result = collector.getFramesDelay(2000, 1000) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns zero delay when no slow frames recorded`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a fast frame (21ns cpu time — well under 16ms budget) + listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0) + + // choreographer is at end of range so no pending delay + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(1)) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(0.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay calculates delay from slow frames`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)), + 0, + ) + + // emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000)), + 0, + ) + + // choreographer is at end of range so no pending delay + Shadows.shadowOf(Looper.getMainLooper()).idle() + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5)) + assertTrue(result.delaySeconds > 0) + assertEquals(2, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay handles partial frame overlap`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a frozen frame (~1s) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.SECONDS.toNanos(1)), + 0, + ) + + // choreographer is at end of range + Shadows.shadowOf(Looper.getMainLooper()).idle() + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) + + // The frame's delay interval is roughly [~16ms, ~1000ms]. + // Query from 500ms so the range clips the delay interval in half. + val queryStart = TimeUnit.MILLISECONDS.toNanos(500) + val queryEnd = TimeUnit.SECONDS.toNanos(5) + + val fullResult = collector.getFramesDelay(0, queryEnd) + val partialResult = collector.getFramesDelay(queryStart, queryEnd) + + // partial overlap should yield less delay than the full range + assertTrue(partialResult.delaySeconds > 0) + assertTrue(partialResult.delaySeconds < fullResult.delaySeconds) + assertEquals(1, partialResult.framesContributingToDelayCount) + } + + @Test + fun `old frames are automatically pruned`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + val choreographer = collector.getProperty("choreographer") + + collector.startCollection(mock()) + + val t0 = TimeUnit.MINUTES.toNanos(10) // start at a realistic base time + + // emit a slow frame at t0 + val frameMetrics1 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics1.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t0) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0) + + choreographer.injectForField("mLastFrameTimeNanos", t0 + TimeUnit.SECONDS.toNanos(1)) + + // verify frame exists + val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(1, resultBefore.framesContributingToDelayCount) + + // emit another slow frame >5 minutes later to trigger auto-pruning + val t1 = t0 + TimeUnit.MINUTES.toNanos(6) + val frameMetrics2 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics2.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t1) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0) + + // the first frame should have been pruned (>5min old) + choreographer.injectForField("mLastFrameTimeNanos", t1 + TimeUnit.SECONDS.toNanos(1)) + val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(0, resultAfter.framesContributingToDelayCount) + } + private fun createMockWindow(refreshRate: Float = 60F): Window { val mockWindow = mock() val mockDisplay = mock() From 7659fe5e58bc7ea6eca4b32b4e34bff0810578ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 13:07:38 +0200 Subject: [PATCH 009/276] ref(feedback): Rename Dialog to Form across feedback APIs (#5349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ref(feedback): Rename Dialog to Form across feedback APIs Rename SentryUserFeedbackDialog to SentryUserFeedbackForm as the primary class. Keep SentryUserFeedbackDialog as a deprecated subclass for backward compatibility. Also rename internal APIs to use Form naming consistently: - IDialogHandler -> IFormHandler - showDialog -> showForm - setDialogHandler/getDialogHandler -> setFormHandler/getFormHandler - AndroidUserFeedbackIDialogHandler -> AndroidUserFeedbackFormHandler Add deprecated Sentry.showUserFeedbackDialog() overloads that delegate to the new Sentry.showUserFeedbackForm() methods. Co-Authored-By: Claude Opus 4.6 * fix(feedback): Preserve binary compatibility for deprecated Builder constructors Use SentryUserFeedbackDialog.OptionsConfiguration as the parameter type in the deprecated Builder constructors so old compiled code looking for the original descriptor still resolves correctly. Co-Authored-By: Claude Opus 4.6 * Make internal ctor package-private * Add missing deprecated annotaiton * Fix api * docs(changelog): Add deprecation entry for feedback Dialog to Form rename Co-Authored-By: Claude Opus 4.6 * docs(changelog): Note removal in next major version Co-Authored-By: Claude Opus 4.6 * feat(feedback): Add Sentry.feedback() API Introduce IFeedbackApi with showForm() and capture() methods, accessible via Sentry.feedback(). This consolidates all feedback operations under a single API entry point. Deprecate Sentry.showUserFeedbackForm(), Sentry.showUserFeedbackDialog(), Sentry.captureFeedback(), and Sentry.captureUserFeedback() in favor of the new Sentry.feedback() API. All deprecated methods will be removed in the next major version. Co-Authored-By: Claude Opus 4.6 * docs(changelog): Update section to Features and remove unpublished API Co-Authored-By: Claude Opus 4.6 * ref(feedback): Move FeedbackApi to IScopes Add feedback() method to IScopes, matching the pattern used by logger() and metrics(). FeedbackApi takes an IScopes reference instead of using Sentry.getCurrentScopes() statically. Implemented in Scopes, NoOpScopes, NoOpHub, HubAdapter, HubScopesWrapper, and ScopesAdapter. Sentry.feedback() now delegates to getCurrentScopes().feedback(). Co-Authored-By: Claude Opus 4.6 * ref: Rename showForm() to show() on IFeedbackApi Since the method is already namespaced under feedback(), the extra "Form" suffix is redundant. This aligns with the convention used by logger() and metrics(). Co-Authored-By: Claude Opus 4.6 * chore: Deprecate UserFeedback and captureUserFeedback, delete showUserFeedbackForm Deprecate the old `UserFeedback` class and `captureUserFeedback()` across IScopes, ISentryClient, and all implementations in favor of `Sentry.feedback().capture()` with the new `Feedback` type. Delete `Sentry.showUserFeedbackForm()` (3 overloads) as it was never published. Co-Authored-By: Claude Opus 4.6 * chore: Deprecate SentryEnvelopeItem.fromUserFeedback() Co-Authored-By: Claude Opus 4.6 * chore: Deprecate SentryClient.buildEnvelope(UserFeedback) Co-Authored-By: Claude Opus 4.6 * ref: Remove unnecessary SuppressWarnings("deprecation") Deprecated methods don't need to suppress deprecation warnings for referencing other deprecated types — the deprecation annotation itself is sufficient. Co-Authored-By: Claude Opus 4.6 * fix test * remove redudndant deprecated annotations * fix test --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 9 + .../api/sentry-android-core.api | 33 +- .../core/AndroidOptionsInitializer.java | 2 +- .../core/FeedbackShakeIntegration.java | 2 +- .../android/core/SentryAndroidOptions.java | 6 +- .../core/SentryUserFeedbackButton.java | 4 +- .../core/SentryUserFeedbackDialog.java | 356 +++------------- .../android/core/SentryUserFeedbackForm.java | 379 ++++++++++++++++++ .../core/AndroidOptionsInitializerTest.kt | 6 +- .../core/FeedbackShakeIntegrationTest.kt | 4 +- ...gTest.kt => SentryUserFeedbackFormTest.kt} | 8 +- .../uitest/android/UserFeedbackUiTest.kt | 14 +- .../compose/SentryUserFeedbackButton.kt | 2 +- sentry/api/sentry.api | 36 +- .../src/main/java/io/sentry/FeedbackApi.java | 51 +++ .../src/main/java/io/sentry/HubAdapter.java | 5 + .../main/java/io/sentry/HubScopesWrapper.java | 5 + .../src/main/java/io/sentry/IFeedbackApi.java | 29 ++ sentry/src/main/java/io/sentry/IScopes.java | 6 + .../main/java/io/sentry/ISentryClient.java | 3 + .../main/java/io/sentry/JsonSerializer.java | 1 + .../main/java/io/sentry/NoOpFeedbackApi.java | 46 +++ sentry/src/main/java/io/sentry/NoOpHub.java | 6 + .../src/main/java/io/sentry/NoOpScopes.java | 6 + .../main/java/io/sentry/NoOpSentryClient.java | 1 + sentry/src/main/java/io/sentry/Scopes.java | 8 + .../main/java/io/sentry/ScopesAdapter.java | 12 +- sentry/src/main/java/io/sentry/Sentry.java | 66 ++- .../src/main/java/io/sentry/SentryClient.java | 2 + .../java/io/sentry/SentryEnvelopeItem.java | 1 + .../java/io/sentry/SentryFeedbackOptions.java | 28 +- .../main/java/io/sentry/SentryOptions.java | 2 +- .../src/main/java/io/sentry/UserFeedback.java | 8 +- .../src/test/java/io/sentry/HubAdapterTest.kt | 2 + .../test/java/io/sentry/ScopesAdapterTest.kt | 2 + .../io/sentry/SentryFeedbackOptionsTest.kt | 8 +- .../test/java/io/sentry/SentryOptionsTest.kt | 4 +- sentry/src/test/java/io/sentry/SentryTest.kt | 32 +- 38 files changed, 786 insertions(+), 409 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java rename sentry-android-core/src/test/java/io/sentry/android/core/{SentryUserFeedbackDialogTest.kt => SentryUserFeedbackFormTest.kt} (94%) create mode 100644 sentry/src/main/java/io/sentry/FeedbackApi.java create mode 100644 sentry/src/main/java/io/sentry/IFeedbackApi.java create mode 100644 sentry/src/main/java/io/sentry/NoOpFeedbackApi.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b5b7d1686..4d1a581769c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Features + +- Add `Sentry.feedback()` API for `show()` and `capture()` ([#5349](https://github.com/getsentry/sentry-java/pull/5349)) + - `Sentry.showUserFeedbackDialog()` is deprecated in favor of `Sentry.feedback().show()` + - `Sentry.captureFeedback()` is deprecated in favor of `Sentry.feedback().capture()` + - `Sentry.captureUserFeedback()` and `UserFeedback` are deprecated in favor of `Sentry.feedback().capture()` with the new `Feedback` type + - `SentryUserFeedbackDialog` is deprecated in favor of `SentryUserFeedbackForm` + - All deprecated APIs will be removed in the next major version + ### Dependencies - Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8af0182bb45..3d4512fc2b4 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -502,23 +502,44 @@ public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Bu public fun setOnClickListener (Landroid/view/View$OnClickListener;)V } -public final class io/sentry/android/core/SentryUserFeedbackDialog : android/app/AlertDialog { - public fun setCancelable (Z)V - public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V - public fun show ()V +public final class io/sentry/android/core/SentryUserFeedbackDialog : io/sentry/android/core/SentryUserFeedbackForm { } -public class io/sentry/android/core/SentryUserFeedbackDialog$Builder { +public class io/sentry/android/core/SentryUserFeedbackDialog$Builder : io/sentry/android/core/SentryUserFeedbackForm$Builder { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;I)V public fun (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V public fun (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V public fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder; + public synthetic fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; public fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder; + public synthetic fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; public fun create ()Lio/sentry/android/core/SentryUserFeedbackDialog; + public synthetic fun create ()Lio/sentry/android/core/SentryUserFeedbackForm; +} + +public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration : io/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration { +} + +public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog { + protected fun onCreate (Landroid/os/Bundle;)V + protected fun onStart ()V + public fun setCancelable (Z)V + public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V + public fun show ()V +} + +public class io/sentry/android/core/SentryUserFeedbackForm$Builder { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;I)V + public fun (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration;)V + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration;)V + public fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; + public fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; + public fun create ()Lio/sentry/android/core/SentryUserFeedbackForm; } -public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration { +public abstract interface class io/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration { public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } 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 5f7fad69b5d..5704cf7d7d4 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 @@ -441,7 +441,7 @@ static void installDefaultIntegrations( } options .getFeedbackOptions() - .setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler()); + .setFormHandler(new SentryAndroidOptions.AndroidUserFeedbackFormHandler()); } /** 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 b845b6ed8c4..fc34f18152f 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 @@ -178,7 +178,7 @@ private void startShakeDetection(final @NotNull Activity activity) { } previousOnFormClose = null; }); - new SentryUserFeedbackDialog.Builder(active).create().show(); + new SentryUserFeedbackForm.Builder(active).create().show(); } catch (Throwable e) { isDialogShowing = false; options.getFeedbackOptions().setOnFormClose(previousOnFormClose); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 054e43322a2..8fe702aad50 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -741,9 +741,9 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } - static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler { + static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { @Override - public void showDialog( + public void showForm( final @Nullable SentryId associatedEventId, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); @@ -758,7 +758,7 @@ public void showDialog( return; } - new SentryUserFeedbackDialog.Builder(activity) + new SentryUserFeedbackForm.Builder(activity) .associatedEventId(associatedEventId) .configurator(configurator) .create() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java index eedafd8f001..729dfd0b4e7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java @@ -104,7 +104,7 @@ private void init( } } - // Set the default ClickListener to open the SentryUserFeedbackDialog + // Set the default ClickListener to open the SentryUserFeedbackForm setOnClickListener(delegate); } @@ -113,7 +113,7 @@ public void setOnClickListener(final @Nullable OnClickListener listener) { delegate = listener; super.setOnClickListener( v -> { - new SentryUserFeedbackDialog.Builder(getContext()).create().show(); + new SentryUserFeedbackForm.Builder(getContext()).create().show(); if (delegate != null) { delegate.onClick(v); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java index 542a7027a4f..155464b7b73 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java @@ -1,380 +1,114 @@ package io.sentry.android.core; -import android.app.AlertDialog; import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import io.sentry.IScopes; -import io.sentry.Sentry; import io.sentry.SentryFeedbackOptions; -import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class SentryUserFeedbackDialog extends AlertDialog { - - private boolean isCancelable = false; - private @Nullable SentryId currentReplayId; - private final @Nullable SentryId associatedEventId; - private @Nullable OnDismissListener delegate; - - private final @Nullable OptionsConfiguration configuration; - private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; +/** + * @deprecated Use {@link SentryUserFeedbackForm} instead. + */ +@Deprecated +public final class SentryUserFeedbackDialog extends SentryUserFeedbackForm { SentryUserFeedbackDialog( final @NotNull Context context, final int themeResId, final @Nullable SentryId associatedEventId, - final @Nullable OptionsConfiguration configuration, + final @Nullable SentryUserFeedbackForm.OptionsConfiguration configuration, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - super(context, themeResId); - this.associatedEventId = associatedEventId; - this.configuration = configuration; - this.configurator = configurator; - SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); - } - - @Override - public void setCancelable(boolean cancelable) { - super.setCancelable(cancelable); - isCancelable = cancelable; - } - - @Override - @SuppressWarnings("deprecation") - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.sentry_dialog_user_feedback); - setCancelable(isCancelable); - - final @NotNull SentryFeedbackOptions feedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); - if (configuration != null) { - configuration.configure(getContext(), feedbackOptions); - } - if (configurator != null) { - configurator.configure(feedbackOptions); - } - final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); - final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); - final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); - final @NotNull EditText edtName = findViewById(R.id.sentry_dialog_user_feedback_edt_name); - final @NotNull TextView lblEmail = findViewById(R.id.sentry_dialog_user_feedback_txt_email); - final @NotNull EditText edtEmail = findViewById(R.id.sentry_dialog_user_feedback_edt_email); - final @NotNull TextView lblMessage = - findViewById(R.id.sentry_dialog_user_feedback_txt_description); - final @NotNull EditText edtMessage = - findViewById(R.id.sentry_dialog_user_feedback_edt_description); - final @NotNull Button btnSend = findViewById(R.id.sentry_dialog_user_feedback_btn_send); - final @NotNull Button btnCancel = findViewById(R.id.sentry_dialog_user_feedback_btn_cancel); - - if (feedbackOptions.isShowBranding()) { - imgLogo.setVisibility(View.VISIBLE); - } else { - imgLogo.setVisibility(View.GONE); - } - - // If name is required, ignore showName flag - if (!feedbackOptions.isShowName() && !feedbackOptions.isNameRequired()) { - lblName.setVisibility(View.GONE); - edtName.setVisibility(View.GONE); - } else { - lblName.setVisibility(View.VISIBLE); - edtName.setVisibility(View.VISIBLE); - lblName.setText(feedbackOptions.getNameLabel()); - edtName.setHint(feedbackOptions.getNamePlaceholder()); - if (feedbackOptions.isNameRequired()) { - lblName.append(feedbackOptions.getIsRequiredLabel()); - } - } - - // If email is required, ignore showEmail flag - if (!feedbackOptions.isShowEmail() && !feedbackOptions.isEmailRequired()) { - lblEmail.setVisibility(View.GONE); - edtEmail.setVisibility(View.GONE); - } else { - lblEmail.setVisibility(View.VISIBLE); - edtEmail.setVisibility(View.VISIBLE); - lblEmail.setText(feedbackOptions.getEmailLabel()); - edtEmail.setHint(feedbackOptions.getEmailPlaceholder()); - if (feedbackOptions.isEmailRequired()) { - lblEmail.append(feedbackOptions.getIsRequiredLabel()); - } - } - - // If Sentry user is set, and useSentryUser is true, populate the name and email - if (feedbackOptions.isUseSentryUser()) { - final @Nullable User user = Sentry.getCurrentScopes().getScope().getUser(); - if (user != null) { - edtName.setText(user.getUsername()); - edtEmail.setText(user.getEmail()); - } - } - - lblMessage.setText(feedbackOptions.getMessageLabel()); - lblMessage.append(feedbackOptions.getIsRequiredLabel()); - edtMessage.setHint(feedbackOptions.getMessagePlaceholder()); - lblTitle.setText(feedbackOptions.getFormTitle()); - - btnSend.setText(feedbackOptions.getSubmitButtonLabel()); - btnSend.setOnClickListener( - v -> { - // Gather fields and trim them - final @NotNull String name = edtName.getText().toString().trim(); - final @NotNull String email = edtEmail.getText().toString().trim(); - final @NotNull String message = edtMessage.getText().toString().trim(); - - // If a required field is missing, shows the error label - if (name.isEmpty() && feedbackOptions.isNameRequired()) { - edtName.setError(lblName.getText()); - return; - } - - if (email.isEmpty() && feedbackOptions.isEmailRequired()) { - edtEmail.setError(lblEmail.getText()); - return; - } - - if (message.isEmpty()) { - edtMessage.setError(lblMessage.getText()); - return; - } - - // Create the feedback object - final @NotNull Feedback feedback = new Feedback(message); - feedback.setName(name); - feedback.setContactEmail(email); - if (associatedEventId != null) { - feedback.setAssociatedEventId(associatedEventId); - } - if (currentReplayId != null) { - feedback.setReplayId(currentReplayId); - } - - // Capture the feedback. If the ID is empty, it means that the feedback was not sent - final @NotNull SentryId id = Sentry.captureFeedback(feedback); - if (!id.equals(SentryId.EMPTY_ID)) { - Toast.makeText( - getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) - .show(); - final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = - feedbackOptions.getOnSubmitSuccess(); - if (onSubmitSuccess != null) { - onSubmitSuccess.call(feedback); - } - } else { - final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitError = - feedbackOptions.getOnSubmitError(); - if (onSubmitError != null) { - onSubmitError.call(feedback); - } - } - cancel(); - }); - - btnCancel.setText(feedbackOptions.getCancelButtonLabel()); - btnCancel.setOnClickListener(v -> cancel()); - setOnDismissListener(delegate); + super(context, themeResId, associatedEventId, configuration, configurator); } - @Override - public void setOnDismissListener(final @Nullable OnDismissListener listener) { - delegate = listener; - // If the user set a custom onDismissListener, we ensure it doesn't override the onFormClose - final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); - final @Nullable Runnable onFormClose = options.getFeedbackOptions().getOnFormClose(); - if (onFormClose != null) { - super.setOnDismissListener( - dialog -> { - onFormClose.run(); - currentReplayId = null; - if (delegate != null) { - delegate.onDismiss(dialog); - } - }); - } else { - super.setOnDismissListener(delegate); - } - } - - @Override - protected void onStart() { - super.onStart(); - final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); - final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); - final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); - if (onFormOpen != null) { - onFormOpen.run(); - } - options.getReplayController().captureReplay(false); - currentReplayId = options.getReplayController().getReplayId(); - } - - @Override - public void show() { - // If Sentry is disabled, don't show the dialog, but log a warning - final @NotNull IScopes scopes = Sentry.getCurrentScopes(); - final @NotNull SentryOptions options = scopes.getOptions(); - if (!scopes.isEnabled() || !options.isEnabled()) { - options - .getLogger() - .log(SentryLevel.WARNING, "Sentry is disabled. Feedback dialog won't be shown."); - return; - } - // Otherwise, show the dialog - super.show(); - } - - public static class Builder { - - @Nullable OptionsConfiguration configuration; - @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; - @Nullable SentryId associatedEventId; - final @NotNull Context context; - final int themeResId; + /** + * @deprecated Use {@link SentryUserFeedbackForm.Builder} instead. + */ + @Deprecated + public static class Builder extends SentryUserFeedbackForm.Builder { /** * Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog * theme. * - *

The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} - * within the parent {@code context}'s theme. - * * @param context the parent context + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context)} instead. */ + @Deprecated public Builder(final @NotNull Context context) { - this(context, 0); + super(context); } /** * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme * resource. * - *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code - * context}'s theme. It may be specified as a style resource containing a fully-populated theme, - * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the - * parent {@code context}'s theme including primary and accent colors. - * - *

To preserve attributes such as primary and accent colors, the {@code themeResId} may - * instead be specified as an overlay theme such as {@link - * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes - * necessary to style the alert window as a dialog. - * - *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent - * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. - * * @param context the parent context - * @param themeResId the resource ID of the theme against which to inflate this dialog, or - * {@code 0} to use the parent {@code context}'s default alert dialog theme + * @param themeResId the resource ID of the theme + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, int)} instead. */ + @Deprecated public Builder(Context context, int themeResId) { - this(context, themeResId, null); + super(context, themeResId); } /** - * Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog - * theme. The {@code configuration} can be used to configure the feedback options for this - * specific dialog. - * - *

The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} - * within the parent {@code context}'s theme. + * Creates a builder for a {@link SentryUserFeedbackDialog} with a configuration. * * @param context the parent context - * @param configuration the configuration for the feedback options, can be {@code null} to use - * the global feedback options. + * @param configuration the configuration for the feedback options + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, + * SentryUserFeedbackForm.OptionsConfiguration)} instead. */ + @Deprecated public Builder( final @NotNull Context context, final @Nullable OptionsConfiguration configuration) { - this(context, 0, configuration); + super(context, configuration); } /** - * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme - * resource. The {@code configuration} can be used to configure the feedback options for this - * specific dialog. - * - *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code - * context}'s theme. It may be specified as a style resource containing a fully-populated theme, - * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the - * parent {@code context}'s theme including primary and accent colors. - * - *

To preserve attributes such as primary and accent colors, the {@code themeResId} may - * instead be specified as an overlay theme such as {@link - * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes - * necessary to style the alert window as a dialog. - * - *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent - * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * Creates a builder for a {@link SentryUserFeedbackDialog} with a theme and configuration. * * @param context the parent context - * @param themeResId the resource ID of the theme against which to inflate this dialog, or - * {@code 0} to use the parent {@code context}'s default alert dialog theme - * @param configuration the configuration for the feedback options, can be {@code null} to use - * the global feedback options. + * @param themeResId the resource ID of the theme + * @param configuration the configuration for the feedback options + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, int, + * SentryUserFeedbackForm.OptionsConfiguration)} instead. */ + @Deprecated public Builder( final @NotNull Context context, final int themeResId, final @Nullable OptionsConfiguration configuration) { - this.context = context; - this.themeResId = themeResId; - this.configuration = configuration; + super(context, themeResId, configuration); } - /** - * Sets the configuration for the feedback options. - * - * @param configurator the configuration for the feedback options, can be {@code null} to use - * the global feedback options. - */ + @Deprecated + @Override public Builder configurator( final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - this.configurator = configurator; + super.configurator(configurator); return this; } - /** - * Sets the associated event ID for the feedback. - * - * @param associatedEventId the associated event ID for the feedback, can be {@code null} to - * avoid associating the feedback to an event. - */ + @Deprecated + @Override public Builder associatedEventId(final @Nullable SentryId associatedEventId) { - this.associatedEventId = associatedEventId; + super.associatedEventId(associatedEventId); return this; } - /** - * Builds a new {@link SentryUserFeedbackDialog} with the specified context, theme, and - * configuration. - * - * @return a new instance of {@link SentryUserFeedbackDialog} - */ + @Deprecated + @Override public SentryUserFeedbackDialog create() { return new SentryUserFeedbackDialog( context, themeResId, associatedEventId, configuration, configurator); } } - /** Configuration callback for feedback options. */ - public interface OptionsConfiguration { - - /** - * configure the feedback options - * - * @param context the context of the feedback dialog - * @param options the feedback options - */ - void configure(final @NotNull Context context, final @NotNull SentryFeedbackOptions options); - } + /** + * @deprecated Use {@link SentryUserFeedbackForm.OptionsConfiguration} instead. + */ + @Deprecated + public interface OptionsConfiguration extends SentryUserFeedbackForm.OptionsConfiguration {} } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java new file mode 100644 index 00000000000..0babe475491 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -0,0 +1,379 @@ +package io.sentry.android.core; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import io.sentry.IScopes; +import io.sentry.Sentry; +import io.sentry.SentryFeedbackOptions; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SentryUserFeedbackForm extends AlertDialog { + + private boolean isCancelable = false; + private @Nullable SentryId currentReplayId; + private final @Nullable SentryId associatedEventId; + private @Nullable OnDismissListener delegate; + + private final @Nullable OptionsConfiguration configuration; + private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + + SentryUserFeedbackForm( + final @NotNull Context context, + final int themeResId, + final @Nullable SentryId associatedEventId, + final @Nullable OptionsConfiguration configuration, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + super(context, themeResId); + this.associatedEventId = associatedEventId; + this.configuration = configuration; + this.configurator = configurator; + SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + isCancelable = cancelable; + } + + @Override + @SuppressWarnings("deprecation") + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.sentry_dialog_user_feedback); + setCancelable(isCancelable); + + final @NotNull SentryFeedbackOptions feedbackOptions = + new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + if (configuration != null) { + configuration.configure(getContext(), feedbackOptions); + } + if (configurator != null) { + configurator.configure(feedbackOptions); + } + final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); + final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); + final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); + final @NotNull EditText edtName = findViewById(R.id.sentry_dialog_user_feedback_edt_name); + final @NotNull TextView lblEmail = findViewById(R.id.sentry_dialog_user_feedback_txt_email); + final @NotNull EditText edtEmail = findViewById(R.id.sentry_dialog_user_feedback_edt_email); + final @NotNull TextView lblMessage = + findViewById(R.id.sentry_dialog_user_feedback_txt_description); + final @NotNull EditText edtMessage = + findViewById(R.id.sentry_dialog_user_feedback_edt_description); + final @NotNull Button btnSend = findViewById(R.id.sentry_dialog_user_feedback_btn_send); + final @NotNull Button btnCancel = findViewById(R.id.sentry_dialog_user_feedback_btn_cancel); + + if (feedbackOptions.isShowBranding()) { + imgLogo.setVisibility(View.VISIBLE); + } else { + imgLogo.setVisibility(View.GONE); + } + + // If name is required, ignore showName flag + if (!feedbackOptions.isShowName() && !feedbackOptions.isNameRequired()) { + lblName.setVisibility(View.GONE); + edtName.setVisibility(View.GONE); + } else { + lblName.setVisibility(View.VISIBLE); + edtName.setVisibility(View.VISIBLE); + lblName.setText(feedbackOptions.getNameLabel()); + edtName.setHint(feedbackOptions.getNamePlaceholder()); + if (feedbackOptions.isNameRequired()) { + lblName.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If email is required, ignore showEmail flag + if (!feedbackOptions.isShowEmail() && !feedbackOptions.isEmailRequired()) { + lblEmail.setVisibility(View.GONE); + edtEmail.setVisibility(View.GONE); + } else { + lblEmail.setVisibility(View.VISIBLE); + edtEmail.setVisibility(View.VISIBLE); + lblEmail.setText(feedbackOptions.getEmailLabel()); + edtEmail.setHint(feedbackOptions.getEmailPlaceholder()); + if (feedbackOptions.isEmailRequired()) { + lblEmail.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If Sentry user is set, and useSentryUser is true, populate the name and email + if (feedbackOptions.isUseSentryUser()) { + final @Nullable User user = Sentry.getCurrentScopes().getScope().getUser(); + if (user != null) { + edtName.setText(user.getUsername()); + edtEmail.setText(user.getEmail()); + } + } + + lblMessage.setText(feedbackOptions.getMessageLabel()); + lblMessage.append(feedbackOptions.getIsRequiredLabel()); + edtMessage.setHint(feedbackOptions.getMessagePlaceholder()); + lblTitle.setText(feedbackOptions.getFormTitle()); + + btnSend.setText(feedbackOptions.getSubmitButtonLabel()); + btnSend.setOnClickListener( + v -> { + // Gather fields and trim them + final @NotNull String name = edtName.getText().toString().trim(); + final @NotNull String email = edtEmail.getText().toString().trim(); + final @NotNull String message = edtMessage.getText().toString().trim(); + + // If a required field is missing, shows the error label + if (name.isEmpty() && feedbackOptions.isNameRequired()) { + edtName.setError(lblName.getText()); + return; + } + + if (email.isEmpty() && feedbackOptions.isEmailRequired()) { + edtEmail.setError(lblEmail.getText()); + return; + } + + if (message.isEmpty()) { + edtMessage.setError(lblMessage.getText()); + return; + } + + // Create the feedback object + final @NotNull Feedback feedback = new Feedback(message); + feedback.setName(name); + feedback.setContactEmail(email); + if (associatedEventId != null) { + feedback.setAssociatedEventId(associatedEventId); + } + if (currentReplayId != null) { + feedback.setReplayId(currentReplayId); + } + + // Capture the feedback. If the ID is empty, it means that the feedback was not sent + final @NotNull SentryId id = Sentry.feedback().capture(feedback); + if (!id.equals(SentryId.EMPTY_ID)) { + Toast.makeText( + getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) + .show(); + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = + feedbackOptions.getOnSubmitSuccess(); + if (onSubmitSuccess != null) { + onSubmitSuccess.call(feedback); + } + } else { + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitError = + feedbackOptions.getOnSubmitError(); + if (onSubmitError != null) { + onSubmitError.call(feedback); + } + } + cancel(); + }); + + btnCancel.setText(feedbackOptions.getCancelButtonLabel()); + btnCancel.setOnClickListener(v -> cancel()); + setOnDismissListener(delegate); + } + + @Override + public void setOnDismissListener(final @Nullable OnDismissListener listener) { + delegate = listener; + // If the user set a custom onDismissListener, we ensure it doesn't override the onFormClose + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @Nullable Runnable onFormClose = options.getFeedbackOptions().getOnFormClose(); + if (onFormClose != null) { + super.setOnDismissListener( + dialog -> { + onFormClose.run(); + currentReplayId = null; + if (delegate != null) { + delegate.onDismiss(dialog); + } + }); + } else { + super.setOnDismissListener(delegate); + } + } + + @Override + protected void onStart() { + super.onStart(); + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); + final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); + if (onFormOpen != null) { + onFormOpen.run(); + } + options.getReplayController().captureReplay(false); + currentReplayId = options.getReplayController().getReplayId(); + } + + @Override + public void show() { + // If Sentry is disabled, don't show the dialog, but log a warning + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + if (!scopes.isEnabled() || !options.isEnabled()) { + options + .getLogger() + .log(SentryLevel.WARNING, "Sentry is disabled. Feedback dialog won't be shown."); + return; + } + // Otherwise, show the dialog + super.show(); + } + + public static class Builder { + + @Nullable OptionsConfiguration configuration; + @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + @Nullable SentryId associatedEventId; + final @NotNull Context context; + final int themeResId; + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses the default alert dialog + * theme. + * + *

The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} + * within the parent {@code context}'s theme. + * + * @param context the parent context + */ + public Builder(final @NotNull Context context) { + this(context, 0); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses an explicit theme resource. + * + *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + */ + public Builder(Context context, int themeResId) { + this(context, themeResId, null); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses the default alert dialog + * theme. The {@code configuration} can be used to configure the feedback options for this + * specific dialog. + * + *

The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} + * within the parent {@code context}'s theme. + * + * @param context the parent context + * @param configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, final @Nullable OptionsConfiguration configuration) { + this(context, 0, configuration); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses an explicit theme resource. + * The {@code configuration} can be used to configure the feedback options for this specific + * dialog. + * + *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + * @param configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, + final int themeResId, + final @Nullable OptionsConfiguration configuration) { + this.context = context; + this.themeResId = themeResId; + this.configuration = configuration; + } + + /** + * Sets the configuration for the feedback options. + * + * @param configurator the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder configurator( + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + this.configurator = configurator; + return this; + } + + /** + * Sets the associated event ID for the feedback. + * + * @param associatedEventId the associated event ID for the feedback, can be {@code null} to + * avoid associating the feedback to an event. + */ + public Builder associatedEventId(final @Nullable SentryId associatedEventId) { + this.associatedEventId = associatedEventId; + return this; + } + + /** + * Builds a new {@link SentryUserFeedbackForm} with the specified context, theme, and + * configuration. + * + * @return a new instance of {@link SentryUserFeedbackForm} + */ + public SentryUserFeedbackForm create() { + return new SentryUserFeedbackForm( + context, themeResId, associatedEventId, configuration, configurator); + } + } + + /** Configuration callback for feedback options. */ + public interface OptionsConfiguration { + + /** + * configure the feedback options + * + * @param context the context of the feedback dialog + * @param options the feedback options + */ + void configure(final @NotNull Context context, final @NotNull SentryFeedbackOptions options); + } +} 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 be54bf7768b..f8724d286f8 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 @@ -18,7 +18,7 @@ import io.sentry.MainEventProcessor import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpTransactionProfiler import io.sentry.SentryOptions -import io.sentry.android.core.SentryAndroidOptions.AndroidUserFeedbackIDialogHandler +import io.sentry.android.core.SentryAndroidOptions.AndroidUserFeedbackFormHandler import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator @@ -882,9 +882,9 @@ class AndroidOptionsInitializerTest { } @Test - fun `AndroidUserFeedbackIDialogHandler is set as feedback dialog handler`() { + fun `AndroidUserFeedbackFormHandler is set as feedback form handler`() { fixture.initSut() - assertIs(fixture.sentryOptions.feedbackOptions.dialogHandler) + assertIs(fixture.sentryOptions.feedbackOptions.formHandler) } @Test 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 cb940686c30..bddc9395c0d 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 @@ -22,10 +22,10 @@ class FeedbackShakeIntegrationTest { val scopes = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } val activity = mock() - val dialogHandler = mock() + val formHandler = mock() init { - options.feedbackOptions.setDialogHandler(dialogHandler) + options.feedbackOptions.setFormHandler(formHandler) } fun getSut(useShakeGesture: Boolean = true): FeedbackShakeIntegration { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt similarity index 94% rename from sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index bd60859c608..04f6a35716b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -26,7 +26,7 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) -class SentryUserFeedbackDialogTest { +class SentryUserFeedbackFormTest { class Fixture { val application: Context = ApplicationProvider.getApplicationContext() private val mockDsn = "http://key@localhost/proj" @@ -55,10 +55,10 @@ class SentryUserFeedbackDialogTest { fun getSut( associatedEventId: SentryId? = null, - configuration: SentryUserFeedbackDialog.OptionsConfiguration? = null, + configuration: SentryUserFeedbackForm.OptionsConfiguration? = null, configurator: SentryFeedbackOptions.OptionsConfigurator? = null, - ): SentryUserFeedbackDialog = - SentryUserFeedbackDialog(application, 0, associatedEventId, configuration, configurator) + ): SentryUserFeedbackForm = + SentryUserFeedbackForm(application, 0, associatedEventId, configuration, configurator) } private val fixture = Fixture() diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt index 39dfae40203..bfcaf2845b3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt @@ -28,7 +28,7 @@ import io.sentry.SentryOptions import io.sentry.android.core.AndroidLogger import io.sentry.android.core.R import io.sentry.android.core.SentryUserFeedbackButton -import io.sentry.android.core.SentryUserFeedbackDialog +import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.assertEnvelopeFeedback import io.sentry.protocol.SentryId import io.sentry.protocol.User @@ -49,21 +49,21 @@ class UserFeedbackUiTest : BaseUiTest() { @Test fun userFeedbackNotShownWhenSdkDisabled() { launchActivity().onActivity { - SentryUserFeedbackDialog.Builder(it).create().show() + SentryUserFeedbackForm.Builder(it).create().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist()) } @Test fun userFeedbackNotShownWhenSdkDisabledViaApi() { - launchActivity().onActivity { Sentry.showUserFeedbackDialog() } + launchActivity().onActivity { Sentry.feedback().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist()) } @Test fun userFeedbackShownViaApi() { initSentry() - launchActivity().onActivity { Sentry.showUserFeedbackDialog() } + launchActivity().onActivity { Sentry.feedback().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)) .inRoot(isDialog()) @@ -639,12 +639,12 @@ class UserFeedbackUiTest : BaseUiTest() { private fun showDialogAndCheck( associatedEventId: SentryId? = null, - checker: (dialog: SentryUserFeedbackDialog) -> Unit = {}, + checker: (dialog: SentryUserFeedbackForm) -> Unit = {}, ) { - lateinit var dialog: SentryUserFeedbackDialog + lateinit var dialog: SentryUserFeedbackForm val feedbackScenario = launchActivity() feedbackScenario.onActivity { - dialog = SentryUserFeedbackDialog.Builder(it).associatedEventId(associatedEventId).create() + dialog = SentryUserFeedbackForm.Builder(it).associatedEventId(associatedEventId).create() dialog.show() } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt index 93460b88893..0836c826d32 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt @@ -21,7 +21,7 @@ public fun SentryUserFeedbackButton( text: String = "Report a Bug", configurator: SentryFeedbackOptions.OptionsConfigurator? = null, ) { - Button(modifier = modifier, onClick = { Sentry.showUserFeedbackDialog(configurator) }) { + Button(modifier = modifier, onClick = { Sentry.feedback().show(configurator) }) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b9cbb2ae1b2..8bd1e90e094 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -664,6 +664,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -740,6 +741,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -834,6 +836,15 @@ public abstract interface class io/sentry/IEnvelopeSender { public abstract fun processEnvelopeFile (Ljava/lang/String;Lio/sentry/Hint;)V } +public abstract interface class io/sentry/IFeedbackApi { + public abstract fun capture (Lio/sentry/protocol/Feedback;)Lio/sentry/protocol/SentryId; + public abstract fun capture (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; + public abstract fun capture (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public abstract fun show ()V + public abstract fun show (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V + public abstract fun show (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V +} + public abstract interface class io/sentry/IHub : io/sentry/IScopes { } @@ -1012,6 +1023,7 @@ public abstract interface class io/sentry/IScopes { public abstract fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public abstract fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public abstract fun endSession ()V + public abstract fun feedback ()Lio/sentry/IFeedbackApi; public abstract fun flush (J)V public abstract fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public abstract fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -1569,6 +1581,16 @@ public final class io/sentry/NoOpEnvelopeReader : io/sentry/IEnvelopeReader { public fun read (Ljava/io/InputStream;)Lio/sentry/SentryEnvelope; } +public final class io/sentry/NoOpFeedbackApi : io/sentry/IFeedbackApi { + public fun capture (Lio/sentry/protocol/Feedback;)Lio/sentry/protocol/SentryId; + public fun capture (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; + public fun capture (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public static fun getInstance ()Lio/sentry/NoOpFeedbackApi; + public fun show ()V + public fun show (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V + public fun show (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V +} + public final class io/sentry/NoOpHub : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V @@ -1595,6 +1617,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -1781,6 +1804,7 @@ public final class io/sentry/NoOpScopes : io/sentry/IScopes { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -2517,6 +2541,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -2596,6 +2621,7 @@ public final class io/sentry/ScopesAdapter : io/sentry/IScopes { public fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public fun endSession ()V + public fun feedback ()Lio/sentry/IFeedbackApi; public fun flush (J)V public fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -2720,6 +2746,7 @@ public final class io/sentry/Sentry { public static fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public static fun distribution ()Lio/sentry/IDistributionApi; public static fun endSession ()V + public static fun feedback ()Lio/sentry/IFeedbackApi; public static fun flush (J)V public static fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public static fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -3151,12 +3178,11 @@ public final class io/sentry/SentryExecutorService : io/sentry/ISentryExecutorSe } public final class io/sentry/SentryFeedbackOptions { - public fun (Lio/sentry/SentryFeedbackOptions$IDialogHandler;)V public fun (Lio/sentry/SentryFeedbackOptions;)V public fun getCancelButtonLabel ()Ljava/lang/CharSequence; - public fun getDialogHandler ()Lio/sentry/SentryFeedbackOptions$IDialogHandler; public fun getEmailLabel ()Ljava/lang/CharSequence; public fun getEmailPlaceholder ()Ljava/lang/CharSequence; + public fun getFormHandler ()Lio/sentry/SentryFeedbackOptions$IFormHandler; public fun getFormTitle ()Ljava/lang/CharSequence; public fun getIsRequiredLabel ()Ljava/lang/CharSequence; public fun getMessageLabel ()Ljava/lang/CharSequence; @@ -3177,10 +3203,10 @@ public final class io/sentry/SentryFeedbackOptions { public fun isUseSentryUser ()Z public fun isUseShakeGesture ()Z public fun setCancelButtonLabel (Ljava/lang/CharSequence;)V - public fun setDialogHandler (Lio/sentry/SentryFeedbackOptions$IDialogHandler;)V public fun setEmailLabel (Ljava/lang/CharSequence;)V public fun setEmailPlaceholder (Ljava/lang/CharSequence;)V public fun setEmailRequired (Z)V + public fun setFormHandler (Lio/sentry/SentryFeedbackOptions$IFormHandler;)V public fun setFormTitle (Ljava/lang/CharSequence;)V public fun setIsRequiredLabel (Ljava/lang/CharSequence;)V public fun setMessageLabel (Ljava/lang/CharSequence;)V @@ -3202,8 +3228,8 @@ public final class io/sentry/SentryFeedbackOptions { public fun toString ()Ljava/lang/String; } -public abstract interface class io/sentry/SentryFeedbackOptions$IDialogHandler { - public abstract fun showDialog (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V +public abstract interface class io/sentry/SentryFeedbackOptions$IFormHandler { + public abstract fun showForm (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V } public abstract interface class io/sentry/SentryFeedbackOptions$OptionsConfigurator { diff --git a/sentry/src/main/java/io/sentry/FeedbackApi.java b/sentry/src/main/java/io/sentry/FeedbackApi.java new file mode 100644 index 00000000000..b8b8a3c9b9a --- /dev/null +++ b/sentry/src/main/java/io/sentry/FeedbackApi.java @@ -0,0 +1,51 @@ +package io.sentry; + +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FeedbackApi implements IFeedbackApi { + + private final @NotNull IScopes scopes; + + FeedbackApi(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public void show() { + show(null, null); + } + + @Override + public void show(final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + show(null, configurator); + } + + @Override + public void show( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + final @NotNull SentryOptions options = scopes.getOptions(); + options.getFeedbackOptions().getFormHandler().showForm(associatedEventId, configurator); + } + + @Override + public @NotNull SentryId capture(final @NotNull Feedback feedback) { + return scopes.captureFeedback(feedback); + } + + @Override + public @NotNull SentryId capture(final @NotNull Feedback feedback, final @Nullable Hint hint) { + return scopes.captureFeedback(feedback, hint); + } + + @Override + public @NotNull SentryId capture( + final @NotNull Feedback feedback, + final @Nullable Hint hint, + final @Nullable ScopeCallback callback) { + return scopes.captureFeedback(feedback, hint, callback); + } +} diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index cf90eb1fe65..5e2d91a9ae8 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -395,6 +395,11 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().metrics(); } + @Override + public @NotNull IFeedbackApi feedback() { + return Sentry.getCurrentScopes().feedback(); + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) { Sentry.setAttribute(key, value); diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index 66a34b4dc36..00395292fd5 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -380,6 +380,11 @@ public void reportFullyDisplayed() { return scopes.metrics(); } + @Override + public @NotNull IFeedbackApi feedback() { + return scopes.feedback(); + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) { scopes.setAttribute(key, value); diff --git a/sentry/src/main/java/io/sentry/IFeedbackApi.java b/sentry/src/main/java/io/sentry/IFeedbackApi.java new file mode 100644 index 00000000000..5bab630fa8b --- /dev/null +++ b/sentry/src/main/java/io/sentry/IFeedbackApi.java @@ -0,0 +1,29 @@ +package io.sentry; + +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface IFeedbackApi { + + void show(); + + void show(final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator); + + void show( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator); + + @NotNull + SentryId capture(final @NotNull Feedback feedback); + + @NotNull + SentryId capture(final @NotNull Feedback feedback, final @Nullable Hint hint); + + @NotNull + SentryId capture( + final @NotNull Feedback feedback, + final @Nullable Hint hint, + final @Nullable ScopeCallback callback); +} diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index b1b437f72e5..26ea0dcc3ea 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -217,7 +217,10 @@ SentryId captureException( * Captures a manually created user feedback and sends it to Sentry. * * @param userFeedback The user feedback to send to Sentry. + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#capture(io.sentry.protocol.Feedback) + * capture(feedback)} with the new {@link io.sentry.protocol.Feedback} type instead. */ + @Deprecated void captureUserFeedback(@NotNull UserFeedback userFeedback); /** Starts a new session. If there's a running session, it ends it before starting the new one. */ @@ -748,6 +751,9 @@ default boolean isNoOp() { @NotNull IMetricsApi metrics(); + @NotNull + IFeedbackApi feedback(); + /** * Sets an attribute. * diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 98b6034bb78..2a1df15f812 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -174,7 +174,10 @@ SentryId captureReplayEvent( * Captures a manually created user feedback and sends it to Sentry. * * @param userFeedback The user feedback to send to Sentry. + * @deprecated Use {@link IFeedbackApi#capture(io.sentry.protocol.Feedback)} with the new {@link + * io.sentry.protocol.Feedback} type instead. */ + @Deprecated void captureUserFeedback(@NotNull UserFeedback userFeedback); /** diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index a0fa80879aa..2b24090d0cc 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -72,6 +72,7 @@ public final class JsonSerializer implements ISerializer { /** * All our custom deserializers need to be registered to be used with the deserializer instance. * */ + @SuppressWarnings("deprecation") public JsonSerializer(@NotNull SentryOptions options) { this.options = options; diff --git a/sentry/src/main/java/io/sentry/NoOpFeedbackApi.java b/sentry/src/main/java/io/sentry/NoOpFeedbackApi.java new file mode 100644 index 00000000000..bdef5d37590 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpFeedbackApi.java @@ -0,0 +1,46 @@ +package io.sentry; + +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpFeedbackApi implements IFeedbackApi { + + private static final NoOpFeedbackApi instance = new NoOpFeedbackApi(); + + private NoOpFeedbackApi() {} + + public static NoOpFeedbackApi getInstance() { + return instance; + } + + @Override + public void show() {} + + @Override + public void show(final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {} + + @Override + public void show( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {} + + @Override + public @NotNull SentryId capture(final @NotNull Feedback feedback) { + return SentryId.EMPTY_ID; + } + + @Override + public @NotNull SentryId capture(final @NotNull Feedback feedback, final @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + + @Override + public @NotNull SentryId capture( + final @NotNull Feedback feedback, + final @Nullable Hint hint, + final @Nullable ScopeCallback callback) { + return SentryId.EMPTY_ID; + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 4a02be1bd40..d5ef143d342 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -80,6 +80,7 @@ public boolean isEnabled() { return SentryId.EMPTY_ID; } + @Deprecated @Override public void captureUserFeedback(@NotNull UserFeedback userFeedback) {} @@ -338,6 +339,11 @@ public boolean isNoOp() { return NoOpMetricsApi.getInstance(); } + @Override + public @NotNull IFeedbackApi feedback() { + return NoOpFeedbackApi.getInstance(); + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) {} diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 1ae357d502e..345c74cc0ee 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -77,6 +77,7 @@ public boolean isEnabled() { return SentryId.EMPTY_ID; } + @Deprecated @Override public void captureUserFeedback(@NotNull UserFeedback userFeedback) {} @@ -336,6 +337,11 @@ public boolean isNoOp() { return NoOpMetricsApi.getInstance(); } + @Override + public @NotNull IFeedbackApi feedback() { + return NoOpFeedbackApi.getInstance(); + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) {} diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 961ef9031be..ac9ff2344bf 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -44,6 +44,7 @@ public void flush(long timeoutMillis) {} return SentryId.EMPTY_ID; } + @Deprecated @Override public void captureUserFeedback(@NotNull UserFeedback userFeedback) {} diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 82c03feac4b..3b67b94916e 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -34,6 +34,7 @@ public final class Scopes implements IScopes { private final @NotNull CombinedScopeView combinedScope; private final @NotNull ILoggerApi logger; private final @NotNull IMetricsApi metrics; + private final @NotNull IFeedbackApi feedbackApi; public Scopes( final @NotNull IScope scope, @@ -61,6 +62,7 @@ private Scopes( this.compositePerformanceCollector = options.getCompositePerformanceCollector(); this.logger = new LoggerApi(this); this.metrics = new MetricsApi(this); + this.feedbackApi = new FeedbackApi(this); } public @NotNull String getCreator() { @@ -344,6 +346,7 @@ private void assignTraceContext(final @NotNull SentryEvent event) { return sentryId; } + @Deprecated @Override public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { if (!isEnabled()) { @@ -1245,6 +1248,11 @@ public void reportFullyDisplayed() { return metrics; } + @Override + public @NotNull IFeedbackApi feedback() { + return feedbackApi; + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) { if (!isEnabled()) { diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index b66b681a332..b697a950501 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -51,18 +51,18 @@ public boolean isEnabled() { @Override public @NotNull SentryId captureFeedback(@NotNull Feedback feedback) { - return Sentry.captureFeedback(feedback); + return Sentry.feedback().capture(feedback); } @Override public @NotNull SentryId captureFeedback(@NotNull Feedback feedback, @Nullable Hint hint) { - return Sentry.captureFeedback(feedback, hint); + return Sentry.feedback().capture(feedback, hint); } @Override public @NotNull SentryId captureFeedback( @NotNull Feedback feedback, @Nullable Hint hint, @Nullable ScopeCallback callback) { - return Sentry.captureFeedback(feedback, hint, callback); + return Sentry.feedback().capture(feedback, hint, callback); } @ApiStatus.Internal @@ -82,6 +82,7 @@ public boolean isEnabled() { return Sentry.captureException(throwable, hint, callback); } + @Deprecated @Override public void captureUserFeedback(@NotNull UserFeedback userFeedback) { Sentry.captureUserFeedback(userFeedback); @@ -392,6 +393,11 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().metrics(); } + @Override + public @NotNull IFeedbackApi feedback() { + return Sentry.getCurrentScopes().feedback(); + } + @Override public void setAttribute(final @Nullable String key, final @Nullable Object value) { Sentry.setAttribute(key, value); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index fee19dc4d09..919607e5879 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -827,40 +827,37 @@ public static void close() { } /** - * Captures the feedback. - * - * @param feedback The feedback to send. - * @return The Id (SentryId object) of the event + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#capture(Feedback) capture(feedback)} + * instead. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static @NotNull SentryId captureFeedback(final @NotNull Feedback feedback) { - return getCurrentScopes().captureFeedback(feedback); + return feedback().capture(feedback); } /** - * Captures the feedback. - * - * @param feedback The feedback to send. - * @param hint An optional hint to be applied to the event. - * @return The Id (SentryId object) of the event + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#capture(Feedback, Hint) + * capture(feedback, hint)} instead. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, final @Nullable Hint hint) { - return getCurrentScopes().captureFeedback(feedback, hint); + return feedback().capture(feedback, hint); } /** - * Captures the feedback. - * - * @param feedback The feedback to send. - * @param hint An optional hint to be applied to the event. - * @param callback The callback to configure the scope for a single invocation. - * @return The Id (SentryId object) of the event + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#capture(Feedback, Hint, ScopeCallback) + * capture(feedback, hint, callback)} instead. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, final @Nullable Hint hint, final @Nullable ScopeCallback callback) { - return getCurrentScopes().captureFeedback(feedback, hint, callback); + return feedback().capture(feedback, hint, callback); } /** @@ -916,7 +913,11 @@ public static void close() { * Captures a manually created user feedback and sends it to Sentry. * * @param userFeedback The user feedback to send to Sentry. + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#capture(Feedback) capture(feedback)} + * with the new {@link Feedback} type instead. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static void captureUserFeedback(final @NotNull UserFeedback userFeedback) { getCurrentScopes().captureUserFeedback(userFeedback); } @@ -1355,20 +1356,41 @@ public static IMetricsApi metrics() { return getCurrentScopes().metrics(); } + @NotNull + public static IFeedbackApi feedback() { + return getCurrentScopes().feedback(); + } + + /** + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#show() show()} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static void showUserFeedbackDialog() { - showUserFeedbackDialog(null); + feedback().show(); } + /** + * @deprecated Use {@link #feedback()}.{@link + * IFeedbackApi#show(SentryFeedbackOptions.OptionsConfigurator) show(configurator)} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static void showUserFeedbackDialog( final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - showUserFeedbackDialog(null, configurator); + feedback().show(configurator); } + /** + * @deprecated Use {@link #feedback()}.{@link IFeedbackApi#show(SentryId, + * SentryFeedbackOptions.OptionsConfigurator) show(associatedEventId, configurator)} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static void showUserFeedbackDialog( final @Nullable SentryId associatedEventId, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - final @NotNull SentryOptions options = getCurrentScopes().getOptions(); - options.getFeedbackOptions().getDialogHandler().showDialog(associatedEventId, configurator); + feedback().show(associatedEventId, configurator); } /** diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index b8178e35517..c99fcaeaa2f 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -688,6 +688,7 @@ private SentryEvent processFeedbackEvent( return feedbackEvent; } + @Deprecated @Override public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(userFeedback, "SentryEvent is required."); @@ -714,6 +715,7 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { } } + @Deprecated private @NotNull SentryEnvelope buildEnvelope(final @NotNull UserFeedback userFeedback) { final List envelopeItems = new ArrayList<>(); diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index dd47d2b99d0..dbbc36524db 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -169,6 +169,7 @@ public final class SentryEnvelopeItem { } } + @Deprecated public static SentryEnvelopeItem fromUserFeedback( final @NotNull ISerializer serializer, final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(serializer, "ISerializer is required."); diff --git a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java index 2a0ead54234..a72b352317e 100644 --- a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java +++ b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java @@ -91,10 +91,10 @@ public final class SentryFeedbackOptions { /** Callback called when there is an error submitting feedback via the prepared form. */ private @Nullable SentryFeedbackCallback onSubmitError; - private @NotNull IDialogHandler iDialogHandler; + private @NotNull IFormHandler iFormHandler; - public SentryFeedbackOptions(@NotNull IDialogHandler iDialogHandler) { - this.iDialogHandler = iDialogHandler; + SentryFeedbackOptions(@NotNull IFormHandler iFormHandler) { + this.iFormHandler = iFormHandler; } /** Creates a copy of the passed {@link SentryFeedbackOptions}. */ @@ -121,7 +121,7 @@ public SentryFeedbackOptions(final @NotNull SentryFeedbackOptions other) { this.onFormClose = other.onFormClose; this.onSubmitSuccess = other.onSubmitSuccess; this.onSubmitError = other.onSubmitError; - this.iDialogHandler = other.iDialogHandler; + this.iFormHandler = other.iFormHandler; } /** @@ -535,23 +535,23 @@ public void setOnSubmitError(final @Nullable SentryFeedbackCallback onSubmitErro } /** - * Sets the dialog handler to be used to show the feedback form. + * Sets the form handler to be used to show the feedback form. * - * @param iDialogHandler the dialog handler to be used to show the feedback form + * @param iFormHandler the form handler to be used to show the feedback form */ @ApiStatus.Internal - public void setDialogHandler(final @NotNull IDialogHandler iDialogHandler) { - this.iDialogHandler = iDialogHandler; + public void setFormHandler(final @NotNull IFormHandler iFormHandler) { + this.iFormHandler = iFormHandler; } /** - * Gets the dialog handler to be used to show the feedback form. + * Gets the form handler to be used to show the feedback form. * - * @return the dialog handler to be used to show the feedback form + * @return the form handler to be used to show the feedback form */ @ApiStatus.Internal - public @NotNull IDialogHandler getDialogHandler() { - return iDialogHandler; + public @NotNull IFormHandler getFormHandler() { + return iFormHandler; } @Override @@ -609,8 +609,8 @@ public interface SentryFeedbackCallback { } @ApiStatus.Internal - public interface IDialogHandler { - void showDialog( + public interface IFormHandler { + void showForm( final @Nullable SentryId associatedEventId, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 86086f8816b..a6f78cfad9c 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3397,7 +3397,7 @@ private SentryOptions(final boolean empty) { feedbackOptions = new SentryFeedbackOptions( (associatedEventId, configurator) -> - logger.log(SentryLevel.WARNING, "showDialog() can only be called in Android.")); + logger.log(SentryLevel.WARNING, "showForm() can only be called in Android.")); if (!empty) { setSpanFactory(SpanFactoryFactory.create(new LoadClass(), NoOpLogger.getInstance())); diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java index b580744ee77..b9d0ade0f9d 100644 --- a/sentry/src/main/java/io/sentry/UserFeedback.java +++ b/sentry/src/main/java/io/sentry/UserFeedback.java @@ -8,7 +8,13 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** Adds additional information about what happened to an event. */ +/** + * Adds additional information about what happened to an event. + * + * @deprecated Use {@link io.sentry.protocol.Feedback} with {@link Sentry#feedback()}.{@link + * IFeedbackApi#capture(io.sentry.protocol.Feedback) capture(feedback)} instead. + */ +@Deprecated public final class UserFeedback implements JsonUnknown, JsonSerializable { private final SentryId eventId; diff --git a/sentry/src/test/java/io/sentry/HubAdapterTest.kt b/sentry/src/test/java/io/sentry/HubAdapterTest.kt index 9b97d8935f7..0dbb6a43c11 100644 --- a/sentry/src/test/java/io/sentry/HubAdapterTest.kt +++ b/sentry/src/test/java/io/sentry/HubAdapterTest.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class HubAdapterTest { val scopes: IScopes = mock() @@ -63,6 +64,7 @@ class HubAdapterTest { val hint = Hint() val scopeCallback = mock() val feedback = Feedback("message") + whenever(scopes.feedback()).thenReturn(FeedbackApi(scopes)) HubAdapter.getInstance().captureFeedback(feedback) verify(scopes).captureFeedback(eq(feedback)) diff --git a/sentry/src/test/java/io/sentry/ScopesAdapterTest.kt b/sentry/src/test/java/io/sentry/ScopesAdapterTest.kt index 43cb5b155a7..1de22cfd3c3 100644 --- a/sentry/src/test/java/io/sentry/ScopesAdapterTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesAdapterTest.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class ScopesAdapterTest { val scopes: IScopes = mock() @@ -63,6 +64,7 @@ class ScopesAdapterTest { val scopeCallback = mock() val hint = mock() val feedback = Feedback("message") + whenever(scopes.feedback()).thenReturn(FeedbackApi(scopes)) ScopesAdapter.getInstance().captureFeedback(feedback) verify(scopes).captureFeedback(eq(feedback)) diff --git a/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt index a50aff02dc5..e4b96cb17d0 100644 --- a/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt @@ -1,6 +1,6 @@ package io.sentry -import io.sentry.SentryFeedbackOptions.IDialogHandler +import io.sentry.SentryFeedbackOptions.IFormHandler import kotlin.test.Test import kotlin.test.assertEquals import org.mockito.kotlin.mock @@ -8,7 +8,7 @@ import org.mockito.kotlin.mock class SentryFeedbackOptionsTest { @Test fun `feedback options is initialized with default values`() { - val options = SentryFeedbackOptions(mock()) + val options = SentryFeedbackOptions(mock()) assertEquals(false, options.isNameRequired) assertEquals(true, options.isShowName) assertEquals(false, options.isEmailRequired) @@ -35,7 +35,7 @@ class SentryFeedbackOptionsTest { @Test fun `feedback options copy constructor`() { val options = - SentryFeedbackOptions(mock()).apply { + SentryFeedbackOptions(mock()).apply { isNameRequired = true isShowName = false isEmailRequired = true @@ -80,6 +80,6 @@ class SentryFeedbackOptionsTest { assertEquals(options.onFormClose, optionsCopy.onFormClose) assertEquals(options.onSubmitSuccess, optionsCopy.onSubmitSuccess) assertEquals(options.onSubmitError, optionsCopy.onSubmitError) - assertEquals(options.dialogHandler, optionsCopy.dialogHandler) + assertEquals(options.formHandler, optionsCopy.formHandler) } } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index da014b30f74..e08d0ed8f72 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -907,8 +907,8 @@ class SentryOptionsTest { setLogger(logger) isDebug = true } - options.feedbackOptions.dialogHandler.showDialog(mock(), mock()) - verify(logger).log(eq(SentryLevel.WARNING), eq("showDialog() can only be called in Android.")) + options.feedbackOptions.formHandler.showForm(mock(), mock()) + verify(logger).log(eq(SentryLevel.WARNING), eq("showForm() can only be called in Android.")) } @Test diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 25f45816b74..3712b083de7 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1,6 +1,6 @@ package io.sentry -import io.sentry.SentryFeedbackOptions.IDialogHandler +import io.sentry.SentryFeedbackOptions.IFormHandler import io.sentry.SentryOptions.ProfilesSamplerCallback import io.sentry.SentryOptions.TracesSamplerCallback import io.sentry.backpressure.BackpressureMonitor @@ -1504,39 +1504,39 @@ class SentryTest { } @Test - fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler`() { - val mockDialogHandler = mock() + fun `feedback show forwards to feedbackOptions_formHandler`() { + val mockFormHandler = mock() initForTest { it.dsn = dsn - it.feedbackOptions.dialogHandler = mockDialogHandler + it.feedbackOptions.setFormHandler(mockFormHandler) } - Sentry.showUserFeedbackDialog() - verify(mockDialogHandler).showDialog(eq(null), eq(null)) + Sentry.feedback().show() + verify(mockFormHandler).showForm(eq(null), eq(null)) } @Test - fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler with configurator`() { - val mockDialogHandler = mock() + fun `feedback show forwards to feedbackOptions_formHandler with configurator`() { + val mockFormHandler = mock() val configurator = mock() initForTest { it.dsn = dsn - it.feedbackOptions.dialogHandler = mockDialogHandler + it.feedbackOptions.setFormHandler(mockFormHandler) } - Sentry.showUserFeedbackDialog(configurator) - verify(mockDialogHandler).showDialog(eq(null), eq(configurator)) + Sentry.feedback().show(configurator) + verify(mockFormHandler).showForm(eq(null), eq(configurator)) } @Test - fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler with associatedEventId and configurator`() { - val mockDialogHandler = mock() + fun `feedback show forwards to feedbackOptions_formHandler with associatedEventId and configurator`() { + val mockFormHandler = mock() val configurator = mock() val associatedEventId = SentryId() initForTest { it.dsn = dsn - it.feedbackOptions.dialogHandler = mockDialogHandler + it.feedbackOptions.setFormHandler(mockFormHandler) } - Sentry.showUserFeedbackDialog(associatedEventId, configurator) - verify(mockDialogHandler).showDialog(eq(associatedEventId), eq(configurator)) + Sentry.feedback().show(associatedEventId, configurator) + verify(mockFormHandler).showForm(eq(associatedEventId), eq(configurator)) } @Test From e63ad341cb0d79622e0dbafcf1fe9a2ec5e5f12b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 13:26:36 +0200 Subject: [PATCH 010/276] ref(feedback): Deprecate SentryUserFeedbackButton (#5350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ref(feedback): Rename Dialog to Form across feedback APIs Rename SentryUserFeedbackDialog to SentryUserFeedbackForm as the primary class. Keep SentryUserFeedbackDialog as a deprecated subclass for backward compatibility. Also rename internal APIs to use Form naming consistently: - IDialogHandler -> IFormHandler - showDialog -> showForm - setDialogHandler/getDialogHandler -> setFormHandler/getFormHandler - AndroidUserFeedbackIDialogHandler -> AndroidUserFeedbackFormHandler Add deprecated Sentry.showUserFeedbackDialog() overloads that delegate to the new Sentry.showUserFeedbackForm() methods. Co-Authored-By: Claude Opus 4.6 * fix(feedback): Preserve binary compatibility for deprecated Builder constructors Use SentryUserFeedbackDialog.OptionsConfiguration as the parameter type in the deprecated Builder constructors so old compiled code looking for the original descriptor still resolves correctly. Co-Authored-By: Claude Opus 4.6 * Make internal ctor package-private * Add missing deprecated annotaiton * Fix api * docs(changelog): Add deprecation entry for feedback Dialog to Form rename Co-Authored-By: Claude Opus 4.6 * docs(changelog): Note removal in next major version Co-Authored-By: Claude Opus 4.6 * feat(feedback): Add Sentry.feedback() API Introduce IFeedbackApi with showForm() and capture() methods, accessible via Sentry.feedback(). This consolidates all feedback operations under a single API entry point. Deprecate Sentry.showUserFeedbackForm(), Sentry.showUserFeedbackDialog(), Sentry.captureFeedback(), and Sentry.captureUserFeedback() in favor of the new Sentry.feedback() API. All deprecated methods will be removed in the next major version. Co-Authored-By: Claude Opus 4.6 * docs(changelog): Update section to Features and remove unpublished API Co-Authored-By: Claude Opus 4.6 * ref(feedback): Move FeedbackApi to IScopes Add feedback() method to IScopes, matching the pattern used by logger() and metrics(). FeedbackApi takes an IScopes reference instead of using Sentry.getCurrentScopes() statically. Implemented in Scopes, NoOpScopes, NoOpHub, HubAdapter, HubScopesWrapper, and ScopesAdapter. Sentry.feedback() now delegates to getCurrentScopes().feedback(). Co-Authored-By: Claude Opus 4.6 * ref: Rename showForm() to show() on IFeedbackApi Since the method is already namespaced under feedback(), the extra "Form" suffix is redundant. This aligns with the convention used by logger() and metrics(). Co-Authored-By: Claude Opus 4.6 * chore: Deprecate UserFeedback and captureUserFeedback, delete showUserFeedbackForm Deprecate the old `UserFeedback` class and `captureUserFeedback()` across IScopes, ISentryClient, and all implementations in favor of `Sentry.feedback().capture()` with the new `Feedback` type. Delete `Sentry.showUserFeedbackForm()` (3 overloads) as it was never published. Co-Authored-By: Claude Opus 4.6 * chore: Deprecate SentryEnvelopeItem.fromUserFeedback() Co-Authored-By: Claude Opus 4.6 * chore: Deprecate SentryClient.buildEnvelope(UserFeedback) Co-Authored-By: Claude Opus 4.6 * ref: Remove unnecessary SuppressWarnings("deprecation") Deprecated methods don't need to suppress deprecation warnings for referencing other deprecated types — the deprecation annotation itself is sufficient. Co-Authored-By: Claude Opus 4.6 * fix test * remove redudndant deprecated annotations * fix test * ref(feedback): Deprecate SentryUserFeedbackButton * Changelog * chore: Deprecate SentryUserFeedbackButton in sentry-compose Co-Authored-By: Claude Opus 4.6 * changelog * message --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 2 ++ .../io/sentry/android/core/SentryUserFeedbackButton.java | 8 ++++++++ .../kotlin/io/sentry/compose/SentryUserFeedbackButton.kt | 1 + 3 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1a581769c..beabdfb7f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - `Sentry.captureUserFeedback()` and `UserFeedback` are deprecated in favor of `Sentry.feedback().capture()` with the new `Feedback` type - `SentryUserFeedbackDialog` is deprecated in favor of `SentryUserFeedbackForm` - All deprecated APIs will be removed in the next major version +- Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) + - It will be removed in the next major version ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java index 729dfd0b4e7..f842f18674b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java @@ -10,25 +10,33 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * @deprecated `SentryUserFeedbackButton` will be removed in the next major version + */ +@Deprecated public class SentryUserFeedbackButton extends Button { private @Nullable OnClickListener delegate; + @Deprecated public SentryUserFeedbackButton(Context context) { super(context); init(context, null, 0, 0); } + @Deprecated public SentryUserFeedbackButton(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, 0, 0); } + @Deprecated public SentryUserFeedbackButton(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr, 0); } + @Deprecated public SentryUserFeedbackButton( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt index 0836c826d32..d82451b79e2 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import io.sentry.Sentry import io.sentry.SentryFeedbackOptions +@Deprecated("`SentryUserFeedbackButton` will be removed in the next major version") @Composable public fun SentryUserFeedbackButton( modifier: Modifier = Modifier, From 867648ba476b1a8bf3febf364387743e635cdf58 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Mon, 4 May 2026 08:06:33 +0200 Subject: [PATCH 011/276] fix: git-fallback for tombstone proto schema check (#5356) --- scripts/check-tombstone-proto-schema.sh | 228 ++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 17 deletions(-) diff --git a/scripts/check-tombstone-proto-schema.sh b/scripts/check-tombstone-proto-schema.sh index abb7212c4af..ecf492af7e8 100755 --- a/scripts/check-tombstone-proto-schema.sh +++ b/scripts/check-tombstone-proto-schema.sh @@ -1,25 +1,219 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail TRACKED_COMMIT="981d145117e8992842cdddee555c57e60c7a220a" +REMOTE_URL='https://android.googlesource.com/platform/system/core' +REMOTE_BRANCH='main' +PROTO_PATH='debuggerd/proto/tombstone.proto' +GITILES_REF="refs/heads/${REMOTE_BRANCH}" +GITILES_LOG_URL="${REMOTE_URL}/+log/${GITILES_REF}/${PROTO_PATH}?format=JSON" -# tail -n +2 to remove the magic anti-XSSI prefix from the Gitiles JSON response -LATEST_COMMIT=$(curl -sf \ - 'https://android.googlesource.com/platform/system/core/+log/refs/heads/main/debuggerd/proto/tombstone.proto?format=JSON' \ - | tail -n +2 \ - | jq -r '.log[0].commit') +MODE=auto +case "${1:-}" in + "") + ;; + --git-only) + MODE=git + ;; + --gitiles-only) + MODE=gitiles + ;; + *) + echo "Usage: $0 [--git-only|--gitiles-only]" >&2 + exit 2 + ;; +esac -if [ -z "$LATEST_COMMIT" ] || [ "$LATEST_COMMIT" = "null" ]; then - echo "ERROR: Failed to fetch latest commit from Gitiles" >&2 - exit 1 -fi +TEMP_FILES=() +TEMP_DIRS=() +LATEST_COMMIT="" -echo "Tracked commit: $TRACKED_COMMIT" -echo "Latest commit: $LATEST_COMMIT" +error() { + echo "ERROR: $*" >&2 +} -if [ "$LATEST_COMMIT" != "$TRACKED_COMMIT" ]; then - echo "Schema has been updated! Latest: https://android.googlesource.com/platform/system/core/+/${LATEST_COMMIT}/debuggerd/proto/tombstone.proto" - exit 1 -fi +show_output() { + local label=$1 + local file=$2 -echo "Schema is up to date." + if [ -s "$file" ]; then + echo "$label:" >&2 + sed 's/^/ /' "$file" >&2 + fi +} + +require_command() { + local command_name=$1 + + if ! command -v "$command_name" >/dev/null 2>&1; then + error "Required command not found: $command_name" + return 1 + fi +} + +make_temp_file() { + local file + file=$(mktemp) + TEMP_FILES+=("$file") + printf '%s\n' "$file" +} + +make_temp_dir() { + local dir + dir=$(mktemp -d) + TEMP_DIRS+=("$dir") + printf '%s\n' "$dir" +} + +cleanup() { + local path + + for path in "${TEMP_FILES[@]}"; do + rm -f "$path" + done + + for path in "${TEMP_DIRS[@]}"; do + rm -rf "$path" + done +} + +handle_unexpected_error() { + local exit_code=$? + error "Unexpected failure at line $1 while running: $2 (exit $exit_code)" + exit "$exit_code" +} + +trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR +trap cleanup EXIT + +run_gitiles_check() { + local response_file + local stderr_file + local status + + require_command curl || return 1 + require_command jq || return 1 + + response_file=$(make_temp_file) + stderr_file=$(make_temp_file) + + if curl -fsS "$GITILES_LOG_URL" -o "$response_file" 2>"$stderr_file"; then + : + else + status=$? + error "Failed to fetch Gitiles history from:" + error " $GITILES_LOG_URL" + error "curl exited with status $status." + show_output "curl output" "$stderr_file" + return 1 + fi + + if LATEST_COMMIT=$(tail -n +2 "$response_file" | jq -er '.log[0].commit' 2>"$stderr_file"); then + : + else + status=$? + error "Failed to parse the latest commit from the Gitiles response." + error "jq exited with status $status." + show_output "jq output" "$stderr_file" + echo "Response preview:" >&2 + head -n 20 "$response_file" >&2 + return 1 + fi + + if [ -z "$LATEST_COMMIT" ]; then + error "Gitiles response did not contain a commit hash." + echo "Response preview:" >&2 + head -n 20 "$response_file" >&2 + return 1 + fi +} + +run_git_check() { + local repo_dir + local stderr_file + local status + + require_command git || return 1 + + repo_dir=$(make_temp_dir) + stderr_file=$(make_temp_file) + + if GIT_TERMINAL_PROMPT=0 git clone \ + --quiet \ + --filter=blob:none \ + --single-branch \ + --branch "$REMOTE_BRANCH" \ + --no-checkout \ + "$REMOTE_URL" "$repo_dir" 2>"$stderr_file"; then + : + else + status=$? + error "Failed to clone $REMOTE_BRANCH from:" + error " $REMOTE_URL" + error "git clone exited with status $status." + show_output "git clone output" "$stderr_file" + return 1 + fi + + if LATEST_COMMIT=$(git -C "$repo_dir" log -n 1 --format=%H HEAD -- "$PROTO_PATH" 2>"$stderr_file"); then + : + else + status=$? + error "Failed to determine the latest commit that modified:" + error " $PROTO_PATH" + error "git log exited with status $status." + show_output "git log output" "$stderr_file" + return 1 + fi + + if [ -z "$LATEST_COMMIT" ]; then + error "Git history did not contain a commit for:" + error " $PROTO_PATH" + return 1 + fi +} + +report_result() { + echo "Tracked commit: $TRACKED_COMMIT" + echo "Latest commit: $LATEST_COMMIT" + + if [ "$LATEST_COMMIT" != "$TRACKED_COMMIT" ]; then + echo "Schema has been updated! Latest: ${REMOTE_URL}/+/${LATEST_COMMIT}/${PROTO_PATH}" + exit 1 + fi + + echo "Schema is up to date." +} + +case "$MODE" in + auto) + if run_gitiles_check; then + report_result + exit 0 + fi + + echo "Falling back to git-based check." >&2 + if run_git_check; then + report_result + exit 0 + fi + + exit 1 + ;; + gitiles) + if run_gitiles_check; then + report_result + exit 0 + fi + + exit 1 + ;; + git) + if run_git_check; then + report_result + exit 0 + fi + + exit 1 + ;; +esac From b469e467b75db6863d3cf24629c2d9b3938961b1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 May 2026 11:38:50 +0200 Subject: [PATCH 012/276] fix(feedback): Show soft input keyboard on the Feedback form (#5359) * fix(feedback): Show soft input keyboard on the Feedback form * test(feedback): Add test verifying soft keyboard is not blocked by dialog window flags Co-Authored-By: Claude Opus 4.6 (1M context) * changelog(feedback): Add entry for soft keyboard fix Co-Authored-By: Claude Opus 4.6 (1M context) * formatting --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++++ .../sentry/android/core/SentryUserFeedbackForm.java | 6 ++++++ .../android/core/SentryUserFeedbackFormTest.kt | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index beabdfb7f15..71af3a9e159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version +### Fixes + +- Fix soft input keyboard not being shown on the Feedback form ([#5359](https://github.com/getsentry/sentry-java/pull/5359)) + ### Dependencies - Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 0babe475491..722fc9110db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -4,6 +4,8 @@ import android.content.Context; import android.os.Bundle; import android.view.View; +import android.view.Window; +import android.view.WindowManager; import android.widget.Button; import android.widget.EditText; import android.widget.ImageView; @@ -55,6 +57,10 @@ public void setCancelable(boolean cancelable) { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.sentry_dialog_user_feedback); + final @Nullable Window window = getWindow(); + if (window != null) { + window.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } setCancelable(isCancelable); final @NotNull SentryFeedbackOptions feedbackOptions = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index 04f6a35716b..9df2a16d72e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.content.Context +import android.view.WindowManager import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -17,6 +18,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic import org.mockito.kotlin.eq @@ -130,4 +132,15 @@ class SentryUserFeedbackFormTest { // And the original options should not be modified assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle) } + + @Test + fun `dialog window does not have FLAG_ALT_FOCUSABLE_IM so soft keyboard can appear`() { + fixture.options.isEnabled = true + val sut = fixture.getSut() + sut.show() + val window = sut.window + assertNotNull(window) + val flags = window.attributes.flags + assertEquals(0, flags and WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + } } From 8558cacae503ef0248a7afe38f2f4e84610c3c7d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 May 2026 14:34:45 +0200 Subject: [PATCH 013/276] feat(feedback): Add per-form shake detection and sample app showcases (#5353) * feat(feedback): Add per-form shake detection and sample app showcases Resolve feedback options once in the constructor and reuse them in onCreate, avoiding duplicate resolution. Add per-form shake-to-show support via SentryShakeDetector that skips activation when the global FeedbackShakeIntegration is already enabled. Update sample app with custom form builder, auto-dismiss, programmatic capture, and shake-to-show examples. Co-Authored-By: Claude Opus 4.6 * docs(changelog): Add per-form shake detection entry Co-Authored-By: Claude Opus 4.6 * Format code * docs(changelog): Add usage example for per-form shake detection Co-Authored-By: Claude Opus 4.6 * docs(changelog): Use Kotlin example and clarify per-screen usage Co-Authored-By: Claude Opus 4.6 * ref(feedback): Extract shared shake listener in SentryUserFeedbackForm Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 7 + .../android/core/SentryUserFeedbackForm.java | 132 ++++++++++++++++-- .../src/main/AndroidManifest.xml | 1 + .../io/sentry/samples/android/MainActivity.kt | 107 +++++++++++++- 4 files changed, 232 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71af3a9e159..042e87b9713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - All deprecated APIs will be removed in the next major version - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version +- Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) + - Useful for enabling shake-to-report on specific screens instead of globally + ```kotlin + SentryUserFeedbackForm.Builder(activity) + .configurator { it.isUseShakeGesture = true } + .create() + ``` ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 722fc9110db..2800d5670a8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -1,7 +1,10 @@ package io.sentry.android.core; +import android.app.Activity; import android.app.AlertDialog; +import android.app.Application; import android.content.Context; +import android.content.ContextWrapper; import android.os.Bundle; import android.view.View; import android.view.Window; @@ -20,6 +23,7 @@ import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,8 +34,10 @@ public class SentryUserFeedbackForm extends AlertDialog { private final @Nullable SentryId associatedEventId; private @Nullable OnDismissListener delegate; - private final @Nullable OptionsConfiguration configuration; - private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + private final @NotNull SentryFeedbackOptions resolvedFeedbackOptions; + + private @Nullable SentryShakeDetector shakeDetector; + private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks; SentryUserFeedbackForm( final @NotNull Context context, @@ -41,9 +47,118 @@ public class SentryUserFeedbackForm extends AlertDialog { final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { super(context, themeResId); this.associatedEventId = associatedEventId; - this.configuration = configuration; - this.configurator = configurator; + this.resolvedFeedbackOptions = + new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + if (configuration != null) { + configuration.configure(context, resolvedFeedbackOptions); + } + if (configurator != null) { + configurator.configure(resolvedFeedbackOptions); + } SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); + maybeStartShakeDetection(context); + } + + private void maybeStartShakeDetection(final @NotNull Context context) { + final @NotNull SentryFeedbackOptions globalFeedbackOptions = + Sentry.getCurrentScopes().getOptions().getFeedbackOptions(); + if (!resolvedFeedbackOptions.isUseShakeGesture() || globalFeedbackOptions.isUseShakeGesture()) { + return; + } + final @Nullable Activity activity = getActivity(context); + if (activity == null) { + return; + } + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + shakeDetector = new SentryShakeDetector(options.getLogger()); + final @NotNull WeakReference activityRef = new WeakReference<>(activity); + shakeDetector.start(activity, shakeListener(activityRef)); + final @NotNull Application app = activity.getApplication(); + shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); + app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.close(); + shakeDetector = null; + } + if (shakeLifecycleCallbacks != null) { + final @Nullable Activity activity = getActivity(getContext()); + if (activity != null) { + activity.getApplication().unregisterActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + shakeLifecycleCallbacks = null; + } + } + + private @NotNull SentryShakeDetector.Listener shakeListener( + final @NotNull WeakReference activityRef) { + return () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }; + } + + private static @Nullable Activity getActivity(final @NotNull Context context) { + Context current = context; + while (current instanceof ContextWrapper) { + if (current instanceof Activity) { + return (Activity) current; + } + current = ((ContextWrapper) current).getBaseContext(); + } + return null; + } + + private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { + private final @NotNull WeakReference activityRef; + + ShakeLifecycleCallbacks(final @NotNull WeakReference activityRef) { + this.activityRef = activityRef; + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.start(activity, shakeListener(activityRef)); + } + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.stop(); + } + } + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) { + if (activity == activityRef.get()) { + stopShakeDetection(); + } + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(final @NotNull Activity activity) {} + + @Override + public void onActivityStopped(final @NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) {} } @Override @@ -63,14 +178,7 @@ protected void onCreate(Bundle savedInstanceState) { } setCancelable(isCancelable); - final @NotNull SentryFeedbackOptions feedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); - if (configuration != null) { - configuration.configure(getContext(), feedbackOptions); - } - if (configurator != null) { - configurator.configure(feedbackOptions); - } + final @NotNull SentryFeedbackOptions feedbackOptions = resolvedFeedbackOptions; final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 548e5e8ac0d..26f526124b4 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -271,6 +271,7 @@ + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 4c4ef05fb1a..e000b54e4cc 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -79,8 +81,9 @@ import io.sentry.MeasurementUnit import io.sentry.Sentry import io.sentry.SentryLogLevel import io.sentry.UpdateStatus +import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.compose.SentryTraced -import io.sentry.compose.SentryUserFeedbackButton +import io.sentry.protocol.Feedback import io.sentry.protocol.User import java.io.File import java.io.FileOutputStream @@ -615,8 +618,106 @@ fun UserFeedbackScreen() { } } - // SentryUserFeedbackButton as a special item - item(span = { GridItemSpan(maxLineSpan) }) { SentryUserFeedbackButton(modifier = Modifier) } + // Bring up User Feedback Form from a custom button using the global Sentry.feedback() API + item(span = { GridItemSpan(maxLineSpan) }) { + Button(modifier = Modifier, onClick = { Sentry.feedback().show() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = + painterResource( + id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24 + ), + contentDescription = null, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(text = "Report a Bug") + } + } + } + + // Create a SentryUserFeedbackForm programmatically and show it + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.formTitle = "Custom Form" + options.submitButtonLabel = "Send" + options.cancelButtonLabel = "Never mind" + options.messageLabel = "What happened?" + options.messagePlaceholder = "Describe the issue..." + options.isShowBranding = false + options.isNameRequired = true + options.isEmailRequired = true + options.setOnSubmitSuccess { feedback -> + Toast.makeText(activity, "Thanks for the feedback!", Toast.LENGTH_SHORT).show() + } + } + .create() + .show() + }, + ) { + Text(text = "Custom Form (Builder)") + } + } + + // Showcases how to manually show and dismiss a form programmatically + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val form = + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> options.formTitle = "Quick! You have 2 seconds" } + .create() + form.show() + Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000) + }, + ) { + Text(text = "Auto-dismiss Form (2s)") + } + } + + // Send feedback programmatically without showing a form + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val feedback = + Feedback("The app crashed when I tapped the button").apply { + name = "Jane Doe" + contactEmail = "jane@example.com" + url = "https://example.com/page" + } + val eventId = Sentry.feedback().capture(feedback) + Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Send Feedback (no form)") + } + } + + // Enable shake-to-show for a specific form instance + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.isUseShakeGesture = true + options.formTitle = "Shake Feedback" + } + .create() + Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Enable Shake-to-Show") + } + } } } From 5bc94fce3ac6668d6626c29dc447e5fe430f7c70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:08:33 +0200 Subject: [PATCH 014/276] chore(deps): update Gradle to v9.5.0 (#5344) Co-authored-by: GitHub --- CHANGELOG.md | 3 +++ gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 7 ++----- gradlew.bat | 3 +-- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 042e87b9713..2aee24e7775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0138) - [diff](https://github.com/getsentry/sentry-native/compare/0.13.7...0.13.8) +- Bump Gradle from v9.4.1 to v9.5.0 ([#5344](https://github.com/getsentry/sentry-java/pull/5344)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v950) + - [diff](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0) ## 8.40.0 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch delta 40557 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 Kt*`#|wEG{3k;I+= delta 35566 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

%6K4SwC7IQze=vR`C9urpeXS*~OW{ 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f7dd..1a704683a00 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.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a93670..739907dfd15 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac207e..c4bdd3ab8e3 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From c7ed38cb3bf1241be0ebf27a7df97a2b13b118e8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 5 May 2026 13:49:10 +0200 Subject: [PATCH 015/276] feat(ci): Notify linked issues on release (#5367) Add a release workflow that comments on GitHub issues closed by PRs included in a published stable release. Also document that PR authors need to use GitHub closing keywords for linked issues to receive the release notification. Co-authored-by: Claude Opus 4.6 --- .github/workflows/release-comment-issues.yml | 39 ++++++++++++++++++++ CONTRIBUTING.md | 7 ++++ 2 files changed, 46 insertions(+) create mode 100644 .github/workflows/release-comment-issues.yml diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 00000000000..0eeff26b9d8 --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,39 @@ +name: 'Automation: Notify issues for release' +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: true + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + release-comment-issues: + runs-on: ubuntu-24.04 + name: 'Notify issues' + steps: + - name: Get version + id: get_version + env: + INPUTS_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + run: echo "version=${INPUTS_VERSION:-$RELEASE_TAG_NAME}" >> "$GITHUB_OUTPUT" + + - name: Comment on linked issues that are mentioned in release + if: | + steps.get_version.outputs.version != '' + && !contains(steps.get_version.outputs.version, '-beta.') + && !contains(steps.get_version.outputs.version, '-alpha.') + && !contains(steps.get_version.outputs.version, '-rc.') + + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e2c8b78bf1..7eb38413d64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,13 @@ or However, if your change did not intend to modify the public API, consider changing the method/property visibility or removing the change altogether. +# Linking issues + +If a PR should notify a linked issue after release, use a GitHub closing keyword in the PR +description, such as `Fixes #123`, `Closes #123`, or `Resolves #123`. Release notification +automation only comments on issues GitHub recognizes as closed by the released PR; mentioning an +issue without a closing keyword is not enough. + # CI Build and tests are automatically run against branches and pull requests From d25ef951db8d52542757932b3dbe4497e60f8465 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 14:32:55 +0200 Subject: [PATCH 016/276] chore(deps): update Native SDK to v0.14.0 (#5365) Co-authored-by: GitHub --- CHANGELOG.md | 6 +++--- gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aee24e7775..213fe354ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,9 @@ ### Dependencies -- Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0138) - - [diff](https://github.com/getsentry/sentry-native/compare/0.13.7...0.13.8) +- Bump Native SDK from v0.13.7 to v0.14.0 ([#5334](https://github.com/getsentry/sentry-java/pull/5334), [#5365](https://github.com/getsentry/sentry-java/pull/5365)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0140) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.7...0.14.0) - Bump Gradle from v9.4.1 to v9.5.0 ([#5344](https://github.com/getsentry/sentry-java/pull/5344)) - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v950) - [diff](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c04ab824c86..cf7bc7b4f32 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,7 +151,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.13.8" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.14.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 e3e78e1c6cc641228dd910ee39c28ed6cf1ee710 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 5 May 2026 14:37:35 +0200 Subject: [PATCH 017/276] fix(feedback): Improve shake detection sensitivity (#5366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(feedback): Improve shake detection sensitivity Replace the threshold-counting approach (2.7g, 2 spikes in 1.5s) with a rolling sample window based on Square's Seismic library. A shake is now detected when >75% of accelerometer readings in a 0.5s window exceed 13 m/s² (~1.33g), which works reliably on budget devices with less sensitive accelerometers. Fixes GH-5331 Co-Authored-By: Claude Opus 4.6 * docs: Add license attribution for Square's Seismic library Co-Authored-By: Claude Opus 4.6 * docs: Add third-party code attribution guidelines to AGENTS.md Co-Authored-By: Claude Opus 4.6 * Format code * docs(changelog): Add shake detection fix entry Co-Authored-By: Claude Opus 4.6 * fix(feedback): Clear message field when form is re-shown via shake Co-Authored-By: Claude Opus 4.6 * fix(feedback): Synchronize SampleQueue access across threads stop() runs on the main thread while onSensorChanged() runs on the background HandlerThread. Without synchronization, concurrent access to the linked list and object pool can corrupt next-pointers and cause clear() to loop forever. Co-Authored-By: Claude Opus 4.6 * ref(feedback): Replace synchronized block with handler.post for queue clear Post queue.clear() to the HandlerThread instead of synchronizing every sensor event. All queue access now stays single-threaded with zero lock contention. quitSafely() drains pending messages before exiting. Co-Authored-By: Claude Opus 4.6 * dont use method ref * ref(feedback): Remove queue.clear() from stop(), rely on timestamp purge Sensor events are delivered via fd callbacks, not Handler messages, so posting clear() to the HandlerThread doesn't guarantee ordering with new events after re-registration. The SampleQueue already purges stale samples by timestamp in add(), so explicit clearing on stop is unnecessary. Co-Authored-By: Claude Opus 4.6 * ref(feedback): Restore handler.post(clear) in stop() Both fd callbacks and posted Messages are serialized by the Looper, so there is no concurrent access risk. Explicit clear is cleaner than relying on timestamp purge alone. Co-Authored-By: Claude Opus 4.6 * fix(feedback): Require minimum sample count before triggering shake The 75% bit-shift formula degrades at low sample counts (e.g. 33% at 3 samples). With SENSOR_DELAY_NORMAL (~5Hz) the queue may hold only 3 samples in 0.5s. Adding a MIN_QUEUE_SIZE guard ensures the threshold stays accurate and prevents false triggers from walking. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Sentry Github Bot --- AGENTS.md | 11 ++ CHANGELOG.md | 2 + THIRD_PARTY_NOTICES.md | 28 +++ .../android/core/SentryShakeDetector.java | 160 +++++++++++++----- .../android/core/SentryUserFeedbackForm.java | 6 + .../android/core/SentryShakeDetectorTest.kt | 88 ++++++++-- 6 files changed, 239 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fad3dc5a54e..42a8e651004 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,17 @@ The repository is organized into multiple modules: 4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters 5. Consider backwards compatibility +### Third-Party Code Attribution +When adapting code from third-party libraries: +1. Add a license header at the top of the adapted file (before the `package` statement): + ```java + // Adapted from . + // Copyright . + // Licensed under the . + // + ``` +2. Add a full attribution entry to `THIRD_PARTY_NOTICES.md` following the existing format (Source, License, Copyright, Scope, full license text) + ### Getting PR Information Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number. diff --git a/CHANGELOG.md b/CHANGELOG.md index 213fe354ffe..ea6befb4b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ ### Fixes - Fix soft input keyboard not being shown on the Feedback form ([#5359](https://github.com/getsentry/sentry-java/pull/5359)) +- Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) +- Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) ### Dependencies diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8b2141cc59e..5a48d567fac 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,6 +118,34 @@ limitations under the License. --- +## Square — Seismic (Apache 2.0) + +**Source:** https://github.com/square/seismic
+**License:** Apache License 2.0
+**Copyright:** Copyright 2010 Square, Inc. + +### Scope + +The Sentry Java SDK includes an adapted version of Square's Seismic shake detection algorithm. The rolling sample window approach and `SampleQueue`/`SamplePool` data structures in `io.sentry.android.core.SentryShakeDetector` are based on Seismic's `ShakeDetector`. + +``` +Copyright 2010 Square, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + ## Square — Curtains (Apache 2.0) **Source:** https://github.com/square/curtains (v1.2.5)
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 5b6f63309ff..a4c4ae0c4f5 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 @@ -1,3 +1,7 @@ +// Adapted from Square's Seismic library. +// Copyright 2010 Square, Inc. +// Licensed under the Apache License, Version 2.0. +// https://github.com/square/seismic package io.sentry.android.core; import android.content.Context; @@ -7,10 +11,8 @@ import android.hardware.SensorManager; import android.os.Handler; import android.os.HandlerThread; -import android.os.SystemClock; import io.sentry.ILogger; import io.sentry.SentryLevel; -import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -21,8 +23,8 @@ *

The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on * Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors. * - *

Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link - * #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event. + *

Uses a rolling sample window: if more than 75% of accelerometer readings in the past 0.5s + * exceed {@link #ACCELERATION_THRESHOLD}, a shake is detected. Based on Square's Seismic library. * *

Sensor events are delivered on a background {@link HandlerThread} to avoid polluting the main * thread. @@ -30,21 +32,16 @@ @ApiStatus.Internal public final class SentryShakeDetector implements SensorEventListener { - private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f; - private static final int SHAKE_WINDOW_MS = 1500; - private static final int SHAKE_COUNT_THRESHOLD = 2; - private static final int SHAKE_COOLDOWN_MS = 1000; + static final int ACCELERATION_THRESHOLD = 13; private @Nullable SensorManager sensorManager; private @Nullable Sensor accelerometer; private @Nullable HandlerThread handlerThread; private @Nullable Handler handler; - private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0); private volatile @Nullable Listener listener; private @NotNull ILogger logger; - private int shakeCount = 0; - private long firstShakeTimestamp = 0; + private final @NotNull SampleQueue queue = new SampleQueue(); public interface Listener { void onShake(); @@ -94,17 +91,24 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL public void stop() { listener = null; - shakeCount = 0; - firstShakeTimestamp = 0; if (sensorManager != null) { sensorManager.unregisterListener(this); } + final @Nullable Handler h = handler; + if (h != null) { + h.post( + () -> { + //noinspection Convert2MethodRef + queue.clear(); + }); + } } /** Stops detection and releases the background thread. */ public void close() { stop(); if (handlerThread != null) { + // quitSafely drains pending messages (including the clear posted by stop) before exiting handlerThread.quitSafely(); handlerThread = null; handler = null; @@ -116,32 +120,17 @@ public void onSensorChanged(final @NotNull SensorEvent event) { if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) { return; } - float gX = event.values[0] / SensorManager.GRAVITY_EARTH; - float gY = event.values[1] / SensorManager.GRAVITY_EARTH; - float gZ = event.values[2] / SensorManager.GRAVITY_EARTH; - double gForceSquared = gX * gX + gY * gY + gZ * gZ; - if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY) { - long now = SystemClock.elapsedRealtime(); - - // Reset counter if outside the detection window - if (now - firstShakeTimestamp > SHAKE_WINDOW_MS) { - shakeCount = 0; - firstShakeTimestamp = now; - } - - shakeCount++; - - if (shakeCount >= SHAKE_COUNT_THRESHOLD) { - // Enforce cooldown so we don't fire repeatedly - long lastShake = lastShakeTimestamp.get(); - if (now - lastShake > SHAKE_COOLDOWN_MS) { - lastShakeTimestamp.set(now); - shakeCount = 0; - final @Nullable Listener currentListener = listener; - if (currentListener != null) { - currentListener.onShake(); - } - } + final float ax = event.values[0]; + final float ay = event.values[1]; + final float az = event.values[2]; + final boolean accelerating = Math.sqrt(ax * ax + ay * ay + az * az) > ACCELERATION_THRESHOLD; + + queue.add(event.timestamp, accelerating); + if (queue.isShaking()) { + queue.clear(); + final @Nullable Listener currentListener = listener; + if (currentListener != null) { + currentListener.onShake(); } } } @@ -150,4 +139,97 @@ public void onSensorChanged(final @NotNull SensorEvent event) { public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) { // Not needed for shake detection. } + + static class SampleQueue { + private static final long MAX_WINDOW_SIZE_NS = 500_000_000L; // 0.5s + private static final long MIN_WINDOW_SIZE_NS = MAX_WINDOW_SIZE_NS >> 1; // 0.25s + private static final int MIN_QUEUE_SIZE = 4; + + private final @NotNull SamplePool pool = new SamplePool(); + private @Nullable Sample oldest; + private @Nullable Sample newest; + private int sampleCount; + private int acceleratingCount; + + void add(final long timestamp, final boolean accelerating) { + purge(timestamp - MAX_WINDOW_SIZE_NS); + + final @NotNull Sample added = pool.acquire(); + added.timestamp = timestamp; + added.accelerating = accelerating; + added.next = null; + if (newest != null) { + newest.next = added; + } + newest = added; + if (oldest == null) { + oldest = added; + } + + sampleCount++; + if (accelerating) { + acceleratingCount++; + } + } + + void clear() { + while (oldest != null) { + final @NotNull Sample removed = oldest; + oldest = removed.next; + pool.release(removed); + } + newest = null; + sampleCount = 0; + acceleratingCount = 0; + } + + private void purge(final long cutoff) { + while (sampleCount >= MIN_QUEUE_SIZE && oldest != null && cutoff - oldest.timestamp > 0) { + final @NotNull Sample removed = oldest; + if (removed.accelerating) { + acceleratingCount--; + } + sampleCount--; + oldest = removed.next; + if (oldest == null) { + newest = null; + } + pool.release(removed); + } + } + + boolean isShaking() { + return newest != null + && oldest != null + && sampleCount >= MIN_QUEUE_SIZE + && newest.timestamp - oldest.timestamp >= MIN_WINDOW_SIZE_NS + && acceleratingCount >= (sampleCount >> 1) + (sampleCount >> 2); + } + } + + static class Sample { + long timestamp; + boolean accelerating; + @Nullable Sample next; + } + + static class SamplePool { + private @Nullable Sample head; + + @NotNull + Sample acquire() { + Sample acquired = head; + if (acquired == null) { + acquired = new Sample(); + } else { + head = acquired.next; + } + return acquired; + } + + void release(final @NotNull Sample sample) { + sample.next = head; + head = sample; + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 2800d5670a8..43500d50ebc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -324,6 +324,12 @@ public void setOnDismissListener(final @Nullable OnDismissListener listener) { @Override protected void onStart() { super.onStart(); + // Clear the message field so subsequent show() calls start with a fresh form + final @NotNull EditText edtMessage = + findViewById(R.id.sentry_dialog_user_feedback_edt_description); + edtMessage.getText().clear(); + edtMessage.setError(null); + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt index 98441e48a8d..24ccfceaa86 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt @@ -5,10 +5,11 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorManager import android.os.Handler -import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -88,29 +89,27 @@ class SentryShakeDetectorTest { } @Test - fun `triggers listener when shake is detected`() { - // Advance clock so cooldown check (now - 0 > 1000) passes - SystemClock.setCurrentTimeMillis(2000) - + fun `triggers listener when sustained shake is detected`() { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - // Needs at least SHAKE_COUNT_THRESHOLD (2) readings above threshold - val event1 = createSensorEvent(floatArrayOf(30f, 0f, 0f)) - sut.onSensorChanged(event1) - val event2 = createSensorEvent(floatArrayOf(30f, 0f, 0f)) - sut.onSensorChanged(event2) + // Send enough accelerating samples over 0.25s+ to trigger (>75% accelerating) + val baseTimestamp = 1_000_000_000L // 1s in nanos + val intervalNs = 20_000_000L // 20ms between samples (~50Hz) + for (i in 0 until 20) { + val event = createSensorEvent(floatArrayOf(20f, 0f, 0f), baseTimestamp + i * intervalNs) + sut.onSensorChanged(event) + } verify(fixture.listener).onShake() } @Test - fun `does not trigger listener on single shake`() { + fun `does not trigger listener on single spike`() { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - // A single threshold crossing should not trigger - val event = createSensorEvent(floatArrayOf(30f, 0f, 0f)) + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), 1_000_000_000L) sut.onSensorChanged(event) verify(fixture.listener, never()).onShake() @@ -121,9 +120,16 @@ class SentryShakeDetectorTest { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - // Gravity only (1G) - no shake - val event = createSensorEvent(floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)) - sut.onSensorChanged(event) + val baseTimestamp = 1_000_000_000L + val intervalNs = 20_000_000L + for (i in 0 until 20) { + val event = + createSensorEvent( + floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH), + baseTimestamp + i * intervalNs, + ) + sut.onSensorChanged(event) + } verify(fixture.listener, never()).onShake() } @@ -133,7 +139,7 @@ class SentryShakeDetectorTest { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), sensorType = Sensor.TYPE_GYROSCOPE) + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), 1_000_000_000L, Sensor.TYPE_GYROSCOPE) sut.onSensorChanged(event) verify(fixture.listener, never()).onShake() @@ -145,8 +151,53 @@ class SentryShakeDetectorTest { sut.stop() } + @Test + fun `sample queue triggers when 75 percent of samples are accelerating`() { + val queue = SentryShakeDetector.SampleQueue() + val intervalNs = 20_000_000L + + // 15 accelerating + 5 not = 75% in a 0.4s window (> 0.25s minimum) + for (i in 0 until 15) { + queue.add(i * intervalNs, true) + } + for (i in 15 until 20) { + queue.add(i * intervalNs, false) + } + + assertTrue(queue.isShaking()) + } + + @Test + fun `sample queue does not trigger below 75 percent`() { + val queue = SentryShakeDetector.SampleQueue() + val intervalNs = 20_000_000L + + // 10 accelerating + 10 not = 50% + for (i in 0 until 10) { + queue.add(i * intervalNs, true) + } + for (i in 10 until 20) { + queue.add(i * intervalNs, false) + } + + assertFalse(queue.isShaking()) + } + + @Test + fun `sample queue does not trigger below minimum window`() { + val queue = SentryShakeDetector.SampleQueue() + + // All accelerating but only 0.06s apart (below 0.25s minimum) + for (i in 0 until 4) { + queue.add(i * 20_000_000L, true) + } + + assertFalse(queue.isShaking()) + } + private fun createSensorEvent( values: FloatArray, + timestamp: Long = 0L, sensorType: Int = Sensor.TYPE_ACCELEROMETER, ): SensorEvent { val sensor = mock() @@ -160,6 +211,9 @@ class SentryShakeDetectorTest { val sensorField = SensorEvent::class.java.getField("sensor") sensorField.set(event, sensor) + val timestampField = SensorEvent::class.java.getField("timestamp") + timestampField.set(event, timestamp) + return event } } From 83a416d1cfa89e79f71fd8eb9fe05d8e5058f665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Wed, 6 May 2026 10:50:23 +0200 Subject: [PATCH 018/276] fix: Avoid stack overflow when deserializing large flat JSON objects (#5361) * fix: Avoid stack overflow when deserializing large flat JSON objects Replace JsonObjectDeserializer's recursive token parsing with an iterative loop. The parser already tracks state explicitly, so recursion was only used to advance to the next JSON token and could overflow on large flat maps. Relates to https://github.com/getsentry/sentry-dart/issues/3668 * use larger module count vm might not respect stack size, so increase the frame count (with smaller payload) to make test more robust * add cl entries * fix cl entry * Update CHANGELOG.md --------- Co-authored-by: Giancarlo Buenaflor --- CHANGELOG.md | 4 + .../io/sentry/JsonObjectDeserializer.java | 85 +++++++++---------- .../test/java/io/sentry/SentryEventTest.kt | 44 ++++++++++ 3 files changed, 90 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6befb4b35..a5c606a2bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v950) - [diff](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0) +### Fixes + +- Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) + ## 8.40.0 ### Fixes diff --git a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java index e7753d44ea7..0916f6e82d5 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectDeserializer.java @@ -82,49 +82,48 @@ private static final class TokenMap implements Token { private void parse(@NotNull JsonObjectReader reader) throws IOException { boolean done = false; - switch (reader.peek()) { - case BEGIN_ARRAY: - reader.beginArray(); - pushCurrentToken(new TokenArray()); - break; - case END_ARRAY: - reader.endArray(); - done = handleArrayOrMapEnd(); - break; - case BEGIN_OBJECT: - reader.beginObject(); - pushCurrentToken(new TokenMap()); - break; - case END_OBJECT: - reader.endObject(); - done = handleArrayOrMapEnd(); - break; - case NAME: - pushCurrentToken(new TokenName(reader.nextName())); - break; - case STRING: - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - done = handlePrimitive(() -> reader.nextString()); - break; - case NUMBER: - done = handlePrimitive(() -> nextNumber(reader)); - break; - case BOOLEAN: - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - done = handlePrimitive(() -> reader.nextBoolean()); - break; - case NULL: - reader.nextNull(); - done = handlePrimitive(() -> null); - break; - case END_DOCUMENT: - done = true; - break; - } - if (!done) { - parse(reader); + while (!done) { + switch (reader.peek()) { + case BEGIN_ARRAY: + reader.beginArray(); + pushCurrentToken(new TokenArray()); + break; + case END_ARRAY: + reader.endArray(); + done = handleArrayOrMapEnd(); + break; + case BEGIN_OBJECT: + reader.beginObject(); + pushCurrentToken(new TokenMap()); + break; + case END_OBJECT: + reader.endObject(); + done = handleArrayOrMapEnd(); + break; + case NAME: + pushCurrentToken(new TokenName(reader.nextName())); + break; + case STRING: + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + done = handlePrimitive(() -> reader.nextString()); + break; + case NUMBER: + done = handlePrimitive(() -> nextNumber(reader)); + break; + case BOOLEAN: + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + done = handlePrimitive(() -> reader.nextBoolean()); + break; + case NULL: + reader.nextNull(); + done = handlePrimitive(() -> null); + break; + case END_DOCUMENT: + done = true; + break; + } } } diff --git a/sentry/src/test/java/io/sentry/SentryEventTest.kt b/sentry/src/test/java/io/sentry/SentryEventTest.kt index 36782c153e1..70514a6b72d 100644 --- a/sentry/src/test/java/io/sentry/SentryEventTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEventTest.kt @@ -3,9 +3,11 @@ package io.sentry import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.SentryId +import java.io.StringReader import java.time.Instant import java.time.temporal.ChronoUnit import java.util.Collections +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -174,6 +176,48 @@ class SentryEventTest { } } + @Test + fun `deserializes event with large flat modules map on a small stack`() { + val moduleCount = 50000 + val json = buildString { + append("{\"event_id\":\"00000000000000000000000000000000\",\"modules\":{") + repeat(moduleCount) { + if (it > 0) { + append(',') + } + append("\"m") + append(it) + append("\":\"v\"") + } + append("}}") + } + + val error = AtomicReference() + val event = AtomicReference() + val thread = + Thread( + null, + Runnable { + try { + event.set( + JsonSerializer(SentryOptions()) + .deserialize(StringReader(json), SentryEvent::class.java) + ) + } catch (throwable: Throwable) { + error.set(throwable) + } + }, + "large-flat-modules-repro", + 1024L * 1024L, + ) + + thread.start() + thread.join() + + assertNull(error.get()) + assertEquals(moduleCount, event.get()?.modules?.size) + } + @Test fun `null tag does not cause NPE`() { val event = SentryEvent() From 0188f486bee02039e435beaa70a120f93bcf390c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 6 May 2026 13:18:01 +0200 Subject: [PATCH 019/276] chore: Fix entry in `CHANGELOG` Refactored duplicate `Fixes` entry --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c606a2bce..9b945938d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Fix soft input keyboard not being shown on the Feedback form ([#5359](https://github.com/getsentry/sentry-java/pull/5359)) - Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) - Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) +- Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) ### Dependencies @@ -35,10 +36,6 @@ - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v950) - [diff](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0) -### Fixes - -- Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) - ## 8.40.0 ### Fixes From 11ad3372fed13e4508810060fd3997fb786ac176 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 6 May 2026 15:39:45 +0200 Subject: [PATCH 020/276] feat(core): Queue Instrumentation for Kafka (#5249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * collection: Queue Instrumentation * feat(core): Add enableQueueTracing option and messaging span data conventions Add enableQueueTracing boolean to SentryOptions (default false) and ExternalOptions (nullable Boolean) with merge support. Add messaging.* keys to SpanDataConvention for queue instrumentation span data. Co-Authored-By: Claude * changelog * feat(samples): Add Kafka producer and consumer to Spring Boot 3 sample app Add spring-kafka dependency and a simple Kafka producer/consumer setup behind a 'kafka' Spring profile. Includes a REST endpoint to produce messages and a KafkaListener that consumes them. Kafka auto-configuration is excluded by default and only activated when the 'kafka' profile is enabled. Co-Authored-By: Claude * feat(spring-jakarta): Add Kafka producer instrumentation Add SentryKafkaProducerWrapper that overrides doSend to create queue.publish spans for all KafkaTemplate send operations. Injects sentry-trace, baggage, and sentry-task-enqueued-time headers for distributed tracing and receive latency calculation. Add SentryKafkaProducerBeanPostProcessor to automatically wrap KafkaTemplate beans. Co-Authored-By: Claude * changelog * feat(spring-jakarta): Add Kafka consumer instrumentation Add SentryKafkaRecordInterceptor that creates queue.process transactions for incoming Kafka records. Forks scopes per record, extracts sentry-trace and baggage headers for distributed tracing via continueTrace, and calculates messaging.message.receive.latency from the enqueued-time header. Composes with existing RecordInterceptor via delegation. Span lifecycle is managed through success/failure callbacks. Add SentryKafkaConsumerBeanPostProcessor to register the interceptor on ConcurrentKafkaListenerContainerFactory beans. Co-Authored-By: Claude * changelog * feat(spring-boot-jakarta): Add Kafka queue auto-configuration Register SentryKafkaProducerBeanPostProcessor and SentryKafkaConsumerBeanPostProcessor when spring-kafka is on the classpath and sentry.enable-queue-tracing=true. Follows the same pattern as SentryCacheConfiguration. Co-Authored-By: Claude * changelog * test(samples): Add Kafka queue system tests for Spring Boot 3 Add KafkaQueueSystemTest with e2e tests for: - Producer endpoint creates queue.publish span - Consumer creates queue.process transaction - Distributed tracing (producer and consumer share same trace) - Messaging attributes on publish span and process transaction Also add produceKafkaMessage to RestTestClient and enable sentry.enable-queue-tracing in the kafka profile properties. Requires a running Kafka broker at localhost:9092 and the sample app started with --spring.profiles.active=kafka. Co-Authored-By: Claude * docs: Add rule against force-pushing stack branches Force-pushing a stack branch can cause GitHub to auto-merge or auto-close other PRs in the stack. Add explicit guidance to never use --force, --force-with-lease, or amend+push on stack branches. * docs: Also prohibit --amend on stack branches * feat(samples): Add Kafka producer and consumer to Spring Boot 3 OTel sample apps Add Kafka queue tracing support to both the OTel agent and agentless Spring Boot 3 sample applications. Each sample gets a KafkaController for producing messages and a KafkaConsumer listener, activated via the 'kafka' Spring profile. Kafka auto-configuration is excluded by default and only enabled when the kafka profile is active. * fix(spring-boot-jakarta): Disable Sentry Kafka instrumentation when OTel is active Skip registration of SentryKafkaProducerBeanPostProcessor and SentryKafkaConsumerBeanPostProcessor when a Sentry OpenTelemetry integration (agent or agentless) is on the classpath. OpenTelemetry provides its own Kafka instrumentation, so Sentry's would create duplicate spans. * fix(core): Add Kafka span origins to ignored list for OpenTelemetry Add auto.queue.spring_jakarta.kafka.producer and auto.queue.spring_jakarta.kafka.consumer to the ignored span origins when running with OTel agent or agentless-spring. Prevents duplicate spans when both Sentry and OTel Kafka instrumentation are active. * ref(spring-jakarta): Replace SentryKafkaProducerWrapper with SentryProducerInterceptor Replace the KafkaTemplate subclass approach with a Kafka-native ProducerInterceptor. The BeanPostProcessor now sets the interceptor on the existing KafkaTemplate instead of replacing the bean, which preserves any custom configuration on the template. Existing customer interceptors are composed using Spring's CompositeProducerInterceptor. If reflection fails to read the existing interceptor, a warning is logged. Co-Authored-By: Claude * fix(spring-jakarta): Update consumer references and add reflection warning log Update SentryKafkaRecordInterceptor and its test to reference SentryProducerInterceptor instead of the removed SentryKafkaProducerWrapper. Add a warning log in SentryKafkaConsumerBeanPostProcessor when reflection fails to read the existing RecordInterceptor, so users know their custom interceptor may not be chained. Co-Authored-By: Claude * fix(spring-jakarta): Initialize Sentry in SentryProducerInterceptorTest TransactionContext constructor requires ScopesAdapter.getOptions() to be non-null for thread checker access. Add initForTest/close to ensure Sentry is properly initialized during tests. Co-Authored-By: Claude * fix(spring-jakarta): Initialize Sentry in consumer test, fix API file ordering Add initForTest/close to SentryKafkaRecordInterceptorTest to fix NPE from TransactionContext constructor requiring initialized Sentry. Regenerate API file to fix alphabetical ordering of SentryProducerInterceptor entry. Co-Authored-By: Claude * fix(spring-jakarta): Clean up stale ThreadLocal context in Kafka consumer interceptor Implement clearThreadState() and defensive cleanup in intercept() to prevent ThreadLocal leaks of SentryRecordContext. Spring Kafka calls clearThreadState() in the poll loop's finally block, making it the most reliable cleanup hook for edge cases where success()/failure() callbacks are skipped (e.g. Error thrown by listener). Also add defensive cleanup at the start of intercept() to handle any stale context from a previous record that was not properly cleaned up. Co-Authored-By: Claude * fix(spring-jakarta): Fork root scopes and skip when OTel is active in Kafka consumer interceptor Use Sentry.forkedRootScopes() instead of scopes.forkedScopes() so each Kafka message starts with a clean scope from root, matching the pattern used by SentryWebFilter for reactive request boundaries. Add isIgnored() check using SpanUtils.isIgnored() on the trace origin so the interceptor no-ops when OTel is active and the origin is in the ignored span origins list, consistent with SentryTracingFilter. Co-Authored-By: Claude * fix(spring-jakarta): Guard entire span lifecycle in Kafka producer interceptor Wrap all span operations (startChild, setData, injectHeaders, finish) in a single try-catch so instrumentation can never break the customer's Kafka send. The record is always returned regardless of any exception in Sentry code. Co-Authored-By: Claude * fix(spring-jakarta): [Queue Instrumentation 12] Add Kafka retry count attribute Set messaging.message.retry.count on queue.process transactions when the Spring Kafka delivery attempt header is present. This keeps retry context on consumer traces without changing transaction lifecycle behavior. Co-Authored-By: Claude * fix(spring-jakarta): [Queue Instrumentation 13] Align enqueue time with Python Store sentry-task-enqueued-time as epoch seconds and compute receive latency from seconds on the consumer side. This aligns Java Kafka queue instrumentation with sentry-python Celery behavior for cross-SDK interoperability. Co-Authored-By: Claude * ref(kafka): Extract sentry-kafka module from spring-jakarta Move Kafka producer interceptor to a new sentry-kafka module and rename to SentryKafkaProducerInterceptor. Add SentryKafkaConsumerInterceptor for vanilla kafka-clients users. Spring integration now depends on sentry-kafka and passes a Spring-specific trace origin. This allows non-Spring applications to use Kafka queue instrumentation directly via kafka-clients interceptor config. Co-Authored-By: Claude * changelog * feat(kafka): Add no-arg producer interceptor for Kafka config Allow kafka-clients to instantiate SentryKafkaProducerInterceptor via interceptor.classes by adding a no-arg constructor that uses ScopesAdapter. This makes native Kafka interceptor wiring work out of the box in applications and samples.\n\nAlso add a Kafka tracing example to the console sample with a transaction-scoped producer send, and cover no-arg constructor behavior in sentry-kafka tests. Co-Authored-By: Claude * feat(kafka): Add consumer demo to console sample Show end-to-end Kafka queue tracing in the console sample by running a background consumer thread, producing a message, and waiting for consume before exit.\n\nAdd a no-arg constructor to SentryKafkaConsumerInterceptor so kafka-clients can instantiate it from interceptor.classes, and add test coverage for that constructor. Co-Authored-By: Claude * ref(samples): Extract Kafka console showcase into dedicated class Move Kafka producer/consumer showcase logic out of Main into KafkaShowcase to make the sample easier to read and follow. Keep runtime behavior unchanged by preserving the same demo entry point and flow. Co-Authored-By: Claude * feat(samples): Add opt-in Kafka console e2e coverage Gate the console Kafka showcase behind SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS so Kafka behavior is enabled only when configured. Keep the showcase isolated in KafkaShowcase and use fail-fast Kafka client timeouts for local runs.\n\nExtend console system tests to assert producer and consumer queue tracing when Kafka is enabled. Update system-test-runner to provision or reuse a local Kafka broker for the console module and clean up runner-managed resources. Co-Authored-By: Claude * ref(samples): Move KafkaShowcase to kafka subpackage Move KafkaShowcase under io.sentry.samples.console.kafka and update Main to import the relocated class. This keeps Kafka-specific sample code grouped in a dedicated package without changing runtime behavior. Co-Authored-By: Claude * Update KafkaShowcase.java extract constant * Update KafkaShowcase.java extract methods * Update KafkaShowcase.java refactor * Format code * fix * ref(samples): Clarify Kafka setup in console showcase Restructure KafkaShowcase to highlight the required Sentry interceptor configuration for producer and consumer setups. Split property construction into explicit helper methods and rename the entrypoint to make customer integration requirements easier to follow without changing behavior. Co-Authored-By: Claude * fix(test): Enable Kafka profile for Spring Kafka system tests Make the system test runner configure Kafka requirements by module. Start Kafka and set SPRING_PROFILES_ACTIVE=kafka for modules that need Kafka-backed Spring endpoints so queue system tests run with the expected routing and broker configuration. Co-Authored-By: Claude * fix(spring): Guard Kafka auto-config on sentry-kafka Require the sentry-kafka producer interceptor class before activating Spring Boot Jakarta queue auto-configuration. This keeps sentry-kafka optional for customers who only use the starter without Kafka queue tracing support on the classpath. Add a regression test that hides sentry-kafka from the classloader and verifies the Kafka bean post-processors are skipped instead of being registered. Co-Authored-By: Claude * feat(kafka): [Queue Instrumentation 17] Add manual consumer tracing helper Add an experimental helper for wrapping raw Kafka consumer record processing in queue.process transactions. This exposes Kafka consumer tracing outside interceptor-based integrations. Capture messaging metadata and distributed tracing context in the helper so future queue instrumentation can reuse the same behavior. Co-Authored-By: Claude * ref(kafka): Remove raw consumer interceptor Remove the raw Kafka consumer interceptor from sentry-kafka and update the console sample to use the manual consumer tracing helper instead. Keep producer tracing on the interceptor path and move consumer tracing to explicit record processing. Co-Authored-By: Claude * ref(samples): Clarify Kafka consumer tracing sample Print the consumed Kafka record inside the manual consumer tracing callback so the sample shows where application processing happens. Update the console system test to assert the manual queue.process transaction and its manual consumer origin. Co-Authored-By: Claude * fix(kafka): Honor ignored producer span origins Short-circuit the raw Kafka producer interceptor when its trace origin is configured in ignoredSpanOrigins. This lets customers disable the integration quickly without relying on the later no-op span path, and keeps the interceptor from injecting tracing headers when the origin is ignored. Co-Authored-By: Claude * ref(spring): Use injected scopes in Kafka interceptor Stop the Spring Kafka record interceptor from reaching through the static Sentry API when forking root scopes. This keeps the raw Kafka and Spring Kafka paths aligned and makes the interceptor easier to test. Co-Authored-By: Claude * ref(samples): [Queue Instrumentation 18] Move Kafka sources into queues.kafka package Move KafkaConsumer and KafkaController in the three Spring Boot Jakarta samples (jakarta, jakarta-opentelemetry, jakarta-opentelemetry-noagent) into a queues.kafka sub-package. No behavior change. Groups the Kafka-specific sample sources so future queue integrations can sit next to them under queues. Co-Authored-By: Claude * ref(samples): [Queue Instrumentation 19] Drop Kafka auto-config exclude from Spring Boot samples Remove `spring.autoconfigure.exclude=KafkaAutoConfiguration` from the default `application.properties` and the matching empty override from `application-kafka.properties` in the three Spring Boot Jakarta samples. `spring.autoconfigure.exclude` is a single list property, so overriding it in a profile replaces the whole list rather than merging. Adding a sibling `rabbitmq` profile with the same pattern would not compose — activating one profile would unsilence the other's auto-config. The `@Profile("kafka")` annotations already on `KafkaConsumer` and `KafkaController` gate the actual listener container and endpoint, so no broker connection is attempted when the profile is inactive. `KafkaAutoConfiguration` still runs and creates an unused `KafkaTemplate` bean in that case, which is harmless. Sentry's own Kafka auto-config remains gated on `sentry.enable-queue-tracing=true`, which is only set in `application-kafka.properties`, so Sentry instrumentation behavior is unchanged. * ref(kafka): [Queue Instrumentation 20] Log Kafka instrumentation failures Previously `SentryKafkaProducerInterceptor.onSend(...)` and `SentryKafkaConsumerTracing` silently swallowed any `Throwable` thrown while instrumenting a Kafka record. That protects customer Kafka I/O from breakage, but makes instrumentation bugs invisible. Log each caught `Throwable` to the SDK logger at `SentryLevel.ERROR` (matching the existing pattern in `RequestPayloadExtractor`) before continuing the fail-open path: - `SentryKafkaProducerInterceptor`: producer span creation / header injection - `SentryKafkaConsumerTracing`: scope fork + `makeCurrent`, transaction start, transaction finish No behavior change for customer callbacks or Kafka send/receive: the catches still swallow the throwable, they now just surface it via the SDK's own logger. `SentryKafkaRecordInterceptor` (Spring) was reviewed and intentionally left as-is — it does not wrap its instrumentation in `catch (Throwable)` blocks, so there is nothing silent to log. The `NumberFormatException` branches on malformed `sentry-task-enqueued-time` headers are expected input, not instrumentation faults, and remain silent. * fix(kafka): [Queue Instrumentation 21] Preserve third-party baggage on Kafka producer records `SentryKafkaProducerInterceptor.injectHeaders(...)` previously removed and overwrote the outgoing `baggage` header on every record, discarding any third-party baggage entries already present (e.g. set by another vendor's instrumentation or the application itself). Read the existing `baggage` header values off the `ProducerRecord` and pass them to `TracingUtils.trace(...)`. The downstream `BaggageHeader.fromBaggageAndOutgoingHeader` preserves non-`sentry-*` entries in the outgoing header while Sentry continues to own its own keys. Co-Authored-By: Claude * test(spring-boot-jakarta): [Queue Instrumentation 22] Cover spring-kafka class-absence gate `SentryKafkaQueueConfiguration` in `SentryAutoConfiguration` gates the Kafka BPPs on both `org.springframework.kafka.core.KafkaTemplate` and `io.sentry.kafka.SentryKafkaProducerInterceptor` being present on the classpath. Only the latter was covered by a test. Add a `FilteredClassLoader(KafkaTemplate::class.java)` test that asserts neither `SentryKafkaProducerBeanPostProcessor` nor `SentryKafkaConsumerBeanPostProcessor` is registered when spring-kafka is missing, even with `sentry.enable-queue-tracing=true`. Co-Authored-By: Claude * fix(spring-jakarta): [Queue Instrumentation 23] Install Kafka context before trace setup Store the lifecycle token in the thread-local context immediately after makeCurrent() so Spring's failure and clearThreadState callbacks can always clean it up. Previously, exceptions from trace continuation or transaction setup could happen before the context was published, leaving cleanup dependent on later stale-context handling instead of the normal interceptor callback path. * fix(kafka): [Queue Instrumentation 24] Read all baggage headers on consumers Pass every Kafka baggage header through trace continuation in both the raw Kafka helper and the Spring Kafka record interceptor. Previously both consumer paths used lastHeader("baggage"), which dropped all earlier baggage values and could break interop with upstream OTel or other W3C baggage producers. Reading the full header list preserves the existing baggage context during queue trace continuation. * fix(kafka): [Queue Instrumentation 25] Finish producer spans on failures Keep a local producer child span reference and always finish it when instrumentation fails after span creation. This preserves fail-open send behavior without leaking unfinished queue.publish spans. Add a regression test covering header injection failures. Co-Authored-By: Claude * fix(kafka): [Queue Instrumentation 26] Mark producer interceptor experimental The raw kafka producer path requires customers to reference SentryKafkaProducerInterceptor directly by class name, so it should not be marked internal. Align it with the customer-facing queue tracing surface by marking it experimental instead. Audit the remaining Kafka classes still marked internal and keep them as-is: the Spring bean post processors and Spring record interceptor remain framework wiring internals rather than direct customer entry points. Co-Authored-By: Claude * fix(spring-jakarta): [Queue Instrumentation 27] Delegate Kafka record thread-state hooks SentryKafkaRecordInterceptor wraps an existing customer RecordInterceptor when one is present on the listener container factory, but it previously only delegated intercept, success, failure, and afterRecord. setupThreadState was not overridden, so the default no-op from ThreadStateProcessor shadowed any delegate implementation. clearThreadState performed Sentry cleanup but never forwarded to the delegate either. Customers relying on these hooks for MDC, security context, or other thread-local state on Kafka listener threads would silently lose that behavior once Sentry auto-wrapped their interceptor. Delegate setupThreadState to the wrapped interceptor, and in clearThreadState run Sentry cleanup inside try and delegate to the wrapped interceptor in finally so delegate cleanup still executes if Sentry cleanup throws. Co-Authored-By: Claude * test(samples): Cover OTel Jakarta Kafka coexistence end-to-end Enable the Kafka Spring profile (and Kafka broker) for the two OTel Spring Boot 3 Jakarta sample modules in the system-test runner, and add a Kafka system test in each that produces a message and asserts no Sentry-style `queue.publish` / `queue.process` span/transaction is emitted. SentryKafkaQueueConfiguration is guarded by @ConditionalOnMissingClass("io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider"), so the Sentry Kafka bean post-processors must not be wired when the Sentry OTel integration is present. The new assertions lock that suppression into CI for both the agent and noagent OTel Jakarta samples. Addresses review finding F-011. * fix(spring-jakarta): [Queue Instrumentation 29] Set body_size on Spring Kafka consumer transaction The Spring Kafka consumer path (`SentryKafkaRecordInterceptor`) never set `messaging.message.body_size`, while the raw Kafka consumer helper (`SentryKafkaConsumerTracing`) already sets it from `ConsumerRecord#serializedValueSize()`. Both are first-party Kafka consumer integrations shipped in the same stack and should emit the same messaging schema so dashboards and queries remain consistent across Spring vs. raw Kafka setups. Mirror the raw helper: set `SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE` on the `queue.process` transaction when `serializedValueSize() >= 0`. Add regression tests for both the positive and the -1 (unknown) cases. #skip-changelog * test(spring-jakarta): [Queue Instrumentation 30] Cover Kafka record interceptor lifecycle edge cases Add three regression tests for SentryKafkaRecordInterceptor that pin down the lifecycle contract around clearThreadState cleanup: - full lifecycle intercept -> success -> clearThreadState closes the lifecycle token exactly once and does not double-finish the transaction - when a delegating interceptor returns null from intercept (filtering the record), the safety net in clearThreadState still finishes the transaction and closes the token - when a delegating interceptor throws from intercept, clearThreadState still finishes the transaction and closes the token after the exception has propagated Addresses review finding R6-F001. Co-Authored-By: Claude * fix(kafka): [Queue Instrumentation 31] Write enqueued-time header as plain decimal The sentry-task-enqueued-time Kafka header was serialized via String.valueOf(double), which emits scientific notation (e.g. 1.776933649613E9) for epoch-seconds values. Cross-SDK consumers (sentry-python, -ruby, -php, -dotnet) expect a plain decimal like 1776938295.692000 and could not parse the Java output, defeating the cross-SDK alignment goal of #5283. Route the value through DateUtils.doubleToBigDecimal(...).toString(), the same helper already used to serialize epoch-seconds timestamps in SentryTransaction, SentrySpan, SentryLogEvent, etc. At the pinned scale of 6, BigDecimal.toString() produces plain decimal form for all realistic epoch-seconds magnitudes. Add regression assertions that reject scientific notation and pin the plain-decimal format in SentryKafkaProducerInterceptorTest. Co-Authored-By: Claude * changelog * test(spring-boot-jakarta): [Queue Instrumentation 32] Filter OTel in Kafka auto-config negative tests The regression tests "does not register Kafka BPPs when sentry-kafka is not present" and "...when spring-kafka is not present" previously passed for the wrong reason: OTel's SentryAutoConfigurationCustomizerProvider is on the test classpath as a testImplementation dependency, so the @ConditionalOnMissingClass(OTel) gate on SentryKafkaQueueConfiguration was already blocking the beans independent of the @ConditionalOnClass check the tests were meant to validate. Make noSentryKafkaClassLoader and noSpringKafkaClassLoader additionally filter SentryAutoConfigurationCustomizerProvider so only the gate under test can be the blocker. Verified by temporarily removing SentryKafkaProducerInterceptor from the @ConditionalOnClass list: the test now correctly fails, proving it actually guards against the regression it is named for. Co-Authored-By: Claude * feat(opentelemetry): [Queue Instrumentation 33] Map OTel messaging spans to Sentry queue ops Wire OTel messaging spans into the Sentry Queues product when `sentry.enable-queue-tracing=true` so OTel-only setups (e.g. the agentless Spring Boot Jakarta sample) populate queue dashboards without needing the Sentry-native Kafka interceptors. `SpanDescriptionExtractor` now recognizes spans carrying `messaging.system` and maps them to `queue.publish` / `queue.process` / `queue.receive` ops, using the destination name as the description and `TransactionNameSource.TASK`. Op selection prefers `messaging.operation.type` (current OTel semconv), falls back to the deprecated `messaging.operation`, and only as a last resort consults `SpanKind` — `SpanKind.CONSUMER` is overloaded for both `receive` and `process`, so attribute-driven mapping is required to disambiguate. The extractor takes `SentryOptions` so the mapping stays gated; when the flag is off, behavior is unchanged. `SentrySpanExporter` additionally transfers the messaging attributes (`system`, `destination.name`, `operation.type`, `message.id`, `message.body.size`, `message.envelope.size`) onto root transactions. Root transactions don't bulk-copy OTel attributes the way child spans do, but the Queues product reads `trace.data.messaging.*`, so consumer root transactions need them propagated explicitly. These are operational metadata only (no payload contents), so the transfer is unconditional. Add `MESSAGING_OPERATION_TYPE` and `MESSAGING_MESSAGE_ENVELOPE_SIZE` to `SpanDataConvention` for use by the exporter and downstream integrations. Document the OTel-mode behavior in the two Jakarta OTel sample `application-kafka.properties` so users know the flag activates the OTel remapping path here, not the Sentry-native Kafka auto-config (which stays suppressed by its `@ConditionalOnMissingClass` OTel guard). * fix(otel): Prefer messaging over http mapping when queue tracing enabled Some OTel instrumentations (notably aws-sdk-2.2 SQS) attach both `http.request.method` and `messaging.system` to the same span. With the previous gate order, those spans resolved to http.client and the Sentry Queues product never lit up for one of the most common OTel-coexistence targets. When `enableQueueTracing` is true and `messaging.system` is present, map to a queue.* op before the http and db checks. When the flag is off, the existing http-first ordering is preserved. Co-Authored-By: Claude * fix(otel): Map messaging "create" to queue.create instead of queue.publish The OTel messaging semconv defines "create" and "publish" as distinct operations: "create" represents message construction, "publish" the network send. Folding both into queue.publish risks double-counting producer transactions on instrumentations that emit a separate create span (per OTel semconv guidance). Per the Sentry Queues telemetry spec (https://develop.sentry.dev/sdk/telemetry/traces/modules/queues/), queue.create is a canonical op distinct from queue.publish, so map "create" to its spec-correct destination rather than dropping it. Empirically, current Kafka OTel instrumentation does not emit a separate create span, so this is a no-op for Kafka users today; the change future-proofs other systems and any future Kafka OTel version. Co-Authored-By: Claude * docs(options): Clarify enableQueueTracing covers native + OTel paths The setEnableQueueTracing Javadoc said only "Whether queue operations (publish, process) should be traced." — silent on the fact that the flag also drives OTel messaging-span transformation when sentry-opentelemetry is on the classpath. Reword on both the getter and setter to make explicit that the flag both emits Sentry-native queue spans and transforms OTel messaging spans to match Sentry's queue conventions, so customers grepping their IDE see what the flag does in either integration mode. Co-Authored-By: Claude * fix(otel): Map messaging "settle" to queue.settle OTel messaging semconv defines messaging.operation.type=settle for consumer ack/nack/reject spans (JMS, RabbitMQ, Pulsar acknowledge). The switch had no case for "settle", so settle spans on SpanKind.CONSUMER were falling through to the SpanKind fallback and becoming queue.process — duplicating the real process span — while on SpanKind.CLIENT they became the generic "queue" default. queue.settle is one of the canonical Queues telemetry ops per https://develop.sentry.dev/sdk/telemetry/traces/modules/queues/, so add the explicit mapping. Co-Authored-By: Claude * chore(samples): Drop verbose comment above sentry.enable-queue-tracing The OTel Kafka sample properties carried a 10-line comment explaining the OTel->Sentry remapping mechanism and SentryKafkaQueueConfiguration suppression behavior. That belongs in the SDK docs, not in a sample config — drop it so the property line speaks for itself. Co-Authored-By: Claude * feat(kafka): [Queue Instrumentation 34] Wrap Producer for send spans Replace SentryKafkaProducerInterceptor with SentryKafkaProducer, a Producer wrapper that records a queue.publish span around each send and finishes it when the broker ack callback fires. The span now reflects the full async send lifecycle, not just the synchronous onSend window. For Spring Boot, the SentryKafkaProducerBeanPostProcessor switches from patching KafkaTemplate.setProducerInterceptor(...) to installing a ProducerPostProcessor on every ProducerFactory bean via ProducerFactory.addPostProcessor(...). KafkaTemplate beans are no longer touched, so all customer-configured listeners, interceptors and observation settings are preserved. The console sample now wraps the raw KafkaProducer instead of setting INTERCEPTOR_CLASSES_CONFIG. Spring Boot samples need no change — the auto-configured ProducerPostProcessor is transparent. Co-Authored-By: Claude * fix(kafka): Inject trace headers even without active span Decouple header injection from span creation in SentryKafkaProducer so that distributed tracing works for background workers, @Scheduled jobs, and startup publishers that have no active span. Restructure send() to match the SentryFeignClient/OkHttp pattern: - isIgnored: pure delegate, no headers, no span - No active span: inject headers from PropagationContext, no span - Active span: start child span, inject headers, wrap callback Also simplify the implementation: - Rename injectHeaders to maybeInjectHeaders with encapsulated try/catch (matches Feign's maybeAddTracingHeaders pattern) - Remove outer try/catch around span setup - Remove redundant span.isNoOp() early-return branch - Remove redundant isFinished() guards before finish() calls Co-Authored-By: Claude * changelog * ref(kafka): Reimplement SentryKafkaProducer as a dynamic Proxy Replace the concrete `implements Producer` class with a `Proxy.newProxyInstance`-based wrapper that intercepts only the two `send()` overloads and forwards every other method reflectively to the delegate. The concrete class required explicitly delegating every method on the `Producer` interface, coupling the wrapper to a specific Kafka version: `clientInstanceId(Duration)` was added in Kafka 3.7, and the deprecated `sendOffsetsToTransaction(Map, String)` was removed in Kafka 4.0. The dynamic proxy has no such coupling — new or removed interface methods are handled automatically, giving full compatibility across all Kafka client versions. Public API change: `SentryKafkaProducer` is now a utility class with static `wrap()` overloads instead of constructors. Callers wrap a producer with `SentryKafkaProducer.wrap(producer)`. The Spring BPP and console sample are updated accordingly. Co-Authored-By: Claude * fix(spring-jakarta): Warn when Kafka producer tracing silently fails When ProducerFactory.addPostProcessor() is a no-op (the interface default), the Sentry post-processor is silently dropped and the customer gets zero producer tracing with no signal. Verify registration succeeded via getPostProcessors() after each addPostProcessor() call, and log a WARNING naming the factory bean and pointing toward SentryKafkaProducer.wrap() as the manual fallback. Co-Authored-By: Claude * fix(kafka): Preserve existing consumer interceptor on reflection failure If reading recordInterceptor via reflection fails, leave the container\nfactory untouched instead of installing Sentry's interceptor with a\nnull delegate. This avoids silently dropping customer-configured\ninterceptors for DLQ routing, auditing, or other message handling\nconcerns.\n\nAdd tests that preserve customer interceptors both when chaining\nsucceeds and when reflection cannot safely determine the existing\ninterceptor.\n\nCo-Authored-By: Claude * fix(spring-boot-jakarta): Skip Kafka autoconfig for OTel agent * fix(spring-jakarta): Close leaked Kafka interceptor scope Store the lifecycle token in the thread-local before trace continuation or transaction startup can throw. This keeps the cleanup path reachable and closes the forked scopes even when interceptor preparation fails. Also log the preparation failure instead of letting the interceptor break customer processing. * fix(test): Remove stale Kafka container before startup Always remove the named Kafka system-test container before starting a new broker. This avoids docker name conflicts after crashed or interrupted runs while still keeping stop_kafka_broker ownership-aware for reused brokers. Co-Authored-By: Claude * test(otel): Add send and deliver mapping coverage * test(kafka): Add no-op producer span coverage * fix(kafka): Pass consumer interceptor log throwable correctly * test(kafka): Exercise consumer interceptor reflection failure Force the reflection-failure path in the consumer bean post processor test so it proves customer interceptors remain untouched when Sentry skips installation. Co-Authored-By: Claude * fix(test): Set SENTRY_ENABLE_QUEUE_TRACING for Kafka system tests When SENTRY_AUTO_INIT=true with the OTel agent, Sentry is initialized early by SentryAutoConfigurationCustomizerProvider before Spring Boot loads application-kafka.properties. Without the env var, queue tracing stays disabled and OTel messaging spans are not mapped to queue.publish/queue.process ops, causing KafkaOtelCoexistenceSystemTest to fail. Co-Authored-By: Claude * feat(spring): Add Kafka queue tracing for Spring Boot 4 Port the Spring Boot 3 Kafka queue tracing support to the Spring 7 and Spring Boot 4 modules. Add Spring Kafka bean post-processors, Boot 4 auto-configuration, and matching sample system-test coverage. Co-Authored-By: Claude * changelog * feat(spring): Add Kafka queue tracing for Spring Boot 2 Port Kafka queue tracing to the Spring and Spring Boot 2 modules. Add Spring Kafka bean post-processors, Boot 2 auto-configuration, and matching sample system-test coverage. Co-Authored-By: Claude * docs(rules): Add queue tracing cursor rules Document when to load queue-specific Cursor rules and summarize how Sentry Queues data is produced by the Java SDK Kafka instrumentation. Co-Authored-By: Claude * changelog * build(samples): Use Spring Boot Kafka starter in Boot 4 samples * fix(queue): Apply queue instrumentation review changes * test(spring): Address Kafka tracing review comments Simplify Kafka interceptor test delegates and rely on Kotlin type inference in Spring Kafka tests. Co-Authored-By: Claude * test(spring): Initialize Sentry in Kafka BPP tests Initialize Sentry before each Kafka bean post-processor test and close it afterwards so logging paths do not depend on test execution order. This prevents failures when earlier tests close the SDK before these tests run. Co-Authored-By: Claude * test(spring): Address Kafka review comments Simplify Spring Kafka test interceptors and cover intercepting records without a consumer. Co-Authored-By: Claude * test(spring): Isolate capture exception advice scopes Initialize Sentry before installing the mocked scopes used by the capture exception parameter advice test. Close Sentry after the test so the mocked scopes do not leak into later tests. Co-Authored-By: Claude * changelog entry * fix README changes * test(otel): Relax Kafka coexistence span assertion Avoid requiring the async Kafka producer span to be embedded in the HTTP transaction. OTel can finish and export the producer span after the request transaction, so this assertion flakes while the test still verifies OTel instrumentation suppresses Spring Kafka integration. Refs #5373 Co-Authored-By: Claude * fix(kafka): Make producer proxy equality reflexive Return true when the Kafka producer proxy is compared with itself. This preserves existing delegate equality behavior for other comparisons while satisfying the equals contract. Co-Authored-By: Claude --------- Co-authored-by: Claude Co-authored-by: Sentry Github Bot --- .cursor/rules/overview_dev.mdc | 10 + .cursor/rules/pr.mdc | 2 + .cursor/rules/queues.mdc | 82 +++ CHANGELOG.md | 10 + README.md | 1 + buildSrc/src/main/java/Config.kt | 1 + gradle/libs.versions.toml | 5 + sentry-kafka/README.md | 5 + sentry-kafka/api/sentry-kafka.api | 19 + sentry-kafka/build.gradle.kts | 83 +++ .../kafka/SentryKafkaConsumerTracing.java | 280 ++++++++++ .../io/sentry/kafka/SentryKafkaProducer.java | 265 ++++++++++ .../kafka/SentryKafkaConsumerTracingTest.kt | 254 +++++++++ .../sentry/kafka/SentryKafkaProducerTest.kt | 375 ++++++++++++++ .../api/sentry-opentelemetry-core.api | 2 +- .../opentelemetry/SentrySpanExporter.java | 18 +- .../opentelemetry/SentrySpanProcessor.java | 4 +- .../SpanDescriptionExtractor.java | 61 ++- .../kotlin/SpanDescriptionExtractorTest.kt | 251 ++++++++- .../sentry-samples-console/build.gradle.kts | 2 + .../java/io/sentry/samples/console/Main.java | 13 + .../samples/console/kafka/KafkaShowcase.java | 143 ++++++ .../ConsoleApplicationSystemTest.kt | 49 +- .../build.gradle.kts | 4 + .../boot4/queues/kafka/KafkaConsumer.java | 19 + .../boot4/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../boot4/queues/kafka/KafkaConsumer.java | 19 + .../boot4/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../boot4/queues/kafka/KafkaConsumer.java | 19 + .../boot4/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 10 + .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 +++++ .../build.gradle.kts | 4 + .../jakarta/queues/kafka/KafkaConsumer.java | 19 + .../jakarta/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../jakarta/queues/kafka/KafkaConsumer.java | 19 + .../jakarta/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../jakarta/queues/kafka/KafkaConsumer.java | 19 + .../jakarta/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 10 + .../src/main/resources/application.properties | 1 + .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 +++++ .../build.gradle.kts | 4 + .../boot/queues/kafka/KafkaConsumer.java | 19 + .../boot/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../boot/queues/kafka/KafkaConsumer.java | 19 + .../boot/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 12 + .../KafkaOtelCoexistenceSystemTest.kt | 37 ++ .../build.gradle.kts | 4 + .../boot/queues/kafka/KafkaConsumer.java | 19 + .../boot/queues/kafka/KafkaController.java | 26 + .../resources/application-kafka.properties | 10 + .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 +++++ sentry-spring-7/api/sentry-spring-7.api | 23 + sentry-spring-7/build.gradle.kts | 4 + .../SentryKafkaConsumerBeanPostProcessor.java | 98 ++++ .../SentryKafkaProducerBeanPostProcessor.java | 76 +++ .../kafka/SentryKafkaRecordInterceptor.java | 292 +++++++++++ ...entryKafkaConsumerBeanPostProcessorTest.kt | 124 +++++ ...entryKafkaProducerBeanPostProcessorTest.kt | 109 ++++ .../kafka/SentryKafkaRecordInterceptorTest.kt | 473 +++++++++++++++++ sentry-spring-boot-4/build.gradle.kts | 3 + .../spring/boot4/SentryAutoConfiguration.java | 30 ++ .../boot4/SentryKafkaAutoConfigurationTest.kt | 125 +++++ sentry-spring-boot-jakarta/build.gradle.kts | 3 + .../boot/jakarta/SentryAutoConfiguration.java | 30 ++ .../SentryKafkaAutoConfigurationTest.kt | 125 +++++ sentry-spring-boot/build.gradle.kts | 4 + .../spring/boot/SentryAutoConfiguration.java | 30 ++ .../boot/SentryKafkaAutoConfigurationTest.kt | 125 +++++ .../api/sentry-spring-jakarta.api | 23 + sentry-spring-jakarta/build.gradle.kts | 4 + .../SentryKafkaConsumerBeanPostProcessor.java | 98 ++++ .../SentryKafkaProducerBeanPostProcessor.java | 76 +++ .../kafka/SentryKafkaRecordInterceptor.java | 292 +++++++++++ ...entryKafkaConsumerBeanPostProcessorTest.kt | 124 +++++ ...entryKafkaProducerBeanPostProcessorTest.kt | 109 ++++ .../kafka/SentryKafkaRecordInterceptorTest.kt | 476 +++++++++++++++++ sentry-spring/api/sentry-spring.api | 24 + sentry-spring/build.gradle.kts | 4 + .../SentryKafkaConsumerBeanPostProcessor.java | 98 ++++ .../SentryKafkaProducerBeanPostProcessor.java | 76 +++ .../kafka/SentryKafkaRecordInterceptor.java | 298 +++++++++++ ...ntryCaptureExceptionParameterAdviceTest.kt | 9 + ...entryKafkaConsumerBeanPostProcessorTest.kt | 110 ++++ ...entryKafkaProducerBeanPostProcessorTest.kt | 95 ++++ .../kafka/SentryKafkaRecordInterceptorTest.kt | 486 ++++++++++++++++++ .../sentry/systemtest/util/RestTestClient.kt | 6 + sentry/api/sentry.api | 12 + .../main/java/io/sentry/ExternalOptions.java | 11 + .../main/java/io/sentry/SentryOptions.java | 26 + .../java/io/sentry/SpanDataConvention.java | 8 + .../main/java/io/sentry/util/SpanUtils.java | 4 + .../java/io/sentry/ExternalOptionsTest.kt | 14 + .../test/java/io/sentry/SentryOptionsTest.kt | 22 + settings.gradle.kts | 1 + test/system-test-runner.py | 124 +++++ 113 files changed, 7335 insertions(+), 21 deletions(-) create mode 100644 .cursor/rules/queues.mdc create mode 100644 sentry-kafka/README.md create mode 100644 sentry-kafka/api/sentry-kafka.api create mode 100644 sentry-kafka/build.gradle.kts create mode 100644 sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java create mode 100644 sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java create mode 100644 sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt create mode 100644 sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt create mode 100644 sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/kafka/KafkaShowcase.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt create mode 100644 sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessor.java create mode 100644 sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessor.java create mode 100644 sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaRecordInterceptor.java create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessorTest.kt create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaRecordInterceptorTest.kt create mode 100644 sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryKafkaAutoConfigurationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryKafkaAutoConfigurationTest.kt create mode 100644 sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryKafkaAutoConfigurationTest.kt create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessor.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptor.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptorTest.kt create mode 100644 sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessor.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessor.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessorTest.kt create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaRecordInterceptorTest.kt diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc index 17ce98f07be..b837be34add 100644 --- a/.cursor/rules/overview_dev.mdc +++ b/.cursor/rules/overview_dev.mdc @@ -66,6 +66,15 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - `SentryMetricsEvent`, `SentryMetricsEvents` - `SentryOptions.getMetrics()`, `beforeSend` callback +- **`queues`**: Use when working with: + - Sentry Queues product data or messaging span conventions + - Queue tracing spans/transactions (`queue.publish`, `queue.process`) + - `enableQueueTracing` option and `sentry.enable-queue-tracing` + - Kafka instrumentation (`sentry-kafka`, `SentryKafkaProducer`, `SentryKafkaConsumerTracing`) + - Spring Kafka queue auto-instrumentation and `SentryKafkaRecordInterceptor` + - Messaging span data (`messaging.system`, `messaging.destination.name`, receive latency, retry count) + - `sentry-task-enqueued-time` header and distributed trace propagation through queues + - **`continuous_profiling_jvm`**: Use when working with: - JVM continuous profiling (`sentry-async-profiler` module) - `IContinuousProfiler`, `JavaContinuousProfiler` @@ -118,6 +127,7 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - System test/e2e/sample → `e2e_tests` - Feature flag/addFeatureFlag/flag evaluation → `feature_flags` - Metrics/count/distribution/gauge → `metrics` + - Queues/queue tracing/Kafka/Spring Kafka/queue.publish/queue.process/enableQueueTracing/messaging spans → `queues` - PR/pull request/stacked PR/stack → `pr` - JVM continuous profiling/async-profiler/JFR/ProfileChunk → `continuous_profiling_jvm` - Android continuous profiling/AndroidProfiler/frame metrics/method tracing → no dedicated rule yet; inspect the code directly diff --git a/.cursor/rules/pr.mdc b/.cursor/rules/pr.mdc index 08a07511c67..e15c0a0a563 100644 --- a/.cursor/rules/pr.mdc +++ b/.cursor/rules/pr.mdc @@ -258,3 +258,5 @@ git push **Never merge into the collection branch.** Syncing only happens between stack PR branches. The collection branch is untouched until the user merges PRs through GitHub. Prefer merge over rebase — it preserves commit history, doesn't invalidate existing review comments, and avoids the need for force-pushing. Only rebase if explicitly requested. + +**Never amend or force-push stack branches.** Do not use `git commit --amend`, `--force`, or `--force-with-lease` on branches that are part of a stack. Amending a pushed commit requires a force-push, which can cause GitHub to auto-merge or auto-close other PRs in the stack. If a commit needs fixing, add a new fixup commit instead. diff --git a/.cursor/rules/queues.mdc b/.cursor/rules/queues.mdc new file mode 100644 index 00000000000..fe082c3b854 --- /dev/null +++ b/.cursor/rules/queues.mdc @@ -0,0 +1,82 @@ +--- +alwaysApply: false +description: Sentry Queues module and Java SDK queue tracing +--- +# Sentry Queues and Java SDK Queue Tracing + +## Product model + +Sentry Queues is built from tracing data. SDKs mark queue work with queue-specific span operations and messaging span data so Sentry can identify producers, consumers, destinations, latency, and failures. + +The important concepts are: +- `queue.publish`: a span for enqueueing/publishing a message to a queue or topic. +- `queue.process`: a transaction for processing a dequeued message. +- Messaging span data, especially: + - `messaging.system` (for example `kafka`) + - `messaging.destination.name` (queue/topic name) + - `messaging.message.id` + - `messaging.message.retry.count` + - `messaging.message.body.size` + - `messaging.message.envelope.size` + - `messaging.message.receive.latency` +- Distributed tracing headers (`sentry-trace` and `baggage`) link producer-side work to consumer-side processing. +- Queue receive latency is the time a message spent waiting between publish/enqueue and processing. For Java Kafka, this comes from the `sentry-task-enqueued-time` header that the producer writes and the consumer reads. + +The Queues UI is not backed by a separate Java event type. The Java SDK contributes data through spans/transactions with the expected operations, trace context, statuses, and messaging attributes. + +## Java SDK implementation + +Queue tracing is opt-in. `SentryOptions.isEnableQueueTracing()` defaults to `false` and can be enabled with `setEnableQueueTracing(true)` or external config key `enable-queue-tracing` (`sentry.enable-queue-tracing` in Spring Boot). Captured queue spans/transactions still depend on tracing being enabled and sampled. + +Kafka support lives in `sentry-kafka`: +- `SentryKafkaProducer.wrap(Producer)` wraps Kafka `Producer.send(...)` calls. + - Creates a `queue.publish` child span when there is an active span. + - Sets `messaging.system=kafka` and `messaging.destination.name=`. + - Injects `sentry-trace`, `baggage`, and `sentry-task-enqueued-time` headers. + - Still injects tracing/enqueued-time headers when queue tracing is enabled but there is no active span, so background producers can link to consumers. + - Finishes the span from the Kafka callback with `OK` or `INTERNAL_ERROR`. +- `SentryKafkaConsumerTracing.withTracing(record, callback)` is the manual raw-Kafka consumer helper. + - Forks root scopes for the processing lifecycle and makes them current. + - Continues the trace from Kafka headers. + - Starts a `queue.process` transaction bound to scope when tracing is enabled. + - Sets Kafka messaging data, body size, retry count, and receive latency when available. + - Finishes with `OK` or `INTERNAL_ERROR` and never lets instrumentation failures break customer processing. + +Spring Kafka support lives in `sentry-spring`, `sentry-spring-jakarta`, and `sentry-spring-7`: +- `SentryKafkaProducerBeanPostProcessor` installs a producer post-processor on `DefaultKafkaProducerFactory` and wraps created producers with `SentryKafkaProducer.wrap(...)`. +- `SentryKafkaConsumerBeanPostProcessor` installs `SentryKafkaRecordInterceptor` on listener container factories. +- `SentryKafkaRecordInterceptor` starts/finishes `queue.process` transactions around listener processing, continues traces from headers, forks scopes for the record lifecycle, and preserves any existing delegate interceptor. +- Spring Boot auto-configuration registers both post-processors only when Spring Kafka and `sentry-kafka` are present and `sentry.enable-queue-tracing=true`. +- Spring Boot queue auto-configuration is disabled when Sentry OpenTelemetry integration classes are present to avoid duplicate Kafka instrumentation. + +## Trace origins and suppression + +Queue instrumentation sets span origins so it can be identified and suppressed with `ignoredSpanOrigins`: +- Raw Kafka producer: `auto.queue.kafka.producer` +- Raw Kafka consumer helper: `manual.queue.kafka.consumer` +- Spring Kafka producer: `auto.queue.spring.kafka.producer`, `auto.queue.spring_jakarta.kafka.producer`, `auto.queue.spring7.kafka.producer` +- Spring Kafka consumer: `auto.queue.spring.kafka.consumer`, `auto.queue.spring_jakarta.kafka.consumer`, `auto.queue.spring7.kafka.consumer` + +## Files to inspect when changing queue tracing + +- Core option and conventions: + - `sentry/src/main/java/io/sentry/SentryOptions.java` + - `sentry/src/main/java/io/sentry/ExternalOptions.java` + - `sentry/src/main/java/io/sentry/SpanDataConvention.java` +- Raw Kafka: + - `sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java` + - `sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java` + - `sentry-kafka/src/test/kotlin/io/sentry/kafka/*Test.kt` +- Spring Kafka: + - `sentry-spring*/src/main/java/io/sentry/**/kafka/*` + - `sentry-spring*/src/test/kotlin/io/sentry/**/kafka/*Test.kt` + - `sentry-spring-boot*/src/main/java/io/sentry/**/SentryAutoConfiguration.java` + - `sentry-spring-boot*/src/test/kotlin/io/sentry/**/SentryKafkaAutoConfigurationTest.kt` + +## Related rules + +Also fetch: +- `options` when changing `enableQueueTracing` or configuration surfaces. +- `scopes` when changing consumer scope forking/lifecycle. +- `opentelemetry` when changing coexistence with OTel auto-instrumentation. +- `api` when changing public Kafka APIs or option methods. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b945938d2b..244d229b994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ .configurator { it.isUseShakeGesture = true } .create() ``` +- Add support for Kafka ([#5249](https://github.com/getsentry/sentry-java/pull/5249)) + - You will need to add the `sentry-kafka` dependency and opt-in via the new option. + - Set `options.setEnableQueueTracing(true)` on `Sentry.init` + - Or set `sentry.enable-queue-tracing=true` in `application.properties` + - For Spring Boot Kafka is auto instrumented and no further configuration is needed. + - also see https://docs.sentry.io/platforms/java/guides/spring-boot/integrations/kafka/ + - When using `kafka-clients` directly + - you need to wrap your `KafkaProducer` via `SentryKafkaProducer.wrap(kafkaProducer)` to get `queue.publish` spans + - and you may use our `SentryKafkaConsumerTracing.withTracing` helper to instrument the consumer side manually. + - also see https://docs.sentry.io/platforms/java/integrations/kafka/ ### Fixes diff --git a/README.md b/README.md index 7d9ad7ba287..9aaf7aca4d8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Sentry SDK for Java and Android | sentry | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry) | 21 | | sentry-jul | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jul?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jul) | | sentry-jdbc | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jdbc?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jdbc) | +| sentry-kafka | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-kafka?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-kafka) | | sentry-apollo | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo) | 21 | | sentry-apollo-3 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-3?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-3) | 21 | | sentry-apollo-4 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-4?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-4) | 21 | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 3285db23a98..3410d9601d3 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -80,6 +80,7 @@ object Config { val SENTRY_JCACHE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jcache" val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" + val SENTRY_KAFKA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kafka" val SENTRY_OPENFEATURE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeature" val SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.launchdarkly-server" val SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.launchdarkly" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cf7bc7b4f32..50d415c212a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -184,6 +184,10 @@ springboot3-starter-security = { module = "org.springframework.boot:spring-boot- springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot3" } springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } +spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = "2.8.11" } +spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version = "3.3.5" } +spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } +kafka-clients = { module = "org.apache.kafka:kafka-clients", version = "3.8.1" } springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } springboot4-resttestclient = { module = "org.springframework.boot:spring-boot-resttestclient", version.ref = "springboot4" } springboot4-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot4" } @@ -200,6 +204,7 @@ springboot4-starter-webclient = { module = "org.springframework.boot:spring-boot springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot4" } springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } +springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature diff --git a/sentry-kafka/README.md b/sentry-kafka/README.md new file mode 100644 index 00000000000..1b1b69238e5 --- /dev/null +++ b/sentry-kafka/README.md @@ -0,0 +1,5 @@ +# sentry-kafka + +This module provides Kafka-native queue instrumentation for applications using `kafka-clients` directly. + +Spring users should use the Sentry Spring (Boot) SDKs, which provide higher-fidelity consumer instrumentation via Spring Kafka hooks. diff --git a/sentry-kafka/api/sentry-kafka.api b/sentry-kafka/api/sentry-kafka.api new file mode 100644 index 00000000000..00649245845 --- /dev/null +++ b/sentry-kafka/api/sentry-kafka.api @@ -0,0 +1,19 @@ +public final class io/sentry/kafka/BuildConfig { + public static final field SENTRY_KAFKA_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/kafka/SentryKafkaConsumerTracing { + public static final field TRACE_ORIGIN Ljava/lang/String; + public static fun withTracing (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Runnable;)V + public static fun withTracing (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/util/concurrent/Callable;)Ljava/lang/Object; +} + +public final class io/sentry/kafka/SentryKafkaProducer { + public static final field SENTRY_ENQUEUED_TIME_HEADER Ljava/lang/String; + public static final field TRACE_ORIGIN Ljava/lang/String; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;)Lorg/apache/kafka/clients/producer/Producer; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;Lio/sentry/IScopes;)Lorg/apache/kafka/clients/producer/Producer; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;Lio/sentry/IScopes;Ljava/lang/String;)Lorg/apache/kafka/clients/producer/Producer; +} + diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts new file mode 100644 index 00000000000..ee3ba0d4a60 --- /dev/null +++ b/sentry-kafka/build.gradle.kts @@ -0,0 +1,83 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + compileOnly(libs.kafka.clients) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kafka.clients) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.kafka") + buildConfigField("String", "SENTRY_KAFKA_SDK_NAME", "\"${Config.Sentry.SENTRY_KAFKA_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_KAFKA_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-kafka", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java new file mode 100644 index 00000000000..dbce760de99 --- /dev/null +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java @@ -0,0 +1,280 @@ +package io.sentry.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.util.SpanUtils; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Helper methods for instrumenting raw Kafka consumer record processing. */ +@ApiStatus.Experimental +public final class SentryKafkaConsumerTracing { + + public static final @NotNull String TRACE_ORIGIN = "manual.queue.kafka.consumer"; + + private static final @NotNull String CREATOR = "SentryKafkaConsumerTracing"; + private static final @NotNull String DELIVERY_ATTEMPT_HEADER = "kafka_deliveryAttempt"; + private static final @NotNull String MESSAGE_ID_HEADER = "messaging.message.id"; + + private final @NotNull IScopes scopes; + + SentryKafkaConsumerTracing(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + /** + * Runs the provided {@link Callable} with a Kafka consumer processing transaction for the given + * record. + * + * @param record the Kafka record being processed + * @param callable the processing callback + * @return the return value of the callback + * @param the Kafka record key type + * @param the Kafka record value type + * @param the callback return type + */ + public static U withTracing( + final @NotNull ConsumerRecord record, final @NotNull Callable callable) + throws Exception { + return new SentryKafkaConsumerTracing(ScopesAdapter.getInstance()) + .withTracingImpl(record, callable); + } + + /** + * Runs the provided {@link Runnable} with a Kafka consumer processing transaction for the given + * record. + * + * @param record the Kafka record being processed + * @param runnable the processing callback + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static void withTracing( + final @NotNull ConsumerRecord record, final @NotNull Runnable runnable) { + new SentryKafkaConsumerTracing(ScopesAdapter.getInstance()).withTracingImpl(record, runnable); + } + + U withTracingImpl( + final @NotNull ConsumerRecord record, final @NotNull Callable callable) + throws Exception { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return callable.call(); + } + + final @NotNull IScopes forkedScopes; + final @NotNull ISentryLifecycleToken lifecycleToken; + try { + forkedScopes = scopes.forkedRootScopes(CREATOR); + lifecycleToken = forkedScopes.makeCurrent(); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to fork scopes for Kafka consumer tracing.", t); + return callable.call(); + } + + try (final @NotNull ISentryLifecycleToken ignored = lifecycleToken) { + final @Nullable ITransaction transaction = startTransaction(forkedScopes, record); + boolean didError = false; + @Nullable Throwable callbackThrowable = null; + + try { + return callable.call(); + } catch (Throwable t) { + didError = true; + callbackThrowable = t; + throw t; + } finally { + finishTransaction( + transaction, didError ? SpanStatus.INTERNAL_ERROR : SpanStatus.OK, callbackThrowable); + } + } + } + + void withTracingImpl( + final @NotNull ConsumerRecord record, final @NotNull Runnable runnable) { + try { + withTracingImpl( + record, + () -> { + runnable.run(); + return null; + }); + } catch (Throwable t) { + throwUnchecked(t); + } + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(final @NotNull Throwable throwable) + throws T { + throw (T) throwable; + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private @Nullable ITransaction startTransaction( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + try { + final @Nullable TransactionContext continued = continueTrace(forkedScopes, record); + if (!forkedScopes.getOptions().isTracingEnabled()) { + return null; + } + + final @NotNull TransactionContext txContext = + continued != null ? continued : new TransactionContext(record.topic(), "queue.process"); + txContext.setName(record.topic()); + txContext.setOperation("queue.process"); + + final @NotNull TransactionOptions txOptions = new TransactionOptions(); + txOptions.setOrigin(TRACE_ORIGIN); + txOptions.setBindToScope(true); + + final @NotNull ITransaction transaction = forkedScopes.startTransaction(txContext, txOptions); + if (transaction.isNoOp()) { + return null; + } + + transaction.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + transaction.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + final @Nullable String messageId = headerValue(record, MESSAGE_ID_HEADER); + if (messageId != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_ID, messageId); + } + + final int bodySize = record.serializedValueSize(); + if (bodySize >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, bodySize); + } + + final @Nullable Integer retryCount = retryCount(record); + if (retryCount != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, retryCount); + } + + final @Nullable Long receiveLatency = receiveLatency(record); + if (receiveLatency != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, receiveLatency); + } + + return transaction; + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to start Kafka consumer tracing transaction.", t); + return null; + } + } + + private void finishTransaction( + final @Nullable ITransaction transaction, + final @NotNull SpanStatus status, + final @Nullable Throwable throwable) { + if (transaction == null || transaction.isNoOp()) { + return; + } + + try { + transaction.setStatus(status); + if (throwable != null) { + transaction.setThrowable(throwable); + } + transaction.finish(); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to finish Kafka consumer tracing transaction.", t); + } + } + + private @Nullable TransactionContext continueTrace( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + final @Nullable String sentryTrace = headerValue(record, SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = + headerValues(record, BaggageHeader.BAGGAGE_HEADER); + return forkedScopes.continueTrace(sentryTrace, baggageHeaders); + } + + private @Nullable Integer retryCount(final @NotNull ConsumerRecord record) { + final @Nullable Header header = record.headers().lastHeader(DELIVERY_ATTEMPT_HEADER); + if (header == null) { + return null; + } + + final byte[] value = header.value(); + if (value == null || value.length != Integer.BYTES) { + return null; + } + + final int attempt = ByteBuffer.wrap(value).getInt(); + if (attempt <= 0) { + return null; + } + + return attempt - 1; + } + + private @Nullable Long receiveLatency(final @NotNull ConsumerRecord record) { + final @Nullable String enqueuedTimeStr = + headerValue(record, SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER); + if (enqueuedTimeStr == null) { + return null; + } + + try { + final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); + final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); + return latencyMs >= 0 ? latencyMs : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private @Nullable String headerValue( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + final @Nullable Header header = record.headers().lastHeader(headerName); + if (header == null || header.value() == null) { + return null; + } + return new String(header.value(), StandardCharsets.UTF_8); + } + + private @Nullable List headerValues( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + @Nullable List values = null; + for (final @NotNull Header header : record.headers().headers(headerName)) { + if (header.value() != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(header.value(), StandardCharsets.UTF_8)); + } + } + return values; + } +} diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java new file mode 100644 index 00000000000..bcc538e339c --- /dev/null +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java @@ -0,0 +1,265 @@ +package io.sentry.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.SpanUtils; +import io.sentry.util.TracingUtils; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.Headers; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps a Kafka {@link Producer} to record a {@code queue.publish} span around each {@code send} + * and to inject Sentry trace propagation headers into the produced record. + * + *

For raw Kafka usage: + * + *

{@code
+ * Producer producer =
+ *     SentryKafkaProducer.wrap(new KafkaProducer<>(props));
+ * }
+ * + *

For Spring Kafka, the {@code SentryKafkaProducerBeanPostProcessor} installs this wrapper + * automatically. + */ +@ApiStatus.Experimental +public final class SentryKafkaProducer { + + public static final @NotNull String TRACE_ORIGIN = "auto.queue.kafka.producer"; + public static final @NotNull String SENTRY_ENQUEUED_TIME_HEADER = "sentry-task-enqueued-time"; + + private SentryKafkaProducer() {} + + /** + * Wraps the given producer with Sentry instrumentation. + * + * @param delegate the Kafka producer to wrap + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static @NotNull Producer wrap(final @NotNull Producer delegate) { + return wrap(delegate, ScopesAdapter.getInstance(), TRACE_ORIGIN); + } + + /** + * Wraps the given producer with Sentry instrumentation using the provided scopes. + * + * @param delegate the Kafka producer to wrap + * @param scopes the Sentry scopes to use for span creation and header injection + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static @NotNull Producer wrap( + final @NotNull Producer delegate, final @NotNull IScopes scopes) { + return wrap(delegate, scopes, TRACE_ORIGIN); + } + + /** + * Wraps the given producer with Sentry instrumentation. + * + * @param delegate the Kafka producer to wrap + * @param scopes the Sentry scopes to use for span creation and header injection + * @param traceOrigin the trace origin to set on created spans + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + @SuppressWarnings("unchecked") + public static @NotNull Producer wrap( + final @NotNull Producer delegate, + final @NotNull IScopes scopes, + final @NotNull String traceOrigin) { + return (Producer) + Proxy.newProxyInstance( + delegate.getClass().getClassLoader(), + new Class[] {Producer.class}, + new SentryProducerHandler<>(delegate, scopes, traceOrigin)); + } + + static final class SentryProducerHandler implements InvocationHandler { + + final @NotNull Producer delegate; + private final @NotNull IScopes scopes; + private final @NotNull String traceOrigin; + + SentryProducerHandler( + final @NotNull Producer delegate, + final @NotNull IScopes scopes, + final @NotNull String traceOrigin) { + this.delegate = delegate; + this.scopes = scopes; + this.traceOrigin = traceOrigin; + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable Object invoke( + final @NotNull Object proxy, final @NotNull Method method, final @Nullable Object[] args) + throws Throwable { + if ("send".equals(method.getName()) && args != null) { + if (args.length == 1) { + return instrumentedSend((ProducerRecord) args[0], null); + } else if (args.length == 2) { + return instrumentedSend((ProducerRecord) args[0], (Callback) args[1]); + } + } + + if ("equals".equals(method.getName()) + && args != null + && args.length == 1 + && proxy == args[0]) { + return true; + } + + if ("toString".equals(method.getName()) && (args == null || args.length == 0)) { + return "SentryKafkaProducer[delegate=" + delegate + "]"; + } + + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + @SuppressWarnings("unchecked") + private @NotNull Object instrumentedSend( + final @NotNull ProducerRecord record, final @Nullable Callback callback) { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return delegate.send(record, callback); + } + + final @Nullable ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + maybeInjectHeaders(record.headers(), null); + return delegate.send(record, callback); + } + + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(traceOrigin); + final @NotNull ISpan span = + activeSpan.startChild("queue.publish", record.topic(), spanOptions); + + span.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + span.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + maybeInjectHeaders(record.headers(), span); + + try { + return delegate.send(record, wrapCallback(callback, span)); + } catch (Throwable t) { + finishWithError(span, t); + throw t; + } + } + + private @NotNull Callback wrapCallback( + final @Nullable Callback userCallback, final @NotNull ISpan span) { + return (metadata, exception) -> { + try { + if (exception != null) { + span.setThrowable(exception); + span.setStatus(SpanStatus.INTERNAL_ERROR); + } else { + span.setStatus(SpanStatus.OK); + } + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to set status on Kafka producer span.", t); + } finally { + try { + span.finish(); + } finally { + if (userCallback != null) { + userCallback.onCompletion(metadata, exception); + } + } + } + }; + } + + private void finishWithError(final @NotNull ISpan span, final @NotNull Throwable t) { + span.setThrowable(t); + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.finish(); + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), traceOrigin); + } + + private void maybeInjectHeaders(final @NotNull Headers headers, final @Nullable ISpan span) { + try { + final @Nullable List existingBaggageHeaders = + readHeaderValues(headers, BaggageHeader.BAGGAGE_HEADER); + final @Nullable TracingUtils.TracingHeaders tracingHeaders = + TracingUtils.trace(scopes, existingBaggageHeaders, span); + if (tracingHeaders != null) { + final @NotNull SentryTraceHeader sentryTraceHeader = + tracingHeaders.getSentryTraceHeader(); + headers.remove(sentryTraceHeader.getName()); + headers.add( + sentryTraceHeader.getName(), + sentryTraceHeader.getValue().getBytes(StandardCharsets.UTF_8)); + + final @Nullable BaggageHeader baggageHeader = tracingHeaders.getBaggageHeader(); + if (baggageHeader != null) { + headers.remove(baggageHeader.getName()); + headers.add( + baggageHeader.getName(), baggageHeader.getValue().getBytes(StandardCharsets.UTF_8)); + } + } + + headers.remove(SENTRY_ENQUEUED_TIME_HEADER); + headers.add( + SENTRY_ENQUEUED_TIME_HEADER, + DateUtils.doubleToBigDecimal(DateUtils.millisToSeconds(System.currentTimeMillis())) + .toString() + .getBytes(StandardCharsets.UTF_8)); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to inject Sentry headers into Kafka record.", t); + } + } + + private static @Nullable List readHeaderValues( + final @NotNull Headers headers, final @NotNull String name) { + @Nullable List values = null; + for (final @NotNull Header header : headers.headers(name)) { + final byte @Nullable [] value = header.value(); + if (value != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(value, StandardCharsets.UTF_8)); + } + } + return values; + } + } +} diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt new file mode 100644 index 00000000000..5529e42c715 --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt @@ -0,0 +1,254 @@ +package io.sentry.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.ITransaction +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Optional +import java.util.concurrent.Callable +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.record.TimestampType +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.check +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryKafkaConsumerTracingTest { + + private lateinit var scopes: IScopes + private lateinit var forkedScopes: IScopes + private lateinit var options: SentryOptions + private lateinit var lifecycleToken: ISentryLifecycleToken + private lateinit var transaction: ITransaction + private lateinit var tracing: SentryKafkaConsumerTracing + + @BeforeTest + fun setup() { + scopes = mock() + forkedScopes = mock() + lifecycleToken = mock() + transaction = mock() + tracing = SentryKafkaConsumerTracing(scopes) + + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + tracesSampleRate = 1.0 + } + + whenever(scopes.options).thenReturn(options) + whenever(scopes.forkedRootScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.options).thenReturn(options) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + whenever(forkedScopes.startTransaction(any(), any())) + .thenReturn(transaction) + whenever(transaction.isNoOp).thenReturn(false) + } + + @Test + fun `withTracing creates queue process transaction with record metadata`() { + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val baggageValue = "sentry-sample_rate=1" + val record = + createRecord( + sentryTrace = sentryTraceValue, + baggage = baggageValue, + messageId = "message-123", + deliveryAttempt = 3, + enqueuedTime = (System.currentTimeMillis() / 1000.0 - 1.0).toString(), + serializedValueSize = 5, + ) + + val txContextCaptor = argumentCaptor() + val txOptionsCaptor = argumentCaptor() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes).forkedRootScopes("SentryKafkaConsumerTracing") + verify(forkedScopes).makeCurrent() + verify(forkedScopes).continueTrace(eq(sentryTraceValue), eq(listOf(baggageValue))) + verify(forkedScopes).startTransaction(txContextCaptor.capture(), txOptionsCaptor.capture()) + + assertEquals("my-topic", txContextCaptor.firstValue.name) + assertEquals("queue.process", txContextCaptor.firstValue.operation) + assertEquals(SentryKafkaConsumerTracing.TRACE_ORIGIN, txOptionsCaptor.firstValue.origin) + assertTrue(txOptionsCaptor.firstValue.isBindToScope) + + verify(transaction).setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka") + verify(transaction).setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, "my-topic") + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_ID, "message-123") + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, 5) + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, 2) + verify(transaction) + .setData( + eq(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY), + check { assertTrue(it >= 0) }, + ) + verify(transaction).setStatus(SpanStatus.OK) + verify(transaction).finish() + verify(lifecycleToken).close() + } + + @Test + fun `withTracing passes all baggage headers to continueTrace`() { + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = + createRecord( + sentryTrace = sentryTraceValue, + baggageHeaders = listOf("third=party", "sentry-sample_rate=1"), + ) + + tracing.withTracingImpl(record, Callable { "done" }) + + verify(forkedScopes) + .continueTrace(eq(sentryTraceValue), eq(listOf("third=party", "sentry-sample_rate=1"))) + } + + @Test + fun `withTracing skips scope forking when queue tracing is disabled`() { + options.isEnableQueueTracing = false + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes, never()).forkedRootScopes(any()) + } + + @Test + fun `withTracing skips scope forking when origin is ignored`() { + options.setIgnoredSpanOrigins(listOf(SentryKafkaConsumerTracing.TRACE_ORIGIN)) + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes, never()).forkedRootScopes(any()) + } + + @Test + fun `withTracing marks transaction as error when callback throws`() { + val record = createRecord() + val exception = RuntimeException("boom") + + val thrown = + assertFailsWith { + tracing.withTracingImpl(record, Callable { throw exception }) + } + + assertEquals(exception, thrown) + verify(transaction).setStatus(SpanStatus.INTERNAL_ERROR) + verify(transaction).setThrowable(exception) + verify(transaction).finish() + verify(lifecycleToken).close() + } + + @Test + fun `withTracing falls back to direct callback execution when instrumentation setup fails`() { + whenever(scopes.forkedRootScopes(any())) + .thenThrow(RuntimeException("broken instrumentation")) + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(forkedScopes, never()).makeCurrent() + verify(transaction, never()).finish() + } + + @Test + fun `withTracing runnable overload executes callback`() { + val record = createRecord() + val didRun = AtomicBoolean(false) + + tracing.withTracingImpl(record, Runnable { didRun.set(true) }) + + assertTrue(didRun.get()) + verify(transaction).setStatus(SpanStatus.OK) + verify(transaction).finish() + } + + @Test + fun `withTracing runnable overload preserves original throwable`() { + val record = createRecord() + val exception = IOException("boom") + + val thrown = + assertFailsWith { tracing.withTracingImpl(record, Runnable { throw exception }) } + + assertEquals(exception, thrown) + verify(transaction).setStatus(SpanStatus.INTERNAL_ERROR) + verify(transaction).setThrowable(exception) + verify(transaction).finish() + } + + private fun createRecord( + topic: String = "my-topic", + sentryTrace: String? = null, + baggage: String? = null, + baggageHeaders: List? = null, + messageId: String? = null, + deliveryAttempt: Int? = null, + enqueuedTime: String? = null, + serializedValueSize: Int = -1, + ): ConsumerRecord { + val headers = RecordHeaders() + sentryTrace?.let { + headers.add(SentryTraceHeader.SENTRY_TRACE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggage?.let { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggageHeaders?.forEach { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + messageId?.let { + headers.add(SpanDataConvention.MESSAGING_MESSAGE_ID, it.toByteArray(StandardCharsets.UTF_8)) + } + deliveryAttempt?.let { + headers.add("kafka_deliveryAttempt", ByteBuffer.allocate(Int.SIZE_BYTES).putInt(it).array()) + } + enqueuedTime?.let { + headers.add( + SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER, + it.toByteArray(StandardCharsets.UTF_8), + ) + } + + return ConsumerRecord( + topic, + 0, + 0L, + System.currentTimeMillis(), + TimestampType.CREATE_TIME, + 3, + serializedValueSize, + "key", + "value", + headers, + Optional.empty(), + ) + } +} diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt new file mode 100644 index 00000000000..a4ba5254c36 --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt @@ -0,0 +1,375 @@ +package io.sentry.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.ISpan +import io.sentry.NoOpSpan +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.test.initForTest +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.Callback +import org.apache.kafka.clients.producer.Producer +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.clients.producer.RecordMetadata +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.header.Header +import org.apache.kafka.common.header.Headers +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryKafkaProducerTest { + + private lateinit var scopes: IScopes + private lateinit var options: SentryOptions + private lateinit var delegate: Producer + + @BeforeTest + fun setup() { + initForTest { + it.dsn = "https://key@sentry.io/proj" + it.isEnableQueueTracing = true + it.tracesSampleRate = 1.0 + } + scopes = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + } + whenever(scopes.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(Scope(options)) } + .whenever(scopes) + .configureScope(any()) + delegate = mock() + whenever(delegate.send(any(), any())).thenReturn(CompletableFuture.completedFuture(null)) + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `creates queue publish span and injects headers`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("queue.publish", span.operation) + assertEquals("my-topic", span.description) + assertEquals("kafka", span.data["messaging.system"]) + assertEquals("my-topic", span.data["messaging.destination.name"]) + assertEquals(SentryKafkaProducer.TRACE_ORIGIN, span.spanContext.origin) + + val sentryTraceHeader = record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER) + assertNotNull(sentryTraceHeader) + + val enqueuedTimeHeader = + record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER) + assertNotNull(enqueuedTimeHeader) + val enqueuedTimeRaw = String(enqueuedTimeHeader.value(), StandardCharsets.UTF_8) + // Cross-SDK consumers (e.g. sentry-python) parse this as a plain decimal — must not use + // scientific notation. + assertFalse(enqueuedTimeRaw.contains('E') || enqueuedTimeRaw.contains('e')) + assertTrue(enqueuedTimeRaw.matches(Regex("""^\d+\.\d{6}$"""))) + } + + @Test + fun `delegates send and does not finish span synchronously`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), any()) + val span = tx.spans.first() + assertFalse(span.isFinished, "span should be open until callback fires") + } + + @Test + fun `finishes span as OK when broker ack callback succeeds`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + val metadata = RecordMetadata(TopicPartition("my-topic", 0), 0L, 0, 0L, 0, 0) + captor.firstValue.onCompletion(metadata, null) + + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + } + + @Test + fun `finishes span as INTERNAL_ERROR when broker ack callback fails`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + val exception = RuntimeException("boom") + + producer.send(record) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + captor.firstValue.onCompletion(null, exception) + + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertSame(exception, span.throwable) + } + + @Test + fun `forwards user callback after finishing span`() { + createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + val userCallback = mock() + + producer.send(record, userCallback) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + val metadata = RecordMetadata(TopicPartition("my-topic", 0), 0L, 0, 0L, 0, 0) + captor.firstValue.onCompletion(metadata, null) + + verify(userCallback).onCompletion(metadata, null) + } + + @Test + fun `finishes span with error when delegate send throws synchronously`() { + val tx = createTransaction() + val exception = RuntimeException("kaboom") + whenever(delegate.send(any(), any())).thenThrow(exception) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + val thrown = runCatching { producer.send(record) }.exceptionOrNull() + + assertSame(exception, thrown) + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertSame(exception, span.throwable) + } + + @Test + fun `delegates send without span when queue tracing is disabled`() { + createTransaction() + options.isEnableQueueTracing = false + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + } + + @Test + fun `delegates send without span when trace origin is ignored`() { + val tx = createTransaction() + options.setIgnoredSpanOrigins(listOf(SentryKafkaProducer.TRACE_ORIGIN)) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + assertEquals(0, tx.spans.size) + verify(delegate).send(eq(record), isNull()) + assertEquals(null, record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + } + + @Test + fun `injects headers but creates no span when no active span`() { + whenever(scopes.span).thenReturn(null) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `injects headers but creates no span when active span is no-op`() { + whenever(scopes.span).thenReturn(NoOpSpan.getInstance()) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `preserves pre-existing third-party baggage header entries`() { + createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + record + .headers() + .add( + BaggageHeader.BAGGAGE_HEADER, + "othervendor=someValue,another=thing".toByteArray(StandardCharsets.UTF_8), + ) + + producer.send(record) + + val baggageHeaders = record.headers().headers(BaggageHeader.BAGGAGE_HEADER).toList() + assertEquals(1, baggageHeaders.size) + val baggageValue = String(baggageHeaders.first().value(), StandardCharsets.UTF_8) + assertTrue(baggageValue.contains("othervendor=someValue")) + assertTrue(baggageValue.contains("another=thing")) + assertTrue(baggageValue.contains("sentry-")) + } + + @Test + fun `header injection failure does not prevent send`() { + val activeSpan = mock() + val span = mock() + val headers = mock() + val record = mock>() + whenever(scopes.span).thenReturn(activeSpan) + whenever(activeSpan.startChild(eq("queue.publish"), eq("my-topic"), any())) + .thenReturn(span) + whenever(span.isNoOp).thenReturn(false) + whenever(span.isFinished).thenReturn(false) + whenever(span.toSentryTrace()) + .thenReturn(SentryTraceHeader("2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1")) + whenever(span.toBaggageHeader(null)).thenReturn(null) + whenever(record.topic()).thenReturn("my-topic") + whenever(record.headers()).thenReturn(headers) + whenever(headers.headers(BaggageHeader.BAGGAGE_HEADER)).thenReturn(emptyList

()) + whenever(headers.remove(SentryTraceHeader.SENTRY_TRACE_HEADER)) + .thenThrow(RuntimeException("boom")) + + val producer = SentryKafkaProducer.wrap(delegate, scopes) + producer.send(record) + + // Header injection failed silently; send still proceeds with wrapped callback for span + // lifecycle. + verify(delegate).send(eq(record), any()) + } + + @Test + fun `delegates non-send methods to underlying producer`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + producer.flush() + producer.partitionsFor("my-topic") + producer.metrics() + producer.close() + + verify(delegate).flush() + verify(delegate).partitionsFor("my-topic") + verify(delegate).metrics() + verify(delegate).close() + } + + @Test + fun `default wrap uses current scopes`() { + val transaction = Sentry.startTransaction("tx", "op") + val record = ProducerRecord("my-topic", "key", "value") + + try { + val token: ISentryLifecycleToken = transaction.makeCurrent() + try { + val producer = SentryKafkaProducer.wrap(delegate) + producer.send(record) + } finally { + token.close() + } + } finally { + transaction.finish() + } + + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + verify(delegate).send(eq(record), any()) + } + + @Test + fun `wraps callback even when child span is no-op`() { + val tx = createTransaction() + // Set max spans to 0 so the child span is no-op (over limit) + options.maxSpans = 0 + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + // Callback is still wrapped (no-op span finish is harmless) + verify(delegate).send(eq(record), any()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `wrapped producer equals itself`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + assertTrue(producer.equals(producer)) + } + + @Test + fun `wrapped producer keeps delegate hashCode`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + assertEquals(delegate.hashCode(), producer.hashCode()) + } + + @Test + fun `toString includes delegate`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + assertTrue(producer.toString().startsWith("SentryKafkaProducer[delegate=")) + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index b51c8cc39bc..847d69bca1b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -149,7 +149,7 @@ public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetr public final class io/sentry/opentelemetry/SpanDescriptionExtractor { public fun ()V - public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;)Lio/sentry/opentelemetry/OtelSpanInfo; + public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SentryOptions;)Lio/sentry/opentelemetry/OtelSpanInfo; } public final class io/sentry/opentelemetry/SpanNode { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java index 680177f8451..2583f4a0469 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -12,6 +12,7 @@ import io.opentelemetry.sdk.trace.data.StatusData; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; import io.opentelemetry.semconv.incubating.ProcessIncubatingAttributes; import io.opentelemetry.semconv.incubating.ThreadIncubatingAttributes; import io.sentry.Baggage; @@ -200,7 +201,7 @@ private void createAndFinishSpanForOtelSpan( final @Nullable IOtelSpanWrapper sentrySpanMaybe = spanStorage.getSentrySpan(spanData.getSpanContext()); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe, scopes.getOptions()); scopes .getOptions() @@ -294,7 +295,7 @@ private void transferSpanDetails( final @NotNull IScopes scopesToUse = scopesToUseBeforeForking.forkedCurrentScope("SentrySpanExporter.createTransaction"); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe, scopesToUse.getOptions()); scopesToUse .getOptions() @@ -361,6 +362,19 @@ private void transferSpanDetails( maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_ID); maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_NAME); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_SYSTEM); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ID); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_BODY_SIZE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ENVELOPE_SIZE); + scopesToUse.configureScope( ScopeType.CURRENT, scope -> attributesExtractor.extract(span, scope, scopesToUse.getOptions())); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java index 9c6a51f17c3..31bd6368318 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java @@ -297,7 +297,7 @@ private boolean isSentryRequest(final @NotNull ReadableSpan otelSpan) { private void updateTransactionWithOtelData( final @NotNull ITransaction sentryTransaction, final @NotNull ReadableSpan otelSpan) { final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentryTransaction.setOperation(otelSpanInfo.getOp()); String transactionName = otelSpanInfo.getDescription(); sentryTransaction.setName( @@ -334,7 +334,7 @@ private void updateSpanWithOtelData( }); final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentrySpan.setOperation(otelSpanInfo.getOp()); sentrySpan.setDescription(otelSpanInfo.getDescription()); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index b66555d68c9..3af3d8f96f0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -7,6 +7,8 @@ import io.opentelemetry.semconv.UrlAttributes; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes; import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; +import io.sentry.SentryOptions; import io.sentry.protocol.TransactionNameSource; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -17,9 +19,19 @@ public final class SpanDescriptionExtractor { @SuppressWarnings("deprecation") public @NotNull OtelSpanInfo extractSpanInfo( - final @NotNull SpanData otelSpan, final @Nullable IOtelSpanWrapper sentrySpan) { + final @NotNull SpanData otelSpan, + final @Nullable IOtelSpanWrapper sentrySpan, + final @NotNull SentryOptions options) { final @NotNull Attributes attributes = otelSpan.getAttributes(); + if (options.isEnableQueueTracing()) { + final @Nullable String messagingSystem = + attributes.get(MessagingIncubatingAttributes.MESSAGING_SYSTEM); + if (messagingSystem != null) { + return descriptionForMessagingSystem(otelSpan); + } + } + final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); if (httpMethod != null) { return descriptionForHttpMethod(otelSpan, httpMethod); @@ -91,6 +103,53 @@ private static boolean isRootSpan(SpanData otelSpan) { return !otelSpan.getParentSpanContext().isValid() || otelSpan.getParentSpanContext().isRemote(); } + @SuppressWarnings("deprecation") + private OtelSpanInfo descriptionForMessagingSystem(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + final @NotNull String op = opForMessaging(otelSpan); + final @Nullable String destination = + attributes.get(MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + final @NotNull String description = destination != null ? destination : otelSpan.getName(); + return new OtelSpanInfo(op, description, TransactionNameSource.TASK); + } + + @SuppressWarnings("deprecation") + private @NotNull String opForMessaging(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + @Nullable + String operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + if (operationType == null) { + operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION); + } + if (operationType != null) { + switch (operationType) { + case "publish": + case "send": + return "queue.publish"; + case "create": + return "queue.create"; + case "receive": + return "queue.receive"; + case "process": + case "deliver": + return "queue.process"; + case "settle": + return "queue.settle"; + default: + break; + } + } + + final @NotNull SpanKind kind = otelSpan.getKind(); + if (SpanKind.PRODUCER.equals(kind)) { + return "queue.publish"; + } + if (SpanKind.CONSUMER.equals(kind)) { + return "queue.process"; + } + return "queue"; + } + @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForDbSystem(final @NotNull SpanData otelSpan) { final @NotNull Attributes attributes = otelSpan.getAttributes(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 9c5a1a352df..a43afb849e6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -11,6 +11,8 @@ import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.UrlAttributes import io.opentelemetry.semconv.incubating.DbIncubatingAttributes import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes +import io.sentry.SentryOptions import io.sentry.protocol.TransactionNameSource import kotlin.test.Test import kotlin.test.assertEquals @@ -228,6 +230,250 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.TASK, info.transactionNameSource) } + @Test + fun `ignores messaging system when queue tracing disabled`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("my-topic publish", info.op) + assertEquals("my-topic publish", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `maps messaging publish operation type to queue publish op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging send operation type to queue publish op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "send", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging process operation type to queue process op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "process", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging deliver operation type to queue process op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "deliver", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging create operation type to queue create op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "create", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.create", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging receive operation type to queue receive op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "receive", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.receive", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging settle operation type to queue settle op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "settle", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.settle", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `falls back to legacy messaging operation attribute`() { + @Suppress("DEPRECATION") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "queue-name", + MessagingIncubatingAttributes.MESSAGING_OPERATION to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("queue-name", info.description) + } + + @Test + fun `falls back to PRODUCER span kind when no operation attribute`() { + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to CONSUMER span kind when no operation attribute`() { + givenSpanKind(SpanKind.CONSUMER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to span name as description when destination missing`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic publish", info.description) + } + + @Test + fun `messaging mapping wins over http when both attributes present and queue tracing enabled`() { + // Some OTel instrumentations (e.g. aws-sdk-2.2 SQS) attach both messaging and http + // attributes to the same span. Messaging is more specific and must win. + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `http mapping wins over messaging when queue tracing disabled`() { + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("http.client", info.op) + assertEquals("POST https://sqs.us-east-1.amazonaws.com/", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + @Test fun `uses span name as op and description if no relevant attributes`() { givenSpanName("span name") @@ -289,9 +535,10 @@ class SpanDescriptionExtractorTest { builder.put(key as AttributeKey, value) } - private fun whenExtractingSpanInfo(): OtelSpanInfo { + private fun whenExtractingSpanInfo(queueTracingEnabled: Boolean = false): OtelSpanInfo { fixture.setup() - return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan) + val options = SentryOptions().apply { isEnableQueueTracing = queueTracingEnabled } + return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan, options) } private fun givenParentContext(parentContext: SpanContext) { diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index c27196e96b8..79878ab9a08 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -36,8 +36,10 @@ dependencies { implementation(projects.sentry) implementation(projects.sentryAsyncProfiler) implementation(projects.sentryJcache) + implementation(projects.sentryKafka) implementation(libs.jcache) implementation(libs.caffeine.jcache) + implementation(libs.kafka.clients) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentry) diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java index 0ed0646c7bc..2a45ef6902c 100644 --- a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java @@ -5,6 +5,7 @@ import io.sentry.jcache.SentryJCacheWrapper; import io.sentry.protocol.Message; import io.sentry.protocol.User; +import io.sentry.samples.console.kafka.KafkaShowcase; import java.util.Collections; import javax.cache.Cache; import javax.cache.CacheManager; @@ -16,6 +17,10 @@ public class Main { private static long numberOfDiscardedSpansDueToOverflow = 0; public static void main(String[] args) throws InterruptedException { + final String kafkaBootstrapServers = System.getenv("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS"); + final boolean kafkaEnabled = + kafkaBootstrapServers != null && !kafkaBootstrapServers.trim().isEmpty(); + Sentry.init( options -> { // NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in @@ -95,6 +100,7 @@ public static void main(String[] args) throws InterruptedException { // Enable cache tracing to create spans for cache operations options.setEnableCacheTracing(true); + options.setEnableQueueTracing(kafkaEnabled); // Determine traces sample rate based on the sampling context // options.setTracesSampler( @@ -178,6 +184,13 @@ public static void main(String[] args) throws InterruptedException { // cache.remove, and cache.flush spans as children of the active transaction. demonstrateCacheTracing(); + // Kafka queue tracing with the kafka-clients producer interceptor and manual consumer tracing. + // + // Enable with: SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS=localhost:9092 + if (kafkaEnabled) { + KafkaShowcase.runKafkaWithSentryTracing(kafkaBootstrapServers); + } + // Performance feature // // Transactions collect execution time of the piece of code that's executed between the start diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/kafka/KafkaShowcase.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/kafka/KafkaShowcase.java new file mode 100644 index 00000000000..de85e46b25f --- /dev/null +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/kafka/KafkaShowcase.java @@ -0,0 +1,143 @@ +package io.sentry.samples.console.kafka; + +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.Sentry; +import io.sentry.kafka.SentryKafkaConsumerTracing; +import io.sentry.kafka.SentryKafkaProducer; +import java.time.Duration; +import java.util.Collections; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; + +public final class KafkaShowcase { + + public static final String TOPIC = "sentry-topic-console-sample"; + + private KafkaShowcase() {} + + public static void runKafkaWithSentryTracing(final String bootstrapServers) { + final CountDownLatch consumedLatch = new CountDownLatch(1); + final Thread consumerThread = startConsumerWithSentryTracing(bootstrapServers, consumedLatch); + final Properties producerProperties = createProducerProperties(bootstrapServers); + + final ITransaction transaction = Sentry.startTransaction("kafka-demo", "demo"); + try (ISentryLifecycleToken ignored = transaction.makeCurrent()) { + // 1. Create the raw Kafka producer as you normally would. + final KafkaProducer rawProducer = new KafkaProducer<>(producerProperties); + + // 2. >>> Sentry instrumentation <<< + // Wrap it with SentryKafkaProducer.wrap() so every send is captured as a + // `queue.publish` span that closes when the broker ack callback fires. + final Producer producer = SentryKafkaProducer.wrap(rawProducer); + + try (producer) { + Thread.sleep(500); + producer.send(new ProducerRecord<>(TOPIC, "sentry-kafka sample message")).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception ignoredException) { + // local broker may not be available when running the sample + } + + try { + consumedLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } finally { + consumerThread.interrupt(); + try { + consumerThread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + transaction.finish(); + } + } + + public static Properties createProducerProperties(final String bootstrapServers) { + final Properties producerProperties = new Properties(); + producerProperties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + producerProperties.put( + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + producerProperties.put( + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + + // Optional tuning for sample stability in CI/local runs. + producerProperties.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 2000); + producerProperties.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, 2000); + producerProperties.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 3000); + + return producerProperties; + } + + public static Properties createConsumerProperties(final String bootstrapServers) { + final Properties consumerProperties = new Properties(); + consumerProperties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + consumerProperties.put( + ConsumerConfig.GROUP_ID_CONFIG, "sentry-console-sample-" + UUID.randomUUID()); + consumerProperties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + consumerProperties.put( + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + consumerProperties.put( + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + + // Optional tuning for sample stability in CI/local runs. + consumerProperties.put(ConsumerConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, 2000); + consumerProperties.put(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, 2000); + + return consumerProperties; + } + + private static Thread startConsumerWithSentryTracing( + final String bootstrapServers, final CountDownLatch consumedLatch) { + final Thread consumerThread = + new Thread( + () -> { + final Properties consumerProperties = createConsumerProperties(bootstrapServers); + + try (KafkaConsumer consumer = + new KafkaConsumer<>(consumerProperties)) { + consumer.subscribe(Collections.singletonList(TOPIC)); + + while (!Thread.currentThread().isInterrupted() && consumedLatch.getCount() > 0) { + final ConsumerRecords records = + consumer.poll(Duration.ofMillis(500)); + for (final ConsumerRecord record : records) { + SentryKafkaConsumerTracing.withTracing( + record, + () -> { + System.out.println( + "Consumed Kafka message from " + + record.topic() + + ": " + + record.value()); + consumedLatch.countDown(); + }); + if (consumedLatch.getCount() == 0) { + break; + } + } + } + } catch (Exception ignored) { + // local broker may not be available when running the sample + } + }, + "sentry-kafka-sample-consumer"); + consumerThread.start(); + return consumerThread; + } +} diff --git a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index 2b009167acb..db6f54a616b 100644 --- a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -19,19 +19,7 @@ class ConsoleApplicationSystemTest { @Test fun `console application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-console") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", - "SENTRY_PROFILE_LIFECYCLE" to "TRACE", - ), - ) + val process = launchConsoleProcess() process.waitFor(30, TimeUnit.SECONDS) assertEquals(0, process.exitValue()) @@ -40,6 +28,41 @@ class ConsoleApplicationSystemTest { verifyExpectedEvents() } + @Test + fun `console application sends kafka producer and consumer tracing when kafka is enabled`() { + val process = + launchConsoleProcess(mapOf("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS" to "localhost:9092")) + + process.waitFor(30, TimeUnit.SECONDS) + assertEquals(0, process.exitValue()) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "kafka-demo" && + testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") + } + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionHaveOp(transaction, "queue.process") && + transaction.contexts.trace?.origin == "manual.queue.kafka.consumer" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" + } + } + + private fun launchConsoleProcess(overrides: Map = emptyMap()): Process { + val jarFile = testHelper.findJar("sentry-samples-console") + val env = + mutableMapOf( + "SENTRY_DSN" to testHelper.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", + "SENTRY_DEBUG" to "true", + "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", + "SENTRY_PROFILE_LIFECYCLE" to "TRACE", + ) + env.putAll(overrides) + return testHelper.launch(jarFile, env) + } + private fun verifyExpectedEvents() { var profilerId: SentryId? = null // Verify we received a "Fatal message!" event diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index a7b2d939cdc..64ef57692c3 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -58,6 +58,10 @@ dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) + // kafka + implementation(libs.springboot4.starter.kafka) + implementation(projects.sentryKafka) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..0c3bea3b757 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..8c7b166fd33 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index d43a628eb9a..e12b960e0fd 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -59,6 +59,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(libs.otel) + // kafka + implementation(libs.springboot4.starter.kafka) + implementation(projects.sentryKafka) + // cache tracing implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..0c3bea3b757 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..8c7b166fd33 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index d96e5602483..cdb33ecc675 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -61,6 +61,10 @@ dependencies { implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) + // kafka + implementation(libs.springboot4.starter.kafka) + implementation(projects.sentryKafka) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..0c3bea3b757 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..8c7b166fd33 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..eaaa62af13b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application-kafka.properties @@ -0,0 +1,10 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt new file mode 100644 index 00000000000..43781cf2c56 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt @@ -0,0 +1,117 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +/** + * System tests for Kafka queue instrumentation. + * + * Requires: + * - The sample app running with `--spring.profiles.active=kafka` + * - A Kafka broker at localhost:9092 + * - The mock Sentry server at localhost:8000 + */ +class KafkaQueueSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `producer endpoint creates queue publish span`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") + } + } + + @Test + fun `consumer creates queue process transaction`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-consumer-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // The consumer runs asynchronously, so wait for the queue.process transaction + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionHaveOp(transaction, "queue.process") + } + } + + @Test + fun `producer and consumer share same trace`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("trace-test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // Capture the trace ID from the producer transaction (has queue.publish span) + var producerTraceId: String? = null + testHelper.ensureTransactionReceived { transaction, _ -> + if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { + producerTraceId = transaction.contexts.trace?.traceId?.toString() + true + } else { + false + } + } + + // Verify the consumer transaction has the same trace ID + // Use retryCount=3 since the consumer may take a moment to process + testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> + val envelope = + testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + ?: return@ensureEnvelopeReceived false + val txItem = + envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } + ?: return@ensureEnvelopeReceived false + val tx = + txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false + + tx.contexts.trace?.operation == "queue.process" && + tx.contexts.trace?.traceId?.toString() == producerTraceId + } + } + + @Test + fun `queue publish span has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + val span = transaction.spans.firstOrNull { it.op == "queue.publish" } + if (span == null) return@ensureTransactionReceived false + + val data = span.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } + + @Test + fun `queue process transaction has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("process-attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { + return@ensureTransactionReceived false + } + + val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index c7fc0106131..ed0af32b031 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -55,6 +55,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + // kafka + implementation(libs.spring.kafka3) + implementation(projects.sentryKafka) + // cache tracing implementation(libs.springboot3.starter.cache) implementation(libs.caffeine) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..5931efa3a3b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..b17d231951d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 767208a6082..d3d66c469b7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -59,6 +59,10 @@ dependencies { implementation(libs.otel) implementation(projects.sentryAsyncProfiler) + // kafka + implementation(libs.spring.kafka3) + implementation(projects.sentryKafka) + // cache tracing implementation(libs.springboot3.starter.cache) implementation(libs.caffeine) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..5931efa3a3b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..b17d231951d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 98f7ba434ff..ae3ef70ad70 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -62,6 +62,10 @@ dependencies { implementation(libs.springboot3.starter.cache) implementation(libs.caffeine) + // kafka + implementation(libs.spring.kafka3) + implementation(projects.sentryKafka) + // OpenFeature SDK implementation(libs.openfeature) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..5931efa3a3b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..b17d231951d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.jakarta.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..eaaa62af13b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application-kafka.properties @@ -0,0 +1,10 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 60b92d369d5..20f9463aabc 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -37,6 +37,7 @@ spring.quartz.job-store-type=memory # Cache tracing sentry.enable-cache-tracing=true + spring.cache.cache-names=todos spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt new file mode 100644 index 00000000000..43781cf2c56 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt @@ -0,0 +1,117 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +/** + * System tests for Kafka queue instrumentation. + * + * Requires: + * - The sample app running with `--spring.profiles.active=kafka` + * - A Kafka broker at localhost:9092 + * - The mock Sentry server at localhost:8000 + */ +class KafkaQueueSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `producer endpoint creates queue publish span`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") + } + } + + @Test + fun `consumer creates queue process transaction`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-consumer-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // The consumer runs asynchronously, so wait for the queue.process transaction + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionHaveOp(transaction, "queue.process") + } + } + + @Test + fun `producer and consumer share same trace`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("trace-test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // Capture the trace ID from the producer transaction (has queue.publish span) + var producerTraceId: String? = null + testHelper.ensureTransactionReceived { transaction, _ -> + if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { + producerTraceId = transaction.contexts.trace?.traceId?.toString() + true + } else { + false + } + } + + // Verify the consumer transaction has the same trace ID + // Use retryCount=3 since the consumer may take a moment to process + testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> + val envelope = + testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + ?: return@ensureEnvelopeReceived false + val txItem = + envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } + ?: return@ensureEnvelopeReceived false + val tx = + txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false + + tx.contexts.trace?.operation == "queue.process" && + tx.contexts.trace?.traceId?.toString() == producerTraceId + } + } + + @Test + fun `queue publish span has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + val span = transaction.spans.firstOrNull { it.op == "queue.publish" } + if (span == null) return@ensureTransactionReceived false + + val data = span.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } + + @Test + fun `queue process transaction has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("process-attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { + return@ensureTransactionReceived false + } + + val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index d96c59ac871..f1665f513d1 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -60,6 +60,10 @@ dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) + // kafka + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..013b3590a71 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..779171942d5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 1a7f62f6e74..7c84875ca07 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -56,6 +56,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(libs.otel) + // kafka + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..013b3590a71 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..779171942d5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..e0abadf5f9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/resources/application-kafka.properties @@ -0,0 +1,12 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer + +logging.level.org.apache.kafka=warn diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt new file mode 100644 index 00000000000..c401c91463e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class KafkaOtelCoexistenceSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `Sentry Kafka integration is suppressed when OTel is active`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("otel-coexistence-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "GET /kafka/produce" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.contexts.trace?.operation == "queue.process" && + transaction.contexts.trace?.origin == "auto.opentelemetry" && + transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && + transaction.sdk?.integrationSet?.contains("SpringKafka") != true + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 5b89ef568e4..cc535c725e1 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -47,6 +47,10 @@ dependencies { implementation(libs.springboot.starter.cache) implementation(libs.springboot.starter.websocket) implementation(libs.caffeine) + + // kafka + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) implementation(Config.Libs.aspectj) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..013b3590a71 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..779171942d5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..eaaa62af13b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application-kafka.properties @@ -0,0 +1,10 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt new file mode 100644 index 00000000000..43781cf2c56 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt @@ -0,0 +1,117 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +/** + * System tests for Kafka queue instrumentation. + * + * Requires: + * - The sample app running with `--spring.profiles.active=kafka` + * - A Kafka broker at localhost:9092 + * - The mock Sentry server at localhost:8000 + */ +class KafkaQueueSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `producer endpoint creates queue publish span`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") + } + } + + @Test + fun `consumer creates queue process transaction`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-consumer-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // The consumer runs asynchronously, so wait for the queue.process transaction + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionHaveOp(transaction, "queue.process") + } + } + + @Test + fun `producer and consumer share same trace`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("trace-test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // Capture the trace ID from the producer transaction (has queue.publish span) + var producerTraceId: String? = null + testHelper.ensureTransactionReceived { transaction, _ -> + if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { + producerTraceId = transaction.contexts.trace?.traceId?.toString() + true + } else { + false + } + } + + // Verify the consumer transaction has the same trace ID + // Use retryCount=3 since the consumer may take a moment to process + testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> + val envelope = + testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + ?: return@ensureEnvelopeReceived false + val txItem = + envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } + ?: return@ensureEnvelopeReceived false + val tx = + txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false + + tx.contexts.trace?.operation == "queue.process" && + tx.contexts.trace?.traceId?.toString() == producerTraceId + } + } + + @Test + fun `queue publish span has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + val span = transaction.spans.firstOrNull { it.op == "queue.publish" } + if (span == null) return@ensureTransactionReceived false + + val data = span.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } + + @Test + fun `queue process transaction has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("process-attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { + return@ensureTransactionReceived false + } + + val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } +} diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index 71a8a022bf6..c9250b550fd 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -244,6 +244,29 @@ public final class io/sentry/spring7/graphql/SentrySpringSubscriptionHandler : i public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; } +public final class io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring7/kafka/SentryKafkaRecordInterceptor : org/springframework/kafka/listener/RecordInterceptor { + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lorg/springframework/kafka/listener/RecordInterceptor;)V + public fun afterRecord (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun clearThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun intercept (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)Lorg/apache/kafka/clients/consumer/ConsumerRecord; + public fun setupThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V +} + public class io/sentry/spring7/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { public fun ()V public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index 8102909afb0..ae8269e7825 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -43,10 +43,12 @@ dependencies { compileOnly(libs.slf4j.api) compileOnly(libs.springboot4.starter.graphql) compileOnly(libs.springboot4.starter.quartz) + compileOnly(libs.spring.kafka4) compileOnly(Config.Libs.springWebflux) compileOnly(projects.sentryGraphql) compileOnly(projects.sentryGraphql22) + compileOnly(projects.sentryKafka) compileOnly(projects.sentryQuartz) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) @@ -60,6 +62,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryGraphql) + testImplementation(projects.sentryKafka) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin.spring7) testImplementation(libs.context.propagation) @@ -69,6 +72,7 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.springboot4.starter.aspectj) testImplementation(libs.springboot4.starter.graphql) + testImplementation(libs.spring.kafka4) testImplementation(libs.springboot4.starter.security) testImplementation(libs.springboot4.starter.test) testImplementation(libs.springboot4.starter.web) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessor.java new file mode 100644 index 00000000000..069330a4247 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessor.java @@ -0,0 +1,98 @@ +package io.sentry.spring7.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import java.lang.reflect.Field; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.config.AbstractKafkaListenerContainerFactory; +import org.springframework.kafka.listener.RecordInterceptor; + +/** + * Registers {@link SentryKafkaRecordInterceptor} on {@link AbstractKafkaListenerContainerFactory} + * beans. If an existing {@link RecordInterceptor} is already set, it is composed as a delegate. + */ +@ApiStatus.Internal +public final class SentryKafkaConsumerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + private static final @NotNull String RECORD_INTERCEPTOR_FIELD_NAME = "recordInterceptor"; + + private final @NotNull String recordInterceptorFieldName; + + public SentryKafkaConsumerBeanPostProcessor() { + this(RECORD_INTERCEPTOR_FIELD_NAME); + } + + SentryKafkaConsumerBeanPostProcessor(final @NotNull String recordInterceptorFieldName) { + this.recordInterceptorFieldName = recordInterceptorFieldName; + } + + private static final class InterceptorReadFailedException extends Exception { + private static final long serialVersionUID = 1L; + + InterceptorReadFailedException(final @NotNull Throwable cause) { + super(cause); + } + } + + @Override + @SuppressWarnings("unchecked") + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof AbstractKafkaListenerContainerFactory) { + final @NotNull AbstractKafkaListenerContainerFactory factory = + (AbstractKafkaListenerContainerFactory) bean; + + final @Nullable RecordInterceptor existing; + try { + existing = getExistingInterceptor(factory); + } catch (InterceptorReadFailedException e) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + e, + "Sentry Kafka consumer tracing disabled for factory '%s' \u2014 could not read " + + "existing recordInterceptor via reflection. Refusing to install Sentry's " + + "interceptor to avoid overwriting a customer-configured RecordInterceptor.", + beanName); + return bean; + } + + if (existing instanceof SentryKafkaRecordInterceptor) { + return bean; + } + + @SuppressWarnings("rawtypes") + final RecordInterceptor sentryInterceptor = + new SentryKafkaRecordInterceptor<>(ScopesAdapter.getInstance(), existing); + factory.setRecordInterceptor(sentryInterceptor); + } + return bean; + } + + private @Nullable RecordInterceptor getExistingInterceptor( + final @NotNull AbstractKafkaListenerContainerFactory factory) + throws InterceptorReadFailedException { + try { + final @NotNull Field field = + AbstractKafkaListenerContainerFactory.class.getDeclaredField(recordInterceptorFieldName); + field.setAccessible(true); + return (RecordInterceptor) field.get(factory); + } catch (NoSuchFieldException | IllegalAccessException | RuntimeException e) { + throw new InterceptorReadFailedException(e); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessor.java new file mode 100644 index 00000000000..eff0b4154bb --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessor.java @@ -0,0 +1,76 @@ +package io.sentry.spring7.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.kafka.SentryKafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.core.ProducerPostProcessor; + +/** + * Installs a {@link ProducerPostProcessor} on every {@link ProducerFactory} bean so that each + * {@link Producer} created by Spring Kafka is wrapped via {@link SentryKafkaProducer#wrap + * SentryKafkaProducer.wrap(Producer)}. + * + *

The wrapper records a {@code queue.publish} span around each {@code send(...)} that finishes + * when the broker ack callback fires, giving a real producer-send lifecycle span. {@code + * KafkaTemplate} beans are left untouched, so all customer-configured listeners, interceptors and + * observation settings are preserved. + * + *

Note: {@link ProducerFactory#addPostProcessor(ProducerPostProcessor)} is a default method on + * the interface that is a no-op unless overridden. Custom factories that do not extend {@code + * DefaultKafkaProducerFactory} will not receive Sentry producer instrumentation; a warning is + * logged at startup in that case. + */ +@ApiStatus.Internal +public final class SentryKafkaProducerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof ProducerFactory) { + final @NotNull ProducerFactory factory = (ProducerFactory) bean; + final @NotNull SentryProducerPostProcessor pp = new SentryProducerPostProcessor<>(); + factory.addPostProcessor(pp); + if (!factory.getPostProcessors().contains(pp)) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Sentry Kafka producer tracing not active for ProducerFactory '%s' (%s). " + + "addPostProcessor() was not honored — the factory may not extend " + + "DefaultKafkaProducerFactory. Wrap producers manually with " + + "SentryKafkaProducer.wrap(producer).", + beanName, + factory.getClass().getName()); + } + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + /** + * Marker {@link ProducerPostProcessor} that wraps the freshly created Kafka {@link Producer} via + * {@link SentryKafkaProducer#wrap}. + */ + static final class SentryProducerPostProcessor implements ProducerPostProcessor { + @Override + public @NotNull Producer apply(final @NotNull Producer producer) { + return SentryKafkaProducer.wrap( + producer, ScopesAdapter.getInstance(), "auto.queue.spring7.kafka.producer"); + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaRecordInterceptor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaRecordInterceptor.java new file mode 100644 index 00000000000..b2b4d20b948 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/kafka/SentryKafkaRecordInterceptor.java @@ -0,0 +1,292 @@ +package io.sentry.spring7.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.kafka.SentryKafkaProducer; +import io.sentry.util.SpanUtils; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.kafka.listener.RecordInterceptor; +import org.springframework.kafka.support.KafkaHeaders; + +/** + * A {@link RecordInterceptor} that creates {@code queue.process} transactions for incoming Kafka + * records with distributed tracing support. + */ +@ApiStatus.Internal +public final class SentryKafkaRecordInterceptor implements RecordInterceptor { + + static final String TRACE_ORIGIN = "auto.queue.spring7.kafka.consumer"; + + private final @NotNull IScopes scopes; + private final @Nullable RecordInterceptor delegate; + + private static final @NotNull ThreadLocal currentContext = + new ThreadLocal<>(); + + public SentryKafkaRecordInterceptor(final @NotNull IScopes scopes) { + this(scopes, null); + } + + public SentryKafkaRecordInterceptor( + final @NotNull IScopes scopes, final @Nullable RecordInterceptor delegate) { + this.scopes = scopes; + this.delegate = delegate; + } + + @Override + public @Nullable ConsumerRecord intercept( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return delegateIntercept(record, consumer); + } + + try { + finishStaleContext(); + + final @NotNull IScopes forkedScopes = scopes.forkedRootScopes("SentryKafkaRecordInterceptor"); + final @NotNull ISentryLifecycleToken lifecycleToken = forkedScopes.makeCurrent(); + currentContext.set(new SentryRecordContext(lifecycleToken, null)); + + final @Nullable TransactionContext transactionContext = continueTrace(forkedScopes, record); + + final @Nullable ITransaction transaction = + startTransaction(forkedScopes, record, transactionContext); + currentContext.set(new SentryRecordContext(lifecycleToken, transaction)); + } catch (Throwable t) { + scopes.getOptions().getLogger().log(SentryLevel.ERROR, "Unable to wrap Kafka consumer.", t); + } + return delegateIntercept(record, consumer); + } + + @Override + public void success( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.success(record, consumer); + } + } finally { + finishSpan(SpanStatus.OK, null); + } + } + + @Override + public void failure( + final @NotNull ConsumerRecord record, + final @NotNull Exception exception, + final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.failure(record, exception, consumer); + } + } finally { + finishSpan(SpanStatus.INTERNAL_ERROR, exception); + } + } + + @Override + public void afterRecord( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.afterRecord(record, consumer); + } + } + + @Override + public void setupThreadState(final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.setupThreadState(consumer); + } + } + + @Override + public void clearThreadState(final @NotNull Consumer consumer) { + try { + finishStaleContext(); + } finally { + if (delegate != null) { + delegate.clearThreadState(consumer); + } + } + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private @Nullable ConsumerRecord delegateIntercept( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (delegate != null) { + return delegate.intercept(record, consumer); + } + return record; + } + + private @Nullable TransactionContext continueTrace( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + final @Nullable String sentryTrace = headerValue(record, SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = + headerValues(record, BaggageHeader.BAGGAGE_HEADER); + return forkedScopes.continueTrace(sentryTrace, baggageHeaders); + } + + private @Nullable ITransaction startTransaction( + final @NotNull IScopes forkedScopes, + final @NotNull ConsumerRecord record, + final @Nullable TransactionContext transactionContext) { + if (!forkedScopes.getOptions().isTracingEnabled()) { + return null; + } + + final @NotNull TransactionContext txContext = + transactionContext != null + ? transactionContext + : new TransactionContext(record.topic(), "queue.process"); + txContext.setName(record.topic()); + txContext.setOperation("queue.process"); + + final @NotNull TransactionOptions txOptions = new TransactionOptions(); + txOptions.setOrigin(TRACE_ORIGIN); + txOptions.setBindToScope(true); + + final @NotNull ITransaction transaction = forkedScopes.startTransaction(txContext, txOptions); + + if (transaction.isNoOp()) { + return null; + } + + transaction.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + transaction.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + final @Nullable String messageId = headerValue(record, "messaging.message.id"); + if (messageId != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_ID, messageId); + } + + final int bodySize = record.serializedValueSize(); + if (bodySize >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, bodySize); + } + + final @Nullable Integer retryCount = retryCount(record); + if (retryCount != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, retryCount); + } + + final @Nullable String enqueuedTimeStr = + headerValue(record, SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER); + if (enqueuedTimeStr != null) { + try { + final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); + final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); + if (latencyMs >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); + } + } catch (NumberFormatException ignored) { + // ignore malformed header + } + } + + return transaction; + } + + private @Nullable Integer retryCount(final @NotNull ConsumerRecord record) { + final @Nullable Header header = record.headers().lastHeader(KafkaHeaders.DELIVERY_ATTEMPT); + if (header == null) { + return null; + } + + final byte[] value = header.value(); + if (value == null || value.length != Integer.BYTES) { + return null; + } + + final int attempt = ByteBuffer.wrap(value).getInt(); + if (attempt <= 0) { + return null; + } + + return attempt - 1; + } + + private void finishStaleContext() { + if (currentContext.get() != null) { + finishSpan(SpanStatus.UNKNOWN, null); + } + } + + private void finishSpan(final @NotNull SpanStatus status, final @Nullable Throwable throwable) { + final @Nullable SentryRecordContext ctx = currentContext.get(); + if (ctx == null) { + return; + } + currentContext.remove(); + + try { + final @Nullable ITransaction transaction = ctx.transaction; + if (transaction != null) { + transaction.setStatus(status); + if (throwable != null) { + transaction.setThrowable(throwable); + } + transaction.finish(); + } + } finally { + ctx.lifecycleToken.close(); + } + } + + private @Nullable String headerValue( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + final @Nullable Header header = record.headers().lastHeader(headerName); + if (header == null || header.value() == null) { + return null; + } + return new String(header.value(), StandardCharsets.UTF_8); + } + + private @Nullable List headerValues( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + @Nullable List values = null; + for (final @NotNull Header header : record.headers().headers(headerName)) { + if (header.value() != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(header.value(), StandardCharsets.UTF_8)); + } + } + return values; + } + + private static final class SentryRecordContext { + final @NotNull ISentryLifecycleToken lifecycleToken; + final @Nullable ITransaction transaction; + + SentryRecordContext( + final @NotNull ISentryLifecycleToken lifecycleToken, + final @Nullable ITransaction transaction) { + this.lifecycleToken = lifecycleToken; + this.transaction = transaction; + } + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..e5eb3b55292 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt @@ -0,0 +1,124 @@ +package io.sentry.spring7.kafka + +import io.sentry.Sentry +import io.sentry.test.initForTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.listener.RecordInterceptor + +class SentryKafkaConsumerBeanPostProcessorTest { + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `wraps ConcurrentKafkaListenerContainerFactory with SentryKafkaRecordInterceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + // Verify via reflection that the interceptor was set + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val interceptor = field.get(factory) + assertTrue(interceptor is SentryKafkaRecordInterceptor<*, *>) + } + + @Test + fun `does not double-wrap when SentryKafkaRecordInterceptor already set`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + + val processor = SentryKafkaConsumerBeanPostProcessor() + // First wrap + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val firstInterceptor = field.get(factory) + + // Second wrap — should be idempotent + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + val secondInterceptor = field.get(factory) + + assertSame(firstInterceptor, secondInterceptor) + } + + @Test + fun `does not wrap non-factory beans`() { + val someBean = "not a factory" + val processor = SentryKafkaConsumerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `chains existing customer RecordInterceptor as delegate`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + + val customerInterceptor = RecordInterceptor { record, _ -> record } + factory.setRecordInterceptor(customerInterceptor) + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val installed = field.get(factory) + assertTrue( + installed is SentryKafkaRecordInterceptor<*, *>, + "expected SentryKafkaRecordInterceptor, got ${installed?.javaClass}", + ) + + val delegateField = SentryKafkaRecordInterceptor::class.java.getDeclaredField("delegate") + delegateField.isAccessible = true + assertSame( + customerInterceptor, + delegateField.get(installed), + "customer interceptor must be preserved as delegate", + ) + } + + @Test + fun `skips installation when reflection fails and preserves customer interceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + val customerInterceptor = RecordInterceptor { record, _ -> record } + factory.setRecordInterceptor(customerInterceptor) + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + assertSame(customerInterceptor, field.get(factory)) + + val processor = SentryKafkaConsumerBeanPostProcessor("missingRecordInterceptor") + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + assertSame( + customerInterceptor, + field.get(factory), + "customer interceptor must remain installed when Sentry cannot read it", + ) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..d11ac1e6c1b --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaProducerBeanPostProcessorTest.kt @@ -0,0 +1,109 @@ +package io.sentry.spring7.kafka + +import io.sentry.Sentry +import io.sentry.test.initForTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.Producer +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.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.core.ProducerPostProcessor + +class SentryKafkaProducerBeanPostProcessorTest { + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `registers Sentry post-processor on ProducerFactory`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + val captor = argumentCaptor>() + verify(factory).addPostProcessor(captor.capture()) + assertTrue( + captor.firstValue is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } + + @Test + fun `does not throw when addPostProcessor is a no-op (default interface method)`() { + // Factory using the default no-op addPostProcessor / getPostProcessors + val factory = mock>() + whenever(factory.postProcessors).thenReturn(emptyList()) + val processor = SentryKafkaProducerBeanPostProcessor() + + // Should complete without throwing, and log a warning via ScopesAdapter + processor.postProcessAfterInitialization(factory, "myFactory") + + verify(factory).addPostProcessor(any()) + } + + @Test + fun `does not modify non-ProducerFactory beans`() { + val someBean = "not a producer factory" + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `returns the same bean instance`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertSame(factory, result, "BPP must return the same bean, not a replacement") + } + + @Test + fun `registered post-processor wraps producers via SentryKafkaProducer wrap`() { + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + val raw = mock>() + + val wrapped = pp.apply(raw) + + assertTrue(java.lang.reflect.Proxy.isProxyClass(wrapped.javaClass)) + } + + @Test + fun `integrates with DefaultKafkaProducerFactory addPostProcessor contract`() { + // Sanity check against the real Spring Kafka API surface — DefaultKafkaProducerFactory + // honors addPostProcessor and exposes it via getPostProcessors(). + val factory = DefaultKafkaProducerFactory(emptyMap()) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertEquals(1, factory.postProcessors.size) + assertTrue( + factory.postProcessors.first() + is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaRecordInterceptorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaRecordInterceptorTest.kt new file mode 100644 index 00000000000..2738f99f4df --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/kafka/SentryKafkaRecordInterceptorTest.kt @@ -0,0 +1,473 @@ +package io.sentry.spring7.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.test.initForTest +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Optional +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.record.TimestampType +import org.mockito.kotlin.any +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 +import org.springframework.kafka.listener.RecordInterceptor +import org.springframework.kafka.support.KafkaHeaders + +class SentryKafkaRecordInterceptorTest { + + private lateinit var scopes: IScopes + private lateinit var forkedScopes: IScopes + private lateinit var options: SentryOptions + private lateinit var consumer: Consumer + private lateinit var lifecycleToken: ISentryLifecycleToken + private lateinit var transaction: SentryTracer + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + scopes = mock() + consumer = mock() + lifecycleToken = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + tracesSampleRate = 1.0 + } + whenever(scopes.options).thenReturn(options) + whenever(scopes.isEnabled).thenReturn(true) + + forkedScopes = mock() + whenever(scopes.forkedRootScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.options).thenReturn(options) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + + transaction = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes) + whenever(forkedScopes.startTransaction(any(), any())) + .thenReturn(transaction) + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + private fun createRecord( + topic: String = "my-topic", + headers: RecordHeaders = RecordHeaders(), + serializedValueSize: Int = -1, + ): ConsumerRecord { + return ConsumerRecord( + topic, + 0, + 0L, + System.currentTimeMillis(), + TimestampType.CREATE_TIME, + 3, + serializedValueSize, + "key", + "value", + headers, + Optional.empty(), + ) + } + + private fun createRecordWithHeaders( + sentryTrace: String? = null, + baggage: String? = null, + baggageHeaders: List? = null, + enqueuedTime: String? = null, + deliveryAttempt: Int? = null, + ): ConsumerRecord { + val headers = RecordHeaders() + sentryTrace?.let { + headers.add(SentryTraceHeader.SENTRY_TRACE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggage?.let { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggageHeaders?.forEach { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + enqueuedTime?.let { + headers.add( + SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER, + it.toByteArray(StandardCharsets.UTF_8), + ) + } + deliveryAttempt?.let { + headers.add( + KafkaHeaders.DELIVERY_ATTEMPT, + ByteBuffer.allocate(Int.SIZE_BYTES).putInt(it).array(), + ) + } + val record = ConsumerRecord("my-topic", 0, 0L, "key", "value") + headers.forEach { record.headers().add(it) } + return record + } + + @Test + fun `intercept forks root scopes`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(scopes).forkedRootScopes("SentryKafkaRecordInterceptor") + verify(forkedScopes).makeCurrent() + verify(forkedScopes) + .startTransaction( + org.mockito.kotlin.check { + assertEquals("my-topic", it.name) + assertEquals("queue.process", it.operation) + }, + any(), + ) + } + + @Test + fun `intercept continues trace from headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = createRecordWithHeaders(sentryTrace = sentryTraceValue) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace(org.mockito.kotlin.eq(sentryTraceValue), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept calls continueTrace with null when no headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(forkedScopes).continueTrace(org.mockito.kotlin.isNull(), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept passes all baggage headers to continueTrace`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = + createRecordWithHeaders( + sentryTrace = sentryTraceValue, + baggageHeaders = listOf("third=party", "sentry-sample_rate=1"), + ) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace( + org.mockito.kotlin.eq(sentryTraceValue), + org.mockito.kotlin.eq(listOf("third=party", "sentry-sample_rate=1")), + ) + } + + @Test + fun `sets body size from serializedValueSize`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = 42) + + interceptor.intercept(record, consumer) + + assertEquals(42, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `does not set body size when serializedValueSize is negative`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = -1) + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `sets retry count from delivery attempt header`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecordWithHeaders(deliveryAttempt = 3) + + interceptor.intercept(record, consumer) + + assertEquals(2, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `does not set retry count when delivery attempt header is missing`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `sets receive latency from enqueued time in epoch seconds`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val enqueuedTime = (System.currentTimeMillis() / 1000.0 - 1.0).toString() + val record = createRecordWithHeaders(enqueuedTime = enqueuedTime) + + interceptor.intercept(record, consumer) + + val latency = transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY) + assertTrue(latency is Long && latency >= 0) + } + + @Test + fun `does not create span when queue tracing is disabled`() { + options.isEnableQueueTracing = false + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `does not create span when origin is ignored`() { + options.setIgnoredSpanOrigins(listOf(SentryKafkaRecordInterceptor.TRACE_ORIGIN)) + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `delegates to existing interceptor`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + interceptor.intercept(record, consumer) + + verify(delegate).intercept(record, consumer) + } + + @Test + fun `success finishes transaction and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + + verify(delegate).success(record, consumer) + } + + @Test + fun `failure finishes transaction with error and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + val exception = RuntimeException("processing failed") + + interceptor.intercept(record, consumer) + interceptor.failure(record, exception, consumer) + + verify(delegate).failure(record, exception, consumer) + } + + @Test + fun `afterRecord delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.afterRecord(record, consumer) + + verify(delegate).afterRecord(record, consumer) + } + + @Test + fun `trace origin is set correctly`() { + assertEquals("auto.queue.spring7.kafka.consumer", SentryKafkaRecordInterceptor.TRACE_ORIGIN) + } + + @Test + fun `clearThreadState cleans up stale context`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken).close() + } + + @Test + fun `clearThreadState is no-op when no context exists`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.clearThreadState(consumer) + } + + @Test + fun `setupThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + + verify(delegate).setupThreadState(consumer) + } + + @Test + fun `setupThreadState is no-op without delegate`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.setupThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.clearThreadState(consumer) + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor even when sentry cleanup throws`() { + val delegate = mock>() + whenever(lifecycleToken.close()).thenThrow(RuntimeException("boom")) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + + try { + interceptor.clearThreadState(consumer) + } catch (ignored: RuntimeException) { + // expected + } + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `full lifecycle intercept success clearThreadState closes token exactly once`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + interceptor.clearThreadState(consumer) + + // token closed once by success(); clearThreadState must not re-close it + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + // delegate hooks still delegated across the full lifecycle + verify(delegate).setupThreadState(consumer) + verify(delegate).success(record, consumer) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept returns null clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + // delegate filters the record — per Spring Kafka contract, success/failure will not be invoked + whenever(delegate.intercept(record, consumer)).thenReturn(null) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val result = interceptor.intercept(record, consumer) + interceptor.clearThreadState(consumer) + + assertNull(result) + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept throws clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + val boom = RuntimeException("delegate boom") + whenever(delegate.intercept(record, consumer)).thenThrow(boom) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val thrown = assertFailsWith { interceptor.intercept(record, consumer) } + assertEquals(boom, thrown) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `intercept cleans up stale context from previous record`() { + val lifecycleToken2 = mock() + val forkedScopes2 = mock() + whenever(forkedScopes2.options).thenReturn(options) + whenever(forkedScopes2.makeCurrent()).thenReturn(lifecycleToken2) + val tx2 = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes2) + whenever(forkedScopes2.startTransaction(any(), any())).thenReturn(tx2) + + var callCount = 0 + + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + whenever(scopes.forkedRootScopes(any())).thenAnswer { + callCount++ + if (callCount == 1) forkedScopes else forkedScopes2 + } + + // First intercept sets up context + interceptor.intercept(record, consumer) + + // Second intercept without success/failure — should clean up stale context first + interceptor.intercept(record, consumer) + + // First lifecycle token should have been closed by the defensive cleanup + verify(lifecycleToken).close() + } +} diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 69a40f7b64f..3b0b3be8630 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { compileOnly(projects.sentryGraphql) compileOnly(projects.sentryGraphql22) compileOnly(projects.sentryQuartz) + compileOnly(libs.spring.kafka4) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springWebflux) compileOnly(libs.context.propagation) @@ -68,6 +69,7 @@ dependencies { testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryGraphql) testImplementation(projects.sentryGraphql22) + testImplementation(projects.sentryKafka) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgent) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) @@ -96,6 +98,7 @@ dependencies { testImplementation(libs.springboot4.starter) testImplementation(libs.springboot4.starter.aspectj) testImplementation(libs.springboot4.starter.graphql) + testImplementation(libs.spring.kafka4) testImplementation(libs.springboot4.starter.quartz) testImplementation(libs.springboot4.starter.security) testImplementation(libs.springboot4.starter.test) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java index ae9e3ac50fe..2429c1e7446 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -31,6 +31,8 @@ import io.sentry.spring7.checkin.SentryQuartzConfiguration; import io.sentry.spring7.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring7.exception.SentryExceptionParameterAdviceConfiguration; +import io.sentry.spring7.kafka.SentryKafkaConsumerBeanPostProcessor; +import io.sentry.spring7.kafka.SentryKafkaProducerBeanPostProcessor; import io.sentry.spring7.opentelemetry.SentryOpenTelemetryAgentWithoutAutoInitConfiguration; import io.sentry.spring7.opentelemetry.SentryOpenTelemetryNoAgentConfiguration; import io.sentry.spring7.tracing.CombinedTransactionNameProvider; @@ -244,6 +246,34 @@ static class SentryCacheConfiguration { } } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass( + name = { + "org.springframework.kafka.core.KafkaTemplate", + "io.sentry.kafka.SentryKafkaProducer" + }) + @ConditionalOnProperty(name = "sentry.enable-queue-tracing", havingValue = "true") + @ConditionalOnMissingClass({ + "io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider", + "io.sentry.opentelemetry.agent.AgentMarker" + }) + @Open + static class SentryKafkaQueueConfiguration { + + @Bean + public static @NotNull SentryKafkaProducerBeanPostProcessor + sentryKafkaProducerBeanPostProcessor() { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringKafka"); + return new SentryKafkaProducerBeanPostProcessor(); + } + + @Bean + public static @NotNull SentryKafkaConsumerBeanPostProcessor + sentryKafkaConsumerBeanPostProcessor() { + return new SentryKafkaConsumerBeanPostProcessor(); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ProceedingJoinPoint.class) @ConditionalOnProperty( diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryKafkaAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryKafkaAutoConfigurationTest.kt new file mode 100644 index 00000000000..d4d2b439427 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryKafkaAutoConfigurationTest.kt @@ -0,0 +1,125 @@ +package io.sentry.spring.boot4 + +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider +import io.sentry.opentelemetry.agent.AgentMarker +import io.sentry.spring7.kafka.SentryKafkaConsumerBeanPostProcessor +import io.sentry.spring7.kafka.SentryKafkaProducerBeanPostProcessor +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.kafka.core.KafkaTemplate + +class SentryKafkaAutoConfigurationTest { + + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.traces-sample-rate=1.0", + "sentry.shutdownTimeoutMillis=0", + "sentry.sessionFlushTimeoutMillis=0", + "sentry.flushTimeoutMillis=0", + "sentry.readTimeoutMillis=50", + "sentry.connectionTimeoutMillis=50", + "sentry.send-modules=false", + "sentry.debug=false", + ) + + private val noOtelClassLoader = + FilteredClassLoader( + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noOtelCustomizerClassLoader = + FilteredClassLoader(SentryAutoConfigurationCustomizerProvider::class.java) + + private val noSentryKafkaClassLoader = + FilteredClassLoader( + SentryKafkaProducer::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noSpringKafkaClassLoader = + FilteredClassLoader( + KafkaTemplate::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + @Test + fun `registers Kafka BPPs when queue tracing is enabled`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).hasSingleBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).hasSingleBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is disabled`() { + contextRunner.withClassLoader(noOtelClassLoader).run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when sentry-kafka is not present`() { + contextRunner + .withClassLoader(noSentryKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when spring-kafka is not present`() { + contextRunner + .withClassLoader(noSpringKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is explicitly false`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=false") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry agent is present`() { + contextRunner + .withClassLoader(noOtelCustomizerClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry integration is present`() { + contextRunner.withPropertyValues("sentry.enable-queue-tracing=true").run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } +} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 04166519240..36b7dad3cc6 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { compileOnly(projects.sentryGraphql) compileOnly(projects.sentryGraphql22) compileOnly(projects.sentryQuartz) + compileOnly(libs.spring.kafka3) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springWebflux) compileOnly(libs.context.propagation) @@ -70,6 +71,7 @@ dependencies { testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryGraphql) testImplementation(projects.sentryGraphql22) + testImplementation(projects.sentryKafka) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgent) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) @@ -90,6 +92,7 @@ dependencies { testImplementation(libs.springboot3.starter) testImplementation(libs.springboot3.starter.aop) testImplementation(libs.springboot3.starter.graphql) + testImplementation(libs.spring.kafka3) testImplementation(libs.springboot3.starter.quartz) testImplementation(libs.springboot3.starter.security) testImplementation(libs.springboot3.starter.test) diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index ef57868ad87..e1f8b026274 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -31,6 +31,8 @@ import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration; import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring.jakarta.exception.SentryExceptionParameterAdviceConfiguration; +import io.sentry.spring.jakarta.kafka.SentryKafkaConsumerBeanPostProcessor; +import io.sentry.spring.jakarta.kafka.SentryKafkaProducerBeanPostProcessor; import io.sentry.spring.jakarta.opentelemetry.SentryOpenTelemetryAgentWithoutAutoInitConfiguration; import io.sentry.spring.jakarta.opentelemetry.SentryOpenTelemetryNoAgentConfiguration; import io.sentry.spring.jakarta.tracing.CombinedTransactionNameProvider; @@ -246,6 +248,34 @@ static class SentryCacheConfiguration { } } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass( + name = { + "org.springframework.kafka.core.KafkaTemplate", + "io.sentry.kafka.SentryKafkaProducer" + }) + @ConditionalOnProperty(name = "sentry.enable-queue-tracing", havingValue = "true") + @ConditionalOnMissingClass({ + "io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider", + "io.sentry.opentelemetry.agent.AgentMarker" + }) + @Open + static class SentryKafkaQueueConfiguration { + + @Bean + public static @NotNull SentryKafkaProducerBeanPostProcessor + sentryKafkaProducerBeanPostProcessor() { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringKafka"); + return new SentryKafkaProducerBeanPostProcessor(); + } + + @Bean + public static @NotNull SentryKafkaConsumerBeanPostProcessor + sentryKafkaConsumerBeanPostProcessor() { + return new SentryKafkaConsumerBeanPostProcessor(); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ProceedingJoinPoint.class) @ConditionalOnProperty( diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryKafkaAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryKafkaAutoConfigurationTest.kt new file mode 100644 index 00000000000..392e5184759 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryKafkaAutoConfigurationTest.kt @@ -0,0 +1,125 @@ +package io.sentry.spring.boot.jakarta + +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider +import io.sentry.opentelemetry.agent.AgentMarker +import io.sentry.spring.jakarta.kafka.SentryKafkaConsumerBeanPostProcessor +import io.sentry.spring.jakarta.kafka.SentryKafkaProducerBeanPostProcessor +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.kafka.core.KafkaTemplate + +class SentryKafkaAutoConfigurationTest { + + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.traces-sample-rate=1.0", + "sentry.shutdownTimeoutMillis=0", + "sentry.sessionFlushTimeoutMillis=0", + "sentry.flushTimeoutMillis=0", + "sentry.readTimeoutMillis=50", + "sentry.connectionTimeoutMillis=50", + "sentry.send-modules=false", + "sentry.debug=false", + ) + + private val noOtelClassLoader = + FilteredClassLoader( + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noOtelCustomizerClassLoader = + FilteredClassLoader(SentryAutoConfigurationCustomizerProvider::class.java) + + private val noSentryKafkaClassLoader = + FilteredClassLoader( + SentryKafkaProducer::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noSpringKafkaClassLoader = + FilteredClassLoader( + KafkaTemplate::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + @Test + fun `registers Kafka BPPs when queue tracing is enabled`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).hasSingleBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).hasSingleBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is disabled`() { + contextRunner.withClassLoader(noOtelClassLoader).run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when sentry-kafka is not present`() { + contextRunner + .withClassLoader(noSentryKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when spring-kafka is not present`() { + contextRunner + .withClassLoader(noSpringKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is explicitly false`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=false") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry agent is present`() { + contextRunner + .withClassLoader(noOtelCustomizerClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry integration is present`() { + contextRunner.withPropertyValues("sentry.enable-queue-tracing=true").run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } +} diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 43150869db5..74f5d7c87bb 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -38,11 +38,13 @@ dependencies { compileOnly(libs.springboot.starter.graphql) compileOnly(libs.springboot.starter.quartz) compileOnly(libs.springboot.starter.security) + compileOnly(libs.spring.kafka2) compileOnly(platform(libs.springboot2.bom)) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springWebflux) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryKafka) compileOnly(projects.sentryQuartz) annotationProcessor(platform(libs.springboot2.bom)) @@ -57,6 +59,7 @@ dependencies { testImplementation(projects.sentryLogback) testImplementation(projects.sentryQuartz) testImplementation(projects.sentryApacheHttpClient5) + testImplementation(projects.sentryKafka) testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) @@ -69,6 +72,7 @@ dependencies { testImplementation(libs.springboot.starter.aop) testImplementation(libs.springboot.starter.quartz) testImplementation(libs.springboot.starter.security) + testImplementation(libs.spring.kafka2) testImplementation(libs.springboot.starter.test) testImplementation(libs.springboot.starter.web) testImplementation(libs.springboot.starter.webflux) diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 99fd602f74b..c7d5a892e9f 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -31,6 +31,8 @@ import io.sentry.spring.checkin.SentryQuartzConfiguration; import io.sentry.spring.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring.exception.SentryExceptionParameterAdviceConfiguration; +import io.sentry.spring.kafka.SentryKafkaConsumerBeanPostProcessor; +import io.sentry.spring.kafka.SentryKafkaProducerBeanPostProcessor; import io.sentry.spring.opentelemetry.SentryOpenTelemetryAgentWithoutAutoInitConfiguration; import io.sentry.spring.opentelemetry.SentryOpenTelemetryNoAgentConfiguration; import io.sentry.spring.tracing.CombinedTransactionNameProvider; @@ -231,6 +233,34 @@ static class SentryCacheConfiguration { } } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass( + name = { + "org.springframework.kafka.core.KafkaTemplate", + "io.sentry.kafka.SentryKafkaProducer" + }) + @ConditionalOnProperty(name = "sentry.enable-queue-tracing", havingValue = "true") + @ConditionalOnMissingClass({ + "io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider", + "io.sentry.opentelemetry.agent.AgentMarker" + }) + @Open + static class SentryKafkaQueueConfiguration { + + @Bean + public static @NotNull SentryKafkaProducerBeanPostProcessor + sentryKafkaProducerBeanPostProcessor() { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringKafka"); + return new SentryKafkaProducerBeanPostProcessor(); + } + + @Bean + public static @NotNull SentryKafkaConsumerBeanPostProcessor + sentryKafkaConsumerBeanPostProcessor() { + return new SentryKafkaConsumerBeanPostProcessor(); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ProceedingJoinPoint.class) @ConditionalOnProperty( diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryKafkaAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryKafkaAutoConfigurationTest.kt new file mode 100644 index 00000000000..fdf12bacf00 --- /dev/null +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryKafkaAutoConfigurationTest.kt @@ -0,0 +1,125 @@ +package io.sentry.spring.boot + +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider +import io.sentry.opentelemetry.agent.AgentMarker +import io.sentry.spring.kafka.SentryKafkaConsumerBeanPostProcessor +import io.sentry.spring.kafka.SentryKafkaProducerBeanPostProcessor +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.kafka.core.KafkaTemplate + +class SentryKafkaAutoConfigurationTest { + + private val contextRunner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SentryAutoConfiguration::class.java)) + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.traces-sample-rate=1.0", + "sentry.shutdownTimeoutMillis=0", + "sentry.sessionFlushTimeoutMillis=0", + "sentry.flushTimeoutMillis=0", + "sentry.readTimeoutMillis=50", + "sentry.connectionTimeoutMillis=50", + "sentry.send-modules=false", + "sentry.debug=false", + ) + + private val noOtelClassLoader = + FilteredClassLoader( + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noOtelCustomizerClassLoader = + FilteredClassLoader(SentryAutoConfigurationCustomizerProvider::class.java) + + private val noSentryKafkaClassLoader = + FilteredClassLoader( + SentryKafkaProducer::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + private val noSpringKafkaClassLoader = + FilteredClassLoader( + KafkaTemplate::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + AgentMarker::class.java, + ) + + @Test + fun `registers Kafka BPPs when queue tracing is enabled`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).hasSingleBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).hasSingleBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is disabled`() { + contextRunner.withClassLoader(noOtelClassLoader).run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when sentry-kafka is not present`() { + contextRunner + .withClassLoader(noSentryKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when spring-kafka is not present`() { + contextRunner + .withClassLoader(noSpringKafkaClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when queue tracing is explicitly false`() { + contextRunner + .withClassLoader(noOtelClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=false") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry agent is present`() { + contextRunner + .withClassLoader(noOtelCustomizerClassLoader) + .withPropertyValues("sentry.enable-queue-tracing=true") + .run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } + + @Test + fun `does not register Kafka BPPs when OpenTelemetry integration is present`() { + contextRunner.withPropertyValues("sentry.enable-queue-tracing=true").run { context -> + assertThat(context).doesNotHaveBean(SentryKafkaProducerBeanPostProcessor::class.java) + assertThat(context).doesNotHaveBean(SentryKafkaConsumerBeanPostProcessor::class.java) + } + } +} diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index fe634da6f4c..24b9af7e14b 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -244,6 +244,29 @@ public final class io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHand public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; } +public final class io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptor : org/springframework/kafka/listener/RecordInterceptor { + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lorg/springframework/kafka/listener/RecordInterceptor;)V + public fun afterRecord (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun clearThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun intercept (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)Lorg/apache/kafka/clients/consumer/ConsumerRecord; + public fun setupThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V +} + public class io/sentry/spring/jakarta/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { public fun ()V public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index f1920e24510..cbf2e5346b5 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -29,6 +29,7 @@ tasks.withType().configureEach { dependencies { api(projects.sentry) + compileOnly(projects.sentryKafka) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springAop) @@ -41,6 +42,7 @@ dependencies { compileOnly(libs.servlet.jakarta.api) compileOnly(libs.slf4j.api) compileOnly(libs.springboot3.starter.graphql) + compileOnly(libs.spring.kafka3) compileOnly(libs.springboot3.starter.quartz) compileOnly(Config.Libs.springWebflux) @@ -58,6 +60,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryGraphql) + testImplementation(projects.sentryKafka) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) testImplementation(libs.context.propagation) @@ -68,6 +71,7 @@ dependencies { testImplementation(libs.springboot3.starter.aop) testImplementation(libs.springboot3.starter.graphql) testImplementation(libs.springboot3.starter.security) + testImplementation(libs.spring.kafka3) testImplementation(libs.springboot3.starter.test) testImplementation(libs.springboot3.starter.web) testImplementation(libs.springboot3.starter.webflux) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessor.java new file mode 100644 index 00000000000..e4676b79cfd --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessor.java @@ -0,0 +1,98 @@ +package io.sentry.spring.jakarta.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import java.lang.reflect.Field; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.config.AbstractKafkaListenerContainerFactory; +import org.springframework.kafka.listener.RecordInterceptor; + +/** + * Registers {@link SentryKafkaRecordInterceptor} on {@link AbstractKafkaListenerContainerFactory} + * beans. If an existing {@link RecordInterceptor} is already set, it is composed as a delegate. + */ +@ApiStatus.Internal +public final class SentryKafkaConsumerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + private static final @NotNull String RECORD_INTERCEPTOR_FIELD_NAME = "recordInterceptor"; + + private final @NotNull String recordInterceptorFieldName; + + public SentryKafkaConsumerBeanPostProcessor() { + this(RECORD_INTERCEPTOR_FIELD_NAME); + } + + SentryKafkaConsumerBeanPostProcessor(final @NotNull String recordInterceptorFieldName) { + this.recordInterceptorFieldName = recordInterceptorFieldName; + } + + private static final class InterceptorReadFailedException extends Exception { + private static final long serialVersionUID = 1L; + + InterceptorReadFailedException(final @NotNull Throwable cause) { + super(cause); + } + } + + @Override + @SuppressWarnings("unchecked") + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof AbstractKafkaListenerContainerFactory) { + final @NotNull AbstractKafkaListenerContainerFactory factory = + (AbstractKafkaListenerContainerFactory) bean; + + final @Nullable RecordInterceptor existing; + try { + existing = getExistingInterceptor(factory); + } catch (InterceptorReadFailedException e) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + e, + "Sentry Kafka consumer tracing disabled for factory '%s' \u2014 could not read " + + "existing recordInterceptor via reflection. Refusing to install Sentry's " + + "interceptor to avoid overwriting a customer-configured RecordInterceptor.", + beanName); + return bean; + } + + if (existing instanceof SentryKafkaRecordInterceptor) { + return bean; + } + + @SuppressWarnings("rawtypes") + final RecordInterceptor sentryInterceptor = + new SentryKafkaRecordInterceptor<>(ScopesAdapter.getInstance(), existing); + factory.setRecordInterceptor(sentryInterceptor); + } + return bean; + } + + private @Nullable RecordInterceptor getExistingInterceptor( + final @NotNull AbstractKafkaListenerContainerFactory factory) + throws InterceptorReadFailedException { + try { + final @NotNull Field field = + AbstractKafkaListenerContainerFactory.class.getDeclaredField(recordInterceptorFieldName); + field.setAccessible(true); + return (RecordInterceptor) field.get(factory); + } catch (NoSuchFieldException | IllegalAccessException | RuntimeException e) { + throw new InterceptorReadFailedException(e); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java new file mode 100644 index 00000000000..8a06e4e338e --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java @@ -0,0 +1,76 @@ +package io.sentry.spring.jakarta.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.kafka.SentryKafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.core.ProducerPostProcessor; + +/** + * Installs a {@link ProducerPostProcessor} on every {@link ProducerFactory} bean so that each + * {@link Producer} created by Spring Kafka is wrapped via {@link SentryKafkaProducer#wrap + * SentryKafkaProducer.wrap(Producer)}. + * + *

The wrapper records a {@code queue.publish} span around each {@code send(...)} that finishes + * when the broker ack callback fires, giving a real producer-send lifecycle span. {@code + * KafkaTemplate} beans are left untouched, so all customer-configured listeners, interceptors and + * observation settings are preserved. + * + *

Note: {@link ProducerFactory#addPostProcessor(ProducerPostProcessor)} is a default method on + * the interface that is a no-op unless overridden. Custom factories that do not extend {@code + * DefaultKafkaProducerFactory} will not receive Sentry producer instrumentation; a warning is + * logged at startup in that case. + */ +@ApiStatus.Internal +public final class SentryKafkaProducerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof ProducerFactory) { + final @NotNull ProducerFactory factory = (ProducerFactory) bean; + final @NotNull SentryProducerPostProcessor pp = new SentryProducerPostProcessor<>(); + factory.addPostProcessor(pp); + if (!factory.getPostProcessors().contains(pp)) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Sentry Kafka producer tracing not active for ProducerFactory '%s' (%s). " + + "addPostProcessor() was not honored — the factory may not extend " + + "DefaultKafkaProducerFactory. Wrap producers manually with " + + "SentryKafkaProducer.wrap(producer).", + beanName, + factory.getClass().getName()); + } + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + /** + * Marker {@link ProducerPostProcessor} that wraps the freshly created Kafka {@link Producer} via + * {@link SentryKafkaProducer#wrap}. + */ + static final class SentryProducerPostProcessor implements ProducerPostProcessor { + @Override + public @NotNull Producer apply(final @NotNull Producer producer) { + return SentryKafkaProducer.wrap( + producer, ScopesAdapter.getInstance(), "auto.queue.spring_jakarta.kafka.producer"); + } + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptor.java new file mode 100644 index 00000000000..72535712695 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptor.java @@ -0,0 +1,292 @@ +package io.sentry.spring.jakarta.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.kafka.SentryKafkaProducer; +import io.sentry.util.SpanUtils; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.kafka.listener.RecordInterceptor; +import org.springframework.kafka.support.KafkaHeaders; + +/** + * A {@link RecordInterceptor} that creates {@code queue.process} transactions for incoming Kafka + * records with distributed tracing support. + */ +@ApiStatus.Internal +public final class SentryKafkaRecordInterceptor implements RecordInterceptor { + + static final String TRACE_ORIGIN = "auto.queue.spring_jakarta.kafka.consumer"; + + private final @NotNull IScopes scopes; + private final @Nullable RecordInterceptor delegate; + + private static final @NotNull ThreadLocal currentContext = + new ThreadLocal<>(); + + public SentryKafkaRecordInterceptor(final @NotNull IScopes scopes) { + this(scopes, null); + } + + public SentryKafkaRecordInterceptor( + final @NotNull IScopes scopes, final @Nullable RecordInterceptor delegate) { + this.scopes = scopes; + this.delegate = delegate; + } + + @Override + public @Nullable ConsumerRecord intercept( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return delegateIntercept(record, consumer); + } + + try { + finishStaleContext(); + + final @NotNull IScopes forkedScopes = scopes.forkedRootScopes("SentryKafkaRecordInterceptor"); + final @NotNull ISentryLifecycleToken lifecycleToken = forkedScopes.makeCurrent(); + currentContext.set(new SentryRecordContext(lifecycleToken, null)); + + final @Nullable TransactionContext transactionContext = continueTrace(forkedScopes, record); + + final @Nullable ITransaction transaction = + startTransaction(forkedScopes, record, transactionContext); + currentContext.set(new SentryRecordContext(lifecycleToken, transaction)); + } catch (Throwable t) { + scopes.getOptions().getLogger().log(SentryLevel.ERROR, "Unable to wrap Kafka consumer.", t); + } + return delegateIntercept(record, consumer); + } + + @Override + public void success( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.success(record, consumer); + } + } finally { + finishSpan(SpanStatus.OK, null); + } + } + + @Override + public void failure( + final @NotNull ConsumerRecord record, + final @NotNull Exception exception, + final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.failure(record, exception, consumer); + } + } finally { + finishSpan(SpanStatus.INTERNAL_ERROR, exception); + } + } + + @Override + public void afterRecord( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.afterRecord(record, consumer); + } + } + + @Override + public void setupThreadState(final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.setupThreadState(consumer); + } + } + + @Override + public void clearThreadState(final @NotNull Consumer consumer) { + try { + finishStaleContext(); + } finally { + if (delegate != null) { + delegate.clearThreadState(consumer); + } + } + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private @Nullable ConsumerRecord delegateIntercept( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (delegate != null) { + return delegate.intercept(record, consumer); + } + return record; + } + + private @Nullable TransactionContext continueTrace( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + final @Nullable String sentryTrace = headerValue(record, SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = + headerValues(record, BaggageHeader.BAGGAGE_HEADER); + return forkedScopes.continueTrace(sentryTrace, baggageHeaders); + } + + private @Nullable ITransaction startTransaction( + final @NotNull IScopes forkedScopes, + final @NotNull ConsumerRecord record, + final @Nullable TransactionContext transactionContext) { + if (!forkedScopes.getOptions().isTracingEnabled()) { + return null; + } + + final @NotNull TransactionContext txContext = + transactionContext != null + ? transactionContext + : new TransactionContext(record.topic(), "queue.process"); + txContext.setName(record.topic()); + txContext.setOperation("queue.process"); + + final @NotNull TransactionOptions txOptions = new TransactionOptions(); + txOptions.setOrigin(TRACE_ORIGIN); + txOptions.setBindToScope(true); + + final @NotNull ITransaction transaction = forkedScopes.startTransaction(txContext, txOptions); + + if (transaction.isNoOp()) { + return null; + } + + transaction.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + transaction.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + final @Nullable String messageId = headerValue(record, "messaging.message.id"); + if (messageId != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_ID, messageId); + } + + final int bodySize = record.serializedValueSize(); + if (bodySize >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, bodySize); + } + + final @Nullable Integer retryCount = retryCount(record); + if (retryCount != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, retryCount); + } + + final @Nullable String enqueuedTimeStr = + headerValue(record, SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER); + if (enqueuedTimeStr != null) { + try { + final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); + final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); + if (latencyMs >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); + } + } catch (NumberFormatException ignored) { + // ignore malformed header + } + } + + return transaction; + } + + private @Nullable Integer retryCount(final @NotNull ConsumerRecord record) { + final @Nullable Header header = record.headers().lastHeader(KafkaHeaders.DELIVERY_ATTEMPT); + if (header == null) { + return null; + } + + final byte[] value = header.value(); + if (value == null || value.length != Integer.BYTES) { + return null; + } + + final int attempt = ByteBuffer.wrap(value).getInt(); + if (attempt <= 0) { + return null; + } + + return attempt - 1; + } + + private void finishStaleContext() { + if (currentContext.get() != null) { + finishSpan(SpanStatus.UNKNOWN, null); + } + } + + private void finishSpan(final @NotNull SpanStatus status, final @Nullable Throwable throwable) { + final @Nullable SentryRecordContext ctx = currentContext.get(); + if (ctx == null) { + return; + } + currentContext.remove(); + + try { + final @Nullable ITransaction transaction = ctx.transaction; + if (transaction != null) { + transaction.setStatus(status); + if (throwable != null) { + transaction.setThrowable(throwable); + } + transaction.finish(); + } + } finally { + ctx.lifecycleToken.close(); + } + } + + private @Nullable String headerValue( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + final @Nullable Header header = record.headers().lastHeader(headerName); + if (header == null || header.value() == null) { + return null; + } + return new String(header.value(), StandardCharsets.UTF_8); + } + + private @Nullable List headerValues( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + @Nullable List values = null; + for (final @NotNull Header header : record.headers().headers(headerName)) { + if (header.value() != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(header.value(), StandardCharsets.UTF_8)); + } + } + return values; + } + + private static final class SentryRecordContext { + final @NotNull ISentryLifecycleToken lifecycleToken; + final @Nullable ITransaction transaction; + + SentryRecordContext( + final @NotNull ISentryLifecycleToken lifecycleToken, + final @Nullable ITransaction transaction) { + this.lifecycleToken = lifecycleToken; + this.transaction = transaction; + } + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..3d52378e35a --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt @@ -0,0 +1,124 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.Sentry +import io.sentry.test.initForTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.listener.RecordInterceptor + +class SentryKafkaConsumerBeanPostProcessorTest { + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `wraps ConcurrentKafkaListenerContainerFactory with SentryKafkaRecordInterceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + // Verify via reflection that the interceptor was set + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val interceptor = field.get(factory) + assertTrue(interceptor is SentryKafkaRecordInterceptor<*, *>) + } + + @Test + fun `does not double-wrap when SentryKafkaRecordInterceptor already set`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val processor = SentryKafkaConsumerBeanPostProcessor() + // First wrap + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val firstInterceptor = field.get(factory) + + // Second wrap — should be idempotent + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + val secondInterceptor = field.get(factory) + + assertSame(firstInterceptor, secondInterceptor) + } + + @Test + fun `does not wrap non-factory beans`() { + val someBean = "not a factory" + val processor = SentryKafkaConsumerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `chains existing customer RecordInterceptor as delegate`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val customerInterceptor = RecordInterceptor { record, _ -> record } + factory.setRecordInterceptor(customerInterceptor) + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val installed = field.get(factory) + assertTrue( + installed is SentryKafkaRecordInterceptor<*, *>, + "expected SentryKafkaRecordInterceptor, got ${installed?.javaClass}", + ) + + val delegateField = SentryKafkaRecordInterceptor::class.java.getDeclaredField("delegate") + delegateField.isAccessible = true + assertSame( + customerInterceptor, + delegateField.get(installed), + "customer interceptor must be preserved as delegate", + ) + } + + @Test + fun `skips installation when reflection fails and preserves customer interceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + val customerInterceptor = RecordInterceptor { record, _ -> record } + factory.setRecordInterceptor(customerInterceptor) + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + assertSame(customerInterceptor, field.get(factory)) + + val processor = SentryKafkaConsumerBeanPostProcessor("missingRecordInterceptor") + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + assertSame( + customerInterceptor, + field.get(factory), + "customer interceptor must remain installed when Sentry cannot read it", + ) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..b3a1a268682 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt @@ -0,0 +1,109 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.Sentry +import io.sentry.test.initForTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.Producer +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.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.core.ProducerPostProcessor + +class SentryKafkaProducerBeanPostProcessorTest { + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `registers Sentry post-processor on ProducerFactory`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + val captor = argumentCaptor>() + verify(factory).addPostProcessor(captor.capture()) + assertTrue( + captor.firstValue is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } + + @Test + fun `does not throw when addPostProcessor is a no-op (default interface method)`() { + // Factory using the default no-op addPostProcessor / getPostProcessors + val factory = mock>() + whenever(factory.postProcessors).thenReturn(emptyList()) + val processor = SentryKafkaProducerBeanPostProcessor() + + // Should complete without throwing, and log a warning via ScopesAdapter + processor.postProcessAfterInitialization(factory, "myFactory") + + verify(factory).addPostProcessor(any()) + } + + @Test + fun `does not modify non-ProducerFactory beans`() { + val someBean = "not a producer factory" + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `returns the same bean instance`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertSame(factory, result, "BPP must return the same bean, not a replacement") + } + + @Test + fun `registered post-processor wraps producers via SentryKafkaProducer wrap`() { + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + val raw = mock>() + + val wrapped = pp.apply(raw) + + assertTrue(java.lang.reflect.Proxy.isProxyClass(wrapped.javaClass)) + } + + @Test + fun `integrates with DefaultKafkaProducerFactory addPostProcessor contract`() { + // Sanity check against the real Spring Kafka API surface — DefaultKafkaProducerFactory + // honors addPostProcessor and exposes it via getPostProcessors(). + val factory = DefaultKafkaProducerFactory(emptyMap()) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertEquals(1, factory.postProcessors.size) + assertTrue( + factory.postProcessors.first() + is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptorTest.kt new file mode 100644 index 00000000000..b09d4f5e147 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaRecordInterceptorTest.kt @@ -0,0 +1,476 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.test.initForTest +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Optional +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.record.TimestampType +import org.mockito.kotlin.any +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 +import org.springframework.kafka.listener.RecordInterceptor +import org.springframework.kafka.support.KafkaHeaders + +class SentryKafkaRecordInterceptorTest { + + private lateinit var scopes: IScopes + private lateinit var forkedScopes: IScopes + private lateinit var options: SentryOptions + private lateinit var consumer: Consumer + private lateinit var lifecycleToken: ISentryLifecycleToken + private lateinit var transaction: SentryTracer + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + scopes = mock() + consumer = mock() + lifecycleToken = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + tracesSampleRate = 1.0 + } + whenever(scopes.options).thenReturn(options) + whenever(scopes.isEnabled).thenReturn(true) + + forkedScopes = mock() + whenever(scopes.forkedRootScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.options).thenReturn(options) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + + transaction = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes) + whenever(forkedScopes.startTransaction(any(), any())) + .thenReturn(transaction) + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + private fun createRecord( + topic: String = "my-topic", + headers: RecordHeaders = RecordHeaders(), + serializedValueSize: Int = -1, + ): ConsumerRecord { + return ConsumerRecord( + topic, + 0, + 0L, + System.currentTimeMillis(), + TimestampType.CREATE_TIME, + 3, + serializedValueSize, + "key", + "value", + headers, + Optional.empty(), + ) + } + + private fun createRecordWithHeaders( + sentryTrace: String? = null, + baggage: String? = null, + baggageHeaders: List? = null, + enqueuedTime: String? = null, + deliveryAttempt: Int? = null, + ): ConsumerRecord { + val headers = RecordHeaders() + sentryTrace?.let { + headers.add(SentryTraceHeader.SENTRY_TRACE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggage?.let { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggageHeaders?.forEach { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + enqueuedTime?.let { + headers.add( + SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER, + it.toByteArray(StandardCharsets.UTF_8), + ) + } + deliveryAttempt?.let { + headers.add( + KafkaHeaders.DELIVERY_ATTEMPT, + ByteBuffer.allocate(Int.SIZE_BYTES).putInt(it).array(), + ) + } + val record = ConsumerRecord("my-topic", 0, 0L, "key", "value") + headers.forEach { record.headers().add(it) } + return record + } + + @Test + fun `intercept forks root scopes`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(scopes).forkedRootScopes("SentryKafkaRecordInterceptor") + verify(forkedScopes).makeCurrent() + verify(forkedScopes) + .startTransaction( + org.mockito.kotlin.check { + assertEquals("my-topic", it.name) + assertEquals("queue.process", it.operation) + }, + any(), + ) + } + + @Test + fun `intercept continues trace from headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = createRecordWithHeaders(sentryTrace = sentryTraceValue) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace(org.mockito.kotlin.eq(sentryTraceValue), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept calls continueTrace with null when no headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(forkedScopes).continueTrace(org.mockito.kotlin.isNull(), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept passes all baggage headers to continueTrace`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = + createRecordWithHeaders( + sentryTrace = sentryTraceValue, + baggageHeaders = listOf("third=party", "sentry-sample_rate=1"), + ) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace( + org.mockito.kotlin.eq(sentryTraceValue), + org.mockito.kotlin.eq(listOf("third=party", "sentry-sample_rate=1")), + ) + } + + @Test + fun `sets body size from serializedValueSize`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = 42) + + interceptor.intercept(record, consumer) + + assertEquals(42, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `does not set body size when serializedValueSize is negative`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = -1) + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `sets retry count from delivery attempt header`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecordWithHeaders(deliveryAttempt = 3) + + interceptor.intercept(record, consumer) + + assertEquals(2, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `does not set retry count when delivery attempt header is missing`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `sets receive latency from enqueued time in epoch seconds`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val enqueuedTime = (System.currentTimeMillis() / 1000.0 - 1.0).toString() + val record = createRecordWithHeaders(enqueuedTime = enqueuedTime) + + interceptor.intercept(record, consumer) + + val latency = transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY) + assertTrue(latency is Long && latency >= 0) + } + + @Test + fun `does not create span when queue tracing is disabled`() { + options.isEnableQueueTracing = false + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `does not create span when origin is ignored`() { + options.setIgnoredSpanOrigins(listOf(SentryKafkaRecordInterceptor.TRACE_ORIGIN)) + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `delegates to existing interceptor`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + interceptor.intercept(record, consumer) + + verify(delegate).intercept(record, consumer) + } + + @Test + fun `success finishes transaction and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + + verify(delegate).success(record, consumer) + } + + @Test + fun `failure finishes transaction with error and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + val exception = RuntimeException("processing failed") + + interceptor.intercept(record, consumer) + interceptor.failure(record, exception, consumer) + + verify(delegate).failure(record, exception, consumer) + } + + @Test + fun `afterRecord delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.afterRecord(record, consumer) + + verify(delegate).afterRecord(record, consumer) + } + + @Test + fun `trace origin is set correctly`() { + assertEquals( + "auto.queue.spring_jakarta.kafka.consumer", + SentryKafkaRecordInterceptor.TRACE_ORIGIN, + ) + } + + @Test + fun `clearThreadState cleans up stale context`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken).close() + } + + @Test + fun `clearThreadState is no-op when no context exists`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.clearThreadState(consumer) + } + + @Test + fun `setupThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + + verify(delegate).setupThreadState(consumer) + } + + @Test + fun `setupThreadState is no-op without delegate`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.setupThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.clearThreadState(consumer) + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor even when sentry cleanup throws`() { + val delegate = mock>() + whenever(lifecycleToken.close()).thenThrow(RuntimeException("boom")) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + + try { + interceptor.clearThreadState(consumer) + } catch (ignored: RuntimeException) { + // expected + } + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `full lifecycle intercept success clearThreadState closes token exactly once`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + interceptor.clearThreadState(consumer) + + // token closed once by success(); clearThreadState must not re-close it + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + // delegate hooks still delegated across the full lifecycle + verify(delegate).setupThreadState(consumer) + verify(delegate).success(record, consumer) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept returns null clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + // delegate filters the record — per Spring Kafka contract, success/failure will not be invoked + whenever(delegate.intercept(record, consumer)).thenReturn(null) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val result = interceptor.intercept(record, consumer) + interceptor.clearThreadState(consumer) + + assertNull(result) + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept throws clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + val boom = RuntimeException("delegate boom") + whenever(delegate.intercept(record, consumer)).thenThrow(boom) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val thrown = assertFailsWith { interceptor.intercept(record, consumer) } + assertEquals(boom, thrown) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `intercept cleans up stale context from previous record`() { + val lifecycleToken2 = mock() + val forkedScopes2 = mock() + whenever(forkedScopes2.options).thenReturn(options) + whenever(forkedScopes2.makeCurrent()).thenReturn(lifecycleToken2) + val tx2 = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes2) + whenever(forkedScopes2.startTransaction(any(), any())).thenReturn(tx2) + + var callCount = 0 + + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + whenever(scopes.forkedRootScopes(any())).thenAnswer { + callCount++ + if (callCount == 1) forkedScopes else forkedScopes2 + } + + // First intercept sets up context + interceptor.intercept(record, consumer) + + // Second intercept without success/failure — should clean up stale context first + interceptor.intercept(record, consumer) + + // First lifecycle token should have been closed by the defensive cleanup + verify(lifecycleToken).close() + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 7148277e2ef..4e1bea84288 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -234,6 +234,30 @@ public final class io/sentry/spring/graphql/SentrySpringSubscriptionHandler : io public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; } +public final class io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/kafka/SentryKafkaRecordInterceptor : org/springframework/kafka/listener/RecordInterceptor { + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lorg/springframework/kafka/listener/RecordInterceptor;)V + public fun afterRecord (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun clearThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun intercept (Lorg/apache/kafka/clients/consumer/ConsumerRecord;)Lorg/apache/kafka/clients/consumer/ConsumerRecord; + public fun intercept (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)Lorg/apache/kafka/clients/consumer/ConsumerRecord; + public fun setupThreadState (Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V +} + public class io/sentry/spring/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { public fun ()V public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index b651a9e62b2..c4c75cb5f07 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { compileOnly(Config.Libs.aspectj) compileOnly(Config.Libs.springWebflux) compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryKafka) compileOnly(projects.sentryQuartz) compileOnly(libs.jetbrains.annotations) compileOnly(libs.nopen.annotations) @@ -35,6 +36,7 @@ dependencies { compileOnly(libs.slf4j.api) compileOnly(libs.springboot.starter.graphql) compileOnly(libs.springboot.starter.quartz) + compileOnly(libs.spring.kafka2) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) @@ -45,6 +47,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryGraphql) + testImplementation(projects.sentryKafka) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) testImplementation(libs.graphql.java17) @@ -54,6 +57,7 @@ dependencies { testImplementation(libs.springboot.starter.aop) testImplementation(libs.springboot.starter.graphql) testImplementation(libs.springboot.starter.security) + testImplementation(libs.spring.kafka2) testImplementation(libs.springboot.starter.test) testImplementation(libs.springboot.starter.web) testImplementation(libs.springboot.starter.webflux) diff --git a/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessor.java new file mode 100644 index 00000000000..7a3ba1caa27 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessor.java @@ -0,0 +1,98 @@ +package io.sentry.spring.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import java.lang.reflect.Field; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.config.AbstractKafkaListenerContainerFactory; +import org.springframework.kafka.listener.RecordInterceptor; + +/** + * Registers {@link SentryKafkaRecordInterceptor} on {@link AbstractKafkaListenerContainerFactory} + * beans. If an existing {@link RecordInterceptor} is already set, it is composed as a delegate. + */ +@ApiStatus.Internal +public final class SentryKafkaConsumerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + private static final @NotNull String RECORD_INTERCEPTOR_FIELD_NAME = "recordInterceptor"; + + private final @NotNull String recordInterceptorFieldName; + + public SentryKafkaConsumerBeanPostProcessor() { + this(RECORD_INTERCEPTOR_FIELD_NAME); + } + + SentryKafkaConsumerBeanPostProcessor(final @NotNull String recordInterceptorFieldName) { + this.recordInterceptorFieldName = recordInterceptorFieldName; + } + + private static final class InterceptorReadFailedException extends Exception { + private static final long serialVersionUID = 1L; + + InterceptorReadFailedException(final @NotNull Throwable cause) { + super(cause); + } + } + + @Override + @SuppressWarnings("unchecked") + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof AbstractKafkaListenerContainerFactory) { + final @NotNull AbstractKafkaListenerContainerFactory factory = + (AbstractKafkaListenerContainerFactory) bean; + + final @Nullable RecordInterceptor existing; + try { + existing = getExistingInterceptor(factory); + } catch (InterceptorReadFailedException e) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + e, + "Sentry Kafka consumer tracing disabled for factory '%s' \u2014 could not read " + + "existing recordInterceptor via reflection. Refusing to install Sentry's " + + "interceptor to avoid overwriting a customer-configured RecordInterceptor.", + beanName); + return bean; + } + + if (existing instanceof SentryKafkaRecordInterceptor) { + return bean; + } + + @SuppressWarnings("rawtypes") + final RecordInterceptor sentryInterceptor = + new SentryKafkaRecordInterceptor<>(ScopesAdapter.getInstance(), existing); + factory.setRecordInterceptor(sentryInterceptor); + } + return bean; + } + + private @Nullable RecordInterceptor getExistingInterceptor( + final @NotNull AbstractKafkaListenerContainerFactory factory) + throws InterceptorReadFailedException { + try { + final @NotNull Field field = + AbstractKafkaListenerContainerFactory.class.getDeclaredField(recordInterceptorFieldName); + field.setAccessible(true); + return (RecordInterceptor) field.get(factory); + } catch (NoSuchFieldException | IllegalAccessException | RuntimeException e) { + throw new InterceptorReadFailedException(e); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessor.java new file mode 100644 index 00000000000..7b3266a3510 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessor.java @@ -0,0 +1,76 @@ +package io.sentry.spring.kafka; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.kafka.SentryKafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.core.ProducerPostProcessor; + +/** + * Installs a {@link ProducerPostProcessor} on every {@link ProducerFactory} bean so that each + * {@link Producer} created by Spring Kafka is wrapped via {@link SentryKafkaProducer#wrap + * SentryKafkaProducer.wrap(Producer)}. + * + *

The wrapper records a {@code queue.publish} span around each {@code send(...)} that finishes + * when the broker ack callback fires, giving a real producer-send lifecycle span. {@code + * KafkaTemplate} beans are left untouched, so all customer-configured listeners, interceptors and + * observation settings are preserved. + * + *

Note: {@link ProducerFactory#addPostProcessor(ProducerPostProcessor)} is a default method on + * the interface that is a no-op unless overridden. Custom factories that do not extend {@code + * DefaultKafkaProducerFactory} will not receive Sentry producer instrumentation; a warning is + * logged at startup in that case. + */ +@ApiStatus.Internal +public final class SentryKafkaProducerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof ProducerFactory) { + final @NotNull ProducerFactory factory = (ProducerFactory) bean; + final @NotNull SentryProducerPostProcessor pp = new SentryProducerPostProcessor<>(); + factory.addPostProcessor(pp); + if (!factory.getPostProcessors().contains(pp)) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Sentry Kafka producer tracing not active for ProducerFactory '%s' (%s). " + + "addPostProcessor() was not honored — the factory may not extend " + + "DefaultKafkaProducerFactory. Wrap producers manually with " + + "SentryKafkaProducer.wrap(producer).", + beanName, + factory.getClass().getName()); + } + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + /** + * Marker {@link ProducerPostProcessor} that wraps the freshly created Kafka {@link Producer} via + * {@link SentryKafkaProducer#wrap}. + */ + static final class SentryProducerPostProcessor implements ProducerPostProcessor { + @Override + public @NotNull Producer apply(final @NotNull Producer producer) { + return SentryKafkaProducer.wrap( + producer, ScopesAdapter.getInstance(), "auto.queue.spring.kafka.producer"); + } + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java new file mode 100644 index 00000000000..d1ad3086098 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java @@ -0,0 +1,298 @@ +package io.sentry.spring.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.kafka.SentryKafkaProducer; +import io.sentry.util.SpanUtils; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.kafka.listener.RecordInterceptor; +import org.springframework.kafka.support.KafkaHeaders; + +/** + * A {@link RecordInterceptor} that creates {@code queue.process} transactions for incoming Kafka + * records with distributed tracing support. + */ +@ApiStatus.Internal +@SuppressWarnings("deprecation") +public final class SentryKafkaRecordInterceptor implements RecordInterceptor { + + static final String TRACE_ORIGIN = "auto.queue.spring.kafka.consumer"; + + private final @NotNull IScopes scopes; + private final @Nullable RecordInterceptor delegate; + + private static final @NotNull ThreadLocal currentContext = + new ThreadLocal<>(); + + public SentryKafkaRecordInterceptor(final @NotNull IScopes scopes) { + this(scopes, null); + } + + public SentryKafkaRecordInterceptor( + final @NotNull IScopes scopes, final @Nullable RecordInterceptor delegate) { + this.scopes = scopes; + this.delegate = delegate; + } + + @Override + public @Nullable ConsumerRecord intercept(final @NotNull ConsumerRecord record) { + return intercept(record, null); + } + + @Override + public @Nullable ConsumerRecord intercept( + final @NotNull ConsumerRecord record, final @Nullable Consumer consumer) { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return delegateIntercept(record, consumer); + } + + try { + finishStaleContext(); + + final @NotNull IScopes forkedScopes = scopes.forkedRootScopes("SentryKafkaRecordInterceptor"); + final @NotNull ISentryLifecycleToken lifecycleToken = forkedScopes.makeCurrent(); + currentContext.set(new SentryRecordContext(lifecycleToken, null)); + + final @Nullable TransactionContext transactionContext = continueTrace(forkedScopes, record); + + final @Nullable ITransaction transaction = + startTransaction(forkedScopes, record, transactionContext); + currentContext.set(new SentryRecordContext(lifecycleToken, transaction)); + } catch (Throwable t) { + scopes.getOptions().getLogger().log(SentryLevel.ERROR, "Unable to wrap Kafka consumer.", t); + } + return delegateIntercept(record, consumer); + } + + @Override + public void success( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.success(record, consumer); + } + } finally { + finishSpan(SpanStatus.OK, null); + } + } + + @Override + public void failure( + final @NotNull ConsumerRecord record, + final @NotNull Exception exception, + final @NotNull Consumer consumer) { + try { + if (delegate != null) { + delegate.failure(record, exception, consumer); + } + } finally { + finishSpan(SpanStatus.INTERNAL_ERROR, exception); + } + } + + @Override + public void afterRecord( + final @NotNull ConsumerRecord record, final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.afterRecord(record, consumer); + } + } + + @Override + public void setupThreadState(final @NotNull Consumer consumer) { + if (delegate != null) { + delegate.setupThreadState(consumer); + } + } + + @Override + public void clearThreadState(final @NotNull Consumer consumer) { + try { + finishStaleContext(); + } finally { + if (delegate != null) { + delegate.clearThreadState(consumer); + } + } + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private @Nullable ConsumerRecord delegateIntercept( + final @NotNull ConsumerRecord record, final @Nullable Consumer consumer) { + if (delegate != null) { + return consumer != null ? delegate.intercept(record, consumer) : delegate.intercept(record); + } + return record; + } + + private @Nullable TransactionContext continueTrace( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + final @Nullable String sentryTrace = headerValue(record, SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = + headerValues(record, BaggageHeader.BAGGAGE_HEADER); + return forkedScopes.continueTrace(sentryTrace, baggageHeaders); + } + + private @Nullable ITransaction startTransaction( + final @NotNull IScopes forkedScopes, + final @NotNull ConsumerRecord record, + final @Nullable TransactionContext transactionContext) { + if (!forkedScopes.getOptions().isTracingEnabled()) { + return null; + } + + final @NotNull TransactionContext txContext = + transactionContext != null + ? transactionContext + : new TransactionContext(record.topic(), "queue.process"); + txContext.setName(record.topic()); + txContext.setOperation("queue.process"); + + final @NotNull TransactionOptions txOptions = new TransactionOptions(); + txOptions.setOrigin(TRACE_ORIGIN); + txOptions.setBindToScope(true); + + final @NotNull ITransaction transaction = forkedScopes.startTransaction(txContext, txOptions); + + if (transaction.isNoOp()) { + return null; + } + + transaction.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + transaction.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + final @Nullable String messageId = headerValue(record, "messaging.message.id"); + if (messageId != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_ID, messageId); + } + + final int bodySize = record.serializedValueSize(); + if (bodySize >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, bodySize); + } + + final @Nullable Integer retryCount = retryCount(record); + if (retryCount != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, retryCount); + } + + final @Nullable String enqueuedTimeStr = + headerValue(record, SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER); + if (enqueuedTimeStr != null) { + try { + final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); + final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); + if (latencyMs >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); + } + } catch (NumberFormatException ignored) { + // ignore malformed header + } + } + + return transaction; + } + + private @Nullable Integer retryCount(final @NotNull ConsumerRecord record) { + final @Nullable Header header = record.headers().lastHeader(KafkaHeaders.DELIVERY_ATTEMPT); + if (header == null) { + return null; + } + + final byte[] value = header.value(); + if (value == null || value.length != Integer.BYTES) { + return null; + } + + final int attempt = ByteBuffer.wrap(value).getInt(); + if (attempt <= 0) { + return null; + } + + return attempt - 1; + } + + private void finishStaleContext() { + if (currentContext.get() != null) { + finishSpan(SpanStatus.UNKNOWN, null); + } + } + + private void finishSpan(final @NotNull SpanStatus status, final @Nullable Throwable throwable) { + final @Nullable SentryRecordContext ctx = currentContext.get(); + if (ctx == null) { + return; + } + currentContext.remove(); + + try { + final @Nullable ITransaction transaction = ctx.transaction; + if (transaction != null) { + transaction.setStatus(status); + if (throwable != null) { + transaction.setThrowable(throwable); + } + transaction.finish(); + } + } finally { + ctx.lifecycleToken.close(); + } + } + + private @Nullable String headerValue( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + final @Nullable Header header = record.headers().lastHeader(headerName); + if (header == null || header.value() == null) { + return null; + } + return new String(header.value(), StandardCharsets.UTF_8); + } + + private @Nullable List headerValues( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + @Nullable List values = null; + for (final @NotNull Header header : record.headers().headers(headerName)) { + if (header.value() != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(header.value(), StandardCharsets.UTF_8)); + } + } + return values; + } + + private static final class SentryRecordContext { + final @NotNull ISentryLifecycleToken lifecycleToken; + final @Nullable ITransaction transaction; + + SentryRecordContext( + final @NotNull ISentryLifecycleToken lifecycleToken, + final @Nullable ITransaction transaction) { + this.lifecycleToken = lifecycleToken; + this.transaction = transaction; + } + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt index f7b43867252..29ab6683450 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt @@ -4,6 +4,8 @@ import io.sentry.Hint import io.sentry.IScopes import io.sentry.Sentry import io.sentry.exception.ExceptionMechanismException +import io.sentry.test.initForTest +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -32,6 +34,13 @@ class SentryCaptureExceptionParameterAdviceTest { @BeforeTest fun setup() { reset(scopes) + initForTest { it.dsn = "https://key@sentry.io/proj" } + Sentry.setCurrentScopes(scopes) + } + + @AfterTest + fun teardown() { + Sentry.close() } @Test diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..76dfd81cd0b --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaConsumerBeanPostProcessorTest.kt @@ -0,0 +1,110 @@ +package io.sentry.spring.kafka + +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.listener.RecordInterceptor + +class SentryKafkaConsumerBeanPostProcessorTest { + + @Test + fun `wraps ConcurrentKafkaListenerContainerFactory with SentryKafkaRecordInterceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + // Verify via reflection that the interceptor was set + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val interceptor = field.get(factory) + assertTrue(interceptor is SentryKafkaRecordInterceptor<*, *>) + } + + @Test + fun `does not double-wrap when SentryKafkaRecordInterceptor already set`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val processor = SentryKafkaConsumerBeanPostProcessor() + // First wrap + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val firstInterceptor = field.get(factory) + + // Second wrap — should be idempotent + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + val secondInterceptor = field.get(factory) + + assertSame(firstInterceptor, secondInterceptor) + } + + @Test + fun `does not wrap non-factory beans`() { + val someBean = "not a factory" + val processor = SentryKafkaConsumerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `chains existing customer RecordInterceptor as delegate`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + + val customerInterceptor = RecordInterceptor { record -> record } + factory.setRecordInterceptor(customerInterceptor) + + val processor = SentryKafkaConsumerBeanPostProcessor() + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + val installed = field.get(factory) + assertTrue( + installed is SentryKafkaRecordInterceptor<*, *>, + "expected SentryKafkaRecordInterceptor, got ${installed?.javaClass}", + ) + + val delegateField = SentryKafkaRecordInterceptor::class.java.getDeclaredField("delegate") + delegateField.isAccessible = true + assertSame( + customerInterceptor, + delegateField.get(installed), + "customer interceptor must be preserved as delegate", + ) + } + + @Test + fun `skips installation when reflection fails and preserves customer interceptor`() { + val consumerFactory = mock>() + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + val customerInterceptor = RecordInterceptor { record -> record } + factory.setRecordInterceptor(customerInterceptor) + + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") + field.isAccessible = true + assertSame(customerInterceptor, field.get(factory)) + + val processor = SentryKafkaConsumerBeanPostProcessor("missingRecordInterceptor") + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") + + assertSame( + customerInterceptor, + field.get(factory), + "customer interceptor must remain installed when Sentry cannot read it", + ) + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..11a943307c8 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaProducerBeanPostProcessorTest.kt @@ -0,0 +1,95 @@ +package io.sentry.spring.kafka + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.Producer +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.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.core.ProducerPostProcessor + +class SentryKafkaProducerBeanPostProcessorTest { + + @Test + fun `registers Sentry post-processor on ProducerFactory`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + val captor = argumentCaptor>() + verify(factory).addPostProcessor(captor.capture()) + assertTrue( + captor.firstValue is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } + + @Test + fun `does not throw when addPostProcessor is a no-op (default interface method)`() { + // Factory using the default no-op addPostProcessor / getPostProcessors + val factory = mock>() + whenever(factory.postProcessors).thenReturn(emptyList()) + val processor = SentryKafkaProducerBeanPostProcessor() + + // Should complete without throwing, and log a warning via ScopesAdapter + processor.postProcessAfterInitialization(factory, "myFactory") + + verify(factory).addPostProcessor(any()) + } + + @Test + fun `does not modify non-ProducerFactory beans`() { + val someBean = "not a producer factory" + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } + + @Test + fun `returns the same bean instance`() { + val factory = mock>() + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + whenever(factory.postProcessors).thenReturn(listOf(pp)) + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertSame(factory, result, "BPP must return the same bean, not a replacement") + } + + @Test + fun `registered post-processor wraps producers via SentryKafkaProducer wrap`() { + val pp = SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor() + val raw = mock>() + + val wrapped = pp.apply(raw) + + assertTrue(java.lang.reflect.Proxy.isProxyClass(wrapped.javaClass)) + } + + @Test + fun `integrates with DefaultKafkaProducerFactory addPostProcessor contract`() { + // Sanity check against the real Spring Kafka API surface — DefaultKafkaProducerFactory + // honors addPostProcessor and exposes it via getPostProcessors(). + val factory = DefaultKafkaProducerFactory(emptyMap()) + val processor = SentryKafkaProducerBeanPostProcessor() + + processor.postProcessAfterInitialization(factory, "kafkaProducerFactory") + + assertEquals(1, factory.postProcessors.size) + assertTrue( + factory.postProcessors.first() + is SentryKafkaProducerBeanPostProcessor.SentryProducerPostProcessor<*, *> + ) + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaRecordInterceptorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaRecordInterceptorTest.kt new file mode 100644 index 00000000000..17df004d40c --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/kafka/SentryKafkaRecordInterceptorTest.kt @@ -0,0 +1,486 @@ +package io.sentry.spring.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import io.sentry.kafka.SentryKafkaProducer +import io.sentry.test.initForTest +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Optional +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.record.TimestampType +import org.mockito.kotlin.any +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 +import org.springframework.kafka.listener.RecordInterceptor +import org.springframework.kafka.support.KafkaHeaders + +class SentryKafkaRecordInterceptorTest { + + private lateinit var scopes: IScopes + private lateinit var forkedScopes: IScopes + private lateinit var options: SentryOptions + private lateinit var consumer: Consumer + private lateinit var lifecycleToken: ISentryLifecycleToken + private lateinit var transaction: SentryTracer + + @BeforeTest + fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } + scopes = mock() + consumer = mock() + lifecycleToken = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + tracesSampleRate = 1.0 + } + whenever(scopes.options).thenReturn(options) + whenever(scopes.isEnabled).thenReturn(true) + + forkedScopes = mock() + whenever(scopes.forkedRootScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.options).thenReturn(options) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + + transaction = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes) + whenever(forkedScopes.startTransaction(any(), any())) + .thenReturn(transaction) + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + private fun createRecord( + topic: String = "my-topic", + headers: RecordHeaders = RecordHeaders(), + serializedValueSize: Int = -1, + ): ConsumerRecord { + return ConsumerRecord( + topic, + 0, + 0L, + System.currentTimeMillis(), + TimestampType.CREATE_TIME, + 3, + serializedValueSize, + "key", + "value", + headers, + Optional.empty(), + ) + } + + private fun createRecordWithHeaders( + sentryTrace: String? = null, + baggage: String? = null, + baggageHeaders: List? = null, + enqueuedTime: String? = null, + deliveryAttempt: Int? = null, + ): ConsumerRecord { + val headers = RecordHeaders() + sentryTrace?.let { + headers.add(SentryTraceHeader.SENTRY_TRACE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggage?.let { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggageHeaders?.forEach { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + enqueuedTime?.let { + headers.add( + SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER, + it.toByteArray(StandardCharsets.UTF_8), + ) + } + deliveryAttempt?.let { + headers.add( + KafkaHeaders.DELIVERY_ATTEMPT, + ByteBuffer.allocate(Int.SIZE_BYTES).putInt(it).array(), + ) + } + val record = ConsumerRecord("my-topic", 0, 0L, "key", "value") + headers.forEach { record.headers().add(it) } + return record + } + + @Test + fun `intercept forks root scopes`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(scopes).forkedRootScopes("SentryKafkaRecordInterceptor") + verify(forkedScopes).makeCurrent() + verify(forkedScopes) + .startTransaction( + org.mockito.kotlin.check { + assertEquals("my-topic", it.name) + assertEquals("queue.process", it.operation) + }, + any(), + ) + } + + @Test + fun `intercept continues trace from headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = createRecordWithHeaders(sentryTrace = sentryTraceValue) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace(org.mockito.kotlin.eq(sentryTraceValue), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept calls continueTrace with null when no headers`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + verify(forkedScopes).continueTrace(org.mockito.kotlin.isNull(), org.mockito.kotlin.isNull()) + } + + @Test + fun `intercept passes all baggage headers to continueTrace`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = + createRecordWithHeaders( + sentryTrace = sentryTraceValue, + baggageHeaders = listOf("third=party", "sentry-sample_rate=1"), + ) + + interceptor.intercept(record, consumer) + + verify(forkedScopes) + .continueTrace( + org.mockito.kotlin.eq(sentryTraceValue), + org.mockito.kotlin.eq(listOf("third=party", "sentry-sample_rate=1")), + ) + } + + @Test + fun `sets body size from serializedValueSize`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = 42) + + interceptor.intercept(record, consumer) + + assertEquals(42, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `does not set body size when serializedValueSize is negative`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord(serializedValueSize = -1) + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE)) + } + + @Test + fun `sets retry count from delivery attempt header`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecordWithHeaders(deliveryAttempt = 3) + + interceptor.intercept(record, consumer) + + assertEquals(2, transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `does not set retry count when delivery attempt header is missing`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + assertNull(transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT)) + } + + @Test + fun `sets receive latency from enqueued time in epoch seconds`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val enqueuedTime = (System.currentTimeMillis() / 1000.0 - 1.0).toString() + val record = createRecordWithHeaders(enqueuedTime = enqueuedTime) + + interceptor.intercept(record, consumer) + + val latency = transaction.data?.get(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY) + assertTrue(latency is Long && latency >= 0) + } + + @Test + fun `does not create span when queue tracing is disabled`() { + options.isEnableQueueTracing = false + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `does not create span when origin is ignored`() { + options.setIgnoredSpanOrigins(listOf(SentryKafkaRecordInterceptor.TRACE_ORIGIN)) + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + val result = interceptor.intercept(record, consumer) + + verify(scopes, never()).forkedRootScopes(any()) + verify(forkedScopes, never()).makeCurrent() + assertEquals(record, result) + } + + @Test + fun `delegates to existing interceptor`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + interceptor.intercept(record, consumer) + + verify(delegate).intercept(record, consumer) + } + + @Test + fun `delegates to existing interceptor when consumer is null`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record)).thenReturn(record) + + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val result = interceptor.intercept(record) + + assertEquals(record, result) + verify(delegate).intercept(record) + } + + @Test + fun `success finishes transaction and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + + verify(delegate).success(record, consumer) + } + + @Test + fun `failure finishes transaction with error and delegates`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + val exception = RuntimeException("processing failed") + + interceptor.intercept(record, consumer) + interceptor.failure(record, exception, consumer) + + verify(delegate).failure(record, exception, consumer) + } + + @Test + fun `afterRecord delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.afterRecord(record, consumer) + + verify(delegate).afterRecord(record, consumer) + } + + @Test + fun `trace origin is set correctly`() { + assertEquals("auto.queue.spring.kafka.consumer", SentryKafkaRecordInterceptor.TRACE_ORIGIN) + } + + @Test + fun `clearThreadState cleans up stale context`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + interceptor.intercept(record, consumer) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken).close() + } + + @Test + fun `clearThreadState is no-op when no context exists`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.clearThreadState(consumer) + } + + @Test + fun `setupThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + + verify(delegate).setupThreadState(consumer) + } + + @Test + fun `setupThreadState is no-op without delegate`() { + val interceptor = SentryKafkaRecordInterceptor(scopes) + + // should not throw + interceptor.setupThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor`() { + val delegate = mock>() + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.clearThreadState(consumer) + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `clearThreadState delegates to existing interceptor even when sentry cleanup throws`() { + val delegate = mock>() + whenever(lifecycleToken.close()).thenThrow(RuntimeException("boom")) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + val record = createRecord() + + interceptor.intercept(record, consumer) + + try { + interceptor.clearThreadState(consumer) + } catch (ignored: RuntimeException) { + // expected + } + + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `full lifecycle intercept success clearThreadState closes token exactly once`() { + val delegate = mock>() + val record = createRecord() + whenever(delegate.intercept(record, consumer)).thenReturn(record) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + interceptor.intercept(record, consumer) + interceptor.success(record, consumer) + interceptor.clearThreadState(consumer) + + // token closed once by success(); clearThreadState must not re-close it + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + // delegate hooks still delegated across the full lifecycle + verify(delegate).setupThreadState(consumer) + verify(delegate).success(record, consumer) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept returns null clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + // delegate filters the record — per Spring Kafka contract, success/failure will not be invoked + whenever(delegate.intercept(record, consumer)).thenReturn(null) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val result = interceptor.intercept(record, consumer) + interceptor.clearThreadState(consumer) + + assertNull(result) + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `when delegate intercept throws clearThreadState still finishes transaction and closes token`() { + val delegate = mock>() + val record = createRecord() + val boom = RuntimeException("delegate boom") + whenever(delegate.intercept(record, consumer)).thenThrow(boom) + val interceptor = SentryKafkaRecordInterceptor(scopes, delegate) + + interceptor.setupThreadState(consumer) + val thrown = assertFailsWith { interceptor.intercept(record, consumer) } + assertEquals(boom, thrown) + + interceptor.clearThreadState(consumer) + + verify(lifecycleToken, times(1)).close() + assertTrue(transaction.isFinished) + verify(delegate).clearThreadState(consumer) + } + + @Test + fun `intercept cleans up stale context from previous record`() { + val lifecycleToken2 = mock() + val forkedScopes2 = mock() + whenever(forkedScopes2.options).thenReturn(options) + whenever(forkedScopes2.makeCurrent()).thenReturn(lifecycleToken2) + val tx2 = SentryTracer(TransactionContext("queue.process", "queue.process"), forkedScopes2) + whenever(forkedScopes2.startTransaction(any(), any())).thenReturn(tx2) + + var callCount = 0 + + val interceptor = SentryKafkaRecordInterceptor(scopes) + val record = createRecord() + + whenever(scopes.forkedRootScopes(any())).thenAnswer { + callCount++ + if (callCount == 1) forkedScopes else forkedScopes2 + } + + // First intercept sets up context + interceptor.intercept(record, consumer) + + // Second intercept without success/failure — should clean up stale context first + interceptor.intercept(record, consumer) + + // First lifecycle token should have been closed by the defensive cleanup + verify(lifecycleToken).close() + } +} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index da552ff93bc..b9dc0f3ccad 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -81,6 +81,12 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl return response?.body?.string() } + fun produceKafkaMessage(message: String = "hello from sentry!"): String? { + val request = Request.Builder().url("$backendBaseUrl/kafka/produce?message=$message") + + return callTyped(request, true) + } + fun getCountMetric(): String? { val request = Request.Builder().url("$backendBaseUrl/metric/count") diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8bd1e90e094..13dfd6b9b39 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -529,6 +529,7 @@ public final class io/sentry/ExternalOptions { public fun isEnableLogs ()Ljava/lang/Boolean; public fun isEnableMetrics ()Ljava/lang/Boolean; public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean; + public fun isEnableQueueTracing ()Ljava/lang/Boolean; public fun isEnableSpotlight ()Ljava/lang/Boolean; public fun isEnabled ()Ljava/lang/Boolean; public fun isForceInit ()Ljava/lang/Boolean; @@ -548,6 +549,7 @@ public final class io/sentry/ExternalOptions { public fun setEnableLogs (Ljava/lang/Boolean;)V public fun setEnableMetrics (Ljava/lang/Boolean;)V public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V + public fun setEnableQueueTracing (Ljava/lang/Boolean;)V public fun setEnableSpotlight (Ljava/lang/Boolean;)V public fun setEnableUncaughtExceptionHandler (Ljava/lang/Boolean;)V public fun setEnabled (Ljava/lang/Boolean;)V @@ -3714,6 +3716,7 @@ public class io/sentry/SentryOptions { public fun isEnableEventSizeLimiting ()Z public fun isEnableExternalConfiguration ()Z public fun isEnablePrettySerializationOutput ()Z + public fun isEnableQueueTracing ()Z public fun isEnableScopePersistence ()Z public fun isEnableScreenTracking ()Z public fun isEnableShutdownHook ()Z @@ -3774,6 +3777,7 @@ public class io/sentry/SentryOptions { public fun setEnableEventSizeLimiting (Z)V public fun setEnableExternalConfiguration (Z)V public fun setEnablePrettySerializationOutput (Z)V + public fun setEnableQueueTracing (Z)V public fun setEnableScopePersistence (Z)V public fun setEnableScreenTracking (Z)V public fun setEnableShutdownHook (Z)V @@ -4418,6 +4422,14 @@ public abstract interface class io/sentry/SpanDataConvention { public static final field HTTP_RESPONSE_CONTENT_LENGTH_KEY Ljava/lang/String; public static final field HTTP_START_TIMESTAMP Ljava/lang/String; public static final field HTTP_STATUS_CODE_KEY Ljava/lang/String; + public static final field MESSAGING_DESTINATION_NAME Ljava/lang/String; + public static final field MESSAGING_MESSAGE_BODY_SIZE Ljava/lang/String; + public static final field MESSAGING_MESSAGE_ENVELOPE_SIZE Ljava/lang/String; + public static final field MESSAGING_MESSAGE_ID Ljava/lang/String; + public static final field MESSAGING_MESSAGE_RECEIVE_LATENCY Ljava/lang/String; + public static final field MESSAGING_MESSAGE_RETRY_COUNT Ljava/lang/String; + public static final field MESSAGING_OPERATION_TYPE Ljava/lang/String; + public static final field MESSAGING_SYSTEM Ljava/lang/String; public static final field PROFILER_ID Ljava/lang/String; public static final field THREAD_ID Ljava/lang/String; public static final field THREAD_NAME Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index e992c04466b..4e44ea422ec 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -58,6 +58,7 @@ public final class ExternalOptions { private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean enableDatabaseTransactionTracing; private @Nullable Boolean enableCacheTracing; + private @Nullable Boolean enableQueueTracing; private @Nullable Boolean globalHubMode; private @Nullable Boolean forceInit; private @Nullable Boolean captureOpenTelemetryEvents; @@ -168,6 +169,8 @@ public final class ExternalOptions { options.setEnableCacheTracing(propertiesProvider.getBooleanProperty("enable-cache-tracing")); + options.setEnableQueueTracing(propertiesProvider.getBooleanProperty("enable-queue-tracing")); + options.setGlobalHubMode(propertiesProvider.getBooleanProperty("global-hub-mode")); options.setCaptureOpenTelemetryEvents( @@ -541,6 +544,14 @@ public void setEnableCacheTracing(final @Nullable Boolean enableCacheTracing) { return enableCacheTracing; } + public void setEnableQueueTracing(final @Nullable Boolean enableQueueTracing) { + this.enableQueueTracing = enableQueueTracing; + } + + public @Nullable Boolean isEnableQueueTracing() { + return enableQueueTracing; + } + public void setGlobalHubMode(final @Nullable Boolean globalHubMode) { this.globalHubMode = globalHubMode; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index a6f78cfad9c..0d038482d07 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -508,6 +508,9 @@ public class SentryOptions { /** Whether cache operations (get, put, remove, flush) should be traced. */ private boolean enableCacheTracing = false; + /** Whether queue operations (publish, process) should be traced. */ + private boolean enableQueueTracing = false; + /** Date provider to retrieve the current date from. */ @ApiStatus.Internal private final @NotNull LazyEvaluator dateProvider = @@ -2704,6 +2707,26 @@ public void setEnableCacheTracing(boolean enableCacheTracing) { this.enableCacheTracing = enableCacheTracing; } + /** + * Whether Sentry emits Queue spans and transforms OpenTelemetry messaging spans to match Sentry's + * queue conventions. + * + * @return true if queue tracing is enabled + */ + public boolean isEnableQueueTracing() { + return enableQueueTracing; + } + + /** + * Whether Sentry emits Queue spans and transforms OpenTelemetry messaging spans to match Sentry's + * queue conventions. + * + * @param enableQueueTracing true to enable queue tracing + */ + public void setEnableQueueTracing(boolean enableQueueTracing) { + this.enableQueueTracing = enableQueueTracing; + } + /** * Whether Sentry is enabled. * @@ -3545,6 +3568,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isEnableCacheTracing() != null) { setEnableCacheTracing(options.isEnableCacheTracing()); } + if (options.isEnableQueueTracing() != null) { + setEnableQueueTracing(options.isEnableQueueTracing()); + } if (options.getMaxRequestBodySize() != null) { setMaxRequestBodySize(options.getMaxRequestBodySize()); } diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index 647c0dacddf..4ede74505cb 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -30,4 +30,12 @@ public interface SpanDataConvention { String CACHE_KEY = "cache.key"; String CACHE_OPERATION = "cache.operation"; String CACHE_WRITE = "cache.write"; + String MESSAGING_SYSTEM = "messaging.system"; + String MESSAGING_DESTINATION_NAME = "messaging.destination.name"; + String MESSAGING_MESSAGE_ID = "messaging.message.id"; + String MESSAGING_MESSAGE_RETRY_COUNT = "messaging.message.retry.count"; + String MESSAGING_MESSAGE_BODY_SIZE = "messaging.message.body.size"; + String MESSAGING_MESSAGE_ENVELOPE_SIZE = "messaging.message.envelope.size"; + String MESSAGING_MESSAGE_RECEIVE_LATENCY = "messaging.message.receive.latency"; + String MESSAGING_OPERATION_TYPE = "messaging.operation.type"; } diff --git a/sentry/src/main/java/io/sentry/util/SpanUtils.java b/sentry/src/main/java/io/sentry/util/SpanUtils.java index cad4d483656..c324feed840 100644 --- a/sentry/src/main/java/io/sentry/util/SpanUtils.java +++ b/sentry/src/main/java/io/sentry/util/SpanUtils.java @@ -40,6 +40,10 @@ public final class SpanUtils { origins.add("auto.http.spring7.resttemplate"); origins.add("auto.http.openfeign"); origins.add("auto.http.ktor-client"); + origins.add("auto.queue.spring_jakarta.kafka.producer"); + origins.add("auto.queue.spring_jakarta.kafka.consumer"); + origins.add("auto.queue.kafka.producer"); + origins.add("auto.queue.kafka.consumer"); } if (SentryOpenTelemetryMode.AGENT == mode) { diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 54630355557..fee707d31f3 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -345,6 +345,20 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with enableQueueTracing set to true`() { + withPropertiesFile("enable-queue-tracing=true") { options -> + assertTrue(options.isEnableQueueTracing == true) + } + } + + @Test + fun `creates options with enableQueueTracing set to false`() { + withPropertiesFile("enable-queue-tracing=false") { options -> + assertTrue(options.isEnableQueueTracing == false) + } + } + @Test fun `creates options with cron defaults`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index e08d0ed8f72..75a24dd68df 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -708,6 +708,11 @@ class SentryOptionsTest { assertFalse(SentryOptions().isEnableCacheTracing) } + @Test + fun `when options are initialized, enableQueueTracing is set to false by default`() { + assertFalse(SentryOptions().isEnableQueueTracing) + } + @Test fun `when options are initialized, metrics is enabled by default`() { assertTrue(SentryOptions().metrics.isEnabled) @@ -1018,6 +1023,23 @@ class SentryOptionsTest { assertEquals("original", options.orgId) } + @Test + fun `merging options applies enableQueueTracing`() { + val externalOptions = ExternalOptions() + externalOptions.setEnableQueueTracing(true) + val options = SentryOptions() + options.merge(externalOptions) + assertTrue(options.isEnableQueueTracing) + } + + @Test + fun `merging options preserves enableQueueTracing default when not set`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.merge(externalOptions) + assertFalse(options.isEnableQueueTracing) + } + @Test fun `getEffectiveOrgId prefers explicit orgId over DSN`() { val options = SentryOptions() diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d431d5fbdf..4b1c606bc64 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,6 +58,7 @@ include( "sentry-graphql-22", "sentry-graphql-core", "sentry-jdbc", + "sentry-kafka", "sentry-opentelemetry:sentry-opentelemetry-bootstrap", "sentry-opentelemetry:sentry-opentelemetry-core", "sentry-opentelemetry:sentry-opentelemetry-agentcustomization", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 1250c6cbab9..784448715e9 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -42,6 +42,7 @@ import argparse import requests import threading +import socket from pathlib import Path from typing import Optional, List, Tuple from dataclasses import dataclass @@ -65,6 +66,32 @@ "SENTRY_ENABLE_CACHE_TRACING": "true" } +KAFKA_CONTAINER_NAME = "sentry-java-system-test-kafka" +KAFKA_BOOTSTRAP_SERVERS = "localhost:9092" +KAFKA_BROKER_REQUIRED_MODULES = { + "sentry-samples-console", + "sentry-samples-spring-boot", + "sentry-samples-spring-boot-opentelemetry", + "sentry-samples-spring-boot-opentelemetry-noagent", + "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-opentelemetry", + "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", + "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-opentelemetry", + "sentry-samples-spring-boot-4-opentelemetry-noagent", +} +KAFKA_PROFILE_REQUIRED_MODULES = { + "sentry-samples-spring-boot", + "sentry-samples-spring-boot-opentelemetry", + "sentry-samples-spring-boot-opentelemetry-noagent", + "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-opentelemetry", + "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", + "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-opentelemetry", + "sentry-samples-spring-boot-4-opentelemetry-noagent", +} + class ServerType(Enum): TOMCAT = 0 SPRING = 1 @@ -155,6 +182,7 @@ def __init__(self): self.mock_server = Server(name="Mock", pid_filepath="sentry-mock-server.pid") self.tomcat_server = Server(name="Tomcat", pid_filepath="tomcat-server.pid") self.spring_server = Server(name="Spring", pid_filepath="spring-server.pid") + self.kafka_started_by_runner = False # Load existing PIDs if available for server in (self.mock_server, self.tomcat_server, self.spring_server): @@ -196,7 +224,84 @@ def kill_process(self, pid: int, name: str) -> None: except (OSError, ProcessLookupError): print(f"Process {pid} was already dead") + def module_requires_kafka(self, sample_module: str) -> bool: + return sample_module in KAFKA_BROKER_REQUIRED_MODULES + def module_requires_kafka_profile(self, sample_module: str) -> bool: + return sample_module in KAFKA_PROFILE_REQUIRED_MODULES + + def wait_for_port(self, host: str, port: int, max_attempts: int = 20) -> bool: + for _ in range(max_attempts): + try: + with socket.create_connection((host, port), timeout=1): + return True + except OSError: + time.sleep(1) + return False + + def remove_kafka_broker_container(self) -> None: + subprocess.run( + ["docker", "rm", "-f", KAFKA_CONTAINER_NAME], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def start_kafka_broker(self) -> None: + if self.wait_for_port("localhost", 9092, max_attempts=1): + print("Kafka broker already running on localhost:9092, reusing it.") + self.kafka_started_by_runner = False + return + + self.remove_kafka_broker_container() + + print("Starting Kafka broker (Redpanda) for system tests...") + run_result = subprocess.run( + [ + "docker", + "run", + "-d", + "--name", + KAFKA_CONTAINER_NAME, + "-p", + "9092:9092", + "docker.redpanda.com/redpandadata/redpanda:v24.1.9", + "redpanda", + "start", + "--overprovisioned", + "--smp", + "1", + "--memory", + "1G", + "--reserve-memory", + "0M", + "--node-id", + "0", + "--check=false", + "--kafka-addr", + "PLAINTEXT://0.0.0.0:9092", + "--advertise-kafka-addr", + "PLAINTEXT://localhost:9092", + ], + check=False, + capture_output=True, + text=True, + ) + + if run_result.returncode != 0: + raise RuntimeError(f"Failed to start Kafka container: {run_result.stderr}") + + if not self.wait_for_port("localhost", 9092, max_attempts=30): + raise RuntimeError("Kafka broker did not become ready on localhost:9092") + + self.kafka_started_by_runner = True + + def stop_kafka_broker(self) -> None: + if not self.kafka_started_by_runner: + return + + self.remove_kafka_broker_container() + self.kafka_started_by_runner = False def start_sentry_mock_server(self) -> None: """Start the Sentry mock server.""" @@ -347,6 +452,13 @@ def start_spring_server(self, sample_module: str, java_agent: str, java_agent_au env.update(SENTRY_ENVIRONMENT_VARIABLES) env["SENTRY_AUTO_INIT"] = java_agent_auto_init + if self.module_requires_kafka_profile(sample_module): + env["SPRING_PROFILES_ACTIVE"] = "kafka" + env["SENTRY_ENABLE_QUEUE_TRACING"] = "true" + print("Enabling Spring profile: kafka") + else: + env.pop("SPRING_PROFILES_ACTIVE", None) + # Build command jar_path = f"sentry-samples/{sample_module}/build/libs/{sample_module}-0.0.1-SNAPSHOT.jar" cmd = ["java"] @@ -564,6 +676,12 @@ def setup_test_infrastructure(self, sample_module: str, java_agent: str, java_agent_auto_init: str, build_before_run: str, server_type: Optional[ServerType]) -> int: """Set up test infrastructure. Returns 0 on success, error code on failure.""" + if self.module_requires_kafka(sample_module): + self.start_kafka_broker() + os.environ["SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS"] = KAFKA_BOOTSTRAP_SERVERS + else: + os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) + # Build if requested if build_before_run == "1": print("Building before test run") @@ -631,6 +749,8 @@ def run_single_test(self, sample_module: str, java_agent: str, elif server_type == ServerType.SPRING: self.stop_spring_server() self.stop_sentry_mock_server() + self.stop_kafka_broker() + os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) def run_all_tests(self) -> int: """Run all system tests.""" @@ -961,6 +1081,8 @@ def cleanup_on_exit(self, signum, frame): self.stop_spring_server() self.stop_sentry_mock_server() self.stop_tomcat_server() + self.stop_kafka_broker() + os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) sys.exit(1) def main(): @@ -1159,6 +1281,8 @@ def main(): runner.stop_spring_server() runner.stop_sentry_mock_server() runner.stop_tomcat_server() + runner.stop_kafka_broker() + os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) if __name__ == "__main__": sys.exit(main()) From d446e68d100ab9363b233ae6b98698bb13782049 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 6 May 2026 18:40:08 +0200 Subject: [PATCH 021/276] chore(codeowners): Add Nelson and Adam (#5369) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4a3ed92029f..6e1f71a7677 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @adinauer @romtsn @markushi +* @adinauer @romtsn @markushi @runningcode @0xadam-brown From 7ce4e911688f63d921a37f085dba629a097d9680 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 6 May 2026 18:59:18 +0200 Subject: [PATCH 022/276] feat(replay): Capture SurfaceView content (experimental) (#5333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(replay): Capture SurfaceView content (experimental) SurfaceView (used by Unity, video players, maps, and similar) renders to a separate Surface that is composited by SurfaceFlinger outside of the View hierarchy. PixelCopy.request(window, ...) only captures the Window surface, so SurfaceView regions appeared as transparent/black holes in Session Replay recordings. When the experimental option options.sessionReplay.isCaptureSurfaceViews is enabled, each visible SurfaceView is now captured separately via PixelCopy.request(surfaceView, ...) and composited onto the screenshot using PorterDuff.DST_OVER, so the SurfaceView content draws behind the Window content (which has transparent holes where the SurfaceViews are). Because SurfaceView redraws do not trigger ViewTreeObserver.OnDrawListener, the recorder bypasses the contentChanged guard when SurfaceViews are present, so subsequent frames are re-captured at the configured frame rate instead of reusing the last screenshot. The option defaults to false to preserve existing behavior. Co-Authored-By: Claude Opus 4.7 (1M context) * test(replay): Cover SurfaceView capture paths Add unit tests for the new SurfaceView capture support and extract a compositeSurfaceViewInto helper so the drawing contract can be verified with hand-built bitmaps (Robolectric's ShadowPixelCopy cannot produce meaningful SurfaceView pixels because there is no real GL producer). The tests cover: - ViewHierarchyNode.fromView returns SurfaceViewHierarchyNode vs. generic - View.traverse collects SurfaceView nodes when a list is supplied, not when it is null, and skips invisible SurfaceViews - PixelCopyStrategy leaves hasSurfaceViews false when the option is off - PixelCopyStrategy flags hasSurfaceViews true when the option is on - PixelCopyStrategy completes gracefully when a SurfaceView has no valid surface (the common Robolectric case) - compositeSurfaceViewInto fills transparent holes behind existing window content via DST_OVER, and respects both window offset and scale factors Also fixes a latent NPE in captureSurfaceViews when SurfaceHolder.surface is null (not just invalid) — happens before the surface is created. Co-Authored-By: Claude Opus 4.7 (1M context) * formatting * api dump * docs(changelog): Move SurfaceView entry to Unreleased Co-Authored-By: Claude Opus 4.7 (1M context) * feat(replay): Wire capture-surface-views option through ManifestMetadataReader Allow enabling the experimental SurfaceView capture in Session Replay via the manifest meta-data `io.sentry.session-replay.capture-surface-views`, so users relying on auto-init don't need to switch to manual SentryAndroid.init just to flip the flag. Co-Authored-By: Claude Opus 4.7 (1M context) * ref(replay): Inline captureSurfaceViewsEnabled local Co-Authored-By: Claude Opus 4.7 (1M context) * ref(replay): Address review comments - Drop the dedicated hasSurfaceViews flag and ScreenshotStrategy hook; PixelCopyStrategy now signals \"capture again next tick\" via a markContentChanged callback that re-arms the recorder's existing contentChanged gate. One source of truth instead of two booleans. - Inline the trivial submitMaskingAndCallback helper at its single call site. - Bail early in the SurfaceView PixelCopy callback if the strategy has been closed mid-flight, mirroring the Window-capture callback. - Document on SentryReplayOptions.captureSurfaceViews and in CHANGELOG that masking granularity is at the SurfaceView level only — content rendered inside a SurfaceView is opaque to the View masking system. - Simplify ViewsTest: build the test view tree inline instead of via a custom Activity subclass, idle the looper after setContentView. - Drop ViewHierarchyNodeTest — its type-dispatch coverage is implicit in ViewsTest, which only counts non-zero results when SurfaceView instances are correctly mapped to SurfaceViewHierarchyNode. Co-Authored-By: Claude Opus 4.7 (1M context) * tweaks * fix(replay): Detect window size changes on activities with configChanges Activities that declare android:configChanges="orientation|screenSize|..." (e.g. Unity, fullscreen video players) keep the same root view across rotations, so onRootViewsChanged never fires and determineWindowSize was never re-invoked. The recording bitmap stayed at the pre-rotation size, the rotated window content rendered into wrong-dim bitmaps, and SurfaceView captures composited at stale coordinates. Attach an OnLayoutChangeListener to each tracked root so a same-root resize triggers determineWindowSize. The existing size-comparison guard (both width and height must differ) keeps IME/adjustResize relayouts from causing spurious reconfigurations. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(replay): Avoid windowLocation race and bitmap leak in SurfaceView capture Address two issues flagged by review: 1. windowLocation race — root.getLocationOnScreen(windowLocation) ran on the main thread, but compositeSurfaceViewsAndMask read windowLocation[0]/[1] later from the executor thread. If a new capture cycle started before the compositor ran, the field was overwritten and SurfaceView pixels would composite at the wrong offset. Snapshot into locals (windowX/windowY) at capture time and pass them through, matching the existing svLocation → capturedX/capturedY pattern. 2. Bitmap leak when isClosed in SurfaceView callback — when the strategy closed mid-capture, the path recycled the in-flight svBitmap but skipped onCaptureComplete(), so remaining never reached zero and any sibling bitmaps already stored in captures[] leaked until GC. Now still drive the completion latch on the closed path, and have the compositor's early-return path recycle leftover captures. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(replay): Reconfig on single-dim resizes and recycle SurfaceView bitmap on throw Two review-bot findings: 1. determineWindowSize used && to compare new vs last-known dimensions, so single-dimension resizes (split-screen drag, partial multi-window adjustments, foldable transitions where only one dim shifts) were silently dropped — onWindowSizeChanged only fired when both width AND height differed. The new layout listener already detects single-dim changes with ||, but then delegated to a function that AND'd them away. Switch the existing checks to || so any size delta reconfigs the recorder, matching the listener's intent. 2. In captureSurfaceViews, if PixelCopy.request or getLocationOnScreen threw after svBitmap was allocated, the catch path logged the error but never recycled the bitmap, leaking it until GC. Track the bitmap in a nullable local that the catch block recycles, and clear it after PixelCopy.request returns successfully so ownership transfers to the async callback without double-recycling. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(replay): Ignore layout changes on non-latest root in WindowRecorder rootViews is a stack of windows (dialogs, popups, IME). The recorder binds to the topmost root, so a background activity resizing underneath a dialog must not reconfigure the recorder — we'd otherwise allocate a bitmap sized to the activity while still recording the dialog. The latest root's correct dimensions are already picked up via determineWindowSize in the onRootViewsChanged remove path when the overlaying window dismisses. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 + .../android/core/ManifestMetadataReader.java | 11 + .../core/ScreenshotEventProcessor.java | 2 +- .../core/ManifestMetadataReaderTest.kt | 25 ++ .../android/replay/ScreenshotRecorder.kt | 1 + .../sentry/android/replay/WindowRecorder.kt | 53 +++- .../replay/screenshot/PixelCopyStrategy.kt | 237 ++++++++++++++++-- .../io/sentry/android/replay/util/Views.kt | 10 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 48 ++++ .../screenshot/PixelCopyStrategyTest.kt | 172 +++++++++++++ .../sentry/android/replay/util/ViewsTest.kt | 76 ++++++ sentry/api/sentry.api | 2 + .../java/io/sentry/SentryReplayOptions.java | 34 +++ 13 files changed, 650 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244d229b994..6b0cdd51337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Features +<<<<<<< rz/feat/replay-capture-surface-views +- Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) + - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` + - Or via manifest: `` + - **Warning:** masking granularity is at the SurfaceView level only — the SDK cannot mask individual elements rendered inside the SurfaceView (e.g. native Unity UI, map labels, video frames). Only enable for SurfaceViews whose content is safe to record. +======= - Add `Sentry.feedback()` API for `show()` and `capture()` ([#5349](https://github.com/getsentry/sentry-java/pull/5349)) - `Sentry.showUserFeedbackDialog()` is deprecated in favor of `Sentry.feedback().show()` - `Sentry.captureFeedback()` is deprecated in favor of `Sentry.feedback().capture()` @@ -36,6 +42,7 @@ - Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) - Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) - Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) +>>>>>>> main ### Dependencies 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 6d90bb5ca8e..7dd6f1c1488 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 @@ -120,6 +120,8 @@ final class ManifestMetadataReader { static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug"; static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy"; + static final String REPLAYS_CAPTURE_SURFACE_VIEWS = + "io.sentry.session-replay.capture-surface-views"; static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS = "io.sentry.session-replay.network-detail-allow-urls"; @@ -547,6 +549,15 @@ static void applyMetadata( } } + options + .getSessionReplay() + .setCaptureSurfaceViews( + readBool( + metadata, + logger, + REPLAYS_CAPTURE_SURFACE_VIEWS, + options.getSessionReplay().isCaptureSurfaceViews())); + // Network Details Configuration if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) { final @Nullable List allowUrls = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 86b13309354..bbef7846cd9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -201,7 +201,7 @@ private boolean isMaskingEnabled() { final ViewHierarchyNode rootNode = ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot()); - ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger()); + ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null); return rootNode; } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e); 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 81b73d5dea7..52cb085b1ee 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 @@ -2022,6 +2022,31 @@ class ManifestMetadataReaderTest { ) } + @Test + fun `applyMetadata reads capture-surface-views to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + + @Test + fun `applyMetadata reads capture-surface-views and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + @Test fun `applyMetadata reads anrProfilingSampleRate to options`() { // Arrange diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 8cc7bccede3..ce987c24ce8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -47,6 +47,7 @@ internal class ScreenshotRecorder( options, config, debugOverlayDrawable, + markContentChanged = { contentChanged.set(true) }, ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index ead8e2645ab..19c61900889 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -17,6 +17,7 @@ import io.sentry.android.replay.util.hasSize import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference +import java.util.WeakHashMap import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean @@ -33,6 +34,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() + private val rootLayoutListeners = WeakHashMap() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() private val backgroundProcessingHandlerLock = AutoClosableReentrantLock() @@ -124,7 +126,9 @@ internal class WindowRecorder( rootViews.add(WeakReference(root)) capturer?.recorder?.bind(root) determineWindowSize(root) + attachLayoutListener(root) } else { + detachLayoutListener(root) capturer?.recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -132,6 +136,7 @@ internal class WindowRecorder( if (newRoot != null && root != newRoot) { capturer?.recorder?.bind(newRoot) determineWindowSize(newRoot) + attachLayoutListener(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -139,9 +144,45 @@ internal class WindowRecorder( } } + /** + * Activities that handle their own configuration changes (e.g. Unity, video players via + * `android:configChanges="orientation|screenSize|..."`) keep the same root view across rotations, + * so [onRootViewsChanged] never fires and [determineWindowSize] would never re-detect the new + * dimensions. Watch the root for layout-time size changes to catch these cases. + */ + private fun attachLayoutListener(root: View) { + if (rootLayoutListeners.containsKey(root)) return + val listener = + View.OnLayoutChangeListener { + v, + left, + top, + right, + bottom, + oldLeft, + oldTop, + oldRight, + oldBottom -> + val width = right - left + val height = bottom - top + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + if (width == oldWidth && height == oldHeight) return@OnLayoutChangeListener + // ignore non-latest roots so a dialog stays sized for itself, not its background activity. + if (v != rootViews.lastOrNull()?.get()) return@OnLayoutChangeListener + determineWindowSize(v) + } + rootLayoutListeners[root] = listener + root.addOnLayoutChangeListener(listener) + } + + private fun detachLayoutListener(root: View) { + rootLayoutListeners.remove(root)?.let { root.removeOnLayoutChangeListener(it) } + } + fun determineWindowSize(root: View) { if (root.hasSize()) { - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } @@ -157,7 +198,7 @@ internal class WindowRecorder( } if (root.hasSize()) { root.removeOnPreDrawListenerSafe(this) - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } @@ -222,7 +263,13 @@ internal class WindowRecorder( override fun reset() { lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { - rootViews.forEach { capturer?.recorder?.unbind(it.get()) } + rootViews.forEach { + val root = it.get() + if (root != null) { + detachLayoutListener(root) + capturer?.recorder?.unbind(root) + } + } rootViews.clear() } } 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 ec3f36647c3..81dd7c5cee5 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 @@ -2,7 +2,13 @@ package io.sentry.android.replay.screenshot import android.annotation.SuppressLint import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF import android.view.PixelCopy import android.view.View import io.sentry.SentryLevel.DEBUG @@ -19,6 +25,7 @@ import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.LazyThreadSafetyMode.NONE @SuppressLint("UseKtx") @@ -28,6 +35,9 @@ internal class PixelCopyStrategy( private val options: SentryOptions, private val config: ScreenshotRecorderConfig, private val debugOverlayDrawable: DebugOverlayDrawable, + // Lets the strategy re-arm the recorder's contentChanged gate so frames keep being captured + // when SurfaceViews are present (their redraws don't trigger ViewTreeObserver.OnDrawListener). + private val markContentChanged: () -> Unit = {}, ) : ScreenshotStrategy { private val executor = executorProvider.getExecutor() @@ -40,6 +50,15 @@ internal class PixelCopyStrategy( private val maskRenderer = MaskRenderer() private val contentChanged = AtomicBoolean(false) private val isClosed = AtomicBoolean(false) + private val dstOverPaint by + lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } + private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } + private val tmpSrcRect = Rect() + private val tmpDstRect = RectF() + private val windowLocation = IntArray(2) + private val svLocation = IntArray(2) + + private class SurfaceViewCapture(val bitmap: Bitmap, val x: Int, val y: Int) @SuppressLint("NewApi") override fun capture(root: View) { @@ -81,31 +100,26 @@ internal class PixelCopyStrategy( // TODO: disableAllMasking here and dont traverse? val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) - root.traverse(viewHierarchy, options.sessionReplay, options.logger) - - executor.submit( - ReplayRunnable("screenshot_recorder.mask") { - if (isClosed.get() || screenshot.isRecycled) { - options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") - return@ReplayRunnable - } - - val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix) + val surfaceViewNodes = + if (options.sessionReplay.isCaptureSurfaceViews) { + mutableListOf() + } else { + null + } + root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) - if (options.replayController.isDebugMaskingOverlayEnabled()) { - mainLooperHandler.post { - if (debugOverlayDrawable.callback == null) { - root.overlay.add(debugOverlayDrawable) - } - debugOverlayDrawable.updateMasks(debugMasks) - root.postInvalidate() - } + if (surfaceViewNodes.isNullOrEmpty()) { + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { + applyMaskingAndNotify(root, viewHierarchy) } - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastCaptureSuccessful.set(true) - contentChanged.set(false) - } - ) + ) + } 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) + } }, mainLooperHandler.handler, ) @@ -115,6 +129,148 @@ internal class PixelCopyStrategy( } } + private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") + return + } + + val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix) + + if (options.replayController.isDebugMaskingOverlayEnabled()) { + mainLooperHandler.post { + if (debugOverlayDrawable.callback == null) { + root.overlay.add(debugOverlayDrawable) + } + debugOverlayDrawable.updateMasks(debugMasks) + root.postInvalidate() + } + } + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastCaptureSuccessful.set(true) + contentChanged.set(false) + } + + @SuppressLint("NewApi") + private fun captureSurfaceViews( + root: View, + surfaceViewNodes: List, + viewHierarchy: ViewHierarchyNode, + ) { + // Snapshot the window location into locals so the executor-side compositor reads stable + // values even if a new capture cycle starts and overwrites the field. + root.getLocationOnScreen(windowLocation) + val windowX = windowLocation[0] + val windowY = windowLocation[1] + + val captures = arrayOfNulls(surfaceViewNodes.size) + val remaining = AtomicInteger(surfaceViewNodes.size) + + fun onCaptureComplete() { + if (remaining.decrementAndGet() == 0) { + compositeSurfaceViewsAndMask(root, captures, viewHierarchy, windowX, windowY) + } + } + + for ((index, node) in surfaceViewNodes.withIndex()) { + val surfaceView = node.surfaceViewRef.get() + // holder.surface can be null before the surface is created — guard against NPE. + val surface = surfaceView?.holder?.surface + if (surfaceView == null || surface == null || !surface.isValid) { + onCaptureComplete() + continue + } + + var svBitmap: Bitmap? = null + try { + svBitmap = + Bitmap.createBitmap(surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888) + val bitmapToCapture = svBitmap + + surfaceView.getLocationOnScreen(svLocation) + val capturedX = svLocation[0] + val capturedY = svLocation[1] + + PixelCopy.request( + surfaceView, + bitmapToCapture, + { copyResult: Int -> + if (isClosed.get()) { + bitmapToCapture.recycle() + // still drive the completion latch so any prior captures get recycled by the + // composite step's early-return path. + onCaptureComplete() + return@request + } + if (copyResult == PixelCopy.SUCCESS) { + captures[index] = SurfaceViewCapture(bitmapToCapture, capturedX, capturedY) + } else { + bitmapToCapture.recycle() + options.logger.log(INFO, "Failed to capture SurfaceView: %d", copyResult) + } + onCaptureComplete() + }, + mainLooperHandler.handler, + ) + // Ownership transferred to the PixelCopy callback — clear local so catch doesn't + // double-recycle if the recycle paths above already ran. + svBitmap = null + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture SurfaceView", e) + svBitmap?.recycle() + onCaptureComplete() + } + } + } + + private fun compositeSurfaceViewsAndMask( + root: View, + captures: Array, + viewHierarchy: ViewHierarchyNode, + windowX: Int, + windowY: Int, + ) { + executor.submit( + ReplayRunnable("screenshot_recorder.composite") { + 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() + } + + applyMaskingAndNotify(root, viewHierarchy) + } + ) + } + + private fun recycleCaptures(captures: Array) { + for (capture in captures) { + if (capture != null && !capture.bitmap.isRecycled) { + capture.bitmap.recycle() + } + } + } + override fun onContentChanged() { contentChanged.set(true) } @@ -148,3 +304,38 @@ internal class PixelCopyStrategy( ) } } + +/** + * Composites [sourceBitmap] (a SurfaceView capture) onto [destCanvas] (wrapping the recording + * screenshot) using [destPaint] (expected to have DST_OVER xfermode), so the SurfaceView content + * draws _behind_ existing Window content — filling the transparent holes the Window PixelCopy + * leaves where SurfaceViews are. + * + * Extracted for testability — the compositing is pure drawing logic that can be driven with + * hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends on + * a real SurfaceView producer that Robolectric cannot provide. + */ +internal fun compositeSurfaceViewInto( + destCanvas: Canvas, + destPaint: Paint, + tmpSrc: Rect, + tmpDst: RectF, + sourceBitmap: Bitmap, + sourceX: Int, + sourceY: Int, + windowX: Int, + windowY: Int, + scaleFactorX: Float, + scaleFactorY: Float, +) { + val left = (sourceX - windowX) * scaleFactorX + val top = (sourceY - windowY) * scaleFactorY + tmpSrc.set(0, 0, sourceBitmap.width, sourceBitmap.height) + tmpDst.set( + left, + top, + left + sourceBitmap.width * scaleFactorX, + top + sourceBitmap.height * scaleFactorY, + ) + destCanvas.drawBitmap(sourceBitmap, tmpSrc, tmpDst, destPaint) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index d0583cdaa6a..cacd2b1c217 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -38,6 +38,7 @@ internal fun View.traverse( parentNode: ViewHierarchyNode, options: SentryMaskingOptions, logger: ILogger, + surfaceViewNodes: MutableList? = null, ) { if (this !is ViewGroup) { return @@ -59,7 +60,14 @@ internal fun View.traverse( if (child != null) { val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) childNodes.add(childNode) - child.traverse(childNode, options, logger) + if ( + surfaceViewNodes != null && + childNode is ViewHierarchyNode.SurfaceViewHierarchyNode && + childNode.isVisible + ) { + surfaceViewNodes.add(childNode) + } + child.traverse(childNode, options, logger, surfaceViewNodes) } } parentNode.children = childNodes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index f54fa79da10..e55ba659a8e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -3,6 +3,7 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Rect +import android.view.SurfaceView import android.view.View import android.view.ViewParent import android.widget.ImageView @@ -15,6 +16,7 @@ import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe +import java.lang.ref.WeakReference @SuppressLint("UseRequiresApi") @TargetApi(26) @@ -121,6 +123,34 @@ internal sealed class ViewHierarchyNode( visibleRect, ) + class SurfaceViewHierarchyNode( + val surfaceViewRef: WeakReference, + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null, + ) : + ViewHierarchyNode( + x, + y, + width, + height, + elevation, + distance, + parent, + shouldMask, + isImportantForContentCapture, + isVisible, + visibleRect, + ) + /** * Basically replicating this: * https://developer.android.com/reference/android/view/View#isImportantForContentCapture() but @@ -379,6 +409,24 @@ internal sealed class ViewHierarchyNode( visibleRect = visibleRect, ) } + + is SurfaceView -> { + parent?.setImportantForCaptureToAncestors(true) + return SurfaceViewHierarchyNode( + surfaceViewRef = WeakReference(view), + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect, + ) + } } return GenericViewHierarchyNode( 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 29a3089e686..277ad941a14 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 @@ -1,9 +1,19 @@ package io.sentry.android.replay.screenshot import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.SurfaceView +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams import android.widget.TextView @@ -15,21 +25,27 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.DebugOverlayDrawable import io.sentry.android.replay.util.MainLooperHandler import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference 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.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import org.robolectric.shadows.ShadowPixelCopy @Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) @RunWith(AndroidJUnit4::class) class PixelCopyStrategyTest { @@ -38,6 +54,7 @@ class PixelCopyStrategyTest { val callback = mock() val debugOverlayDrawable = mock() val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000) + val contentChangedMarked = AtomicBoolean(false) fun getSut(executor: ScheduledExecutorService = mock()): PixelCopyStrategy { return PixelCopyStrategy( @@ -52,8 +69,21 @@ class PixelCopyStrategyTest { options, config, debugOverlayDrawable, + markContentChanged = { contentChangedMarked.set(true) }, ) } + + /** Executor mock that runs submitted tasks synchronously on the calling thread. */ + fun inlineExecutor(): ScheduledExecutorService { + return mock { + doAnswer { + (it.arguments[0] as Runnable).run() + null // submit(Runnable) returns Future; returning Unit breaks the cast + } + .whenever(mock) + .submit(any()) + } + } } private val fixture = Fixture() @@ -101,6 +131,125 @@ class PixelCopyStrategyTest { if (failure.get() != null) throw failure.get() } + + @Test + fun `capture does not call markContentChanged when option is disabled`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + // Default: isCaptureSurfaceViews = false + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fixture.contentChangedMarked.get()) + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + fun `capture re-arms contentChanged when option is enabled and SurfaceView is present`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(fixture.contentChangedMarked.get()) + } + + @Test + fun `capture completes when SurfaceView surface is not valid`() { + // In Robolectric the SurfaceView holder surface is not valid — this exercises the + // `surfaceView.holder.surface.isValid == false` branch: each SurfaceView skips its + // PixelCopy and onCaptureComplete still fires, eventually running the compositor and + // callback. + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + fun `compositeSurfaceViewInto draws source behind existing destination with DST_OVER`() { + // Destination ("Window capture"): 100x100, opaque red in the top half, + // fully transparent in the bottom half (the "hole" where the SurfaceView sits). + val dest = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + destCanvas.drawColor(Color.RED) + val clearPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } + destCanvas.drawRect(0f, 50f, 100f, 100f, clearPaint) + + // Source ("SurfaceView capture"): 100x50, solid blue — matches the hole. + val source = Bitmap.createBitmap(100, 50, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.BLUE) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 0, + sourceY = 50, + windowX = 0, + windowY = 0, + scaleFactorX = 1f, + scaleFactorY = 1f, + ) + + // Top region: still red (DST_OVER must not overwrite existing opaque pixels). + assertEquals(Color.RED, dest.getPixel(50, 10)) + assertEquals(Color.RED, dest.getPixel(50, 49)) + // Bottom region: now blue (source filled the transparent hole). + assertEquals(Color.BLUE, dest.getPixel(50, 50)) + assertEquals(Color.BLUE, dest.getPixel(99, 99)) + } + + @Test + fun `compositeSurfaceViewInto respects scale factors and window offset`() { + // Destination is 50x50 (scaled recording), fully transparent. + val dest = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + + // Source is 40x40, solid green; its on-screen location is (20, 20). + val source = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.GREEN) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 20, + sourceY = 20, + windowX = 10, // window is at (10, 10) + windowY = 10, + scaleFactorX = 0.5f, // 0.5x scale → destination coords halve + scaleFactorY = 0.5f, + ) + + // Expected destination rect: ((20-10)*0.5, (20-10)*0.5) = (5, 5), size 40*0.5 = 20x20 + // → occupies pixels [5..25) × [5..25). Check inside, on the edge, and just outside. + assertEquals(Color.GREEN, dest.getPixel(5, 5)) + assertEquals(Color.GREEN, dest.getPixel(15, 15)) + assertEquals(Color.GREEN, dest.getPixel(24, 24)) + // Just outside the rect — still transparent. + assertEquals(0, dest.getPixel(4, 4)) + assertEquals(0, dest.getPixel(25, 25)) + } } private class SimpleActivity : Activity() { @@ -123,3 +272,26 @@ private class SimpleActivity : Activity() { setContentView(linearLayout) } } + +private class ActivityWithSurfaceView : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = + FrameLayout(this).apply { + setBackgroundColor(android.R.color.white) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + root.addView( + TextView(this).apply { + text = "Overlay" + layoutParams = FrameLayout.LayoutParams(200, 50) + } + ) + root.addView(SurfaceView(this).apply { layoutParams = FrameLayout.LayoutParams(200, 200) }) + setContentView(root) + } +} 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 530c124af4f..2eaa8411cfe 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 @@ -1,15 +1,36 @@ package io.sentry.android.replay.util +import android.app.Activity +import android.os.Looper +import android.view.SurfaceView import android.view.View +import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.NoOpLogger +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +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.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class ViewsTest { + + @BeforeTest + fun setup() { + // Required so Robolectric reports the activity window as visible; otherwise + // View.isVisibleToUser() returns false and SurfaceView nodes are skipped. + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + @Test fun `hasSize returns true for positive values`() { val view = View(ApplicationProvider.getApplicationContext()) @@ -33,4 +54,59 @@ class ViewsTest { view.bottom = -1 assertFalse(view.hasSize()) } + + @Test + fun `traverse collects visible SurfaceView nodes when a list is supplied`() { + val (root, _) = buildSurfaceViewHierarchy() + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(2, collected.size) + } + + @Test + fun `traverse does not collect SurfaceView nodes when list parameter is null`() { + val (root, _) = buildSurfaceViewHierarchy() + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), null) + } + + @Test + fun `traverse skips invisible SurfaceViews`() { + val (root, surfaceViews) = buildSurfaceViewHierarchy() + surfaceViews.first().visibility = View.GONE + + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(1, collected.size) + } + + /** + * Builds and attaches a small view tree: `FrameLayout(SurfaceView, TextView, FrameLayout( + * SurfaceView))`. Returns the root [FrameLayout] and the two [SurfaceView]s in tree order so + * tests can mutate visibility without re-walking the hierarchy. + */ + private fun buildSurfaceViewHierarchy(): Pair> { + val activity = buildActivity(Activity::class.java).setup().get() + val sv1 = SurfaceView(activity).apply { layoutParams = LayoutParams(100, 100) } + val sv2 = SurfaceView(activity).apply { layoutParams = LayoutParams(50, 50) } + val nested = FrameLayout(activity).apply { addView(sv2) } + val root = + FrameLayout(activity).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(sv1) + addView(TextView(activity).apply { text = "label" }) + addView(nested) + } + activity.setContentView(root) + // Flush the layout/attach pass so isAttachedToWindow / visibility computations are accurate. + shadowOf(Looper.getMainLooper()).idle() + return root to listOf(sv1, sv2) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 13dfd6b9b39..a433abbb37c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4069,12 +4069,14 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun isCaptureSurfaceViews ()Z public fun isDebug ()Z public fun isNetworkCaptureBodies ()Z public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V + public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d4e0fd257cd..6eb4a58e1c2 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -146,6 +146,20 @@ public enum SentryReplayQuality { @ApiStatus.Experimental private @NotNull ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; + /** + * Whether to capture SurfaceView content (e.g. Unity, video players, maps) during replay + * recording. When enabled, each SurfaceView in the view hierarchy will be captured separately via + * PixelCopy and composited onto the screenshot. Only applies when {@link #screenshotStrategy} is + * {@link ScreenshotStrategyType#PIXEL_COPY}. Default is disabled. + * + *

Warning: the SDK cannot mask individual elements rendered inside a SurfaceView (e.g. + * native Unity UI, map labels, video frames) — masking granularity is at the SurfaceView level + * only. If the SurfaceView is configured to be masked, the entire region is redacted; otherwise + * its full pixel content is sent in the replay. Only enable this for SurfaceViews whose content + * is safe to record. + */ + @ApiStatus.Experimental private boolean captureSurfaceViews = false; + /** * Capture request and response details for XHR and fetch requests that match the given URLs. * Default is empty (network details not collected). @@ -383,6 +397,26 @@ public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screensh this.screenshotStrategy = screenshotStrategy; } + /** + * Whether SurfaceView capture is enabled. See {@link #captureSurfaceViews}. + * + * @return true if SurfaceView capture is enabled + */ + @ApiStatus.Experimental + public boolean isCaptureSurfaceViews() { + return captureSurfaceViews; + } + + /** + * Enables or disables SurfaceView capture. See {@link #captureSurfaceViews}. + * + * @param captureSurfaceViews true to enable SurfaceView capture + */ + @ApiStatus.Experimental + public void setCaptureSurfaceViews(final boolean captureSurfaceViews) { + this.captureSurfaceViews = captureSurfaceViews; + } + /** * Gets the list of URLs for which network request and response details should be captured. * From 566492415f8cb9a2596b13798f4088f3d5b7f614 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 6 May 2026 22:12:40 +0200 Subject: [PATCH 023/276] Fix Changelog (#5381) Fix faultly changelog #skip-changelog --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0cdd51337..fba1451f4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,10 @@ ### Features -<<<<<<< rz/feat/replay-capture-surface-views - Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` - Or via manifest: `` - **Warning:** masking granularity is at the SurfaceView level only — the SDK cannot mask individual elements rendered inside the SurfaceView (e.g. native Unity UI, map labels, video frames). Only enable for SurfaceViews whose content is safe to record. -======= - Add `Sentry.feedback()` API for `show()` and `capture()` ([#5349](https://github.com/getsentry/sentry-java/pull/5349)) - `Sentry.showUserFeedbackDialog()` is deprecated in favor of `Sentry.feedback().show()` - `Sentry.captureFeedback()` is deprecated in favor of `Sentry.feedback().capture()` @@ -42,7 +40,6 @@ - Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) - Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) - Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) ->>>>>>> main ### Dependencies From 6219eb3d898ce527b1024eaa75e6a3ee5e985601 Mon Sep 17 00:00:00 2001 From: romtsn <4999776+romtsn@users.noreply.github.com> Date: Wed, 6 May 2026 20:20:52 +0000 Subject: [PATCH 024/276] release: 8.41.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fba1451f4ae..681753db082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.41.0 ### Features diff --git a/gradle.properties b/gradle.properties index 38ad043eee8..81fdf72ff04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.40.0 +versionName=8.41.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From eb95dedf76a910ecab60a3764126936bedf0d505 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 7 May 2026 09:18:41 +0200 Subject: [PATCH 025/276] Upload screenshot snapshots to Sentry (#5378) * feat(android-core): Upload screenshot snapshots to Sentry Replace local golden-image comparison in ScreenshotEventProcessorTest with Sentry Snapshots for visual diffing. Screenshots are now generated to build/test-snapshots/ and uploaded via sentry-cli in CI. - Remove local snapshot comparison logic and dropbox-differ dependency - Delete golden images from version control - Add sentry-cli install and upload steps to build.yml - Use PR head SHA checkout for correct base-vs-head diffing Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ci): Use correct Sentry org and project for snapshot upload Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 13 ++++ gradle/libs.versions.toml | 2 +- sentry-android-core/build.gradle.kts | 2 +- .../core/ScreenshotEventProcessorTest.kt | 57 +----------------- .../screenshot_mask_all.png | Bin 2608 -> 0 bytes .../screenshot_mask_custom_view.png | Bin 14948 -> 0 bytes ...eenshot_mask_ellipsized_compose_masked.png | Bin 2917 -> 0 bytes ...nshot_mask_ellipsized_compose_unmasked.png | Bin 20367 -> 0 bytes ...screenshot_mask_ellipsized_view_masked.png | Bin 2331 -> 0 bytes ...reenshot_mask_ellipsized_view_unmasked.png | Bin 16750 -> 0 bytes .../screenshot_mask_images.png | Bin 9353 -> 0 bytes .../screenshot_mask_text.png | Bin 8366 -> 0 bytes .../screenshot_multiline_compose_masked.png | Bin 3272 -> 0 bytes .../screenshot_multiline_compose_unmasked.png | Bin 28628 -> 0 bytes .../screenshot_multiline_view_masked.png | Bin 2924 -> 0 bytes .../screenshot_multiline_view_unmasked.png | Bin 20865 -> 0 bytes .../screenshot_no_masking.png | Bin 14845 -> 0 bytes 17 files changed, 17 insertions(+), 57 deletions(-) delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_all.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_custom_view.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_masked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_unmasked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_view_masked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_view_unmasked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_images.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_text.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_masked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_unmasked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_view_masked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_view_unmasked.png delete mode 100644 sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_no_masking.png diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 089913c9727..b16444183f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,7 @@ jobs: - name: Checkout Repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' - name: Setup Java Version @@ -44,6 +45,18 @@ jobs: - name: Run Tests with coverage and Lint run: make preMerge + - name: Install Sentry CLI + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Upload Snapshots to Sentry + run: | + sentry-cli build snapshots ./sentry-android-core/build/test-snapshots \ + --app-id sentry-android-core + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android + - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v4 with: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 50d415c212a..8b7cbee3700 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -247,4 +247,4 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.14" } -dropbox-differ = { module = "com.dropbox.differ:differ-jvm", version = "0.3.0" } + diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index ffd42c7d4d7..f61cec89265 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -108,7 +108,7 @@ dependencies { testImplementation(projects.sentryAndroidReplay) testImplementation(projects.sentryCompose) testImplementation(projects.sentryAndroidNdk) - testImplementation(libs.dropbox.differ) + testImplementation(libs.androidx.activity.compose) testImplementation(libs.androidx.compose.ui) testImplementation(libs.androidx.compose.foundation) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index 300936153f7..b8e223f08e9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -2,8 +2,6 @@ package io.sentry.android.core import android.app.Activity import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color import android.graphics.drawable.Drawable @@ -31,9 +29,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.dropbox.differ.Color as DifferColor -import com.dropbox.differ.Image -import com.dropbox.differ.SimpleImageComparator import io.sentry.Attachment import io.sentry.Hint import io.sentry.MainEventProcessor @@ -66,17 +61,8 @@ import org.robolectric.shadows.ShadowPixelCopy class ScreenshotEventProcessorTest { companion object { - /** - * Set to `true` to record/update golden images for snapshot tests. When `true`, screenshots - * will be saved to src/test/resources/snapshots/{testName}.png. Set back to `false` after - * recording to run comparison tests. - */ - private const val RECORD_SNAPSHOTS = false - private val SNAPSHOTS_DIR = - File("src/test/resources/snapshots/ScreenshotEventProcessorTest").also { - if (RECORD_SNAPSHOTS) it.mkdirs() - } + File("build/test-snapshots/ScreenshotEventProcessorTest").also { it.mkdirs() } } private class Fixture { @@ -507,17 +493,6 @@ class ScreenshotEventProcessorTest { private fun getEvent(): SentryEvent = SentryEvent(Throwable("Throwable")) - /** - * Helper method for snapshot testing. Processes an event and captures a screenshot, then either - * saves it as a golden image (when RECORD_SNAPSHOTS=true) or compares it against an existing - * golden image. - * - * @param testName The name used for the golden image file (without extension) - * @param attachScreenshot Whether to enable screenshot attachment - * @param isReplayAvailable Whether the replay module is available (enables masking) - * @param configureOptions Lambda to configure additional options before processing - * @return The captured screenshot bytes, or null if no screenshot was captured - */ private fun processEventForSnapshots( testName: String, attachScreenshot: Boolean = true, @@ -536,38 +511,10 @@ class ScreenshotEventProcessorTest { val screenshot = hint.screenshot ?: return null val bytes = screenshot.bytes ?: screenshot.byteProvider?.call() ?: return null - val snapshotFile = File(SNAPSHOTS_DIR, "$testName.png") - if (RECORD_SNAPSHOTS) { - snapshotFile.writeBytes(bytes) - println("Recorded snapshot: ${snapshotFile.absolutePath}") - } else if (snapshotFile.exists()) { - val expectedBitmap = BitmapFactory.decodeFile(snapshotFile.absolutePath) - val actualBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - - val result = - SimpleImageComparator(maxDistance = 0.01f) - .compare(BitmapImage(expectedBitmap), BitmapImage(actualBitmap)) - assertEquals( - 0, - result.pixelDifferences, - "Screenshot does not match golden image: ${snapshotFile.absolutePath}. " + - "Pixel differences: ${result.pixelDifferences}", - ) - } + File(SNAPSHOTS_DIR, "$testName.png").writeBytes(bytes) return bytes } - - /** Adapter to wrap Android Bitmap for use with dropbox/differ library */ - private class BitmapImage(private val bitmap: Bitmap) : Image { - override val height: Int - get() = bitmap.height - - override val width: Int - get() = bitmap.width - - override fun getPixel(x: Int, y: Int): DifferColor = DifferColor(bitmap.getPixel(x, y)) - } } private class CustomView(context: Context) : View(context) { diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_all.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_all.png deleted file mode 100644 index aa1ec41ee06c2a1dbbd7292cf8629935c5c9b634..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2608 zcmeHJSx}Q#6#jqy6oLgwt%ig^g42%_faSY5~U;WG$hA4od`N69_5FfCW*9 zQ4mz>Sgi|$6h;yUBrFm{1gZ#xKnQ_}tOAiG34zc*ee*%9Qy+Zjow;-8+~v&qzVqF4 z@0tDH9_DMT)&Kxt?zxw7002Nb+tM^? zAMY@}n-ZPBz`h-|No&+F6f&zLe>K#m_+G6Hkf)wgi!qjvZA3;t1W3>zq7s36ypC?W z5BSCYtwxy#EpEip5?u`Pss#eULpg<0ALrX>QWlBZI1mA3SqRH(%oO55Xgw|D%jwEZ z#7w{sXZl+of$y2ry?zJXcCrWDse?GDYa(*7w&yo->6ZW_KOAQ@CNg!qp^5V)8 zETc&fqSA34y^0=EFnxD3i%ahj8%3azQH_Dsk4$br>c&&6A@dcc8)qr~OUn4mTqgiAJsuIQW!OWs4{-A!=r9Y%EvFpHS$~q|>tb=FNOfddZ4W z&CeU?Cfe>Pf`p=DItwe>IGM8HE!)oDXCHwMT?TorG&70Ko9Kjg=b9U)B}sJXupxLo z#(R=yI96E~{EX@#55Fy!V@w;E>&~-3NeA8gWY+d$Dq&spzJVB|?MvF-Jmqowm zn)aY+CLh{=_KoV~UiZ9n?_C-0`6|_2&$<_s}VR zUbM2hfIjvPK0j3)u2}*o-f4$dR99pkhXA zz-2OLX7C}7Jy`o-KHOsMTCqx{N?x4mgLl*P8s_qfTHb4wqmkty8`}d@z6f)@${SWs zbl=-L;v~kDQq!&Alh}%&hgQtI*todux4OoYOg3_y!sQL^f!KOWhoC%?zC>FdA3HKX zf1);39Rq`g(XdHmE@AXJ(7~!wX;dSB`r6mI&FL-~0)#QZ6cx3z3iVg8X8dmojgO~H zEGe3@anYKtKYZ@>29544Dw*>^#JqeC;PLo;l2`+~IO|lG_A1UFP`BO$0|quEuDk%4%S`U>}s6nCK|RjIh_$Wgq@L>cOLZyJrqvx=8wo3fBrf{F?w*o=Y4S5()w-vn(!@_Y=3I-InvzV?|^P8HQ4x3r#Haoky z;YLSCiOGP4SPIvO4frhRzu(S9B9WLld{B|bBJzoF-G1k*@WI#l<9drUROitCQ-H+y o6OZ)c;{M;;r>)EP&rjJZSjl6n5Eo>4_}2$`?(t^u+(R<|28Xc1xBvhE diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_custom_view.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_custom_view.png deleted file mode 100644 index 217c73490cec235b4b9a07fb87fcc4c371a5f406..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14948 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 diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_masked.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_masked.png deleted file mode 100644 index 53e5a236c3e86ca447dd8674c6d046302bd8cda1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2917 zcmeH}c~p~E7QnwOBmsp41ZAW``)|n-23i*zjyCV zw6ZXklsF;*0DvUroRJLxK*-?RheCie2&R<~{6J_n#s)wcS78<$z-a~)I~4dtpxhGx z-~gIpbk;63i$B8lJ7fxL8yBhB6pX7BrelhHO0)Iz`7O5K& z26iAx%D9{A7bXlb*gh=e$p$P=%igvG)Ou1X_oXr}PJQsQ*#7$WGhK2R8NhYD3hnhy zs&;}vD-hzj_E=ngQO2&4Bgg3u(Hu;HPonEBspHMhcP5ZSq<&hFjToa5f1q; zF8yJ$Pot&;YV#M+#0NhAv0yhG7atG<8b*lgqRPt3pAoRdtj;TfH`KoQ;ODEyq{gVt z$c{^FeO0_(e!sla7F{*eD>(^PJH*eJpZ8vZ$^T2$99+fc`_jU~UTr1N|d2`gShME$a zN}#Yvj(UnrQMik+cGNMZ+^2c<+9osalfGZ%YYir}^_X7S@N&%?R_&8jrFQ^_7WB*- zm&?syN9}O+4)}JLzAVB`%<0n$<4Zo@WL~K{e;QQhXJKJ6r6dKza3m7P78?tK zAB&asZlg1TmKUUDSj$~at9Eo;} z;hehZpT5FlG&%AIpidzaAqpE!mQa-C+R|kFnqoR}fdMoh8f8b3RxvYq-f*$qE4G+W zx)9jV(4cXn1c2JFFOC~7QQryPJC#R4b;m7$acQ>XZ?z*l-eHmh5dFy6#YK+PH{v!X z{?2LR8NqZ{=j^Q4(qt(xP8yI@My z%%CPjWjXi`Rl{zHjm-=|Vf#XUj3^zxOLwqZ9F#@~QW1u;PHZT#m~d&Lo5iN&+;#hN z%&*JWAf_*BDA`bu>lUNrd>fnME2_D%ASuR34argVUV zHCb5pJaF*Cq+(z1Ebpj5W-DO|Z_8mZ#kN2xG+K3ju#l@kmWKaOXqovt&v%>5=OPVz z->L;=<8W1<$zN{mhr)W>KF+e`4OZN}T$fjx*`n~s#jeg%af`!x*Sj}OzCZHi>)5wc zJcZG!M>S(rb55K8+WuE!HPu#d-C9tgcy9s#ajL9qYDx*6lt~FJ>Nr(kr@-N{qqG?e z1~tXQ+Kd~P=4n<$p!<(Deo9m5*18?m_9O|m_S;AUz8zxT0}7vo(UAiWabf}=;+|BB ztaVOQPxBP0OXX_s{+RtDfk}9&PN(eXswvLD_=2@UX|FfRcBG5@J2m4^xHMn?KH!#V zzxM%W!BIzWdzl%$aSL{6Uz1VS2IXpxb=nl|nUVULIS9ok8vbd6R>Ba7vG;^fIQBuL%GBTNv8==u@72TezIy3dCLDxj-dE5RZh`x?;OV^Bj4Rx< zoVljfmU4f+jcV9Yw;LIN3L)qc#Sr^e1N#US;olk}m*=Z;?uXB~KNE%PkDsIGiJD~c zR~GdLNEQtDvo0WuwiByPbAg7Xx=5TpbhaIRdRCT9+S*yxB`Zcq9Berzm1~c-%A6Rw zRO(P%(S9vxm~t>Bb5CMfdHt9`xB*71t`1}exThhN+v7$b3>t(Et-PHds`00xA)k<^ zcVK3B#;#O)y-QvQ9Q~qlO#XK`QEi?B%RixBIW~iNNM66tajA*}R0S%1v}k*aR`?ej zV}R7%Q0}4ey}a|M=)FSpXQ2M4e(=NI=p${@|6R0=?vO>6;d>-!z*ZTcd~0D;X5bd{ EZ)r_j+5i9m diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_unmasked.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_mask_ellipsized_compose_unmasked.png deleted file mode 100644 index efc2304c4f2db20dc4d7d5ee73a741da009a9001..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20367 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 z 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 diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_masked.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_masked.png deleted file mode 100644 index fb8f816218d0610b70c51cf01fc4a13ada281590..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3272 zcmeHKdpMN&7k}rnGn0%NR^t{k+H7jYxP+2x3Z+8Cij1LM6ru-}8D{8L;(W22k;i65TFE6?lA*CAg8=soB;Oo^>4rz_$enhZxnb@ zP@xO}P>ywTcJw}7FeYkQ{6RNub(dK+P$(rI4CY=X#t#ti8E?L`o^V<1Bpk(r@vp*2J@Djliz59W;l82os

81hjI$%-Uus@3pzxUy z=T3G2O8ApU40c)fhyejcbJx?Wax9#&Z7(qPo@Sa8k*-U?jtU_U7GX?}+gsy5)%!0@ za^ryGNw?MzgA97eNK~V?0WF@+&)d6Apn_#=^9RPDbki#*(oFUs^DCxsToxIPfoAeV>N>`F*dGWoVau6PVrVmHxI_i`V z8*BD@1c979{uoJVJ*(r-cy;ka+!~`KKDMlyd=e;Uv$-R!g;vWcw5kVvnU5fp`FF=; zuiJ_@cR`xVlRJr)L7=<)Y7@#AXWkqdJJv^_ zIhaQG*Zsgdrvt|t4v&j!Ik_iO0jQv_ru}eik3X7te4?Mn`mjunjxJA}ed_~$N3^Du z*|#Gfj14wj6t%T^f+7+Ur9SoB)9i7%{NbEgRCk!RyI2;*Kv-4<7}pBOo{3uG?$gNE zUYgy`V%h$m%15i};5bJTLwKZe)}dooWB9(N{PyhYvfdrEAO^l-ydnPuSu?80J|P%91N&J?^ZGgrgsxo3n-c7X!*HnLSF=uLPTe$N1GH>X4OUs1#9 z1_o-E$G;3C1+KNC6c!XHFVvJ3!d2}giNf9&PRhxltbL>@*!eKtx>VTdxVTQ+SLR<^e}O8E;8rTT7rj2u_$8|_^vL=KDLQ-_j+nTmX)4WEW{;H zsevCzgTdQLpSQNM3VK1P=MTlc6}c)x(6n?=;2{) z@WuJ7R0Xzy+5XGYE0pNm0&DJ`?YFKo=@VMh%s1KD*^c(UCmJ&* zA@=wC8D0l3sn@`9_N32C@=e26iftpNdv992yEZ>4SrOO#1ZHj(2d642H|87BjHz@X z$l(3gW6eLU^A=+=adM%wJNvVW+2~Yg{FAn}w$P?37(73oLfQGUaud2JkZ@AxkX7hA z-69mU*Rbuo4Y~Xcs1vun&C}7&fys_HKS$Ib4k`BDNJ~L3xo4%l$VPa`X^~gbZ^igI zw8uwjXUZWV`K6nSkseF5&F*cum71~#T_nO2OIli5JTHEWL`gHnNsEykgP*ozn-FQ! z0Jyi(mw$y_UhZOhQ~aA--}2bLFLfCa%61i5UOIQl_>^u{sC~PP+ICoe09&M0(x9>8o*c5`o*T?C zi67}M!UmTb;YyVC;oS9kK^8#iw3j}4dx;Q^NNdrz5< zG_x?7!OdSayCu+!|3>f4%2(9` z5FYOg9RJRva0m2{JUBwG_0d@L zM>9Z(QPh~2DN%CaBKjwLuKvt#zkM&+ORJ1jZ2YRw}`=LL=i1!@iDCG}Bx ztSnfb9(|?@bLze)z?)wlYJfdUJxHhfrNY9xV9$P$#Q0?h$Zt8)_1TT2>KZs=^_a%r zdd5g4UMvBOb6YldK|J~-02htOyPjoC51dnsPBbjDd2kY3XBkbjO`(bt78e&exsXf} z3*+eZMcx88m|dmE;OeDrx8G!RmKc;;_!%Nk2b?(ma`OAbMO?$uyNpeaG}r%Tg!{Bt pu6E+%F7_Xeq5oI-KMm9Z-q-j1TS2&9B=|Q1xa~gR%yuHB{0{46L=gZ0 diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_unmasked.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_compose_unmasked.png deleted file mode 100644 index dc4beee80a68507a82bce68ebd330f48314fabe5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28628 zcmdqJby!yG+AsQoNC-$NsRDu^h?JCyfOHBX2+|VL-HnnGA|ir-goHHGUD6<-bazO1 z$GOLxYpt{P`PO%?v-dgYpFOY3Yfh%XJH|7f=e~b+kGBd>B=9d$UP7Tz_)?OhiYOGC z5d7)K#)SWanX9k^|3b4>ln_ScbYA-dUtrh@ODSQ)KW^Ci0Vos=N=o#xl2hzTm9y%z zfxYu{aWo=AQNpXgKA9|c-_h;M@VfBCo<+A2Et&a9*%a$x+CXtXPL)SazoMvGqf)N& zh)thq%91^LU7$uCpO>Qf^)$RUZL);)-#tAyW>1JEZT9Zp$H^PYU1;4FvgsT)U9akF zqi`5+zl1{buG3Maxf?x#hUr~r#Hxy}&rVK?E8~TSS+DyN=RN!-3kw=2IbM+OEg?+r zmwDeYT6EZ1NI#({@cbHo5Yu?qeOLYe?1w(k*XHRp>rPa=M{%2ex-w7BFdHr}fJTeq zL+Iq8Q`|*jQWB*8AbpA%9p|Sv%H3Ike*4ws4+K{rSW6S(BFIB@6Y>)gp_r2a3<1i&KV2 zMoI{ah{TKu%D73{U^>nJq_}eR>eR|mfnwA>DolJzpV?V`dPc_jiHVxBG3T6v%qAbz z>Z7GQSz1+;Po6w!>FR3UoN2Z`J8}LP8ChRne__IX|N6MwcE`L!&rRb!7PFjO>zO9& ziQnOj@)i!B!JC}^1#Gq(by2xqUMNL%bs22h*@MlQhVk*Z;}uKKW2)Bnb~+!z4`E@y zR;ty8+bM}R{(Ou7(cFx+zcyC?E#4zzbLgvB5Ft5vz`N@Yg%cHM9zD9QUSc7prFA31 zoQ;hwOEnMuk?Wr5a({MMOiYmH`SHZTe$Dxq{>b<5>_4M8F5I|r<2DA zJgEx|yPB}5-MoJM5H=etzZw@8Cn+f@6vJcAZa&O)ve``Sw6jQ5ZujR+Z0t1__2S@> ziSh9p$ik=<8ol=M!Dwh|T3RV6D7g2DP5a&1>2ZBW2Yx8EVAD{6A<^~gA;ZIJb?#4p zmDw7)ogFTav+JOimX@rLCI0x4%UD}wV%+1{9A#&lafiXkz1u$|1Yh9nKrfh*`;vu) z#qB$HL=+W?k!Pf&q-10!Gjm7V6RmvkcOxvDj=6bAbTp;g&M%3yjErf43hNn6hxPFg z<;>@a^6>)XTt=&(|o{_I=fUi*8!5e;^} zpg2D3H-y3g8mtTB1$lY2H78p#;m6lATyJ<|;!hXBx2r0usb%yv!6N78tya4q7g|Jy zg*_fFvFtK;TPv_UbDvION%}y{7_fiqaMDP&yQSrO2&dI?%C~?d7hum)_G&8ja1wHAe? z%{G#0(>tz@o9h;omPQ?`&*kP+<9vQ*&OzINrJ2iPKK%aU$1B+aEps;0OH0grXLh?P zMrMCp(vMDH0ZdLSwQD@|VYQGy4P9OGh3CW5sc?~I6V+doR8)8mWf{V`^AaCWDSt~% z_4D!Zk#sSO`PI&cV+DuRhfsKFd09$U7LS70;@XRReH`b54O&{C_c6SdTf*e!{Z)FjeYzT5c|5h2uH{ zJjq+_k!%aZT54*PXq2p;g0NR|vajBG{=PMw(KeDT!wY@}zVidM$HD89?f7$gG&Ho) zW29&dti8T~g-z>o<;oR8Dk`ck?oVW8XNzHK(pnMfG>5Du=KyX@P|n&RS5qGDoU2?;d$CVk8~n!?yp^74#u z4jG^<&h}@k!}`SuxJ$==`ND11+t(L%{h`Bqc*67i5v*QwD7EeSp9-wBOHskWc)wHSs6x6dCaUG2w(b1s8FkID zTyQ_$`?I<&cz!6Kuh)E;({W8BWFPm^C0N{*u?iI{Z8I~oQhM9j7NQVyx|=tn>8Ul{ zNjSfS=n1D0lxsn4VDKT#_dNFq*+1XS^@OD9Q|(_(2XaP_amiR`&4S4}gzKB28fxQHWw4=yu|M;eN9#8imsJMm6S_>+ zzFzE2CqYdv{z{^ZUmdH6tXQ~bHppJ(U@=~agNl0S$m(&jDX|tOtDr!bD*O3OX(^xG z`uKG%EiDvM1zFSz16+xOQ79;`i^D~keydIU7B>Vv@KE33X!Q=jI#sp0e}h_qLiyoQ zyn(Z%Gx+`G;_nn(lu?yqdgsLXnInrSDyXyb9ml$Pzk?zy17&EJFyQJ z8ec&1d7P9s6{n=6D7oLWd(wE6(D|NLWO9BUGhvFU-~nvRA8@+A>o#8cRm~G8lb6>= z-dB8At^2x#?y2X|PI}XM=#59{D5#5*up>r~38)2T&m8RRdIu#E6^u^U!dv;yk9U_D zhw{u>CASEZIhKN6tWVTT%x+7B-l%JAytq12GGMy!#c{l@^UFgHSfHP=_et&>U#B2 z7qp0zt#&q-efZsM&}F~=W=^1r1?C$p#!63lFV$Y4Y*PF(Qeru?w>pBN ztfs-=RQ6TMOHsHzxwwd}qM}l@n*M(2An`TZIJKZh@Ya?M-@}LXLqkK!)t}Lm=!JH_ zX+U+B6Hv)^vpfmui-O;z*APGifJ{h7hsN*n^*|`M(;d7M4I5MS9Bv0zA>Y12eGclE zLZhs%lWXkDRIy$i=H6KzARu9o6)Le9L!lVu<7USkp)oHG)#uYK(q<|-bg>sq+;VqTez4z*8Efj~R&z^Y$ZlVjG@m2RF4`SGiI6G8%hpW9{y#<#eYN zGRl4+{pWx@j*XrU`EL_XCEpa{C$58e-uVYR{9z&9Y*F63dCW+Y!=G z!(k5%l$M>W4BZnBm=71Fb$@!wwF{mn==?Rht|6T)Qc37?>qHcHjh<;cXih5NS<3>4kPdQc4Pkj2|wvU-es| zaSwyV2y6;MVaoo_^fzj!r?BU2HmAklh}Kvwe{5@OE3Mn#+@zI{*v+|cgY?uHOgXI7V8AyzMU!xVzj(zaU#*kZKCGag{P^%=j*PQbC^`!-JwocDyn zS1w%)YoDVuMemE(!QGTAI+00N`kxA?Vt+u&3)K=p zL?`LuQZDW#=y4L2rB)=ZSK)cK9Y2anW7{$BNyQ(kaLjFTVSs)tCwEJ)DfriS4x?_G zEdWF|h6+A}icg0KZ{u(_k z?Q7FqfJ?A>Y$wWxKkany&A}zjU49jjy}LYMd%k^3i1r_tmsyp%3OlzxBK3;KKap4G=MEt%LH?HGs5GDe*166@I5aKinME)*3N&H7V& z82zSJn|Pk9C5Hg7Cr>V019d|7?8S>0ldG%aShTBSQ8b&In?8gc26_x`6g6SUS_M-J z#vL3U{?Wh~O_B&@F#WFd7cvC2@PR|G={*pKKjxWG8)idz$7z66%#k`%9xXw0rFiJT z;tZhq;%bS@t_hqTUW-w}(S;n%iVp}ws^Je?GO)Ci7Z0Hn6Ava&bbMTV#nvQ^;HU`; z1zVcnGy6;QKLfe(zuc};s=Enf9+MCf3Z-z;N1PvNg@mVth7wQ+dQ>(YJ^qt3AJ<x;f%m3)nuMFioO3$CGDgDgN{o#dw(+Co)F{)knSub6>v|}+d zGjjn83(LpX_vH^C9E8Gmn%}#3@1?0}(2Ykf-L7}0&U97FY;HZUTl5MQIBL;17a-{V z>1g|l?+IlWZOpTFzXxbqWi}U~!q1;|{ro8k)ZtgPyX#sNAPGJ`K82y6pdjzRi6U#Q z=AS*!aai8RfaHN<%Rw)U`kBfx%$;g36H4Oekw&5ehvJR5ciFk}!~yg^!oCe?H^096 z9^A1K+4mfG-k3xK=;Z^99g3on8D)~D-f-~_-^)e+2WWw`4Tf>r1$}zj8Dm9vmJ0=iM0#ft~#zYB2}<1MQ<=L_&r2x$xq3W5u4u8REp83#o< zk_tElu4b_>GqRsAQB2kq7IqQZrnI#5`-li%;0}EE?{78@m)S0KXRGrfm~p8u^Lt)i zUBYAUjp-n^rQI+Fzo5Xt_tDXTU%%dTa&oGjn4r#9FTq58IooRFuJx?Md2}P@LXvZM zY-})q3v}oib-uV{gv7*efU8=^Q{T72c>DG(#Y~NX>VV-6nozmla`jY)cu&J zBt(`$L|;F7JP-K_#2xFsl^38K&@eM!GVRZLEz8g|yavB1EHV-cRcgC%8FnfL3f8q2 zmY0Z{Iy57L`5M0y;d<5n4d@u7qoY=nwP=7n$WTJqb;af*8_)`CJ38dP$wfOT&*bKY zy3NS-cszYdByhZP4?3Uix|q25O-{}a$2F;`nDL%x_hISLxI##11B~Sv!e0WQP_1yd zo1TdEtnBQ36w@1^f`xtj=o9zI)nv>A))t$9 zK*Gt152Z8P5*7s-K&pKFI~8sBh>}N%$LGK{qIfND%>Vp+0Tl(bmElQjz|M|cwxQ0?;a4<9~!x111ARaajwlibl7UYUQbsyO*b1Luqtr# ztnBPE^7pO!0fB+Pm#F3Nnwy$*Ti(;E7MWbl%F0quR}V^&iM+|mdO3*n?(e1bz^iwh zW}?@f?(I17Tosq`0+8gBl%yv@*WVAM(ZAo){wmPJD}=rAr` zM8quQsVga62@DL}I6Gdi2RK-c@4nGdk~o8p0_AdJdmF*_l~q;Nhg$})K%`f%UVYy_ zx3RO+&=JEswZ7h^IZyRwaPQ>ggzE<8@4YIOT&>a3q2Xa&=;j9-^#o6!KgTpQTwplc z98&%w1y7LV`Sa%}6bL158+BN4Wxtl#%r&;JTl(GTO8t?xkLoiX%1>!Z5!L&2SKSYG zyKFQU4je=YAt4m(9Dg`kibG`Oo-be00>$&!YkpS;vhUvB9*C?6Q&4sl6B9$BK!pt1 zS3VbXJ9sbS74noCH{uJRXoaH8AfRI?)VTZp7(@K-+E~o7YiI0z?8}$^f`V{UpL{V< zh&eLipYVHEa!FfTdu?k8j!6_qIT@Lm)+Yzn7?_yCtdaEt11k9*lE2~xs~uMciBJj} z8d=13cnP7V)iWL4Geb&I9oMDcYLMgc?a3EmNlEPAJv|(jL1GB*Nb7J^@qW3f<3HZ_yh&Xgvaz|oKp#+_4&rc zP(gLv7_qD|t~>`4WTNfm=VfbUCG_|)8b3dOX=FvkLq~@fPoKVq4TTi$C{UZAg0Q4@ z=^{7d|dq1w?{BfPR_XA|WA45YF zAn<-&DX*wN6l37_l*e%o9WVP6Q1>sf$^CPyHLwsTBqh-)Dk{>1w<3oTs;ow(6OnWZ zF(}m^*4JleF+eB>oK45eO9sCsAUIeQ3WtfQX}Vi+K|%eSi@4uS2Us=A?Qq~sl!8VK z*X0LtFsOxYP>GO|%=sA)u!69)HHYG}XPfl|f{SChaxpw8%1I!;mdp5~2l$kt`F!3ljF~HR5H3?7E6fkOz>329kJ4Noha&Opl)b#=; z-lRY{rN}S|v1xU6^#Rm7Xn+_fY&^V%pP%pH=O6d0TPBv4lCM{8Uq*WR*jS#^rf%?! zM{3`?+^pqnD=BRFV~Fnvzn~aXvQY&JnwFE}2L?r&r48*diLe4xIskTcxCwpEdn*)e z?d?-gzTSUnYm@Hn@Bab981zHvK=p!Wn*?7*;0F{H5slC2#O7;pF)19TuP46@o-Oi} z@6AwI<`s8budeO_?0p&(0M z0LD5sJL>~V`Gv&9#3=Tc!nPGnIwvU{6*4+9V(CgZ0V!YdMF+7{uHS;=Y#0g@8MG9$ z@mtenRT#xe&|2+TV_%)@{sAU$TcHyc)yPSXxQ&R5idR*YYbBTI@Zfys zfWFV?cJRH)n&b7z-s4oB?qSjcW)Zy+TTQ^WHrYxFIoNVe}Ghuvh?k=Db{n`ZLN#LzyIVqbjem zjGV&SJ%5vnYwxxAR$Q(e?beiE+YhRnrt)+VoxB5FzxSB|)A#{ENdSQlK%In&};es@lw^%g{Xz+p;#9WmfPQ1Sy@R?Vn#GsFu}}5itz!Se4STZ z+QRU%)(vFK@ofn9_s0SOq~p{Ryfp-&HU?g`vbIiKs)yFtUE}F#p#e<+Gz)DjDcRe? z)3s69)l%I_5(H~pBmMoP0EC&fJWuUI+7lwRJzl)e2&6R%Cz1!f69`zixpMv{9B?&* znZ~d3m*Z09><_I_v2ISeT2;}7DXcx0y~2%6mfayQ@OKt~`jHElV>;7wQY~#kCB;8= zb#-#dzvpb6NjmN`ZS3zacu1IldkJ8Vj+^@`SO8xaKBV&3ePntT{gJWTH}4;J^Z?kU zYK_OErcmmx)7}rc1aot9lG4(@$;kkhg^Xx)-N6*Z032ozZg~bZo@*rhx;y7|Vg9Dy zS5_?fo>YZHt7ol0OvBg6o}mjdL!rE{tyD9~WygwtS6(M0%H-OBF8u-yEg)9Eh={A> zuIrDUeDTdCP>e8Vm-xBI^j&qQH6XLSiMM*wJd5!amwCo)eI%egDqag3@JB&n0>mw6 zl827Zxr%HK{b6u>kf#Ka(38|ta2CP}sTCto>==Ec!$U*XtJ3!)b=ydym{Td6i@KxG zyb!x}wl#dP%C)4a9=}>3?PCW1-4UI4QS@lVrURGZ?kU&%dV3{py0OEER3bxERQ8pb zBg7wi9S$Rq2jn&m>uFISt2%%)oKANKhzJS2z_h}^z~IVDmrK3w^-F&WO)h;0SfSNA zFDn|&oQ4Yc$S0o5{da*8*8%X@-QE4rmEz)zzI$wR)J8&nCv~T>C@Kn=GJIN0EiDb}Yed_g#wkVVC%y;T0s@&Yl-b0zG;A=dnnC&8SrfTa#VRTuE6$^3 zaxi>_j@lwkwu1bH>o75j%T{jWY`n@9;d7uyf%OV|`DZjYu7iVv_-7f8Ih^3D&yJ?m zjDDuA9alj!blUAx2@4OeuL9cOe5|9V=Up=45knRE;RA7UYU(ru2!0za{7#V><)ol& zXh`ghn#E(Mr^i6T1wcGs!VUv4@Dg$YF8hfQ5@a{&E(_(FRpxaMqz>Ak{8|cw!iumK zrBU5Kxy_FO;RJ++Hs>3(qfiiU5H~Wqyxo>iFM-1ZKqcfcn5)UXg<7rd(Hm7j-5E2Sw zj2&}5$H6G*y>O+RMI?Z~oRGr0mVL-6Z==Xq`p`-wdBHgO_u;&n?IVtZbss$F0?So)PP128!-Xc7+k$+8rC!#Zl!xFThMjYQ7a4-0=Z6u?PwK-p5 zWNnu{5~RU1?`9C%?r(ny|49d784*PgS2t%{NwRAiU$Othmm*1EGUzi-Uh|c9h#|HO zGmZQJS}83bA33z}`n0D>NU#Gju*=3=SSQ0fczF~f)mjc$9a9her}8-U`zN1HpmHD( zh03zB?(T=H{w+E&u%IYVc5PkRl{HuqWt5a*PgK~9IWpM%o}j+Qzaz~AUFrd z$4Q{c^j2@RX7#ik_p}uCyu;1ruMl`)Q|*xnjpcgh0yq`3b8|0u7JsRg^ckuIk9z7E z8LUH1^MSts(LM>j4JhXf>6K+lvgl3uCr=Q?>?zsni-EM48eJ*42~=BAwa z`k{=PT}LR~x4q*9&to%+H}k>@8L43wyuMlGJPnc#hX0K+qIlfKSn%Y|x;)*$lY6Yf zvqScW;ysr3(lRpl(MGWdZcIY7!t(1YVZFaEvFRymq~6iapO%&uA)uJxB8A?P`IL~6 zA-!JO9b6;JP+xAp{2f||MvcdH=xmj%n_)n-YR*sBDVZNKF)=tCzxGklj4(U>tbj zP3Y{PS&j203GD)A0%+z3U7ly&pYItiDczbcFijg3T;)6aZ*&@9ZlgM7$a1U<=6N4e zsLRR*kMAA8QhO_=`#{j782V8iB#erV`eO}93TeeOsBr(j$`0C0R`GmwWwTcRyGPf_ z%+1Vzw^P@SjHtiQJvw5+5LuO^?Xt+a|26MU#Hio|pSY-~4p3?uAH?9ax3}Ne*m$4C z9vUAX-(=~8Z{7uLJxM%d`r}Ig$YG%Qy{z{$9;+^EV~{KhO(*<_LK*>3f8F27QmTbU zL>@#Xc4s8$^MEri}~%IgKR*hNXK<^npksI{xW zYeq*kn5mlL=maVN>I2fafQl9Lya9BhsXI{wJ{Ax4b@q%ZR1a_lQgWcM)9~|OgK!5e z5R)RUVwu-hRzpDUK(L6I*hT2{egM@$HkDY|Ya*904|r>z{*Hi&(5{L}+)q7;gRV6r zD2!9((V`PL=>1(CkI}VmE4CC+Wlj`2gxuYi$F9pz&g?ESRk`-a#EnnRomMsNdmJ@l1mH zJDSczy_CPS)ZLb`{1p^~SpAX*NB!(&e^K7-)>~uHWvmW1UYcmj;Y^gK$;7xiIa#qe zIyx>D=;`go^yl!DodW=pX4uz${@f3+r7qNR4NgVo2sz>c9-pR^#MN{?OUqEWplRr% ze75s=;C*K}y6T0_XkT2yxGi2@#i7`iktuR#DEnP@_TBl;FF~-6^$iRXGcqp0HP^!< zoLpIvUA=>^DCM~w5=8F2*HkSvWNB}-O3A$6zM^x>RxauS!7l~HO( z4bRKU8ePe`h#COL zom^Xc2awy=bkN8xzJ~CBsxCUcZDDw4g-z zyy@w$yIK?>xo(n7r*i+OA`D_RMK4(@gcTlz^?ggrGSz5kxFzT61{9l?ot^O4uU`x= zgmqcUT%OhLpeTZC$N}o`*)Ltu=Nz)#wJn&hWifpNJz+}!)hk^n^(`G8jUaNp*a{~^ zf|d8afiUE>KjydzK5LK%c4PNt2xR(w?tviLxB(lU^>GYJ{ zoAxRuqtLUqNf|l4LkkYhS?C9|t2OuAVtPK}vd@nq)(SM+dXO-XJ+pKgO*bCL``Hom z(Hr7PZ8?S$=kWDZG1(f~`z0WbKqG2|>;oc~%YJ!KH01ufS)2O_IXd;5lRo_)@w|r? zY7IN>A8n7JodFBcgIaGiQk?zbAUU+gB$ge6UPwegAI;^n!__1D25^C%YH8)r9-e|Q zM9;wRV`L;ITy;8}){&uc+ZR@)2BliJ{5+1V?9=EIl3o{wL1_^%Q+;hKaA$lx&`tu1M$@7&z})RoiRX~ zPGnrQT0M>WzmgRrl0SC#KEeKpTk|MS_>B2t4!8XM!mY#9K;CFo6aKiP6&c#)F81ra zhF*Rx`nX}nSO>S-fqw)5hc%V-fy|mN+_A=Maseu??qRs|yFg#{T~PCEhLWLAJ#c>K8_28?3u&fSaRB)0{||Di9DRdCF!^f&fddrm zx<>deg%>a0g6485zs_HkYHe)|Maim7mH9h>f&i4#oSPGe+Y20!>;!LP2096-D!q3s zSyMH<1bP>qtEj`H>XVU{_D$SuB9mF_9^}=pCQof#6!axw?s;;D_G~FL_s7`Sb&!u6 zEo)Bw0AE^1N-4Iyr_vQkARUhd}1-mmyoS*3IV5G73 zL|}A%I$GMiBMao#ko!( zR8e^!#q4amYlD{YJH_kF22J;AWYs!E_x7uq+OY#DwB7UQo2qHI8`P4HEU69HPf`5Z;mRlo_33y zX1U$lit`rsM{hb&jcv$=BD+buHaF)BI>yZIfcAi@1cNVL86nkwh0$sBGl@S4oo%CS z&=jQ-WSG8L20A@Uyz{AH_}iCUV8;;qHKLdH`$L$8Z5|+7tnk@E)? z=#@5em!8B6bezV2{`?lPSD-nLx`IAwWo4C+Pew+D1sDi23mRS(zgN#I!^6Y90eMy) z&c|Sy?&IVc2RnoqkNlqTNu*xFAis7k7&7E*o_)M$5SSC?t^|bVElPtwcJ90Y6mhW7 zm;?wk+l%~!fB(TGQn9wq&L$AsQCwFNp0fqgMYg{*H1vnyn28x%WcyQ1&5%ef7aR!l z{-&t(TO&Y0+)fzGYViM%G&9JjIM4U8|5wf7+@b(i5SlDTOVJV@D~^Gob0I!w0SGJOJ9|51P5J+^D%kFPV#=-z&HgK%JJaiNXg%;{H;gu^GjEs!qsvtLA z16b@A7IBHoh z3NSf8%bHf~uTMxof*v&q={b$38?a|tG)f5}`~qI{57-f#pmaP=Q~=u-A6h~!R0A|F z$vjICl^`RC3oeM=aI&!q_u0`=3-37BDjCcW6m$vnr2QnLpm^KTBDr=BHV=|!ho}H% zH`fy%n$$<1W&NP~zXaE?6arKk85wE1=D>*%ZU>pRr)p|}J>TT%xVS#blny84#eDe^ z0ttRlsS?0dh6un9uqhNEE#9LYT{{18+~-DUhwraW)bi5O#%jsL4@jawPv2hXl7KY4 z%ic=5{V^!$2o#2TgYrUB?2xtubG=;rzJwFYt1dj5k;_g!rx=>uPI1!PNNjW)}K|6PGS6Tq|UQ$+8)Xt7e#jO%l zV6ZEjz_AE}hYb<5?RR1jX(uKj0h8t;tSHJ0067GZ0zd^*I7CO?x^;`sc`I3bx~Qb2 z8C+YzUFB;(#S{+@4`94r0Ek(&tAimu z{q^ftbepnk;E$C$Z!?^no~GkIqeyy$0BHZ$h3@NpnPj|<#l^)@5A2x*PBuh7MnqT_ zrT0x0WIhN7zll-BucxUAliF z)tSleCGbz%>?MyQ{Ud(F-aTg_gkVmgOH%On_Fd*>Gpibr~@Q^gh2Iz7_s&K znx^+~vRL5eb0js3c$bJi2UWbg#8P`_eS#XX9wGn2s#O^PacQafio{il-;T<%X}Y3m zntk5y;3^dNBYFOH;iNd<0ULlHg!~jNFOqLI8~lz20dNSo`OO|jMnp_OyodbWE4=-V z3?=5>xI~_&o*ohyp^~#u%sjI^!B8Rt^VQ1MwjMxS>gE~1kTiERO7u=>>yQ`(Enrd$ zpyTL}0g!4$1_d1jQ40`(BtakuxtXcCxr<=DLXwq)PCVEPI@NHoc@U6ZJ)rwY{(e3D z*|TEC(H#;KqOJ9yCAzFelU+PgpkFc)j@=I1BOPHkT?r|;f$Q#2>762?!pL_7fiV9 zpM8H>Zv~wY&WDv@y`qkhtFZ1WM+MPs0luoqP70K~f3I#q|s zEXdpeht_HP$OM?G7EC6{i}?$lpM(#u!>NI=H;g4JL#_j%*ROs?y#_4)Y(!zGwX17k zZ2}+cF}RDCwzl78@`!w6x2OPP5!i4_4#3`q3%&#==C!*ly(R*nHKp zX(pZ4t{W|VITNY@IKEa64y3zstom?{;R)#&8ZtVe6+VC)5rIQ_?fP~6{Ach~Aq@?+ zg@%Cv6PeU#7CcL|brr^jn%9jCy=XrHJNRo>mh4Btagxa1#^G@_3LlFZks5j}kw*vntXM(^Xnn>6 z)jxaR*yz5;3FNq-Y(mtl0iYSEEV;XHa~^=TWdzzx_&$huqQ^^&r_IHF=0`KP-O-Fa-Kv~Y?sK79Ki%hUU>H}GNaM(5=G)2S3bp@syj4TGogKM~6 zIpUb5!zqG;Cj!`~zCNjS_->PS3?#dMw6?zOs1W5ZiLCt;2%&>c-=5$a0Z6u)T9C02 z7pP*^>#&n|p#42n$`ON63g{mHGp#wuA10Dpk_IX*XS!}i7DjHDadCT8uZ)etAW4so z=TCzi%I5b>x@}k7Cu%$efBg87R-!Tx#6|X#0gZ=&kr4}tdZ^x|CL{9)M)7K=jsGtX zw`5Q@g|m)~!RiSZ^7HeM; zEhMgYT=(~?#c+bEQh6{Pgrw}C%<32z_yXvI%oHwI%%x77H<1yDZ}QY9tCpTKdos|m zVX?X6Y%)Ie8M$g?|LvfvQdn#L?VvtKY)Lh+7YwHGBZ?Nnr59pMDgU_sUqlZJ&XS`h z!APgrpSU=DzP8 z;7-p=gVBPx(~unCNU|sZQ4kWal)11ObpalZ&M|6J1GWnRkUaxP<~9pU1K{_~iSx6_ z49{UgVyq_7jawTq^Wp^)HA!IKI6h-%Dj{-q3d216WenZn& zfx=o~$cbY^>VLdc8PuXk%P0shq!1_dg`h4dWBoarxB#rd0aVYjDvYU-X z@1r(1Hfo{Cm7T+Qh&PO(BBQd<;FR14i%iKN>D(0}V{U#IVJ56^al|CAk(1Q%;=qVW z-IrG*!^6{kD%y?EvU98{c0i@WRaEA{Fz1{G4**6^P4ly*c?8(m6*A2#!2+%8DNJXj z28WjbkdsPyaj(HJnic0Npu~Ar?#8ws%gg@K%#Zl^O5ZA#Yp_~=z0Y{@?CflGT{1g6 zo5OiaAFR5sg@sBx8oxg>LjFcNd<&zT{rTT_@$_4MJ~8BGnn8*wi&xv)O{}cECM6|B zIRmkcB&ZT-U3b(+f6sA7|v_&j$<%TqF$= zps=B098IPL#4&uTqIyzT!+3uqmm_l(d}FOZl5iOV!dTzm2fiwPU~*M<$~n!wOW807+M@N4j7WUf9%Zq`hsI~9{do&Aw0#n2|V&tDT-9jbDE(4VfIky}uzX${uFh^o_ zwboXd5}SO22eq6w*qMzVt4m2q?m4cF*1=xggpq8X!!ZtRYPc_eQ!&9ZwmB2wm%>@D zZ0+qefv0P=KK=+9B!_1ND)7f~ccYX6-o*q8VaZ`g9bm;Z#zqyO2Wg{Nx-VgAlC?k0^( zcjakPsD-E1mZGMXy^l({{0Py70}@>0o0wngd~)#;jx@jy`^XP{yut>%$qqP7Uw z;SWKgH@L>}@>`m1WmnYWgiHjLHX4h1IqC|1K%wb^00w@L;iKX2t2 zziii$^(t#SzVN&CgCJ-t08##$0~E^_9&nE)`FCeHXc9w`Zp^~$g}r7#*vfxWZK8)& z1U}oVIeSu;ISgATMi>q-%5)A`+CyWhKaBnK$q(iikO19s@zF|P*s&#E_R1;LxA*Wj z0KDwhMk#<~j=s^=oDsfv_Mc`8{+Ne`!X#HxYO1)Nh7#vKg!qi|t;+7VGX&ff>DIuO z?&JEs`cEenQm`;&0$36a++jYuMM6>*RWHaonP?yC_Z!ch8qW?JH;D!CSFlbP9c4d( z$P}a3@nqeF6Nm!J^~xIkeTf}>ZF4rV@dDQ=C@5e)ycv|(JpEQ;@NoWI2U9w-RUdi7 z1hkMKsIN&G8G$8_1i&=hg!GlBhymlRTNfdQ+Mkg;3QlT6gT3v|MZEXoej``%mxc-H zhOUD-Nh{)~10Wd+r!ce<7)T3*WEwIZJgnl*xh$Mqa|jnoc(W^~oj1{z3oK1U@;pE6 z@?^K2m;I8_0Aaj{<8RlSVQPQ^SYbWPHK0)7Xw@~mB|+FdkR>XXfwg}m+6DEXz5g1y zK@Cd{aZE^WkM2P~B8Ne!=M&KdW^Z_s155!jAXiy&U%#&r@mD)!qu5^`pMFJNn{Y6B z!O7JXcS~72-d88>I@9b>2)6{6L&}Ha1*&GC1JJ;+di%{IruSc~ z0Lo0mt)!);)fSl!AW$99e6Chf&HIlPT6R;FC9=wH}#D9&eKU55yL z-%;6p$y!{oOU@2|d8>JIXE=Wi2te2`5H`Dwz21(oB;^~WReTs30(qp=?T{16fP-|F zZ9uKWB{Af)K(utP7Ix~&a1ky@lo0SlOt!q2^_cvSV1L**FQWNe^ST-w#6p-W4PSXK zF+s@yelBz&?y_V=YK82$>}n>{6cw4hu`RT6Cma-G0CBQm(>B_A9Ha$OR!i6+G`caLIQqELW^gGKIdN8YN43#id`^ z+Z)}g=*#J{yUB%d{#qasxlZ+S|bHq!tpyvn-K&|DK-DBBFT#-Ji!Jk*cpuZ zM^_^3LxQ48lZ``>*&rme^x(lTy^Lh=Efvd4Q8#G3H^~s#12X_`WEos@JS_UJMH$Eu zVtUiYg2!PtT1o`=_h7aALm3ZZM4s!Chgay=Q!w-kX&|$Itj2F#qjUJ-WRU=o3}0k#`c3ajB`PVVaHxa_hkEYC)H< zvxtE)Fd&s+X^MOl^jl-|1K#r$$(}4Gh06BNZ=*Wgc*#O$k~gpA-#=Z}>BVq@d4pI1 z_vm9mNRz@mP48{dD?~(zPan%rgVzo8xe187*rDFyS=U2gO_0o}kFl&~`dm#FBBxhq zDm(J&2ID86r~hR}&%hECa7-yQit_XAPVj;ec@2h4wes~J>-CVYQuY+5G{3#|(!oX* zqWY!qA`mY>Y1Jh$Cy?$S;fW|>A**vX_rbc~gn9dyFdYBDd!YLiZSq%=1YCp&MD!ur zjt5d^-K82Z2|*`9GN|AkVu8CElHr0hr{SVIF!W>h?jj0OOj96zfH>p}Go?uK^!&^{ zyRn#rgapP^@lmxvY(U6ryxREu_LWkrDRdZwgd>FohHX7)?g(E51rZ#pKIH~y#mc;+W-q6p_R~3kpb@gL$Cwh zM@6OOfm{EV{0Jo<(mVJ_I|Q-6aG2N5vrOY1jFHgM(OrQ2;~=o}Pb@FqzS zloymbE8lc@CkQfW089e|l9@%3I zLjipU%gb~VPCbR%N=iOm0>`pRm(<}sEH~-s%FaP>-8fu`PlEivP!Au`o2Wf~P71_R zf?hBUv1w$SFt7;WuN~3c$S4+|2XGogy1Tm>BKCU||DRYdiaO5?y&w{G0Pq`T1F%yR z--0?u$HXLMcD}syF|?-}D`+_lsM#uwM~}OGxBuIRtf$PM@nFleVMa{(~01IG9HVj@MgYKugnO z!uz2C0jO>(1|^uL0vi#szl*z1zkmTi2XnT-ay6bVL!{{@WH6C8nK(G`z|?r>LsAHS zBGwGZr4U3#lWhe{zf<|lA4KhnL4N!g}&ba+S$Cun?p9Dxc=@21q;4Dv$Gn{^xi zv6(9%I!b~I#>eBCDr^>O$k3ntl9JoOls||KGfRY-^F5{U0I2HJ~fzP`SLC6?6Ssbj(@7`#}G z4rZ0$j4Q1|wL~+zt&{U^=^TVN8<@CQ(o4)2QD4y@gIQiQRL;k=2M`~bKZ}FGE||NX znx1|GIOA^QpyZ@%RMgMXxb=#5i2R` zwxp8XbV|sBbdKzzroc~3O&lgGO8T-SMN4VsI$V7&^>@VHP;wYi6^Ro4qG=x+xl|)~ zvif<;<9htB!&g6`}#OsyhgNr2!80g@ucu-J0q9hsKXW2eebHNAUcV_OA_ z+7H^bhhUrG?ym;Sv7JazyZgX&W{fA);I_enFc!z z=^(5crC#x;D~5l)S_Y(jRn-7c`ldjH;x=1Z+gs=?R-gAVcxPlju=ThZ=JtF`RP3}e ztHwv&A1(jV$JK!%hKyN&9B2oFjNrE*(ih?^LBw`wV*xRIKoSSTCwMEe&y1&nU!-UN zW_=-w+vglUyj5_@n9vDCl@5*tBzKAqPr+#OX-4Odw}P#C8*F}<|JY)qrx(3nfsDI@ zrH@cD7z3lZfB!`f&$2V#}poA>-VDKj%OGE9j8HK?a#TAS@KLl061GQA6bjZDNt=A`Cy)si(Hbl-=lF3UdT z6?!AZ=3(#Odx6w836^VGR+cE>3WQUwjFb@6Zp*{u>OnAhN(*1J)+r*=DGx4sfcrq3=YatMcs{BcKZ*4lT4<81>hI4+NR_ z**I1IumyyQ5zdHs>TGNTaQg@}0_wWF@qv;sX22wu*HOF6g?niqr3trE7tL)7z7i(9 z5$5knKW@|h1n8xHf;Aw)B6SXuC5UVWFsbr%Zv;tFz~I0g{x#?`Q5~-!SaXdb90wk~ z({=|h2jnxRcvx0pw1|0q0-ur#8*;zGT3T^E3*g$2Ao|2Vh7h4MkpVi~*xLF5DOnxP z&)r}VG&D3Iulhj_6vQpUK79B(%2*8Zc$NF(t}q704z>HwceyZ+zJN8$U&Zi3Du3h! zh44n4wslZxb58`5%6j5ImGOW{OvFM&mOUR9#t0N;LY2&4fO#0fUj=8>GzQK=;B8Qi$z;#)l9@#@iJmSmRP}UFV3a@ zY${M@Lm>JChT|k4c6cMwH3&aVbc@iLa7H)Y`kn=c3RIA%aG3y!9_(8@-F5&Z4Bx&{ z`jDm%zQN7SEq0twu44k0WXA7?aQJ|}r8n(O8!}uPqW`14GY_XafBX2+I#Wqy(n1tT z6sJWe%2HXPlPwc6mU1XdiVj&yXeA`3QiP%`Ar9HsK@_2reXB5*kbPgD_ota_p67b5 z-|u?Q5FE&Zzi^OanR3s4|Pm#n*%Wv~1qa$oua^L{MUQEGF` zHVs`F>pkr3?7Z;aD_ifwJEnIZY)BoA&Y8L>R$Ri}cHX=38kDl2E2m_oxdLbIY`!PR z=2dv%#8`hrXN)ms`XJk5syjEDZ-<6=lIZzNkxuuc%f7-7JrFK1`xC)XVtw=#KE1;? zmTbYq1l&^*4q8nxw7|bvOwFB19Lj>HB^;YpTIhNG3%RxHoK)ftW~Mb z_poG#QJe<*osC2I*JhtYU9Y3M2Wtdv$7_a$hRALjstW^?iv7K~nAun3dKRbr51H~= zj+9&JGK(_GAQ#MlA9lGebyTW4<#|HFDh%!%12UUyHtBb|>+p-tLft2}bK=4w%m}DM zM9(GoG?++QAny``F-n{*IA85ffA@@Ll{L_h9$u8KWAb=j5U_YHa-iwtuUS7$@AfEc ziW!X--qX~)-O(YeSE-dpCZkh0f5=)cQ+6c0O#BP5j13^zplv?Q$KJy=GEgTx zz~wrjK(;U>^@sCimsQQqt3&TakeiOYv~3lVS+MXCkx^Gb(65U}KfItitaVh=D_yro zn{JwxsWs52SnpLdml@Vxw`9o@6YL)e3Z?#hQOUy{e&CWI&zxUcO0Q)}I;$?o+siy= zZDjRiFEXuqER$P9!&r=w3$)WP;}>wjjx;6!yN86mMm-ge`rCfj`719yEMA)NceSv* z=20xRN7fS#1`>R`fM5tJeTI#+)v*({cfz{{&~&57$Cfk1wFdE^uMBS;y|k8t`YF=h zREi?JXC_j9)*?73HoTr=JG&RQ#gdm1pSL^eHlFc38j1~1u;U13%>&X*W$0Q_L(N(k z*&)<%z}mcBT)f(4r_!UXkELu)f`xy6I#~7eL{P1Mhj%hoTwqcyLkppuV}whNupg>n zLOm5V;EhvI3@bX7>o5=xJu~eWVUfS#lHVnEz(7*$vtiBZ0YMi_pB3@(+Z~VE&N`dS z`yg;0=KXd^O~b+WWhQ-1RqfNn&{}7c4$|ykCWnWpMK^cqq5r$E6i#LFYR%BVnaDin z^t&rl@7+yxZEMsHee5}6;km*X@J`a+;Or$4&gkf9`x@iLAxD!Spo+44!MoTMR7O64 zHDz*eg(;*KMAf-E=-rEpkB|Qp?^k@H!hg}|zR2Fr60`;*f8$4tlCa;MmV!xRYruHU#9STU0-^>jeTXL>-o%Qxv?+r7=~I}XOI?w+Ju`B z9Z6MH)%t5Y;{vuOToWj(_FohdsQtPlIYgR?0t`HGTWapCb#VX280#DL=ao^pdU^wH zXOwWWqb^XoL9K&?=Z%N36-#BZpTd9k4oQdE_UMNdD2289MASrvkWx`U`r~%?;H>{2QQcwvo~Nv={#s%R>aJdnLZvfzQb*_2 z1qHPIgAmn3r{9|H*#fx+yR?*VB0F}_)76x3hB$41r4`IkR8&Mp4zeL2=1{mHp&b^r zGZlr{$%=Ot+H6?cFZz0_Dsz$BL3XcmST6^w&ee}0-r$N9F*HshoKx~{{>;5<-MYvb zR}DV8EPaim2_G&?jWiSXQ?nVkkWwaHN%BVd9$$0@CqnXQ924uQcs|~J`0#7P0hp{| z);T}7Ju}RK|E~E1CpX-|Fo!&}5sTH|n(?{ICNDe6F}rF?QgDyKgM+rif`eU_#rpL5 zzgIz(j2=%$w7u#Aq2lLeM!d4;s#5-N&aI1yi&L_;j*A$VOD-?i7Q8fSG-ZXjUl*=6 z$2bbeiJl}mp@L@5pmm<4EbC9{dO7VM((5+#&+D?)iz>4U@APhBe? zzOXw;t5igCB&)-EmyagHXL6~xmk3otq6pH)0DLo|`%5je=aY6@{Et66=E(7jr_H!s zR>ofEuuK6NM>i;z1YL|8Nd$K6?u2A?lGE(jk6r!hF0*F^H&*tEg&0_8#(^$uxM3J7 zhb;nt@C`{_jg7fHS~%Cp?US49Ixk1ec1WF)O%Tk8B|dM<$GqOnVK23n$Z@~FzljA> z*4$JN2%I+cp@BG8*WmC?si!_lBT@g_bocZv0U94O`P{W+^qrLin|g$dnpexRn=urS z*eOK)0dSc~uMO$su!TkRfL?WLjS^!{$)r(f%3TXHH;lU}>yD`Wj=G(X~(* zPflxVV-Q1z@XI#qPEXcJ|&weo}q6J#=fio2^gTokcrdGQJ+Cw=PR^wwB+vZJP<8Fi=MctPA$m+aEt_7Vvc8$E8k6#AnXhW7tGb ztFy)gAwb)qKUGOUv89dIa&nPSXn4TU0iHp_d&h+5PWbQDwFcblMhSx%ZmST z*G(@ggCF;yxL0$X>-Q+Ag%}4Ceq$tmKI>p^U3{Q53m2#)7riR}`6;>O8{ zchT_dOX=w5kGOw-6^8U$EQXZut_fP*zP?+W%kRGg!DUKX3n0nf)y9H6+x|jI%|H~In`ylzp_wGFj5$0E%<~~zT#f z6*Fqq%CuxRj{$k=jiT})R9*mST;SRNfc^Htl{&>DvHYl?Z2V@!_C++a>%1E-TnYQ0 z!95)x@$bgl)KirW>}%}3RHbQdcBaEcvXVhc;D`gAd@`JIMy(0n9&I#1)Er%Qb2%aX zk)OD1*`*1iC-3yC)3}u`YMAE!_2>$vG_KZ2j=9y&eM}@}-{uY4!85zr;J);0M%d&f`v7F~n z*x8O->x}ZKu9I}w^$f@FXeOSbe)atU?+U1C!6Pr*2hRB9(Nb<{f;Mm6-XhdpC+K_B=3$+oGHa29mySvjr8FYGz?8{uxr7p97Ge6hG7O@N&0GDvW)3n+M3lQda za^-~^RK@3-rrIPQch4-Y35iK_ImenEGZu}B zpWL=5fbYA;x$nHJr-zsyd*=+&xGUm>`S~?{kL`bQ+&=6*lgVU32N(7@x%KbY-?@@c z$_-r|CiWKKPavkm;Dk^sowM1mvq{r9A}^R>r6GR7STZFyrz7Z{LE@Lt*A4qkq}@B7 z)dzEA{PX&Vrc-bIIfvH+8Uf&YZW zH%#AiV4l}+y}o{Rszd`xIX^5l`S0XQ~Fp8+XDq!b)>WHV|On-OBR z%z!1AQa*KQZR*AB2QRgnf-hP!-v_Ep_wmJd9O+(Qu4W~ZWp3w$QHm^!P>)(6PyYCY zfA{u~cZlSqA7F)Z(MJl~Gr414pL?<(dt<@SV&}2=nDiHsE~gj+aV*N5e2=4JkD@_R zeeb~HGoBKmvR6_{yDrgJomEy|0_XkBFSC6PfsHmwbP^7v$s1>0=7jdW@{7VJuse%c z-ISXq)o}r$2|J%GOEZc%MD0nNB~xB*d`0{6kzQk6rmDt|Hym~B1B9Wrln;TR&fXPtH)+^?OCj>7|*+@ z%~)W#EWh61Q)SMhHeA@3S@U&IB}?}n${uXeZ*56d47hXa)^iZtkfM23=3=T`7;0r9 z1k(#@rXHp$%4sC^antTKb=6Db{_#9(v3rz@CTfeXCfU#eMpCHGS;RUCy)fuVJ)dNw zqPatBdU8=gCa`Ft0h987W~ zUdm8b#m@>Xr;yYj`bq{El2TY5sE5dSN;5k)i;Dh?xlWt$#uhZ8USR3XOcLKQWZ2Zz z`@{p6eZ_`>^c4`|&cJ~~^1y_CMA~Fry^xLJKKOxKL`C15ZCT^wEixq>7NJxrjI}il zX~e~lJ=+}!0G%8#a3wKN>uA0e01rX{cI#Yzu#rmB2FMPrR4Y1)u8+?b68;9%Te10h zY=VfH{`fX-s5`5WEyP1lapY%j;^EOb$L@eu)CN6brGPdRG_07OL9T`yHy;l&D2u*W zjkdv7T7*94!Gk}i$NLH1i6CF`Voy_471aVF7GOVPjA0PWw-cw_S5Tm|Yj(2Gr)C3tSCHdm}4Q5qi7YMOt^kp;Wf3vKRId@D;`nr(RE30=hVC}gUXbsjkc zHJhviD0xsqte3HV7>}XcMwT-OHWJ@>vJry4<79gPnWB3?XY*o+RR*B)a$l1tC?!h> zPmN$LY}=F}5s@Ll`ge~&nwsAP)^K_mcB0^J5bp_=V&UlK>>mw!`TE|8kEas!C(?mX zmI)S$bjdi!DI|{!^+p+9e*M%7KKL73kU&Jgj}XaTh>V0O01>cF!*%NS6L#ba2XHr& zLatt`Xg~58tOdBqmJr+qR`+PvcS9gQ9z^3LCeTYv=5EMKpkyrM;+M%oWWX2bY@qx; zSOjHp-H)=U9N|BWHYNd^WikaIn3#uwcmQLxw1Gr_r~G_+VtEu^7lvSWd`b%`In#Xy z$vzAAEG?cwa4L(Cn!AtODLmURg9ip|QY+GMPZ03}v^X>2IMi1`019AZptun!5m;9e zZzRZDhtEz&MXxDV$ynrY7QyJGrKN>pm`{cltgad8TFIZxFDM9>s{o(lRO~dQ1WE|? z^ZBx&VSGOa8;IUG1?DU4xG4|XdE!WcFLABF*Ztt#9R>t3czmyC)Z}UjH)JZwl6vs> z&>6*3`tcAk_0nv%;NymKuOpGtU^g{??#!d>=4U4B=lmNCAH5BhpXv%fAW*1B9d^K$ z1a!l2EHjW+iekD7t=>n?56B~_7!?&wInHDroPSU+Thuf0?LQE;iQqf)c__)n?~*R? zKv;-qVnL6Yq1_nDJQ$CQzD|mK!bVIl;WP)ylwi$qU~!;F;JTIt#Hav_?izTT38n%e z+ClKpTQL6;Z;4fW_O_;I+)CiY-N1Dc+_7Uhsyu=Q%SfsPR)sc5s5zCL1PetW)+oI5 z<+2gbk?>ygu<^A)+#Y1cFvzPUxP}{RDc(#u`cIO`hxZJC)7vN~7Tp0JfjlUG$S&dA zj8=dkPP9zIG@~5CdJ8Xg8`cnuZYjh9vUh@Al10P8{3D;IvR!w!nyVE z@p+rspR;4+F|2_Q)kwA4_2jsDbH+Y6`gdKMaYWvNB#B^D(URx~?b9235iUF<$~bav z{Z+WEck#doiCypwUtix#XJ6DpJq0avC7!956H(DHue>+?G0wn%r{?SQ*IGhyv@=%i z^Ad%sH{ZnEM);O*)~YO$+WB`Sr%$W_WH!BJ!@sHOdjHh%e~N!;3}n(7w63b2|M#iz vFaG|&Iz0aK$Ntx8`kxE(|8PO3q&A diff --git a/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_view_masked.png b/sentry-android-core/src/test/resources/snapshots/ScreenshotEventProcessorTest/screenshot_multiline_view_masked.png deleted file mode 100644 index 373752de30f95ef642b961822be5ddcdbdeb9cba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2924 zcmeHJc~nzZ9)5WV5Y}W6F%4T_1W^!BSu_fSUBqZaR0t5+oU)XovdSKi5u_|~T8ITR zU>OW3OIQSKSOQ3K#7KY;c7dV=jFcq^fwIhtwdb_VbWTtInm>BadFOZT@4b7!d%ySl zzI$(z&N#@+U}XRRkas+3>kI%e0`%~al2AsnlC%s3Sg5muHBj+Vbr$L%LaiNLkWh<7 z`lSMZtg@r6mCKELb7M_sR7TWOCa?RQ%B%e+(Mmp&Kr^`18i(td1{;tMU34XgcZ`%s z1e+X%{b{zc&lpFm{T%kCd@^z*=&NyIt@pf}|J4>Z;rrE+fV==Wdd6l}0{BvkO3;TP ze57$`Cm_)kL_Sp{;Bo=53V}}5{?)4~i|sdkrB+AS+h*8|=W483Z`Irdm}d@hk_^|4 zWk0+nH#uAZOVeN~*wj5GqH%mO=)Wpe)<+^%!4W>?ssxs0AxVjhF9NI!?gxhe7{6$g=i32NyeL2)j+UKJVGqNoKpB`iJ=nmO#yW zA`SlLUUov3Rb^iw9DpYMuRGuQ^?&fiZ}HIwO;5LsM14(Hk~L~1{tibA0-&{Rt}Hw z#Q@Bxp{MqZSy7gKQS$)U7-}geZQ<-63WOQzm6wY~e9XfR>Mr{XLXLx(ysclkWhsM*%J`KE3>UUCQ zU+3NuIqxk@#gcpXQ%&?SH!LB}@~5Y#eL7bUsFHa02<{K4nf9R}x6qgv_t%3FRKht2 z2M5m7lyA1LERHnV+G>?OCG(4NEi4fVg+84tbR6MaWMm|#ukU=e?+6UEQ&U$r-M4R_ zUFKK-_J!Z!MS?NYlt!a*3M>=y=H}+KI?WU{bbU+@Y)^G&arpd;*(UnoGWtpE1Azh0 zdg14$6JU%EwtL|1S110unD~l^X^OJ{4&GFHU2aR9u;LBML!p(&M~Qp=yuVyc0;H$O63FKp=jag5H*pfe$!S@g8LFRoF7L?W~w_$kVIZl)~*ozeRM(b5?*3q zpz?BDIo#_S9TdG63^em$+3&!vKsC08W&8)Yj0b(Qwf(3}`?yL8pC_8tl$e;9^7xlW z^>-BhV}(MN3peZPcykWLTK_mf_uY@H^TjlD*U4DjnWd%bfHhqMb0ZX52Ag3|Ijs9E zGKrvR;q>OwhTb7we56g6gu89n)*?zQ0NV>>6hp#;V@W6TH&r-L`e!G)-{sDl)j6E0T`XVgG8 zb=hIE?1|>sHRot)VqX|?qNBsEKmcGMC(Z2CLuJBB(y~SD!oWNk-@O(_cZb)#p^%DY zK>-3RGV7+xV@}Q;I%%ADhS68NJ=x<=4@yMp#lCi}DUN{rs4G7%KAGA0(#+~9ir@Ze z=?RF+D!c@D+?7eOIDZ9_ZxvaJJMO|o6_kg(gHA>D0}u7vhZNPK(}ZQjO4)kFtOY_BNL} z2G~LE?>z7S9Q7wr hzjYVi`ETUq_Fq$FZc_#i>$KtIrxwFx%2lvO2zn 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

u;MA`Qqc_ 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 PPCPAjJ+(>|+ZX>0n>sh_ From 648fbf7377201213bb9afdc500d24c862c7fbc10 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 8 May 2026 14:18:33 +0200 Subject: [PATCH 026/276] chore: Add Dependabot (#5380) * chore(deps): Add Dependabot for Gradle plugins Co-Authored-By: Claude Opus 4.6 (1M context) * chore(deps): Exclude Spring Boot from Dependabot Co-Authored-By: Claude Opus 4.6 (1M context) * chore(deps): Add commit message prefix for Dependabot Co-Authored-By: Claude Opus 4.6 (1M context) * chore(deps): Set dependabot to weekly and add dependency grouping Switch both gradle and github-actions ecosystems from daily to weekly. Group related dependencies so they're updated together in single PRs (androidx, compose, kotlin, spring, opentelemetry, graphql, jackson, and all github-actions). Co-Authored-By: Claude Opus 4.6 (1M context) * fixup: Remove gradle dependency groups, keep only gh-actions group Co-Authored-By: Claude Opus 4.6 (1M context) * chore(deps): Add gradle dependency groups for androidx, compose, kotlin, jackson Co-Authored-By: Claude Opus 4.6 (1M context) * chore(deps): Set dependabot to daily and reorder compose group Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b88a67a7f0c..10325576354 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,43 @@ version: 2 +registries: + gradle-plugin-portal: + type: maven-repository + url: https://plugins.gradle.org/m2 + username: dummy # Required by dependabot + password: dummy # Required by dependabot updates: + - package-ecosystem: "gradle" + directory: "/" + registries: + - gradle-plugin-portal + schedule: + interval: "daily" + ignore: + - dependency-name: "org.springframework.boot*" + commit-message: + prefix: "chore(deps)" + groups: + compose: + patterns: + - "androidx.compose*" + - "org.jetbrains.compose*" + androidx: + patterns: + - "androidx.*" + kotlin: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + jackson: + patterns: + - "com.fasterxml.jackson*" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: weekly + interval: "daily" + commit-message: + prefix: "chore(deps)" + groups: + github-actions: + patterns: + - "*" From ae06e700a1ace0e5063d0dced160f25f2757bedb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 11 May 2026 09:15:05 +0200 Subject: [PATCH 027/276] fix(android): Declare test-snapshots as task output for cache compatibility (#5396) * fix(android): Declare test-snapshots as task output for cache compatibility The screenshot snapshot PNGs written by ScreenshotEventProcessorTest are not declared as outputs of testDebugUnitTest. When the task result comes from the Gradle remote cache, the test code never runs and the directory is never created, so sentry-cli finds an empty folder and uploads nothing. Declaring the directory as a task output ensures Gradle caches and restores the snapshots on cache hits. Co-Authored-By: Claude Opus 4.6 (1M context) * Format code --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Sentry Github Bot --- sentry-android-core/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f61cec89265..abcca4f8833 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -70,6 +70,13 @@ tasks.withType().configureEach { } } +// Snapshot PNGs are written by ScreenshotEventProcessorTest at runtime but must be declared as +// 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" } + .configureEach { outputs.dir(layout.buildDirectory.dir("test-snapshots")) } + dependencies { api(projects.sentry) compileOnly(libs.jetbrains.annotations) From f829d5acb53476f24789dacba5cbfe4d0b2d0b7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 10:42:48 +0200 Subject: [PATCH 028/276] chore(deps): bump the github-actions group across 1 directory with 5 updates (#5395) Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [phoenix-actions/test-reporting](https://github.com/phoenix-actions/test-reporting) | `15` | `16` | | [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) | `2.26.2` | `2.26.3` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.35.2` | `4.35.4` | | [saucelabs/saucectl-run-action](https://github.com/saucelabs/saucectl-run-action) | `4.3.0` | `4.4.0` | | [getsentry/craft](https://github.com/getsentry/craft) | `2.26.2` | `2.26.3` | Updates `phoenix-actions/test-reporting` from 15 to 16 - [Release notes](https://github.com/phoenix-actions/test-reporting/releases) - [Changelog](https://github.com/phoenix-actions/test-reporting/blob/main/CHANGELOG.md) - [Commits](https://github.com/phoenix-actions/test-reporting/compare/f957cd93fc2d848d556fa0d03c57bc79127b6b5e...7317eea6e13c47348dd0bb318669485157c518d6) Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.2 to 2.26.3 - [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/3dc647fee3586e57c7c31eb900fdec7cbb44f23f...bae212ca7aec50bb716eafd387c80bcfb28da937) Updates `github/codeql-action` from 4.35.2 to 4.35.4 - [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/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...68bde559dea0fdcac2102bfdf6230c5f70eb485e) Updates `saucelabs/saucectl-run-action` from 4.3.0 to 4.4.0 - [Release notes](https://github.com/saucelabs/saucectl-run-action/releases) - [Commits](https://github.com/saucelabs/saucectl-run-action/compare/39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8...bc81720eb01738d9c664b07fe42621bd0014283f) Updates `getsentry/craft` from 2.26.2 to 2.26.3 - [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/3dc647fee3586e57c7c31eb900fdec7cbb44f23f...bae212ca7aec50bb716eafd387c80bcfb28da937) --- updated-dependencies: - dependency-name: getsentry/craft dependency-version: 2.26.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action dependency-version: 4.35.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: phoenix-actions/test-reporting dependency-version: '16' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: saucelabs/saucectl-run-action dependency-version: 4.4.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/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.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 +- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 33ba8ae93e8..df0b0ebca3c 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -103,7 +103,7 @@ jobs: **/build/outputs/mapping/release/* - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b16444183f5..f5e89b2be40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,7 +73,7 @@ jobs: **/build/reports/* - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Build diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 64e68738b2e..4d5a78a4114 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@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index acb0483b5ba..ae8d78d305e 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # pin@v2 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # pin@v2 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # pin@v2 diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index b5457751809..2f5a63f747a 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@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v3 + uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # 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@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v3 + uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # 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 bbaaa88f53a..0549577f629 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@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v4.3.0 + uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v4.4.0 env: GITHUB_TOKEN: ${{ github.token }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66776935d9e..8464e8d0399 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@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 + uses: getsentry/craft@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index dfe742087d6..38aaacec27a 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -160,7 +160,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 577f0144179..629535e282d 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -160,7 +160,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 5246cf90cdd..bbd4f986d96 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -160,7 +160,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 321d6ae5652..007fe575d14 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -162,7 +162,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit System Tests ${{ matrix.sample }} From d3b16ee7f4aaffbc53e65ad762c3194e7c6718a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 10:43:04 +0200 Subject: [PATCH 029/276] chore(deps): bump com.launchdarkly:launchdarkly-java-server-sdk (#5394) Bumps [com.launchdarkly:launchdarkly-java-server-sdk](https://github.com/launchdarkly/java-core) from 7.10.2 to 7.13.4. - [Release notes](https://github.com/launchdarkly/java-core/releases) - [Commits](https://github.com/launchdarkly/java-core/compare/launchdarkly-java-server-sdk-7.10.2...launchdarkly-java-server-sdk-7.13.4) --- updated-dependencies: - dependency-name: com.launchdarkly:launchdarkly-java-server-sdk dependency-version: 7.13.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b7cbee3700..ab39c981b44 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -122,7 +122,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } -launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.10.2" } +launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.13.4" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } From 48277cdf92e11f1b1956f117735957b4bdf7008b Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Mon, 11 May 2026 11:28:44 +0200 Subject: [PATCH 030/276] feat: added `ANR_REPORT_HISTORICAL` to the `ManifestMetaDataReader` (#5387) --- CHANGELOG.md | 6 +++++ .../android/core/ManifestMetadataReader.java | 4 +++ .../core/ManifestMetadataReaderTest.kt | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 681753db082..ceda85d8b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) + ## 8.41.0 ### Features 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 7dd6f1c1488..b52634774d6 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 @@ -33,6 +33,7 @@ final class ManifestMetadataReader { static final String ANR_REPORT_DEBUG = "io.sentry.anr.report-debug"; static final String ANR_TIMEOUT_INTERVAL_MILLIS = "io.sentry.anr.timeout-interval-millis"; static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; + static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; @@ -254,6 +255,9 @@ static void applyMetadata( options.setAttachAnrThreadDump( readBool(metadata, logger, ANR_ATTACH_THREAD_DUMPS, options.isAttachAnrThreadDump())); + options.setReportHistoricalAnrs( + readBool(metadata, logger, ANR_REPORT_HISTORICAL, options.isReportHistoricalAnrs())); + final @Nullable String dsn = readString(metadata, logger, DSN, options.getDsn()); final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled()); 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 52cb085b1ee..cedf5ca18bb 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 @@ -288,6 +288,31 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads anr report historical to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ANR_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isReportHistoricalAnrs) + } + + @Test + fun `applyMetadata reads anr report historical to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isReportHistoricalAnrs) + } + @Test fun `applyMetadata reads activity breadcrumbs to options`() { // Arrange From f26c741ed9ebb9d0647921f474fcea33f92d743f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 12 May 2026 10:04:34 +0200 Subject: [PATCH 031/276] chore: Remove dependabot grouping for gradle deps (#5406) Keep only the github-actions grouping. Individual PRs for gradle dependencies make it easier to review and merge them independently. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 10325576354..2824699563c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,21 +16,6 @@ updates: - dependency-name: "org.springframework.boot*" commit-message: prefix: "chore(deps)" - groups: - compose: - patterns: - - "androidx.compose*" - - "org.jetbrains.compose*" - androidx: - patterns: - - "androidx.*" - kotlin: - patterns: - - "org.jetbrains.kotlin*" - - "org.jetbrains.kotlinx*" - jackson: - patterns: - - "com.fasterxml.jackson*" - package-ecosystem: "github-actions" directory: "/" schedule: From e0bd00576b1905a3ec3b37b97861eddc35dd4529 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:34:43 +0000 Subject: [PATCH 032/276] chore(deps): bump urllib3 in the uv group across 1 directory (#5405) Bumps the uv group with 1 update in the / directory: [urllib3](https://github.com/urllib3/urllib3). Updates `urllib3` from 2.6.3 to 2.7.0 - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.7.0 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ace4a3e0374..8bdd5f892df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ certifi==2025.7.14 charset-normalizer==3.4.2 idna==3.10 requests==2.33.0 -urllib3==2.6.3 +urllib3==2.7.0 From 16da8b8f402c9c3fa5915c9093dbade965ec42c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:46:32 +0000 Subject: [PATCH 033/276] chore(deps): bump spotless from 7.0.4 to 8.4.0 (#5411) * chore(deps): bump spotless from 7.0.4 to 8.4.0 Bumps `spotless` from 7.0.4 to 8.4.0. Updates `com.diffplug.spotless:com.diffplug.spotless.gradle.plugin` from 7.0.4 to 8.4.0 Updates `com.diffplug.spotless` from 7.0.4 to 8.4.0 --- updated-dependencies: - dependency-name: com.diffplug.spotless:com.diffplug.spotless.gradle.plugin dependency-version: 8.4.0 dependency-type: direct:production update-type: version-update:semver-major - dependency-name: com.diffplug.spotless dependency-version: 8.4.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Format code --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sentry Github Bot --- gradle/libs.versions.toml | 2 +- .../android/core/AnrV2IntegrationTest.kt | 92 +++++++++---------- .../sentry/android/core/SentryAndroidTest.kt | 58 ++++++------ .../debugmeta/AssetsDebugMetaLoaderTest.kt | 24 ++--- .../modules/AssetsModulesLoaderTest.kt | 16 ++-- .../distribution/UpdateResponseParserTest.kt | 30 +++--- .../uitest/android/critical/MainActivity.kt | 6 +- .../io/sentry/uitest/android/EnvelopeTests.kt | 8 +- .../viewhierarchy/ComposeViewHierarchyNode.kt | 22 ++--- .../replay/AnrWithReplayIntegrationTest.kt | 58 ++++++------ .../graphql22/SentryInstrumentationTest.kt | 14 +-- .../graphql/SentryInstrumentationTest.kt | 14 +-- .../src/main/kotlin/io/sentry/Assertions.kt | 8 +- .../io/sentry/JsonObjectDeserializerTest.kt | 52 +++++------ .../debugmeta/ResourcesDebugMetaLoaderTest.kt | 84 ++++++++--------- .../modules/ResourcesModulesLoaderTest.kt | 16 ++-- .../io/sentry/util/CollectionUtilsTest.kt | 12 +-- 17 files changed, 258 insertions(+), 258 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab39c981b44..4a17b2ac237 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ springboot4 = "4.0.0" targetSdk = "36" compileSdk = "36" minSdk = "21" -spotless = "7.0.4" +spotless = "8.4.0" gummyBears = "0.12.0" camerax = "1.4.0" openfeature = "1.18.2" diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index abd27b51560..d9fd9c1889e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -56,29 +56,29 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { whenever(mock.traceInputStream) .thenReturn( """ - Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) - Here are no Binder-related exception messages available. - Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) + Here are no Binder-related exception messages available. + Pid(12233) have D state thread(tid:12236 name:Signal Catcher) - RssHwmKb: 823716 - RssKb: 548348 - RssAnonKb: 382156 - RssShmemKb: 13304 - VmSwapKb: 82484 + RssHwmKb: 823716 + RssKb: 548348 + RssAnonKb: 382156 + RssShmemKb: 13304 + VmSwapKb: 82484 - --- CriticalEventLog --- - capacity: 20 - timestamp_ms: 1731507490032 - window_ms: 300000 + --- CriticalEventLog --- + capacity: 20 + timestamp_ms: 1731507490032 + window_ms: 300000 - ----- dumping pid: 12233 at 313446151 - libdebuggerd_client: unexpected registration response: 0 + ----- dumping pid: 12233 at 313446151 + libdebuggerd_client: unexpected registration response: 0 - ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- - Cmd line: com.example.app:mainProcess - """ + ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- + Cmd line: com.example.app:mainProcess + """ .trimIndent() .byteInputStream() ) @@ -86,35 +86,35 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { whenever(mock.traceInputStream) .thenReturn( """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ .trimIndent() .byteInputStream() ) 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 9c0f68c3f98..8524a1cc807 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 @@ -132,35 +132,35 @@ class SentryAndroidTest { whenever(mock.traceInputStream) .thenReturn( """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ .trimIndent() .byteInputStream() ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt index ba11c3c7966..76f13b63822 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt @@ -45,12 +45,12 @@ class AssetsDebugMetaLoaderTest { fixture.getSut( content = """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + io.sentry.build-tool=maven + """ .trimIndent() ) @@ -68,12 +68,12 @@ class AssetsDebugMetaLoaderTest { fixture.getSut( content = """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 + io.sentry.build-tool=maven + """ .trimIndent() ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt index 128087a315d..34d03b175d7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt @@ -44,9 +44,9 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -62,9 +62,9 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -93,8 +93,8 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3;3.14.9 - """ + com.squareup.okhttp3;3.14.9 + """ .trimIndent() ) 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 89013c430dd..3f1d083919c 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 @@ -37,7 +37,7 @@ class UpdateResponseParserTest { }, "current": null } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -68,7 +68,7 @@ class UpdateResponseParserTest { "created_date": "2023-09-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -90,7 +90,7 @@ class UpdateResponseParserTest { "created_date": "2023-09-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -146,7 +146,7 @@ class UpdateResponseParserTest { "build_version": "2.0.0" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -173,7 +173,7 @@ class UpdateResponseParserTest { "created_date": "" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -214,7 +214,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -240,7 +240,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -266,7 +266,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -292,7 +292,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -316,7 +316,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -345,7 +345,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -372,7 +372,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -397,7 +397,7 @@ class UpdateResponseParserTest { "install_groups": [] } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -422,7 +422,7 @@ class UpdateResponseParserTest { "install_groups": null } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -447,7 +447,7 @@ class UpdateResponseParserTest { "install_groups": ["beta-testers"] } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) 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 f6b81c869ef..46bfe7e44b7 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 @@ -69,9 +69,9 @@ class MainActivity : ComponentActivity() { val file = File(outboxPath, "corrupted.envelope") val corruptedEnvelopeContent = """ - {"event_id":"1990b5bc31904b7395fd07feb72daf1c","sdk":{"name":"sentry.java.android","version":"7.21.0"}} - {"type":"test","length":50} - """ + {"event_id":"1990b5bc31904b7395fd07feb72daf1c","sdk":{"name":"sentry.java.android","version":"7.21.0"}} + {"type":"test","length":50} + """ .trimIndent() file.writeText(corruptedEnvelopeContent) println("Wrote corrupted envelope to: ${file.absolutePath}") 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 30cdfefde25..ade47363296 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 @@ -267,10 +267,10 @@ class EnvelopeTests : BaseUiTest() { File(optionsRef!!.outboxPath, "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"} - {"type":"transaction","length":1335} - {"event_id":"729ff878-5539-458d-f657-a1acf423a127","platform":"native","transaction":"little.teapot","start_timestamp":"2025-04-02T10:02:04.731697Z","spans":[{"op":"littlest.teapot","span_id":"00028ba394454124","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"b0dc1649a8ec4101","description":null,"start_timestamp":"2025-04-02T10:02:04.732127Z","timestamp":"2025-04-02T10:02:04.732133Z"},{"op":"littler.teapot","span_id":"b0dc1649a8ec4101","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"7ad2e40529af4650","description":null,"start_timestamp":"2025-04-02T10:02:04.732118Z","data":{"span_data_says":"hi!"},"timestamp":"2025-04-02T10:02:04.732137Z"}],"type":"transaction","timestamp":"2025-04-02T10:02:04.732142Z","level":"info","contexts":{"trace":{"trace_id":"7160e289fe4c4496f02c72bbc7edb392","span_id":"7ad2e40529af4650","op":"Short and stout here is my handle and here is my spout","status":"ok","data":{"url":"https://example.com"}},"os":{"build":"android14-4-00257-g7e35917775b8-ab9964412","name":"Linux","version":"6.1.23"}},"release":"1.0.0","dist":"dist","environment":"production","sdk":{"name":"io.sentry.ndk","version":"0.8.3","packages":[{"name":"github:getsentry/sentry-native","version":"0.8.3"}],"integrations":["inproc"]},"tags":{},"extra":{},"breadcrumbs":[]} - """ + {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} + {"type":"transaction","length":1335} + {"event_id":"729ff878-5539-458d-f657-a1acf423a127","platform":"native","transaction":"little.teapot","start_timestamp":"2025-04-02T10:02:04.731697Z","spans":[{"op":"littlest.teapot","span_id":"00028ba394454124","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"b0dc1649a8ec4101","description":null,"start_timestamp":"2025-04-02T10:02:04.732127Z","timestamp":"2025-04-02T10:02:04.732133Z"},{"op":"littler.teapot","span_id":"b0dc1649a8ec4101","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"7ad2e40529af4650","description":null,"start_timestamp":"2025-04-02T10:02:04.732118Z","data":{"span_data_says":"hi!"},"timestamp":"2025-04-02T10:02:04.732137Z"}],"type":"transaction","timestamp":"2025-04-02T10:02:04.732142Z","level":"info","contexts":{"trace":{"trace_id":"7160e289fe4c4496f02c72bbc7edb392","span_id":"7ad2e40529af4650","op":"Short and stout here is my handle and here is my spout","status":"ok","data":{"url":"https://example.com"}},"os":{"build":"android14-4-00257-g7e35917775b8-ab9964412","name":"Linux","version":"6.1.23"}},"release":"1.0.0","dist":"dist","environment":"production","sdk":{"name":"io.sentry.ndk","version":"0.8.3","packages":[{"name":"github:getsentry/sentry-native","version":"0.8.3"}],"integrations":["inproc"]},"tags":{},"extra":{},"breadcrumbs":[]} + """ .trimIndent() ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index ec01d28d4fb..a0312b69cd0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -137,12 +137,12 @@ internal object ComposeViewHierarchyNode { SentryLevel.ERROR, t, """ - Error retrieving semantics information from Compose tree. Most likely you're using - an unsupported version of androidx.compose.ui:ui. The supported - version range is 1.5.0 - 1.10.2. - If you're using a newer version, please open a github issue with the version - you're using, so we can add support for it. - """ + Error retrieving semantics information from Compose tree. Most likely you're using + an unsupported version of androidx.compose.ui:ui. The supported + version range is 1.5.0 - 1.10.2. + If you're using a newer version, please open a github issue with the version + you're using, so we can add support for it. + """ .trimIndent(), ) } @@ -284,11 +284,11 @@ internal object ComposeViewHierarchyNode { SentryLevel.ERROR, e, """ - Error traversing Compose tree. Most likely you're using an unsupported version of - androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer - version, please open a github issue with the version you're using, so we can add - support for it. - """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """ .trimIndent(), ) return false 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 f3d03fd5bc5..1214c55c057 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 @@ -80,35 +80,35 @@ class AnrWithReplayIntegrationTest { whenever(mock.traceInputStream) .thenReturn( """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ .trimIndent() .byteInputStream() ) diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt index e61fb44a856..c684688299e 100644 --- a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -54,14 +54,14 @@ class SentryInstrumentationTest { activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ - type Query { - shows: [Show] - } + type Query { + shows: [Show] + } - type Show { - id: Int - } - """ + type Show { + id: Int + } + """ .trimIndent() val graphQLSchema = diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 1e2e5c8f0f0..972b091a226 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -50,14 +50,14 @@ class SentryInstrumentationTest { activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ - type Query { - shows: [Show] - } + type Query { + shows: [Show] + } - type Show { - id: Int - } - """ + type Show { + id: Int + } + """ .trimIndent() val graphQLSchema = diff --git a/sentry-test-support/src/main/kotlin/io/sentry/Assertions.kt b/sentry-test-support/src/main/kotlin/io/sentry/Assertions.kt index 8d621adb1a2..43b083d50e9 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/Assertions.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/Assertions.kt @@ -118,11 +118,11 @@ private inline fun check(noinline predicate: (T) -> Unit): T = if (arg == null) { error( """ - The argument passed to the predicate was null. + The argument passed to the predicate was null. -If you are trying to verify an argument to be null, use `isNull()`. -If you are using `check` as part of a stubbing, use `argThat` or `argForWhich` instead. - """ + If you are trying to verify an argument to be null, use `isNull()`. + If you are using `check` as part of a stubbing, use `argThat` or `argForWhich` instead. + """ .trimIndent() ) } diff --git a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt index 3e60f4ff8a7..04e2aaceba0 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectDeserializerTest.kt @@ -162,12 +162,12 @@ class JsonObjectDeserializerTest { fun `deserialize json object object`() { val json = """ - { - "key": { - "key": "value" - } - } - """ + { + "key": { + "key": "value" + } + } + """ .trimIndent() val expected = mapOf("key" to mapOf("key" to "value")) @@ -179,26 +179,26 @@ class JsonObjectDeserializerTest { fun `deserialize json object object with nesting`() { val json = """ - { - "fixture-key": - { - "string": "fixture-string", - "int": 123, - "double": 123.321, - "boolean": true, - "array": - [ - "a", - "b", - "c" - ], - "object": - { - "key": "value" - } - } - } - """ + { + "fixture-key": + { + "string": "fixture-string", + "int": 123, + "double": 123.321, + "boolean": true, + "array": + [ + "a", + "b", + "c" + ], + "object": + { + "key": "value" + } + } + } + """ .trimIndent() val expected = diff --git a/sentry/src/test/java/io/sentry/internal/debugmeta/ResourcesDebugMetaLoaderTest.kt b/sentry/src/test/java/io/sentry/internal/debugmeta/ResourcesDebugMetaLoaderTest.kt index 0e776717436..59bc9012348 100644 --- a/sentry/src/test/java/io/sentry/internal/debugmeta/ResourcesDebugMetaLoaderTest.kt +++ b/sentry/src/test/java/io/sentry/internal/debugmeta/ResourcesDebugMetaLoaderTest.kt @@ -51,12 +51,12 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + io.sentry.build-tool=maven + """ .trimIndent() ) ) @@ -76,12 +76,12 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 + io.sentry.build-tool=maven + """ .trimIndent() ) ) @@ -104,20 +104,20 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=7b1fae93-63fb-43ff-a70a-608dc5005970 - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=7b1fae93-63fb-43ff-a70a-608dc5005970 + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 + io.sentry.build-tool=maven + """ .trimIndent(), """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=37c90685-32a1-40db-9019-a2f0b05674cb - io.sentry.bundle-ids=13e16819-accf-48da-a82d-f6ec94af9948 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=37c90685-32a1-40db-9019-a2f0b05674cb + io.sentry.bundle-ids=13e16819-accf-48da-a82d-f6ec94af9948 + io.sentry.build-tool=maven + """ .trimIndent(), ) ) @@ -151,13 +151,13 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - io.sentry.build-tool=maven - io.sentry.build-tool-version=1.0 - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + io.sentry.build-tool=maven + io.sentry.build-tool-version=1.0 + """ .trimIndent() ) ) @@ -176,12 +176,12 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + io.sentry.build-tool=maven + """ .trimIndent() ) ) @@ -200,11 +200,11 @@ class ResourcesDebugMetaLoaderTest { content = listOf( """ - #Generated manually - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - """ + #Generated manually + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + """ .trimIndent() ) ) diff --git a/sentry/src/test/java/io/sentry/internal/modules/ResourcesModulesLoaderTest.kt b/sentry/src/test/java/io/sentry/internal/modules/ResourcesModulesLoaderTest.kt index d9791fd7608..290fc0d0c0b 100644 --- a/sentry/src/test/java/io/sentry/internal/modules/ResourcesModulesLoaderTest.kt +++ b/sentry/src/test/java/io/sentry/internal/modules/ResourcesModulesLoaderTest.kt @@ -35,9 +35,9 @@ class ResourcesModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -53,9 +53,9 @@ class ResourcesModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -84,8 +84,8 @@ class ResourcesModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3;3.14.9 - """ + com.squareup.okhttp3;3.14.9 + """ .trimIndent() ) diff --git a/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt index f5b358d9763..1b1ffc02493 100644 --- a/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt @@ -46,12 +46,12 @@ class CollectionUtilsTest { fun `concurrent hashmap creation ignores null values`() { val json = """ - { - "key1": "value1", - "key2": null, - "key3": "value3" - } - """ + { + "key1": "value1", + "key2": null, + "key3": "value3" + } + """ .trimIndent() val reader = JsonObjectReader(StringReader(json)) val deserializedMap = reader.nextObjectOrNull() as Map From 4ffeec8c0020ea4305a5bd1c1ebab575e130b579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:47:44 +0000 Subject: [PATCH 034/276] chore(deps): bump androidx.constraintlayout:constraintlayout (#5416) Bumps androidx.constraintlayout:constraintlayout from 2.0.4 to 2.2.1. --- updated-dependencies: - dependency-name: androidx.constraintlayout:constraintlayout dependency-version: 2.2.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- .../test-app-plain/build.gradle.kts | 2 +- .../test-app-sentry/build.gradle.kts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a17b2ac237..ae13fb664bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ androidx-compose-material-icons-extended = { module = "androidx.compose.material androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } # Note: don't change without testing forwards compatibility androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.10.2" } -androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.3" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.1" } androidx-core = { module = "androidx.core:core", version = "1.3.2" } androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } diff --git a/sentry-android-integration-tests/test-app-plain/build.gradle.kts b/sentry-android-integration-tests/test-app-plain/build.gradle.kts index 4d6655132c3..9778363ede8 100644 --- a/sentry-android-integration-tests/test-app-plain/build.gradle.kts +++ b/sentry-android-integration-tests/test-app-plain/build.gradle.kts @@ -45,7 +45,7 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.3.0") implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("androidx.navigation:navigation-fragment:2.3.5") implementation("androidx.navigation:navigation-ui:2.3.5") } diff --git a/sentry-android-integration-tests/test-app-sentry/build.gradle.kts b/sentry-android-integration-tests/test-app-sentry/build.gradle.kts index cd340b9a4a6..db0cb4a46ab 100644 --- a/sentry-android-integration-tests/test-app-sentry/build.gradle.kts +++ b/sentry-android-integration-tests/test-app-sentry/build.gradle.kts @@ -45,7 +45,7 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.3.0") implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("androidx.navigation:navigation-fragment:2.3.5") implementation("androidx.navigation:navigation-ui:2.3.5") implementation(projects.sentryAndroid) From 1f987ea15b2878502b59e75518b08577211f2cc7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 12 May 2026 16:28:32 +0200 Subject: [PATCH 035/276] Remove testBuildType mechanism from UI test modules (#5388) * fix(build): Remove testBuildType mechanism from UI test modules The debug and release build types were configured identically in both uitest modules (same minification, proguard rules, signing). Disable the debug variant unconditionally, hardcode testBuildType to release, and combine the now-simplified Gradle invocations. Co-Authored-By: Claude Opus 4.6 * fix(build): Combine Gradle invocations in AGENTS.md Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/agp-matrix.yml | 2 +- AGENTS.md | 3 +-- Makefile | 6 ++---- .../build.gradle.kts | 18 ++++-------------- .../sentry-uitest-android/README.md | 6 ++---- .../sentry-uitest-android/build.gradle.kts | 13 ++++--------- 6 files changed, 14 insertions(+), 34 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index df0b0ebca3c..7ef34ea563e 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -90,7 +90,7 @@ jobs: disable-spellchecker: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disk-size: 4096M - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release -Denvironment=github --daemon + script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -Denvironment=github --daemon - name: Upload test results if: always() diff --git a/AGENTS.md b/AGENTS.md index 42a8e651004..1784e4f950e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,8 +75,7 @@ make systemTest ### Android-Specific Commands ```bash # Assemble Android test APKs -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release +./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest # Run critical UI tests ./scripts/test-ui-critical.sh diff --git a/Makefile b/Makefile index 55f465a9663..c9eca8b8b7e 100644 --- a/Makefile +++ b/Makefile @@ -37,13 +37,11 @@ api: # Assemble release and Android test apk of the uitest-android-benchmark module assembleBenchmarkTestRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease - ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest -DtestBuildType=release + ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest # Assemble release and Android test apk of the uitest-android module assembleUiTestRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease - ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release + ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest # Assemble release of the uitest-android-critical module assembleUiTestCriticalRelease: 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 4b5993644ee..459c1653fa9 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 @@ -46,21 +46,9 @@ android { } } - testBuildType = System.getProperty("testBuildType", "debug") + testBuildType = "release" buildTypes { - getByName("debug") { - isMinifyEnabled = true - signingConfig = signingConfigs.getByName("debug") - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "benchmark-proguard-rules.pro", - ) - testProguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "benchmark-proguard-rules.pro", - ) - } getByName("release") { isMinifyEnabled = true isShrinkResources = true @@ -89,7 +77,9 @@ android { } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + if (it.buildType == "debug") { + it.enable = false + } } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/README.md b/sentry-android-integration-tests/sentry-uitest-android/README.md index c11397383d0..08389e0c16c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/README.md +++ b/sentry-android-integration-tests/sentry-uitest-android/README.md @@ -14,14 +14,12 @@ You can run benchmark tests only with `./gradlew :sentry-android-integration-tes To run on saucelabs execute following commands (need also `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` environment variables): For Benchmarks: ``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest -DtestBuildType=release +./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest saucectl run -c .sauce/sentry-uitest-android-benchmark.yml ``` For End 2 End: ``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release +./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest saucectl run -c .sauce/sentry-uitest-android-end2end.yml ``` 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 a4d46405fb8..5258a33f92a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -51,18 +51,11 @@ android { } } - testBuildType = System.getProperty("testBuildType", "debug") + testBuildType = "release" buildTypes { - getByName("debug") { - isMinifyEnabled = true - signingConfig = signingConfigs.getByName("debug") - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - testProguardFiles("proguard-rules.pro") - } getByName("release") { isMinifyEnabled = true - isShrinkResources = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") // to be able to run release mode testProguardFiles("proguard-rules.pro") @@ -82,7 +75,9 @@ android { } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + if (it.buildType == "debug") { + it.enable = false + } } } From d8912da3e338a789eb940dc4e5ba54b97d7b6fde Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 12 May 2026 17:55:17 +0200 Subject: [PATCH 036/276] chore: Add missing binary types to .gitattributes (#5420) Mark *.bin, *.zip, *.jar, and *.gpg as binary to prevent line-ending conversions and diff noise. Co-authored-by: Claude Opus 4.6 --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 92fa746b911..f444fd5957d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,10 @@ *.jpg binary *.pb binary *.gz binary +*.bin binary +*.zip binary +*.jar binary +*.gpg binary # These are explicitly windows files and should use crlf *.bat text eol=crlf From 271ed531ac58a295cca62ff1206e10cf015b58aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 09:33:10 +0200 Subject: [PATCH 037/276] chore: update scripts/update-gradle.sh to v9.5.1 (#5419) Co-authored-by: GitHub --- CHANGELOG.md | 6 +++++ gradle/wrapper/gradle-wrapper.jar | Bin 48966 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++- gradlew | 2 +- gradlew.bat | 31 ++++++++--------------- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda85d8b99..bfb51947698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) +### Dependencies + +- Bump Gradle from v9.5.0 to v9.5.1 ([#5419](https://github.com/getsentry/sentry-java/pull/5419)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v951) + - [diff](https://github.com/gradle/gradle/compare/v9.5.0...v9.5.1) + ## 8.41.0 ### Features diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc60f4cff0e7451d19d49a82fa986695d07..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch delta 39760 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 e(*aw}>J`&Ep$5$o2mVP)i30-M3~u@DBh0 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 delta 40115 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 e{Sg5G5zENPNVU~b5#<2@M#Os+0czL&{q{eZe&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,7 +65,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line @@ -73,21 +73,10 @@ goto fail @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@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 -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From 5dee26ba7ca9931bca62b730b210e01063e1c2d1 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 13:54:17 +0200 Subject: [PATCH 038/276] chore(deps): Bump Robolectric from 4.14 to 4.15 (#5425) Co-authored-by: Claude --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae13fb664bd..bf16f9b0edd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -246,5 +246,5 @@ mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } -roboelectric = { module = "org.robolectric:robolectric", version = "4.14" } +roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } From 9745881ec5ee89409a8a274966f7348403bcb501 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:09:58 +0200 Subject: [PATCH 039/276] chore: update scripts/update-sentry-native-ndk.sh to 0.14.1 (#5433) Co-authored-by: GitHub --- CHANGELOG.md | 3 +++ gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb51947698..edf15cf84e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Bump Gradle from v9.5.0 to v9.5.1 ([#5419](https://github.com/getsentry/sentry-java/pull/5419)) - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v951) - [diff](https://github.com/gradle/gradle/compare/v9.5.0...v9.5.1) +- Bump Native SDK from v0.14.0 to v0.14.1 ([#5433](https://github.com/getsentry/sentry-java/pull/5433)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0141) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.0...0.14.1) ## 8.41.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf16f9b0edd..0d4850d288a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,7 +151,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.14.0" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.14.1" } 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 d65ecce0b77cf13f8d12dcf36d099420c327b33f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 15 May 2026 09:41:55 +0200 Subject: [PATCH 040/276] chore: Replace custom Gradle wrapper updater with Dependabot (#5421) Remove the custom `scripts/update-gradle.sh` script and its corresponding `gradle-wrapper` job in the `update-deps.yml` workflow. The existing Dependabot `gradle` package ecosystem already handles Gradle wrapper updates, making this custom machinery redundant. Co-authored-by: Claude Opus 4.6 --- .github/workflows/update-deps.yml | 10 ------- scripts/update-gradle.sh | 47 ------------------------------- 2 files changed, 57 deletions(-) delete mode 100755 scripts/update-gradle.sh diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index a8bb5f655a1..bfcf9ccfa85 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -23,13 +23,3 @@ jobs: path: scripts/update-sentry-native-ndk.sh name: Native SDK ssh-key: ${{ secrets.CI_DEPLOY_KEY }} - - gradle-wrapper: - runs-on: ubuntu-latest - steps: - - uses: getsentry/github-workflows/updater@26f565c05d0dd49f703d238706b775883037d76b # v3 - with: - path: scripts/update-gradle.sh - name: Gradle - pattern: '^v[0-9.]+$' # only match non-preview versions - ssh-key: ${{ secrets.CI_DEPLOY_KEY }} diff --git a/scripts/update-gradle.sh b/scripts/update-gradle.sh deleted file mode 100755 index c2bfe979224..00000000000 --- a/scripts/update-gradle.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd $(dirname "$0")/../ - -if [[ -n ${CI+x} ]]; then - export JAVA_HOME=$JAVA_HOME_17_X64 -fi - -case $1 in -get-version) - # `./gradlew` shows some info on the first run, breaking the parsing in the next step. - # Therefore, we run it once without checking any output. - ./gradlew --version >/dev/null - version="$(./gradlew --version | sed -E -n 's/.*Gradle +([0-9.]+).*/\1/p')" - - # Add trailing ".0" - gradlew outputs '7.1' instead of '7.1.0' - if [[ "$version" =~ ^[0-9]\.[0-9]$ ]]; then - version="$version.0" - fi - - echo "v$version" - ;; -get-repo) - echo "https://github.com/gradle/gradle.git" - ;; -set-version) - version=$2 - - # Remove leading "v" - if [[ "$version" == v* ]]; then - version="${version:1}" - fi - - echo "Setting gradle version to '$version'" - - # This sets version to gradle-wrapper.properties. - ./gradlew wrapper --gradle-version "$version" - - # Verify it works. - ./gradlew --version - ;; -*) - echo "Unknown argument $1" - exit 1 - ;; -esac From 8e739fbff43f310efdeb5bd5a66c98a4ccb308a3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 15 May 2026 14:17:18 +0200 Subject: [PATCH 041/276] chore(deps): Bump Tomcat from 11.0.10 to 11.0.22 (#5440) Co-authored-by: Claude Opus 4.6 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d4850d288a..47c74dde9c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -213,8 +213,8 @@ gummy-bears-api21 = { module = "com.toasttab.android:gummy-bears-api-21", versio # tomcat libraries tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0.108" } tomcat-embed-jasper = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "9.0.108" } -tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.10" } -tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.10" } +tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.22" } +tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" } # test libraries androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.5" } From 4c04bb8999d177ffba8c5d68476e2664a043c789 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 15 May 2026 15:55:12 +0200 Subject: [PATCH 042/276] chore(deps): Bump SAGP from 6.0.0-alpha.6 to 6.6.0 (#5427) * chore(deps): Bump SAGP from 6.0.0-alpha.6 to 6.6.0 Co-Authored-By: Claude Opus 4.6 * changelog --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edf15cf84e8..93d3888bd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - Bump Native SDK from v0.14.0 to v0.14.1 ([#5433](https://github.com/getsentry/sentry-java/pull/5433)) - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0141) - [diff](https://github.com/getsentry/sentry-native/compare/0.14.0...0.14.1) +- Bump SAGP (Sentry Android Gradle Plugin) from v6.0.0-alpha.6 to v6.6.0 ([#5427](https://github.com/getsentry/sentry-java/pull/5427)) + - [changelog](https://github.com/getsentry/sentry-android-gradle-plugin/blob/main/CHANGELOG.md) + - [diff](https://github.com/getsentry/sentry-android-gradle-plugin/compare/6.0.0-alpha.6...6.6.0) ## 8.41.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47c74dde9c5..7f37847604f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,7 +66,7 @@ springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } -sentry = { id = "io.sentry.android.gradle", version = "6.0.0-alpha.6"} +sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] From f6cdbf09de34beb48f0daec2694ec8913751d89a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:52:35 +0200 Subject: [PATCH 043/276] chore: update scripts/update-sentry-native-ndk.sh to 0.14.2 (#5441) Co-authored-by: GitHub --- CHANGELOG.md | 6 +++--- gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d3888bd36..5632b6926b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ - Bump Gradle from v9.5.0 to v9.5.1 ([#5419](https://github.com/getsentry/sentry-java/pull/5419)) - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v951) - [diff](https://github.com/gradle/gradle/compare/v9.5.0...v9.5.1) -- Bump Native SDK from v0.14.0 to v0.14.1 ([#5433](https://github.com/getsentry/sentry-java/pull/5433)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0141) - - [diff](https://github.com/getsentry/sentry-native/compare/0.14.0...0.14.1) +- Bump Native SDK from v0.14.0 to v0.14.2 ([#5433](https://github.com/getsentry/sentry-java/pull/5433), [#5441](https://github.com/getsentry/sentry-java/pull/5441)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0142) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.0...0.14.2) - Bump SAGP (Sentry Android Gradle Plugin) from v6.0.0-alpha.6 to v6.6.0 ([#5427](https://github.com/getsentry/sentry-java/pull/5427)) - [changelog](https://github.com/getsentry/sentry-android-gradle-plugin/blob/main/CHANGELOG.md) - [diff](https://github.com/getsentry/sentry-android-gradle-plugin/compare/6.0.0-alpha.6...6.6.0) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f37847604f..4e580db7498 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,7 +151,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.14.1" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.14.2" } 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 48cc9d8840f7adada488dfffc280a9d4e75ef68d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 19 May 2026 14:00:26 +0200 Subject: [PATCH 044/276] meta(craft): Register missing SDK modules (#5399) --- .craft.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.craft.yml b/.craft.yml index cb52926ad56..cc4636cd32d 100644 --- a/.craft.yml +++ b/.craft.yml @@ -42,16 +42,20 @@ targets: maven:io.sentry:sentry-bom: maven:io.sentry:sentry-openfeign: maven:io.sentry:sentry-openfeature: + maven:io.sentry:sentry-launchdarkly-android: + maven:io.sentry:sentry-launchdarkly-server: maven:io.sentry:sentry-opentelemetry-agent: maven:io.sentry:sentry-opentelemetry-agentcustomization: maven:io.sentry:sentry-opentelemetry-agentless: maven:io.sentry:sentry-opentelemetry-agentless-spring: maven:io.sentry:sentry-opentelemetry-bootstrap: maven:io.sentry:sentry-opentelemetry-core: -# maven:io.sentry:sentry-opentelemetry-otlp: -# maven:io.sentry:sentry-opentelemetry-otlp-spring: + maven:io.sentry:sentry-opentelemetry-otlp: + maven:io.sentry:sentry-opentelemetry-otlp-spring: + maven:io.sentry:sentry-kafka: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: + maven:io.sentry:sentry-jcache: maven:io.sentry:sentry-graphql: maven:io.sentry:sentry-graphql-22: maven:io.sentry:sentry-graphql-core: From 11f90db91673ad57a61458c7b8ce0b3d52646295 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 19 May 2026 14:23:13 +0200 Subject: [PATCH 045/276] feat(core): Add API to clear scope feature flags (#5426) * feat(core): Add API to clear scope feature flags Allow feature flags stored on a scope to be cleared without resetting other scope data. Scope.clear now also resets the feature flag buffer so stale flag evaluations do not carry over after clearing a scope. Fixes #5422 Co-Authored-By: Claude * changelog --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + sentry/api/sentry.api | 8 ++++++++ .../src/main/java/io/sentry/CombinedScopeView.java | 5 +++++ sentry/src/main/java/io/sentry/IScope.java | 2 ++ sentry/src/main/java/io/sentry/NoOpScope.java | 3 +++ sentry/src/main/java/io/sentry/Scope.java | 6 ++++++ .../io/sentry/featureflags/FeatureFlagBuffer.java | 7 +++++++ .../io/sentry/featureflags/IFeatureFlagBuffer.java | 2 ++ .../sentry/featureflags/NoOpFeatureFlagBuffer.java | 3 +++ .../sentry/featureflags/SpanFeatureFlagBuffer.java | 7 +++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 14 ++++++++++++++ .../sentry/featureflags/FeatureFlagBufferTest.kt | 13 +++++++++++++ .../featureflags/SpanFeatureFlagBufferTest.kt | 12 ++++++++++++ 13 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5632b6926b9..4241d6e4f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) ### Dependencies diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37c..e48c03ffb15 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -273,6 +273,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun clear ()V public fun clearAttachments ()V public fun clearBreadcrumbs ()V + public fun clearFeatureFlags ()V public fun clearSession ()V public fun clearTransaction ()V public fun clone ()Lio/sentry/IScope; @@ -901,6 +902,7 @@ public abstract interface class io/sentry/IScope { public abstract fun clear ()V public abstract fun clearAttachments ()V public abstract fun clearBreadcrumbs ()V + public abstract fun clearFeatureFlags ()V public abstract fun clearSession ()V public abstract fun clearTransaction ()V public abstract fun clone ()Lio/sentry/IScope; @@ -1715,6 +1717,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun clear ()V public fun clearAttachments ()V public fun clearBreadcrumbs ()V + public fun clearFeatureFlags ()V public fun clearSession ()V public fun clearTransaction ()V public fun clone ()Lio/sentry/IScope; @@ -2401,6 +2404,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun clear ()V public fun clearAttachments ()V public fun clearBreadcrumbs ()V + public fun clearFeatureFlags ()V public fun clearSession ()V public fun clearTransaction ()V public fun clone ()Lio/sentry/IScope; @@ -5039,6 +5043,7 @@ public final class io/sentry/exception/SentryHttpClientException : java/lang/Exc public final class io/sentry/featureflags/FeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun clear ()V public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public synthetic fun clone ()Ljava/lang/Object; public static fun create (Lio/sentry/SentryOptions;)Lio/sentry/featureflags/IFeatureFlagBuffer; @@ -5048,6 +5053,7 @@ public final class io/sentry/featureflags/FeatureFlagBuffer : io/sentry/featuref public abstract interface class io/sentry/featureflags/IFeatureFlagBuffer { public abstract fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public abstract fun clear ()V public abstract fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public abstract fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; } @@ -5055,6 +5061,7 @@ public abstract interface class io/sentry/featureflags/IFeatureFlagBuffer { public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { public fun ()V public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun clear ()V public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public synthetic fun clone ()Ljava/lang/Object; public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; @@ -5063,6 +5070,7 @@ public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/feat public final class io/sentry/featureflags/SpanFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun clear ()V public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public synthetic fun clone ()Ljava/lang/Object; public static fun create ()Lio/sentry/featureflags/IFeatureFlagBuffer; diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index 0c61bdf9126..f21f8697fa4 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -549,6 +549,11 @@ public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean } } + @Override + public void clearFeatureFlags() { + getDefaultWriteScope().clearFeatureFlags(); + } + @Override public @Nullable FeatureFlags getFeatureFlags() { return getFeatureFlagBuffer().getFeatureFlags(); diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index ccab8dbdeb3..5b6c38bbcfb 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -465,6 +465,8 @@ void setSpanContext( void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result); + void clearFeatureFlags(); + @ApiStatus.Internal @Nullable FeatureFlags getFeatureFlags(); diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index 7693ab81deb..9d2f603c673 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -321,6 +321,9 @@ public void removeAttribute(@Nullable String key) {} @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} + @Override + public void clearFeatureFlags() {} + @Override public @Nullable FeatureFlags getFeatureFlags() { return null; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index fa44e90a194..9e8d3ee554e 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -574,6 +574,7 @@ public void clear() { eventProcessors.clear(); clearTransaction(); clearAttachments(); + clearFeatureFlags(); } /** @@ -1211,6 +1212,11 @@ public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean featureFlags.add(flag, result); } + @Override + public void clearFeatureFlags() { + featureFlags.clear(); + } + @Override public @Nullable FeatureFlags getFeatureFlags() { return featureFlags.getFeatureFlags(); diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index f38d0b6db52..fc696b5948f 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -69,6 +69,13 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } } + @Override + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + flags.clear(); + } + } + @Override public @Nullable FeatureFlags getFeatureFlags() { List featureFlags = new ArrayList<>(); diff --git a/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java index 7f12026a590..90a503cce49 100644 --- a/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java @@ -9,6 +9,8 @@ public interface IFeatureFlagBuffer { void add(final @Nullable String flag, final @Nullable Boolean result); + void clear(); + @Nullable FeatureFlags getFeatureFlags(); diff --git a/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java index 3bfc8f8fd2a..e093531149a 100644 --- a/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java @@ -16,6 +16,9 @@ public static NoOpFeatureFlagBuffer getInstance() { @Override public void add(final @Nullable String flag, final @Nullable Boolean result) {} + @Override + public void clear() {} + @Override public @Nullable FeatureFlags getFeatureFlags() { return null; diff --git a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java index 2afa45d38d1..d31bc231d44 100644 --- a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java @@ -48,6 +48,13 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } } + @Override + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + flags = null; + } + } + @Override public @Nullable FeatureFlags getFeatureFlags() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 7093473a60b..4b0047fdc18 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -294,6 +294,7 @@ class ScopeTest { scope.setAttribute("some", "attribute") scope.addEventProcessor(eventProcessor()) scope.addAttachment(Attachment("path")) + scope.addFeatureFlag("flag", true) scope.clear() @@ -309,6 +310,7 @@ class ScopeTest { assertEquals(0, scope.extras.size) assertEquals(0, scope.eventProcessors.size) assertEquals(0, scope.attachments.size) + assertEquals(0, scope.featureFlags!!.values.size) } @Test @@ -1155,6 +1157,18 @@ class ScopeTest { assertEquals(0, flags.values.size) } + @Test + fun `feature flags can be cleared`() { + val scope = Scope(SentryOptions.empty()) + + scope.addFeatureFlag("flag1", true) + scope.clearFeatureFlags() + + val flags = scope.featureFlags + assertNotNull(flags) + assertEquals(0, flags.values.size) + } + @Test fun `setAttribute stores attribute on scope`() { val scope = Scope(SentryOptions()) diff --git a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt index 471ba880eb4..8ec18ce02b8 100644 --- a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt @@ -33,6 +33,19 @@ class FeatureFlagBufferTest { assertFalse(featureFlagValues[1]!!.result) } + @Test + fun `clears values`() { + val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 2 }) + buffer.add("a", true) + buffer.add("b", false) + + buffer.clear() + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + assertEquals(0, featureFlags.values.size) + } + @Test fun `drops oldest entry when limit is reached`() { val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 2 }) diff --git a/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt index 07c3feaf5c9..c6c89d9f3ab 100644 --- a/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class SpanFeatureFlagBufferTest { @@ -26,6 +27,17 @@ class SpanFeatureFlagBufferTest { assertFalse(featureFlagValues[1]!!.result) } + @Test + fun `clears values`() { + val buffer = SpanFeatureFlagBuffer.create() + buffer.add("a", true) + buffer.add("b", false) + + buffer.clear() + + assertNull(buffer.featureFlags) + } + @Test fun `rejects new entries when limit is reached`() { val buffer = SpanFeatureFlagBuffer.create() From 69508a17fe4442278632fb13fd7f658a94e6f008 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 20 May 2026 12:17:27 +0200 Subject: [PATCH 046/276] feat(tombstones): Add option to attach raw tombstone as protobuf (#5446) * feat(android): Add option to attach raw tombstone as protobuf Co-Authored-By: Claude Opus 4.6 (1M context) * changelog * changelog * ref(android): Rename attachTombstone to attachRawTombstone Co-Authored-By: Claude Opus 4.6 (1M context) * test(android): Add tests for raw tombstone attachment Co-Authored-By: Claude Opus 4.6 (1M context) * Format code * fix(android): Close tombstone InputStream after reading bytes Co-Authored-By: Claude Opus 4.6 (1M context) * ref(android): Extract shared readBytes into NativeEventUtils Co-Authored-By: Claude Opus 4.6 (1M context) * fix(android): Fix JavaDoc and address review feedback Co-Authored-By: Claude Opus 4.6 (1M context) * ref(android): Only pre-buffer tombstone bytes when attach option is on Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 2 + .../api/sentry-android-core.api | 2 + .../sentry/android/core/AnrV2Integration.java | 18 +------ .../android/core/ManifestMetadataReader.java | 3 ++ .../android/core/SentryAndroidOptions.java | 14 +++++ .../android/core/TombstoneIntegration.java | 51 ++++++++++++------- .../core/internal/util/NativeEventUtils.java | 15 ++++++ .../core/ManifestMetadataReaderTest.kt | 25 +++++++++ .../android/core/TombstoneIntegrationTest.kt | 39 ++++++++++++++ sentry/api/sentry.api | 3 ++ .../src/main/java/io/sentry/Attachment.java | 10 ++++ sentry/src/main/java/io/sentry/Hint.java | 9 ++++ .../src/main/java/io/sentry/SentryClient.java | 5 ++ .../test/java/io/sentry/SentryClientTest.kt | 32 ++++++++++++ .../src/test/java/io/sentry/hints/HintTest.kt | 11 ++++ 15 files changed, 205 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4241d6e4f82..fca85f96435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Add option to attach raw tombstone protobuf on native crash events ([#5446](https://github.com/getsentry/sentry-java/pull/5446)) + - Enable via `options.isAttachRawTombstone = true` or manifest: `` - Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 3d4512fc2b4..249549f8366 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -374,6 +374,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isAnrProfilingEnabled ()Z public fun isAnrReportInDebug ()Z public fun isAttachAnrThreadDump ()Z + public fun isAttachRawTombstone ()Z public fun isAttachScreenshot ()Z public fun isAttachViewHierarchy ()Z public fun isCollectAdditionalContext ()Z @@ -401,6 +402,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V public fun setAttachAnrThreadDump (Z)V + public fun setAttachRawTombstone (Z)V public fun setAttachScreenshot (Z)V public fun setAttachViewHierarchy (Z)V public fun setBeforeScreenshotCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index af3a942c8cc..8d88285a356 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -18,6 +18,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; +import io.sentry.android.core.internal.util.NativeEventUtils; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; @@ -32,7 +33,6 @@ import io.sentry.util.Objects; import java.io.BufferedReader; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -194,7 +194,7 @@ public boolean shouldReportHistorical() { if (trace == null) { return new ParseResult(ParseResult.Type.NO_DUMP); } - dump = getDumpBytes(trace); + dump = NativeEventUtils.readBytes(trace); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to read ANR thread dump", e); return new ParseResult(ParseResult.Type.NO_DUMP); @@ -223,20 +223,6 @@ public boolean shouldReportHistorical() { return new ParseResult(ParseResult.Type.ERROR, dump); } } - - private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException { - try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - - int nRead; - final byte[] data = new byte[1024]; - - while ((nRead = trace.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } - - return buffer.toByteArray(); - } - } } @ApiStatus.Internal 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 b52634774d6..e16d4b312fc 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 @@ -36,6 +36,7 @@ final class ManifestMetadataReader { static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; + static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -226,6 +227,8 @@ static void applyMetadata( options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); options.setTombstoneEnabled( readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); + options.setAttachRawTombstone( + readBool(metadata, logger, TOMBSTONE_ATTACH_RAW, options.isAttachRawTombstone())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 8fe702aad50..bb9ec17aabd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -238,6 +238,12 @@ public interface BeforeCaptureCallback { */ private boolean attachAnrThreadDump = false; + /** + * Controls whether to attach the raw tombstone protobuf as an attachment. The tombstone is being + * attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. + */ + private boolean attachRawTombstone = false; + private boolean enablePerformanceV2 = true; private @Nullable SentryFrameMetricsCollector frameMetricsCollector; @@ -643,6 +649,14 @@ public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { this.attachAnrThreadDump = attachAnrThreadDump; } + public boolean isAttachRawTombstone() { + return attachRawTombstone; + } + + public void setAttachRawTombstone(final boolean attachRawTombstone) { + this.attachRawTombstone = attachRawTombstone; + } + /** * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index f2b87742544..2663051f7e4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -24,6 +24,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.tombstone.NativeExceptionMechanism; import io.sentry.android.core.internal.tombstone.TombstoneParser; +import io.sentry.android.core.internal.util.NativeEventUtils; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; import io.sentry.hints.NativeCrashExit; @@ -36,6 +37,7 @@ import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.HintUtils; import io.sentry.util.Objects; +import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -150,26 +152,35 @@ public boolean shouldReportHistorical() { public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { SentryEvent event; + @Nullable byte[] rawTombstone = null; try { - final InputStream tombstoneInputStream = exitInfo.getTraceInputStream(); - if (tombstoneInputStream == null) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "No tombstone InputStream available for ApplicationExitInfo from %s", - DateTimeFormatter.ISO_INSTANT.format( - Instant.ofEpochMilli(exitInfo.getTimestamp()))); - return null; - } + final boolean attachRaw = options.isAttachRawTombstone(); + try (final InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { + if (tombstoneInputStream == null) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No tombstone InputStream available for ApplicationExitInfo from %s", + DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochMilli(exitInfo.getTimestamp()))); + return null; + } - try (final TombstoneParser parser = - new TombstoneParser( - tombstoneInputStream, - this.options.getInAppIncludes(), - this.options.getInAppExcludes(), - this.context.getApplicationInfo().nativeLibraryDir)) { - event = parser.parse(); + if (attachRaw) { + rawTombstone = NativeEventUtils.readBytes(tombstoneInputStream); + } + + final InputStream parserInput = + attachRaw ? new ByteArrayInputStream(rawTombstone) : tombstoneInputStream; + try (final TombstoneParser parser = + new TombstoneParser( + parserInput, + this.options.getInAppIncludes(), + this.options.getInAppExcludes(), + this.context.getApplicationInfo().nativeLibraryDir)) { + event = parser.parse(); + } } } catch (Throwable e) { options @@ -190,6 +201,10 @@ public boolean shouldReportHistorical() { options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + if (rawTombstone != null) { + hint.setTombstone(Attachment.fromTombstone(rawTombstone)); + } + try { final @Nullable SentryEvent mergedEvent = mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java index f8bd70cb6c3..c5e766b3c44 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java @@ -1,5 +1,8 @@ package io.sentry.android.core.internal.util; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.math.BigInteger; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; @@ -8,6 +11,18 @@ import org.jetbrains.annotations.Nullable; public class NativeEventUtils { + + public static byte[] readBytes(final @NotNull InputStream stream) throws IOException { + try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + int nRead; + final byte[] data = new byte[1024]; + while ((nRead = stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + return buffer.toByteArray(); + } + } + @Nullable public static String buildIdToDebugId(final @NotNull String buildId) { try { 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 cedf5ca18bb..d8ac959601a 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 @@ -288,6 +288,31 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads tombstone attach raw to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_ATTACH_RAW to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isAttachRawTombstone) + } + + @Test + fun `applyMetadata reads tombstone attach raw to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isAttachRawTombstone) + } + @Test fun `applyMetadata reads anr report historical to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index 3b27d69d087..9890d553dbc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -96,6 +96,45 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase + options.isAttachRawTombstone = true + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val tombstone = this.tombstone + tombstone != null && + tombstone.filename == "tombstone.pb" && + tombstone.contentType == "application/x-protobuf" && + tombstone.bytes != null && + tombstone.bytes!!.isNotEmpty() + }, + ) + } + + @Test + fun `when attachRawTombstone is disabled, no tombstone is attached to hint`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + options.isAttachRawTombstone = false + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), argThat { this.tombstone == null }) + } + @Test fun `when matching native event has attachments, they are added to the hint`() { val integration = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e48c03ffb15..6b8377de3a3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -19,6 +19,7 @@ public final class io/sentry/Attachment { public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment; public static fun fromScreenshot ([B)Lio/sentry/Attachment; public static fun fromThreadDump ([B)Lio/sentry/Attachment; + public static fun fromTombstone ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; public fun getByteProvider ()Ljava/util/concurrent/Callable; @@ -614,6 +615,7 @@ public final class io/sentry/Hint { public fun getReplayRecording ()Lio/sentry/ReplayRecording; public fun getScreenshot ()Lio/sentry/Attachment; public fun getThreadDump ()Lio/sentry/Attachment; + public fun getTombstone ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V @@ -621,6 +623,7 @@ public final class io/sentry/Hint { public fun setReplayRecording (Lio/sentry/ReplayRecording;)V public fun setScreenshot (Lio/sentry/Attachment;)V public fun setThreadDump (Lio/sentry/Attachment;)V + public fun setTombstone (Lio/sentry/Attachment;)V public fun setViewHierarchy (Lio/sentry/Attachment;)V public static fun withAttachment (Lio/sentry/Attachment;)Lio/sentry/Hint; public static fun withAttachments (Ljava/util/List;)Lio/sentry/Hint; diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 439ad812b0c..3e4cb859e5e 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -396,4 +396,14 @@ boolean isAddToTransactions() { public static @NotNull Attachment fromThreadDump(final byte[] bytes) { return new Attachment(bytes, "thread-dump.txt", "text/plain", false); } + + /** + * Creates a new Tombstone Attachment + * + * @param bytes the array bytes + * @return the Attachment + */ + public static @NotNull Attachment fromTombstone(final byte[] bytes) { + return new Attachment(bytes, "tombstone.pb", "application/x-protobuf", false); + } } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index d7949b3133b..1e09dca5541 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -32,6 +32,7 @@ public final class Hint { private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; private @Nullable Attachment threadDump = null; + private @Nullable Attachment tombstone = null; private @Nullable ReplayRecording replayRecording = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @@ -147,6 +148,14 @@ public void setThreadDump(final @Nullable Attachment threadDump) { return threadDump; } + public void setTombstone(final @Nullable Attachment tombstone) { + this.tombstone = tombstone; + } + + public @Nullable Attachment getTombstone() { + return tombstone; + } + @Nullable public ReplayRecording getReplayRecording() { return replayRecording; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index c99fcaeaa2f..6f328d0fd58 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -399,6 +399,11 @@ private boolean shouldSendSessionUpdateForDroppedEvent( attachments.add(threadDump); } + @Nullable final Attachment tombstone = hint.getTombstone(); + if (tombstone != null) { + attachments.add(tombstone); + } + return attachments; } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 11ff80fd573..663b1f9bdee 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2124,6 +2124,37 @@ class SentryClientTest { .send(check { envelope -> assertEquals(1, envelope.items.count()) }, anyOrNull()) } + @Test + fun `tombstone is added to the envelope from the hint`() { + val sut = fixture.getSut() + val attachment = Attachment.fromTombstone(byteArrayOf()) + val hint = Hint().also { it.tombstone = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport) + .send( + check { envelope -> + val tombstone = envelope.items.last() + assertNotNull(tombstone) { assertEquals(attachment.filename, tombstone.header.fileName) } + }, + anyOrNull(), + ) + } + + @Test + fun `tombstone is dropped from hint via before send`() { + fixture.sentryOptions.beforeSend = CustomBeforeSendCallback() + val sut = fixture.getSut() + val attachment = Attachment.fromTombstone(byteArrayOf()) + val hint = Hint().also { it.tombstone = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport) + .send(check { envelope -> assertEquals(1, envelope.items.count()) }, anyOrNull()) + } + @Test fun `capturing an error updates session and sends event + session`() { val sut = fixture.getSut() @@ -3647,6 +3678,7 @@ class SentryClientTest { hint.screenshot = null hint.viewHierarchy = null hint.threadDump = null + hint.tombstone = null return event } } diff --git a/sentry/src/test/java/io/sentry/hints/HintTest.kt b/sentry/src/test/java/io/sentry/hints/HintTest.kt index 7be03e7dd67..7b0c695bfd4 100644 --- a/sentry/src/test/java/io/sentry/hints/HintTest.kt +++ b/sentry/src/test/java/io/sentry/hints/HintTest.kt @@ -210,6 +210,7 @@ class HintTest { hint.screenshot = newAttachment("2") hint.viewHierarchy = newAttachment("3") hint.threadDump = newAttachment("4") + hint.tombstone = newAttachment("5") hint.clear() @@ -219,6 +220,7 @@ class HintTest { assertNotNull(hint.screenshot) assertNotNull(hint.viewHierarchy) assertNotNull(hint.threadDump) + assertNotNull(hint.tombstone) } @Test @@ -248,6 +250,15 @@ class HintTest { assertNotNull(hint.threadDump) } + @Test + fun `can create hint with a tombstone`() { + val hint = Hint() + val attachment = newAttachment("tombstone") + hint.tombstone = attachment + + assertNotNull(hint.tombstone) + } + companion object { fun newAttachment(content: String) = Attachment(content.toByteArray(), "$content.txt") } From 01a40a9d2a67409223317b9e93d94d3fa8e3f6dd Mon Sep 17 00:00:00 2001 From: 0xadam-brown <281682121+0xadam-brown@users.noreply.github.com> Date: Wed, 20 May 2026 12:16:13 +0000 Subject: [PATCH 047/276] release: 8.42.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fca85f96435..3e869e9aac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.42.0 ### Features diff --git a/gradle.properties b/gradle.properties index 81fdf72ff04..a8f42329732 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.41.0 +versionName=8.42.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 184b99116b540d45687084b79e0d8d1d9b0483f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:35:33 +0200 Subject: [PATCH 048/276] chore(deps): bump idna in the uv group across 1 directory (#5451) Bumps the uv group with 1 update in the / directory: [idna](https://github.com/kjd/idna). Updates `idna` from 3.10 to 3.15 - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.10...v3.15) --- updated-dependencies: - dependency-name: idna dependency-version: '3.15' dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8bdd5f892df..c573fa72259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ certifi==2025.7.14 charset-normalizer==3.4.2 -idna==3.10 +idna==3.15 requests==2.33.0 urllib3==2.7.0 From 9392427e0ff0657e38f47ec82342ef2e0fdc2d5a Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 21 May 2026 12:03:44 +0200 Subject: [PATCH 049/276] chore(ci): Skip Spring Boot tests when updating Android modules (#5453) * chore(ci): Skip Spring Boot matrix jobs on Android-only pull requests (JAVA-510) Prior to this commit, Android-only PRs were silently triggering 14 unrelated Spring Boot matrix jobs (up to 45 min each). This commit fixes that by adding missing PR filters for all Android directories, letting us skip Spring Boot CI entirely when nothing Spring-related changes. Note: Every push to main still runs the full matrix. --------- Co-authored-by: Cursor --- .github/workflows/spring-boot-2-matrix.yml | 6 ++++-- .github/workflows/spring-boot-3-matrix.yml | 6 ++++-- .github/workflows/spring-boot-4-matrix.yml | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 38aaacec27a..9a69765657c 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 629535e282d..c6a83c597fb 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index bbd4f986d96..93d314de2e3 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 93590c466b71c92baddd217c6af595be6a897647 Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 21 May 2026 12:04:57 +0200 Subject: [PATCH 050/276] chore(ci): Skip backend system tests when updating Android modules (#5455) chore(ci): Stop backend system tests from running on Android-only PRs (JAVA-519) Prior to this commit, Android-only PRs were silently triggering 24 unrelated backend system test jobs (up to 10 min each). This update lets us skip those jobs when we don't need them. Note: Every push to main still runs the full matrix. --------- Co-authored-by: Cursor --- .github/workflows/system-tests-backend.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 007fe575d14..ea6a53a8750 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -5,6 +5,10 @@ on: branches: - main pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From c3ee041489d813b39609501374678f04b3e4677f Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 21 May 2026 12:52:12 +0200 Subject: [PATCH 051/276] chore(ai): Add check-code-attribution skill (JAVA-499) (#5449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore(ai): Add check-code-attribution skill (JAVA-499) Adds a check-code-attribution skill that validates license headers + THIRD_PARTY_NOTICES.md entries for code copied or adapted from third parties. Also verifies license compatiblity against Sentry's licensing policy. Focus is limited to the branch diff. Reports any issues found via PR comments (when run on CI) or to the terminal (when run locally). To run it in Claude Code: ``` /check-code-attribution ``` Runs on CI automatically via [Warden](https://warden.sentry.dev/). - Purely advisory / does not block merge. - Generates PR comments with code suggestions for all discovered issues. - Automatically manages removing stale comments as PRs are updated. Current Warden configs: ┌─────────────────┬─────────────────────────────┬───────────────────────────────────────────────────┐ │ Setting │ Value │ Effect │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ model │ anthropic/claude-sonnet-4-6 │ Model used for analysis │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ maxTurns │ 30 │ Max tool calls per chunk │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ skill │ check-code-attribution │ Per-file vendored code attribution check │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ failOn │ off │ Do not fail workflow if attribution issues found │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ reportOn │ medium │ Show findings at >= medium severity via PR comment│ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ requestChanges │ false │ Never post REQUEST_CHANGES comments on PRs │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ failCheck │ false │ No red X on workflow in GitHub UI if it fails │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ triggers │ pull_request + local │ Runs on PR open/sync and local warden invocations │ ├─────────────────┼─────────────────────────────┼───────────────────────────────────────────────────┤ │ reportOnSuccess │ false (default) │ No comment when everything is clean │ └─────────────────┴─────────────────────────────┴───────────────────────────────────────────────────┘ Going forward, we can consider blocking PRs once we've had a chance to vet behavior in the wild. --- .claude/skills/.gitignore | 2 + .../skills/check-code-attribution/SKILL.md | 244 +++++++++++ .../validation-tests/EXPECTED.json | 53 +++ .../validation-tests/README.md | 86 ++++ .../THIRD_PARTY_NOTICES.catalog.md | 130 ++++++ .../validation-tests/assert-scenarios.mjs | 401 ++++++++++++++++++ .../check-code-attribution-tests.sh | 246 +++++++++++ .../HeaderCompleteAndNoticePresent.java | 19 + .../HeaderCompleteButNoticeMissing.java | 17 + .../scenarios/HeaderFullyStripped.java | 7 + .../HeaderMissingButNoticePresent.java | 8 + .../HeaderMissingNonEssentialInfo.java | 12 + .../scenarios/HeaderPartiallyStripped.java | 10 + .../scenarios/NewLicenseType.java | 10 + .../THIRD_PARTY_NOTICES.mismatch-snippet.md | 37 ++ .gitignore | 3 + AGENTS.md | 2 + agents.toml | 4 + warden.toml | 101 +++++ 19 files changed, 1392 insertions(+) create mode 100644 .claude/skills/check-code-attribution/SKILL.md create mode 100644 .claude/skills/check-code-attribution/validation-tests/EXPECTED.json create mode 100644 .claude/skills/check-code-attribution/validation-tests/README.md create mode 100644 .claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md create mode 100755 .claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs create mode 100755 .claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java create mode 100644 .claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md create mode 100644 warden.toml diff --git a/.claude/skills/.gitignore b/.claude/skills/.gitignore index 229f4495ee3..2dd55eba801 100644 --- a/.claude/skills/.gitignore +++ b/.claude/skills/.gitignore @@ -8,3 +8,5 @@ !test/** !btrace-perfetto/ !btrace-perfetto/** +!check-code-attribution/ +!check-code-attribution/** diff --git a/.claude/skills/check-code-attribution/SKILL.md b/.claude/skills/check-code-attribution/SKILL.md new file mode 100644 index 00000000000..ee66327c260 --- /dev/null +++ b/.claude/skills/check-code-attribution/SKILL.md @@ -0,0 +1,244 @@ +--- +name: check-code-attribution +description: Per-file check of vendored code attribution in the current branch diff, including license headers, THIRD_PARTY_NOTICES.md entries, and compatibility with Sentry's licensing policy +allowed-tools: Bash Read Grep Glob +--- + +# Check Code Attribution + +You are reviewing changed files for third-party code attribution compliance in **sentry-java**, an MIT-licensed repository. + +## Local runs + +When running locally (not via Warden), review every file changed on this branch vs the base branch. Apply the same path exclusions as `ignorePaths` in `warden.toml`, then run Quick triage and the checks below on each file. For git commands to list changed files and Warden CLI setup, see `validation-tests/README.md`. `/check-code-attribution` in the IDE does not require Warden credentials. + +When running via Warden, the changed file is already provided — skip branch-wide discovery, but follow **Warden execution** below. + +## Warden execution + +Warden analyzes one changed file per run (whole-file mode). Complete every Quick triage step — the diff alone is not sufficient. + +**Mandatory on every run (do not skip):** + +1. Read the first 50 lines of the changed file. +2. Search `THIRD_PARTY_NOTICES.md` for the class name (filename without extension, e.g. `ANRWatchDog` for `ANRWatchDog.java`). On renames, also search for the old basename and read Scope sections (see Quick triage). +3. When you can compare against the base branch version, inspect the header at that revision (first 50 lines). + +**Do not dismiss findings because:** + +- A `THIRD_PARTY_NOTICES.md` entry exists — file headers are still required; NOTICES does not replace them. +- The diff only removes a header comment block — if removed `-` lines include a **required field** (see below) or vendoring language ("adapted from", etc.), attribution was stripped. Removing boilerplate alone is not stripping. +- The header says "Adapted from …" but omits copyright holder or license name — flag missing header fields. +- The file header has all four required fields — a missing THIRD_PARTY_NOTICES.md entry is independently required and is ⚠️ medium regardless of header completeness. + +For `THIRD_PARTY_NOTICES.md` runs: for every **removed** entry in the diff, confirm whether Scope files still exist with attribution headers. If they do, the entry must not be removed. + +## Quick triage + +Sentry's own files carry **no** copyright headers — any copyright/license line indicates third-party code. Every file that reaches this skill is in scope — do not skip files based on extension. + +If this file is `THIRD_PARTY_NOTICES.md`, go to the THIRD_PARTY_NOTICES section below. + +For all other files, perform these checks **before** deciding whether to proceed: + +1. **Read the file header** — inspect the first 50 lines. Look for vendored-code signals: `Copyright`, `Licensed under`, `SPDX-License-Identifier`, or vendoring language ("adapted from", "backported from", "based on", "copied from", "derived from", "inspired by", "ported from", "translated from", "vendored"). +2. **Check THIRD_PARTY_NOTICES.md** — search for the file name without extension (e.g. `ANRWatchDog` when reviewing `ANRWatchDog.java`). A match means this is a known vendored file. **Renames:** if the diff is a rename (`similarity index` / `rename from` in the diff, or a delete of one path and add of another with the same content), also search for the **old** basename and read **Scope** sections in matching entries — NOTICES may still reference the previous class or path name. + > **A complete NOTICES entry does NOT end the check.** It confirms the file is vendored and that the NOTICES requirement is satisfied. The file header is a separate, additional requirement — continue to header verification regardless of NOTICES completeness. +3. **Scan the diff** — check for vendored-code signals on both added (`+`) and **removed (`-`)** lines. Removed lines that drop a **required field** (copyright, license name, source URL, vendoring origin) ARE signals. Removed disclaimer/boilerplate lines alone are not. + +**A signal in ANY of these three sources means this is vendored code — proceed to the vendored source file section.** + +A file referenced in THIRD_PARTY_NOTICES.md is ALWAYS vendored, even if its current header has no attribution. + +**If none of the three sources have signals, report no findings and stop.** + +--- + +## If this file is `THIRD_PARTY_NOTICES.md` + +Validate the changed entries using the diff context: + +1. For each added or modified entry, verify it has all required fields: **Source URL**, **License name**, **Copyright**, **Scope** (file paths), and **full license text** in a fenced code block. +2. For each Scope path, verify the file(s) exist. +3. Flag new license types using the same license-tier table as for source files: weak copyleft (LGPL, MPL, EPL) → 🚨 **high**, strong copyleft (GPL) → 🚨 **high**, AGPL → 🚨 **high** (absolute ban, must be removed). Do not use low or medium for copyleft or AGPL. +4. Flag orphaned entries whose Scope files no longer exist. +5. For **removed** entries (lines prefixed with `-` in the diff), check whether the Scope files still exist and still have attribution headers. If they do, the entry must not be removed. +6. Check **copyright consistency** — the Copyright field must match the copyright line inside the embedded license text. Flag mismatches. + +--- + +## If this is a vendored file + +### 1. Check attribution header + +Check each of the following by reading the file header — not NOTICES. Each is an independent yes/no; a "no" is ⚠️ medium regardless of NOTICES completeness: + +- [ ] **Vendoring origin phrase** — explicit wording such as `Adapted from …`, `Based on …`, `Vendored from …`, or a library name. +- [ ] **Copyright line** — e.g. `Copyright (c) 2016 …`, `Copyright 2010 Square, Inc.` +- [ ] **License name** — e.g. `Licensed under the Apache License, Version 2.0`, `The MIT License` +- [ ] **Source URL** — e.g. `https://github.com/…` + +Exact wording and comment style may vary. **Do not flag** missing or changed content that is not one of these four fields. + +**Each field must be physically present in the file header. A complete `THIRD_PARTY_NOTICES.md` entry does not satisfy any required field — both are independently required. Check each of the four fields by reading the file header, not by reasoning from NOTICES.** + +**Not required in the file header** (full text belongs in `THIRD_PARTY_NOTICES.md`, not in every source file): + +- Full license boilerplate (MIT permission paragraph, Apache "Unless required by applicable law…" disclaimer, ASF contributor grant preamble) +- Wording differences vs the NOTICES embedded license text (e.g. shortened Apache header vs canonical ASF phrasing) +- Comment style (`//` vs `/* */`), line wrapping, or extra Sentry modification notes + +Compare the current header against the NOTICES entry **only for the four required fields** — e.g. if NOTICES says MIT by "Salomon BRYS" but the header has no copyright or license name, flag it. If both have copyright + license name but the header omits the Apache disclaimer while NOTICES still has the full text, **do not flag**. + +When comparing against the base branch version (local runs), use the header at that revision for additional context. + +Flag these issues: +- **Header stripped** — file is in NOTICES but current header has none of the four required fields +- **Header truncated** — one or more **required** fields were removed (e.g. copyright line or `Licensed under …` removed) while the file remains vendored +- **Header inconsistent** — a **required** field contradicts NOTICES (wrong copyright holder/year, wrong license name) — not boilerplate or phrasing differences +- **Diff removes required attribution** — removed `-` lines drop a required field or vendoring origin (`Adapted from`, etc.); removing disclaimer/boilerplate lines alone is **not** this + +**Do not report** (no finding — prefer silence): + +- Apache/MIT disclaimer or permission paragraphs removed but all four required fields remain +- Header reworded to a shorter permissive-license form with the same copyright holder and license name +- Header and NOTICES differ only in full license body text (wording or boilerplate, not missing required fields) + +These exceptions apply only when an entry already exists in NOTICES and only to header-vs-NOTICES wording differences. A **missing** NOTICES entry is ⚠️ medium per section 2 — never covered by these exceptions. + +### 2. Check THIRD_PARTY_NOTICES.md entry + +**Severity: always `medium`. Do not output `severity: "low"` for a missing entry even if the attribution header is complete.** + +`THIRD_PARTY_NOTICES.md` is a mandatory legal exhibit that Sentry ships with every SDK distribution. It must enumerate all vendored code regardless of what the source file header says. A missing entry is a distribution-level compliance failure, not a nit. A complete file header does not satisfy the NOTICES requirement — both are mandatory. + +From the NOTICES search in Quick triage: if no matching entry exists, output `severity: "medium"` and flag as ⚠️ Missing THIRD_PARTY_NOTICES.md entry. A valid entry needs: Source URL, License name, Copyright, Scope, full license text. + +### 3. Check license compatibility + +Classify the license per Sentry's Open Source Legal Policy (https://open.sentry.io/licensing/): + +| Tier | Examples | Finding | +|-----------------|-------------------------------------------------|---------------------------------------------| +| Permissive | MIT, BSD, Apache 2.0, ISC, CC0, Unlicense, Zlib | None — license is compatible | +| Weak copyleft | LGPL, MPL, EPL, CDDL | 🚨 **high** — requires review | +| Strong copyleft | GPL, QPL, Sleepycat, OSL | 🚨 **high** — requires legal review | +| AGPL | — | 🚨 **high** — absolute ban, must be removed | +| No license | — | 🚨 **high** — assume no permission | + +**Permissive licenses:** do not report a finding solely because the license is MIT/BSD/Apache/etc. Only flag missing or stripped **required** header fields, or missing/inconsistent `THIRD_PARTY_NOTICES.md` entry. Do not flag disclaimer/boilerplate-only diffs. Copyleft and unlicensed code still get 🚨 findings per the table. + +--- + +## If this is a deleted vendored file + +If the diff deletes a file and the removed lines contained attribution headers, check whether `THIRD_PARTY_NOTICES.md` still references it — the entry should be updated or removed. + +--- + +## Severity guide + +| Level | Use for | +|------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **high** | 🚨 License violations: AGPL, copyleft, unlicensed, no-license code | +| **medium** | ⚠️ Missing **required** header fields, stripped required fields, missing/inconsistent NOTICES entries (even when header is complete), deleted/renamed vendored files needing NOTICES update | +| **low** | 👀 Cosmetic/style differences only (shortened license wording, comment style). **Never** use for a missing NOTICES entry or missing header field — those are always medium. | + +Warden relies on these severity levels when deciding whether to comment on PRs or require changes. Put the severity emoji **only on the finding title** (see Output) so reviewers can triage at a glance. + +## Output + +**No issues → empty response (say nothing).** + +Otherwise, report each finding ordered by severity (most severe first). + +### Emoji placement (required) + +Use the emoji from the severity guide (🚨, ⚠️, or 👀) — not the word `high`, `medium`, or `low`. + +| Field | Emoji? | Example | +|-------------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| **Title** | Yes — once, at the start | `⚠️ Copyright line stripped from vendored file header` | +| **Description** | **No** | `**io.sentry.cache.tape.FileObjectQueue** — The Copyright (C) 2010 Square, Inc. line was removed…` (see **Description subject** below) | +| **Verification** | **No** | Evidence steps only | +| **Suggested fix** | **No** | Fix text only | + +**Good (Warden PR comment):** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: **io.sentry.cache.tape.FileObjectQueue** — The `Copyright (C) 2010 Square, Inc.` line was removed from this vendored file's header. Please restore the copyright line. +``` + +**Bad — emoji in the description (never do this):** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: ⚠️ The `Copyright (C) 2010 Square, Inc.` line was removed… +``` + +**Bad — emoji before the class name:** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: ⚠️ **io.sentry.cache.tape.FileObjectQueue** — The copyright line was removed… +``` + +### Description subject (required) + +Every description **must** start with `**** —` (bold subject, space, em dash, space). Pick **one** subject by file type: + +| File type | Subject format | Example | +|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------| +| Java / Kotlin source (`.java`, `.kt`) with a top-level type | Fully qualified class name (FQCN) | `**io.sentry.CircularFifoQueue** —` | +| Java / Kotlin with no single clear type (multiple top-level types, unclear which changed) | FQCN of the primary type under review, or repo-relative path if none | `**sentry/src/.../Foo.kt** —` | +| `THIRD_PARTY_NOTICES.md` | `THIRD_PARTY_NOTICES.md — ` | `**THIRD_PARTY_NOTICES.md — Square — Seismic (Apache 2.0)** —` | +| Gradle / other scripts (e.g. `.kts`, `.gradle`) | Repo-relative path from repository root | `**build.gradle.kts** —` | + +- Prefer **FQCN** for `.java` / `.kt` vendored source (derive from `package` + primary public top-level class). Do not use file paths when a FQCN is clear. +- For license-tier / policy issues, include https://open.sentry.io/licensing/ in the description body. + +### Warden runs + +For each finding, set these fields exactly: + +| Field | Value | +|------------------|-------------------------------------------------------------------------------------------------------------------| +| **severity** | `high`, `medium`, or `low` — **never** put emoji here; Warden maps severity from this field, not from the title | +| **title** | ` ` — emoji allowed **only** here (imperative, no class name) | +| **description** | `**** — ` — **plain text only**; subject per **Description subject** above | +| **verification** | Optional evidence steps — plain text only | + +**Description rules (Warden):** + +- **Must** match `**** — …` using the table in **Description subject**. +- **Must not** contain 🚨, ⚠️, 👀, or the words `high`, `medium`, or `low` as severity labels. +- **Must not** repeat the title or paraphrase it with an emoji prefix. + +**Good (NOTICES entry removed while scope files remain):** + +``` +Title: ⚠️ NOTICES entry removed for vendored code still in tree +Description: **THIRD_PARTY_NOTICES.md — Square — Seismic (Apache 2.0)** — The Seismic entry was removed but `io.sentry.android.core.SentryShakeDetector` still has an attribution header. Restore the entry or remove attribution from the scope files. +``` + +**Before submitting findings:** For every finding, confirm `description` does not match `[🚨⚠️👀]` and matches `^\*\*.+\*\* — `. If it contains any emoji, rewrite the description without it. + +### Local / IDE runs + +Use this numbered format — same title vs description split as above: + +``` +1\. **** + **** — + +2\. **** + **** — +``` + +Rules: + +- Put the severity emoji **only** on the title line (`1\. ⚠️ **…**`), never on the description line. +- The description line uses `**** —` per **Description subject** and must not contain 🚨, ⚠️, or 👀. +- **Escape the period** after the number (`1\.` not `1.`) so markdown does not collapse entries into a tight list. +- Leave an empty line between each numbered finding. diff --git a/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json b/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json new file mode 100644 index 00000000000..a82637b84e2 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json @@ -0,0 +1,53 @@ +[ + { + "id": "header-complete-and-notice-present", + "file": "HeaderCompleteAndNoticePresent.java", + "expectFinding": false, + "notes": "Header matches catalog entry" + }, + { + "id": "header-complete-but-notice-missing", + "file": "HeaderCompleteButNoticeMissing.java", + "expectFinding": true, + "isolated": true, + "notes": "Full header; no catalog / root NOTICES entry. Isolated: prompt-cache priming in a concurrent batch suppresses the missing-NOTICES finding below medium." + }, + { + "id": "header-missing-but-notice-present", + "file": "HeaderMissingButNoticePresent.java", + "expectFinding": true, + "isolated": true, + "notes": "NOTICES entry claims file is vendored but file has no attribution header. Isolated: a complete NOTICES entry suppresses the missing-header finding in a concurrent batch." + }, + { + "id": "header-fully-stripped", + "file": "HeaderFullyStripped.java", + "expectFinding": true, + "notes": "Header has no required attribution fields" + }, + { + "id": "header-partially-stripped", + "file": "HeaderPartiallyStripped.java", + "expectFinding": true, + "notes": "Adapted from + URL only; no copyright or license name" + }, + { + "id": "header-missing-non-essential-info", + "file": "HeaderMissingNonEssentialInfo.java", + "expectFinding": false, + "notes": "All four required fields present; no license boilerplate — boilerplate is not required in the header" + }, + { + "id": "header-vs-notice-mismatch", + "file": "THIRD_PARTY_NOTICES.md", + "expectFinding": true, + "isolated": true, + "notes": "Copyright in metadata field does not match embedded license text. Isolated: mismatch finding needs an independent assertion free of interference from other NOTICES changes." + }, + { + "id": "new-license-type", + "file": "NewLicenseType.java", + "expectFinding": true, + "notes": "AGPL v3 license in file header — absolute ban, must be removed" + } +] diff --git a/.claude/skills/check-code-attribution/validation-tests/README.md b/.claude/skills/check-code-attribution/validation-tests/README.md new file mode 100644 index 00000000000..99fb42a6836 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/README.md @@ -0,0 +1,86 @@ +# Attribution skill validation tests + +Self-contained samples for validating `check-code-attribution` without touching production SDK sources. + + +## Run the tests + +```bash +./check-code-attribution-tests.sh +``` + +Requires Node.js and a Warden provider (see **Warden CLI** below). + +In practice, straight command line runs tend to be a bit flakier than asking Claude Code to run the tests for you. + +## Local development + +### Discovering changed files + +When running `/check-code-attribution` outside Warden, list files changed on the current branch vs the base branch, then apply the same exclusions as `ignorePaths` in `warden.toml`: + +```bash +MB=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main) +git diff --name-only "${MB}"..HEAD +``` + +### Warden CLI + +Warden does **not** use Cursor auth. Before running Warden locally, configure a provider (same model family as `warden.toml`, or override with `-m`): + +```bash +# Option A: Anthropic API key (matches CI model in warden.toml) +export WARDEN_ANTHROPIC_API_KEY=sk-ant-... # or: export ANTHROPIC_API_KEY=sk-ant-... + +# Option B: Pi OAuth / API key store (~/.pi/agent/auth.json) +npx pi # then run /login and pick Anthropic (or another provider) + +# Option C: Different provider for a one-off run +export WARDEN_OPENAI_API_KEY=sk-... +npx @sentry/warden origin/main..HEAD --skill check-code-attribution -m openai/gpt-5.5 -vv +``` + +```bash +npx @sentry/warden origin/main..HEAD --skill check-code-attribution -vv +``` + +## Layout + +- `EXPECTED.json` — scenario IDs and expected outcomes (single source of truth). +- `THIRD_PARTY_NOTICES.catalog.md` — NOTICES-style entries for validation class names. +- `scenarios/` — `.java` files and `THIRD_PARTY_NOTICES.mismatch-snippet.md` (copyright-mismatch fixture). +- `check-code-attribution-tests.sh` — runs Warden on a temp branch and asserts per-scenario pass/fail. +- `assert-scenarios.mjs` — validation driver (`list-isolated`, `routing-set`, `assert` subcommands); parses Warden JSONL and checks outcomes from `EXPECTED.json`. + +### assert-scenarios.mjs commands + +```bash +node assert-scenarios.mjs validate EXPECTED.json scenarios/ # pre-flight (no API); run automatically by the shell script +node assert-scenarios.mjs list-isolated EXPECTED.json # idfile per isolated scenario +node assert-scenarios.mjs list-main-java EXPECTED.json scenarios/ # .java files for the main Warden batch +node assert-scenarios.mjs routing-set routing.json # update id → Warden JSONL path +node assert-scenarios.mjs assert EXPECTED.json routing.json +``` + +Warden runs are limited to 300s. On macOS the script uses `gtimeout` (from `brew install coreutils`) when available, otherwise GNU `timeout`, otherwise `perl` with `alarm`. + +## Add a scenario + +1. Add `scenarios/.java`. +2. Add or omit a catalog entry in `THIRD_PARTY_NOTICES.catalog.md`. +3. Add an entry to `EXPECTED.json`. +4. **Isolation (if needed):** If the scenario relies on a finding that could be suppressed by Anthropic prompt-cache priming when analyzed alongside many other files (e.g. a missing-NOTICES entry, or a missing header on a file that has a complete NOTICES entry), add `"isolated": true` to its `EXPECTED.json` entry. The test script creates a dedicated worktree for each isolated scenario automatically — no changes to the script itself are needed. + +## Validation (maintainers) + +Test samples live under `validation-tests/` and are excluded from normal skill runs via `.claude/**` in `warden.toml`. + +```bash +.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh +``` + +Expected outcomes are in `EXPECTED.json`. The script creates isolated git worktrees, runs Warden with `--report-on medium --json`, and asserts per-scenario pass/fail. Scenarios marked `"isolated": true` in `EXPECTED.json` each get their own worktree to avoid Anthropic prompt-cache priming that can suppress findings below medium in concurrent batches. Exit 0 = all pass. + +When manually reviewing a file under `scenarios/`, search `THIRD_PARTY_NOTICES.catalog.md` in addition to root `THIRD_PARTY_NOTICES.md` (Quick triage step 2 in `SKILL.md`). + +Non-Java fixtures required by the test script are listed in `REQUIRED_SCENARIO_FIXTURES` in `assert-scenarios.mjs`; pre-flight `validate` fails if any are missing. diff --git a/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md b/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md new file mode 100644 index 00000000000..478d0b06313 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md @@ -0,0 +1,130 @@ +# Test THIRD_PARTY_NOTICES catalog (not shipped) + +Used only when validating `check-code-attribution` against `validation-tests/scenarios/**`. +Grep this file in addition to the repository root `THIRD_PARTY_NOTICES.md`. + +--- + +## Example — HeaderFullyStripped (MIT) + +**Source:** https://github.com/example/attribution-fixtures
+**License:** MIT License
+**Copyright:** Copyright (c) 2016 Example Author + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderFullyStripped` (`validation-tests/scenarios/HeaderFullyStripped.java`). + +``` +MIT License + +Copyright (c) 2016 Example Author + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +--- + +## Example — HeaderMissingButNoticePresent (Apache 2.0) + +**Source:** https://github.com/example/notices-without-header
+**License:** Apache License 2.0
+**Copyright:** Copyright 2023 Example Corp. + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderMissingButNoticePresent`. + +``` +Copyright 2023 Example Corp. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Example — HeaderMissingNonEssentialInfo (MIT) + +**Source:** https://github.com/example/examplelib
+**License:** MIT License
+**Copyright:** Copyright 2020 Example Corp. + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderMissingNonEssentialInfo`. + +``` +MIT License + +Copyright (c) 2020 Example Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +--- + +## Example — HeaderCompleteAndNoticePresent (Apache 2.0) + +**Source:** https://github.com/example/something
+**License:** Apache License 2.0
+**Copyright:** Copyright 2020 Example Authors + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderCompleteAndNoticePresent`. + +``` +Copyright 2020 Example Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs b/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs new file mode 100755 index 00000000000..3ff4cce9980 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node +/** + * Validation driver for check-code-attribution scenario tests. + * + * Usage: + * node assert-scenarios.mjs validate + * node assert-scenarios.mjs list-isolated + * node assert-scenarios.mjs list-main-java + * node assert-scenarios.mjs routing-set + * node assert-scenarios.mjs assert + * + * routing.json maps scenario id to Warden JSONL output path, e.g. { "main": "/tmp/..." }. + * Non-isolated scenarios use the "main" entry when no dedicated id is present. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const ISOLATED_FILE_JAVA = /\.java$/i; +const ISOLATED_FILE_NOTICES = 'THIRD_PARTY_NOTICES.md'; + +/** Non-Java fixtures under scenarios/ that check-code-attribution-tests.sh requires. */ +const REQUIRED_SCENARIO_FIXTURES = [ + 'THIRD_PARTY_NOTICES.mismatch-snippet.md', +]; + +export function loadExpected(expectedPath) { + return JSON.parse(fs.readFileSync(expectedPath, 'utf8')); +} + +export function listIsolated(scenarios) { + return scenarios.filter((s) => s.isolated); +} + +/** Repo-relative path normalization for Warden JSONL matching. */ +export function normalizeRepoPath(filePath) { + if (!filePath) return filePath; + return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/'); +} + +/** True when a Warden-reported path refers to the expected scenario file. */ +export function pathMatchesWardenFile(reportedPath, wardenFile) { + const reported = normalizeRepoPath(reportedPath); + const expected = normalizeRepoPath(wardenFile); + if (reported === expected) return true; + const base = expected.split('/').pop(); + return base != null && reported.endsWith(`/${base}`); +} + +export function findingCountForFile(fileMap, wardenFile) { + const expected = normalizeRepoPath(wardenFile); + if (fileMap[expected] != null) return fileMap[expected]; + for (const [key, count] of Object.entries(fileMap)) { + if (pathMatchesWardenFile(key, wardenFile)) return count; + } + return 0; +} + +export function findingsForFile(findings, wardenFile) { + return findings.filter( + (f) => f.location && pathMatchesWardenFile(f.location.path, wardenFile), + ); +} + +export function listMainBatchJava(scenarios, scenariosDir) { + const isolatedJava = new Set( + listIsolated(scenarios) + .map((s) => s.file) + .filter((file) => ISOLATED_FILE_JAVA.test(file)), + ); + return fs + .readdirSync(scenariosDir) + .filter((name) => name.endsWith('.java') && !isolatedJava.has(name)) + .sort(); +} + +/** + * @returns {string[]} validation error messages (empty = ok) + */ +export function validateExpected(scenarios, scenariosDir) { + const errors = []; + + if (!Array.isArray(scenarios)) { + return ['EXPECTED.json must be a JSON array']; + } + + const ids = new Set(); + const expectedJava = new Set(); + + for (const [index, s] of scenarios.entries()) { + const label = `entry ${index}`; + if (!s || typeof s !== 'object') { + errors.push(`${label}: must be an object`); + continue; + } + if (typeof s.id !== 'string' || !s.id) { + errors.push(`${label}: missing or empty "id"`); + } else { + if (ids.has(s.id)) errors.push(`duplicate id "${s.id}"`); + ids.add(s.id); + if (s.id === 'main') { + errors.push(`id "main" is reserved for routing.json`); + } + } + if (typeof s.file !== 'string' || !s.file) { + errors.push(`${label}: missing or empty "file"`); + } else if (ISOLATED_FILE_JAVA.test(s.file)) { + expectedJava.add(s.file); + const onDisk = path.join(scenariosDir, s.file); + if (!fs.existsSync(onDisk)) { + errors.push(`${s.id}: scenarios/${s.file} does not exist`); + } + } else if (s.file !== ISOLATED_FILE_NOTICES) { + errors.push( + `${s.id}: unsupported file "${s.file}" (use *.java or ${ISOLATED_FILE_NOTICES})`, + ); + } + if (typeof s.expectFinding !== 'boolean') { + errors.push(`${s.id ?? label}: "expectFinding" must be a boolean`); + } + if (s.isolated) { + if ( + !ISOLATED_FILE_JAVA.test(s.file) && + s.file !== ISOLATED_FILE_NOTICES + ) { + errors.push( + `${s.id}: isolated scenarios must use *.java or ${ISOLATED_FILE_NOTICES}`, + ); + } + } + } + + let diskEntries = []; + try { + diskEntries = fs.readdirSync(scenariosDir); + } catch (e) { + errors.push(`cannot read scenarios dir ${scenariosDir}: ${e.message}`); + return errors; + } + + const diskJava = diskEntries.filter((n) => n.endsWith('.java')); + for (const name of diskJava) { + if (!expectedJava.has(name)) { + errors.push(`scenarios/${name} has no matching entry in EXPECTED.json`); + } + } + + for (const name of REQUIRED_SCENARIO_FIXTURES) { + const onDisk = path.join(scenariosDir, name); + if (!fs.existsSync(onDisk)) { + errors.push(`scenarios/${name} is required but missing`); + } + } + + const diskNonJava = diskEntries.filter( + (n) => !n.endsWith('.java') && fs.statSync(path.join(scenariosDir, n)).isFile(), + ); + for (const name of diskNonJava) { + if (!REQUIRED_SCENARIO_FIXTURES.includes(name)) { + errors.push( + `scenarios/${name} is not listed in REQUIRED_SCENARIO_FIXTURES (update assert-scenarios.mjs)`, + ); + } + } + + if (listMainBatchJava(scenarios, scenariosDir).length === 0) { + errors.push('main Warden batch needs at least one non-isolated .java scenario'); + } + + return errors; +} + +export function parseWardenJsonl(jsonlPath) { + /** @type {Record} */ + const fileMap = {}; + const allFindings = []; + try { + const raw = fs.readFileSync(jsonlPath, 'utf8').trim(); + if (!raw) return { fileMap, findings: [] }; + const records = raw + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); + for (const record of records) { + const file = record.chunk && record.chunk.file; + if (!file) continue; + const normalized = normalizeRepoPath(file); + const recordFindings = record.findings || []; + fileMap[normalized] = (fileMap[normalized] || 0) + recordFindings.length; + for (const f of recordFindings) { + allFindings.push({ + ...f, + location: f.location || { path: normalized, startLine: 1 }, + }); + } + } + } catch (e) { + console.error( + 'ERROR: Could not parse Warden output from ' + jsonlPath + ':', + e.message, + ); + process.exit(2); + } + return { fileMap, findings: allFindings }; +} + +export function routingSet(routingPath, id, jsonlPath) { + const routing = JSON.parse(fs.readFileSync(routingPath, 'utf8')); + routing[id] = jsonlPath; + fs.writeFileSync(routingPath, JSON.stringify(routing)); +} + +function wardenFileForScenario(destPkg, scenario) { + return scenario.file === ISOLATED_FILE_NOTICES + ? ISOLATED_FILE_NOTICES + : `${destPkg}/${scenario.file}`; +} + +function loadRouting(routingPath) { + /** @type {Record} */ + let routing; + try { + routing = JSON.parse(fs.readFileSync(routingPath, 'utf8')); + } catch (e) { + console.error(`ERROR: Could not read routing file ${routingPath}:`, e.message); + process.exit(2); + } + + if (typeof routing.main !== 'string' || !routing.main) { + console.error('ERROR: routing.json must include a non-empty "main" JSONL path.'); + process.exit(2); + } + return routing; +} + +function cmdValidate(expectedPath, scenariosDir) { + if (!expectedPath || !scenariosDir) { + console.error( + 'Usage: node assert-scenarios.mjs validate ', + ); + process.exit(2); + } + const errors = validateExpected(loadExpected(expectedPath), scenariosDir); + if (errors.length > 0) { + console.error('EXPECTED.json validation failed:'); + for (const err of errors) console.error(` - ${err}`); + process.exit(1); + } + console.log('EXPECTED.json OK'); +} + +function cmdListIsolated(expectedPath) { + for (const s of listIsolated(loadExpected(expectedPath))) { + process.stdout.write(`${s.id}\t${s.file}\n`); + } +} + +function cmdListMainJava(expectedPath, scenariosDir) { + if (!expectedPath || !scenariosDir) { + console.error( + 'Usage: node assert-scenarios.mjs list-main-java ', + ); + process.exit(2); + } + for (const name of listMainBatchJava(loadExpected(expectedPath), scenariosDir)) { + process.stdout.write(`${name}\n`); + } +} + +function cmdRoutingSet(routingPath, id, jsonlPath) { + if (!routingPath || !id || !jsonlPath) { + console.error( + 'Usage: node assert-scenarios.mjs routing-set ', + ); + process.exit(2); + } + routingSet(routingPath, id, jsonlPath); +} + +function cmdAssert(expectedPath, destPkg, routingPath) { + if (!expectedPath || !destPkg || !routingPath) { + console.error( + 'Usage: node assert-scenarios.mjs assert ', + ); + process.exit(2); + } + + const routing = loadRouting(routingPath); + const scenarios = loadExpected(expectedPath); + + /** @type {Record>} */ + const parsed = {}; + function getSource(id) { + const jsonlPath = routing[id] ?? routing.main; + if (!parsed[jsonlPath]) parsed[jsonlPath] = parseWardenJsonl(jsonlPath); + return parsed[jsonlPath]; + } + + const GREEN = '\x1b[32m'; + const RED = '\x1b[31m'; + const RESET = '\x1b[0m'; + + const failures = []; + let pass = 0; + + for (const s of scenarios) { + if (s.isolated && !routing[s.id]) { + console.error( + `ERROR: isolated scenario "${s.id}" has no routing entry (missing Warden run?)`, + ); + process.exit(2); + } + + const wardenFile = wardenFileForScenario(destPkg, s); + const source = getSource(s.id); + const count = findingCountForFile(source.fileMap, wardenFile); + const passed = s.expectFinding ? count > 0 : count === 0; + + if (passed) { + console.log(`${GREEN}PASS${RESET} ${s.id}`); + pass++; + } else { + const reason = s.expectFinding + ? 'expected finding (>= medium), got none' + : `expected no finding (>= medium), got ${count}`; + console.log(`${RED}FAIL${RESET} ${s.id} (${reason})`); + + failures.push({ + id: s.id, + findings: findingsForFile(source.findings, wardenFile), + }); + } + } + + const total = scenarios.length; + console.log(''); + console.log(`${total} scenarios: ${pass} passed, ${total - pass} failed`); + + if (failures.length > 0) { + console.log(''); + console.log('Warden output'); + console.log('══════════════════════'); + + for (const { id, findings } of failures) { + console.log(''); + console.log(id); + console.log('-'.repeat(id.length)); + if (findings.length === 0) { + console.log('(Warden produced no findings for this file)'); + } else { + for (const f of findings) { + console.log(f.title); + if (f.description) console.log(f.description); + if (f.verification) console.log('\nVerification: ' + f.verification); + console.log(''); + } + } + } + + process.exit(1); + } +} + +function usage() { + console.error(`Usage: + node assert-scenarios.mjs validate + node assert-scenarios.mjs list-isolated + node assert-scenarios.mjs list-main-java + node assert-scenarios.mjs routing-set + node assert-scenarios.mjs assert `); + process.exit(2); +} + +function main() { + const [, , cmd, ...args] = process.argv; + switch (cmd) { + case 'validate': + cmdValidate(args[0], args[1]); + break; + case 'list-isolated': + if (!args[0]) usage(); + cmdListIsolated(args[0]); + break; + case 'list-main-java': + cmdListMainJava(args[0], args[1]); + break; + case 'routing-set': + cmdRoutingSet(args[0], args[1], args[2]); + break; + case 'assert': + cmdAssert(args[0], args[1], args[2]); + break; + default: + usage(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh b/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh new file mode 100755 index 00000000000..090acbe129b --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# check-code-attribution-tests.sh — Validate the check-code-attribution skill against synthetic scenarios. +# +# Usage: +# ./check-code-attribution-tests.sh [--help] +# +# What it does: +# 1. Validates EXPECTED.json and scenario fixtures (no API calls). +# 2. Creates an isolated git worktree on a temp branch from HEAD. +# 3. Creates a diff (non-isolated .java files, NOTICES catalog, mismatch snippet), +# commits, and runs Warden on the main batch. +# 4. Scenarios marked "isolated" in EXPECTED.json each get their own worktree and Warden +# run to avoid prompt-cache priming that can suppress findings in concurrent batches. +# 5. Asserts per-scenario pass/fail against EXPECTED.json (>= medium findings only). +# 6. Prints Warden's actual output for each failing scenario. +# 7. Cleans up all worktrees. +# +# Requires: +# - Node.js / npx +# - One of: WARDEN_ANTHROPIC_API_KEY, ANTHROPIC_API_KEY, or Pi OAuth config +# (see validation-tests/README.md "Warden CLI" section for setup options) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +SCENARIOS_DIR="$SCRIPT_DIR/scenarios" +CATALOG="$SCRIPT_DIR/THIRD_PARTY_NOTICES.catalog.md" +EXPECTED_JSON="$SCRIPT_DIR/EXPECTED.json" +VALIDATION="$SCRIPT_DIR/assert-scenarios.mjs" +MISMATCH_SNIPPET="$SCENARIOS_DIR/THIRD_PARTY_NOTICES.mismatch-snippet.md" + +# Destination path inside the worktree — must not appear in warden.toml ignorePaths. +DEST_PACKAGE_PATH="sentry/src/test/java/io/sentry/skills/verification" + +# Warden wall-clock limit (seconds). +TIMEOUT_SEC=300 + +die() { echo "ERROR: $*" >&2; exit 1; } + +show_usage() { + cat <<'EOF' +Usage: check-code-attribution-tests.sh [--help] + +Validates the check-code-attribution skill against all scenarios in EXPECTED.json. +Runs Warden on a temporary branch and asserts per-scenario pass/fail (>= medium findings). + +Prerequisites: + - Node.js (npx) + - API key: WARDEN_ANTHROPIC_API_KEY or ANTHROPIC_API_KEY + (or Pi OAuth: npx pi && /login — see README.md "Warden CLI" section) + - Wall-clock limit: gtimeout (brew install coreutils), GNU timeout, or perl +EOF +} + +[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { show_usage; exit 0; } + +# --- prereq checks --- + +command -v node >/dev/null 2>&1 || die "node not found — install Node.js." +command -v npx >/dev/null 2>&1 || die "npx not found — install Node.js." +command -v git >/dev/null 2>&1 || die "git not found." + +# macOS: GNU timeout is `gtimeout` from coreutils; fall back to perl alarm. +TIMEOUT_CMD=() +if command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_CMD=(gtimeout "$TIMEOUT_SEC") +elif command -v timeout >/dev/null 2>&1; then + TIMEOUT_CMD=(timeout "$TIMEOUT_SEC") +elif command -v perl >/dev/null 2>&1; then + TIMEOUT_CMD=(perl -e 'alarm shift; exec @ARGV' "$TIMEOUT_SEC") +else + die "Need gtimeout (brew install coreutils), GNU timeout, or perl for Warden wall-clock limit" +fi + +if [[ -z "${WARDEN_ANTHROPIC_API_KEY:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then + if [[ ! -f "$HOME/.pi/agent/auth.json" ]]; then + die "No API key found. Set WARDEN_ANTHROPIC_API_KEY, ANTHROPIC_API_KEY, or run: npx pi && /login" + fi +fi + +node "$VALIDATION" validate "$EXPECTED_JSON" "$SCENARIOS_DIR" + +# --- cleanup tracking --- + +declare -a WORKTREES=() +declare -a BRANCHES=() +declare -a JSON_FILES=() + +cleanup() { + for wt in "${WORKTREES[@]+"${WORKTREES[@]}"}"; do + git -C "$REPO_ROOT" worktree remove --force "$wt" 2>/dev/null || true + done + for b in "${BRANCHES[@]+"${BRANCHES[@]}"}"; do + git -C "$REPO_ROOT" branch -D "$b" 2>/dev/null || true + done + (( ${#JSON_FILES[@]} )) && rm -f "${JSON_FILES[@]}" +} +trap cleanup EXIT + +# --- resolve base commit --- +# Branch from HEAD so the worktree includes the current skill definition. + +BASE=$(git -C "$REPO_ROOT" rev-parse HEAD || die "Cannot resolve HEAD.") +TS=$(date +%s) + +# --- helpers --- + +# Commits paths in a validation worktree with consistent author metadata. +# Usage: git_commit_in_worktree [path...] +git_commit_in_worktree() { + local worktree="$1" message="$2" + shift 2 + if (($# > 0)); then + git -C "$worktree" add "$@" + fi + git -C "$worktree" \ + -c user.email="ci@sentry.io" \ + -c user.name="Validation Test" \ + commit --quiet -m "$message" +} + +# Creates a git worktree from $BASE and commits the NOTICES catalog as the Warden +# analysis base — so only fixture changes appear in the diff Warden analyzes. +# Prints the catalog-commit SHA to stdout. +setup_catalog_base() { + local worktree="$1" branch="$2" + git -C "$REPO_ROOT" worktree add --quiet "$worktree" "$BASE" -b "$branch" + printf '\n' >> "$worktree/THIRD_PARTY_NOTICES.md" + sed "s|validation-tests/scenarios/|${DEST_PACKAGE_PATH}/|g" \ + "$CATALOG" >> "$worktree/THIRD_PARTY_NOTICES.md" + git_commit_in_worktree "$worktree" "test: apply NOTICES catalog [skip ci]" \ + THIRD_PARTY_NOTICES.md + git -C "$worktree" rev-parse HEAD +} + +# Appends the mismatch snippet to THIRD_PARTY_NOTICES.md, stripping the fixture's +# prose header so only the NOTICES entry itself lands in the file. +append_mismatch_snippet() { + local worktree="$1" + printf '\n' >> "$worktree/THIRD_PARTY_NOTICES.md" + sed '1,/^---$/d' "$MISMATCH_SNIPPET" >> "$worktree/THIRD_PARTY_NOTICES.md" +} + +# Runs Warden and writes JSON output to the given file. +run_warden() { + local base="$1" worktree="$2" json_out="$3" label="$4" + echo "Running Warden on ${base:0:7}..HEAD ($label)..." + : > "$json_out" + if ! "${TIMEOUT_CMD[@]}" npx @sentry/warden "${base}..HEAD" \ + --skill check-code-attribution \ + --fail-on off \ + --report-on medium \ + --json \ + -C "$worktree" \ + > "$json_out"; then + if [[ ! -s "$json_out" ]]; then + die "Warden failed for $label with no JSON output (check API key, network, and Warden logs)." + fi + die "Warden exited with an error for $label but left partial JSON in $json_out." + fi + [[ -s "$json_out" ]] || die "Warden succeeded but produced no JSON output for $label." +} + +# --- main worktree: non-isolated scenarios --- +# Isolated .java files are omitted here; they get dedicated worktrees below. + +echo "Creating worktrees from $(git -C "$REPO_ROOT" rev-parse --short "$BASE")..." +echo "" + +MAIN_WORKTREE=$(mktemp -d) +MAIN_BRANCH="validation-main-${TS}" +MAIN_JSON=$(mktemp) +ROUTING_JSON_FILE=$(mktemp) +echo '{}' > "$ROUTING_JSON_FILE" +WORKTREES+=("$MAIN_WORKTREE") +BRANCHES+=("$MAIN_BRANCH") +JSON_FILES+=("$MAIN_JSON" "$ROUTING_JSON_FILE") + +MAIN_BASE=$(setup_catalog_base "$MAIN_WORKTREE" "$MAIN_BRANCH") + +DEST_DIR="$MAIN_WORKTREE/$DEST_PACKAGE_PATH" +mkdir -p "$DEST_DIR" + +shopt -s nullglob +copied=0 +while IFS= read -r java_file; do + cp "$SCENARIOS_DIR/$java_file" "$DEST_DIR/" + copied=$((copied + 1)) +done < <(node "$VALIDATION" list-main-java "$EXPECTED_JSON" "$SCENARIOS_DIR") +echo "Copied ${copied} scenario files → $DEST_PACKAGE_PATH/ (non-isolated batch)" +append_mismatch_snippet "$MAIN_WORKTREE" +git_commit_in_worktree "$MAIN_WORKTREE" \ + "test: add check-code-attribution validation fixtures [skip ci]" \ + "$DEST_PACKAGE_PATH" THIRD_PARTY_NOTICES.md + +run_warden "$MAIN_BASE" "$MAIN_WORKTREE" "$MAIN_JSON" "main" +node "$VALIDATION" routing-set "$ROUTING_JSON_FILE" main "$MAIN_JSON" + +# --- isolated worktrees: one per scenario marked "isolated" in EXPECTED.json --- +# +# Scenarios where Anthropic prompt-cache priming can suppress findings in a concurrent +# batch get their own worktree and Warden run. EXPECTED.json is the single source of +# truth for which scenarios need isolation — add "isolated": true there, not here. +# Java isolates omit the mismatch snippet; the NOTICES mismatch scenario adds it alone. + +while IFS=$'\t' read -r id file; do + worktree=$(mktemp -d) + branch="validation-isolated-${TS}-${id//[^a-zA-Z0-9]/-}" + json=$(mktemp) + WORKTREES+=("$worktree") + BRANCHES+=("$branch") + JSON_FILES+=("$json") + + base=$(setup_catalog_base "$worktree" "$branch") + + commit_paths=() + if [[ "$file" == *.java ]]; then + dest_dir="$worktree/$DEST_PACKAGE_PATH" + mkdir -p "$dest_dir" + cp "$SCENARIOS_DIR/$file" "$dest_dir/" + commit_paths=("$DEST_PACKAGE_PATH") + elif [[ "$file" == "THIRD_PARTY_NOTICES.md" ]]; then + append_mismatch_snippet "$worktree" + commit_paths=(THIRD_PARTY_NOTICES.md) + else + die "Unsupported isolated scenario file: $file (id: $id)" + fi + + git_commit_in_worktree "$worktree" "test: isolated fixture for $id [skip ci]" \ + "${commit_paths[@]}" + + echo "" + run_warden "$base" "$worktree" "$json" "$id" + node "$VALIDATION" routing-set "$ROUTING_JSON_FILE" "$id" "$json" + +done < <(node "$VALIDATION" list-isolated "$EXPECTED_JSON") + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# --- assert per-scenario --- +# +# ROUTING_JSON_FILE maps scenario id → Warden JSONL path; non-isolated scenarios use "main". + +node "$VALIDATION" assert "$EXPECTED_JSON" "$DEST_PACKAGE_PATH" "$ROUTING_JSON_FILE" diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java new file mode 100644 index 00000000000..63727be1d5c --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java @@ -0,0 +1,19 @@ +/* + * Adapted from https://github.com/example/something + * + * Copyright 2020 Example Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.sentry.skills.verification; + +public final class HeaderCompleteAndNoticePresent { + + public int sum(int a, int b) { + return a + b; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java new file mode 100644 index 00000000000..081d1848300 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java @@ -0,0 +1,17 @@ +/* + * Adapted from https://github.com/example + * + * Copyright 2024 Example Authors + * + * Licensed under the MIT License + * + * https://github.com/example/something + */ +package io.sentry.skills.verification; + +public final class HeaderCompleteButNoticeMissing { + + public boolean ok() { + return true; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java new file mode 100644 index 00000000000..6973848c61e --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java @@ -0,0 +1,7 @@ +/* Attribution stripped — fixture for check-code-attribution validation only. */ +package io.sentry.skills.verification; + +public final class HeaderFullyStripped { + + public void run() {} +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java new file mode 100644 index 00000000000..5c4953ea3ad --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java @@ -0,0 +1,8 @@ +package io.sentry.skills.verification; + +public final class HeaderMissingButNoticePresent { + + public int compute(int x) { + return x * 2; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java new file mode 100644 index 00000000000..c524a2593a4 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java @@ -0,0 +1,12 @@ +// Adapted from ExampleLib. +// Copyright 2020 Example Corp. +// Licensed under the MIT License. +// https://github.com/example/examplelib +package io.sentry.skills.verification; + +public final class HeaderMissingNonEssentialInfo { + + public int compute(int x) { + return x + 1; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java new file mode 100644 index 00000000000..0389934d94a --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java @@ -0,0 +1,10 @@ +// Adapted from Example RateLimiter. +// https://github.com/example +package io.sentry.skills.verification; + +public final class HeaderPartiallyStripped { + + public synchronized boolean tryAcquire() { + return true; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java new file mode 100644 index 00000000000..e148f5a1a4f --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java @@ -0,0 +1,10 @@ +// Adapted from ExampleLib. +// Copyright 2020 Example Corp. +// Licensed under the GNU Affero General Public License v3.0. +// https://github.com/example/agpl-lib +package io.sentry.skills.verification; + +public final class NewLicenseType { + + public void run() {} +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md b/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md new file mode 100644 index 00000000000..5a9b87285df --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md @@ -0,0 +1,37 @@ +# Snippet fixture — MismatchLib entry for the isolated mismatch worktree. +# header-vs-notice-mismatch: copyright in metadata field does not match embedded license text. + +--- + +## Example — MismatchLib (MIT) + +**Source:** https://github.com/example/mismatch
+**License:** MIT License
+**Copyright:** Copyright (c) 2020 Wrong Holder + +### Scope + +Validation sample only. The code resides in `io.sentry.skills.verification.MismatchLib`. + +``` +MIT License + +Copyright (c) 2016 Correct Holder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/.gitignore b/.gitignore index a7899736a86..f252087a5ab 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ spy.log # Auto-generated by dotagents — do not commit these files. agents.lock .agents/.gitignore + +# Warden local run logs +.warden/logs/ diff --git a/AGENTS.md b/AGENTS.md index 1784e4f950e..ff50727c662 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,8 @@ When adapting code from third-party libraries: ``` 2. Add a full attribution entry to `THIRD_PARTY_NOTICES.md` following the existing format (Source, License, Copyright, Scope, full license text) +3. Run the `check-code-attribution` skill locally or wait for it to be auto-run against your PR to check for required fields and verify new licenses against [Sentry's Open Source Legal Policy](https://open.sentry.io/licensing/). + ### Getting PR Information Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number. diff --git a/agents.toml b/agents.toml index b4c9e091b70..d9770ee7df5 100644 --- a/agents.toml +++ b/agents.toml @@ -35,3 +35,7 @@ source = "path:.agents/skills/test" [[skills]] name = "btrace-perfetto" source = "path:.agents/skills/btrace-perfetto" + +[[skills]] +name = "check-code-attribution" +source = "path:.agents/skills/check-code-attribution" diff --git a/warden.toml b/warden.toml new file mode 100644 index 00000000000..3ce15f9a11f --- /dev/null +++ b/warden.toml @@ -0,0 +1,101 @@ +version = 1 + +[defaults] +model = "anthropic/claude-sonnet-4-6" + +# Warden's schema does not support per-skill verification config; this is the only +# placement available. Disabled for attribution policy checks: a second verifier +# pass often rejects valid header/NOTICES mismatches (e.g. "NOTICES still documents it"). +[defaults.verification] +enabled = false + +# Warden's schema does not support per-skill chunking config; these patterns apply +# globally but are tuned for check-code-attribution. Attribution checks need the full +# file header and a NOTICES cross-check — not isolated diff hunks. +[[defaults.chunking.filePatterns]] +pattern = "**/*.api" +mode = "skip" + +[[defaults.chunking.filePatterns]] +pattern = "**/gradlew" +mode = "skip" + +[[defaults.chunking.filePatterns]] +pattern = "**/gradlew.bat" +mode = "skip" + +[[defaults.chunking.filePatterns]] +pattern = "**/*.java" +mode = "whole-file" + +[[defaults.chunking.filePatterns]] +pattern = "**/*.kt" +mode = "whole-file" + +[[defaults.chunking.filePatterns]] +pattern = "**/*.kts" +mode = "whole-file" + +[[defaults.chunking.filePatterns]] +pattern = "THIRD_PARTY_NOTICES.md" +mode = "whole-file" + +# Coalesce hunks aggressively for any remaining per-hunk files +[defaults.chunking.coalesce] +enabled = true +maxGapLines = 100 +maxChunkSize = 16000 + +[[skills]] +name = "check-code-attribution" +maxTurns = 30 +# Phase 1: report only — Warden comments on PRs but does not block merges. +# Tighten to failOn = "medium" / requestChanges = true once the false-positive baseline is established. +failOn = "off" +reportOn = "medium" +ignorePaths = [ + # Infrastructure directories + ".agents/**", + ".claude/**", + ".cursor/**", + ".github/**", + ".gradle/**", + ".idea/**", + ".mvn/**", + "gradle/**", + # Generated files + "**/*.aidl", + "**/*.api", + "**/*.g.kt", + "**/*.interp", + "**/*.pb.java", + "**/*.tokens", + "**/build/**", + "**/databinding/*Binding.java", + "**/generated/**", + "**/gradlew", + "**/gradlew.bat", + "**/grpc/*Grpc.java", + "**/ksp/**", + "**/mvnw", + "**/mvnw.cmd", + # Binary files + "**/*.jar", + # Repo docs (attribution examples in prose, not vendored code) + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "**/README.md", + # Warden infrastructure + ".warden/**", + "warden.toml", +] + +[[skills.triggers]] +type = "pull_request" +actions = ["opened", "synchronize"] +requestChanges = false +failCheck = false + +[[skills.triggers]] +type = "local" From e4890419828e026a9274db932adfcc9f372f024b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 22 May 2026 15:54:02 +0200 Subject: [PATCH 052/276] chore(build): Enable configuration cache parallel (#5461) Co-authored-by: Claude Opus 4.6 --- gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle.properties b/gradle.properties index a8f42329732..2eb795118a0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,6 +4,7 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.configuration-cache=true +org.gradle.configuration-cache.parallel=true org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled From 44472dad40ff9fcb705cf476fea94023bbbf66a4 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 22 May 2026 15:54:12 +0200 Subject: [PATCH 053/276] ref(build): Move apply() outside afterEvaluate (#5464) Co-authored-by: Claude Opus 4.6 --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6656e00e49a..8df6e48fe53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -214,9 +214,9 @@ subprojects { } } - afterEvaluate { - apply() + apply() + afterEvaluate { configure { assignAarTypes() } From 9669c2d4e1fcf4aa0aa5b73df61d90026a3822b7 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 26 May 2026 14:59:55 +0200 Subject: [PATCH 054/276] feat(android): Parse memory and GC info from ANR thread dumps (#5428) --- CHANGELOG.md | 1 + .../sentry/android/core/AnrV2Integration.java | 16 +- .../internal/threaddump/ArtContextParser.java | 149 ++++++++ .../internal/threaddump/ThreadDumpParser.java | 10 + .../threaddump/ArtContextParserTest.kt | 130 +++++++ .../threaddump/ThreadDumpParserTest.kt | 31 ++ sentry/api/sentry.api | 55 +++ .../java/io/sentry/protocol/ArtContext.java | 331 ++++++++++++++++++ .../java/io/sentry/protocol/Contexts.java | 13 + .../protocol/ArtContextSerializationTest.kt | 72 ++++ .../java/io/sentry/protocol/ArtContextTest.kt | 54 +++ .../CombinedContextsViewSerializationTest.kt | 1 + .../protocol/ContextsSerializationTest.kt | 1 + .../src/test/resources/json/art_context.json | 13 + sentry/src/test/resources/json/contexts.json | 14 + 15 files changed, 888 insertions(+), 3 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt create mode 100644 sentry/src/main/java/io/sentry/protocol/ArtContext.java create mode 100644 sentry/src/test/java/io/sentry/protocol/ArtContextSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/protocol/ArtContextTest.kt create mode 100644 sentry/src/test/resources/json/art_context.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e869e9aac8..acc07254df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Enable via `options.isAttachRawTombstone = true` or manifest: `` - Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) +- Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 8d88285a356..285c3b77ade 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -22,6 +22,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.ArtContext; import io.sentry.protocol.DebugImage; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.Message; @@ -173,6 +174,9 @@ public boolean shouldReportHistorical() { debugMeta.setImages(result.debugImages); event.setDebugMeta(debugMeta); } + if (result.artContext != null) { + event.getContexts().setArt(result.artContext); + } } event.setLevel(SentryLevel.FATAL); event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); @@ -209,6 +213,7 @@ public boolean shouldReportHistorical() { final @NotNull List threads = threadDumpParser.getThreads(); final @NotNull List debugImages = threadDumpParser.getDebugImages(); + final @Nullable ArtContext artContext = threadDumpParser.getArtContext(); if (threads.isEmpty()) { // if the list is empty this means the system failed to capture a proper thread dump of @@ -217,7 +222,7 @@ public boolean shouldReportHistorical() { // fall back to not reporting them return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages); + return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages, artContext); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); return new ParseResult(ParseResult.Type.ERROR, dump); @@ -286,15 +291,17 @@ enum Type { } final Type type; - final byte[] dump; + final @Nullable byte[] dump; final @Nullable List threads; final @Nullable List debugImages; + final @Nullable ArtContext artContext; ParseResult(final @NotNull Type type) { this.type = type; this.dump = null; this.threads = null; this.debugImages = null; + this.artContext = null; } ParseResult(final @NotNull Type type, final byte[] dump) { @@ -302,17 +309,20 @@ enum Type { this.dump = dump; this.threads = null; this.debugImages = null; + this.artContext = null; } ParseResult( final @NotNull Type type, final byte[] dump, final @Nullable List threads, - final @Nullable List debugImages) { + final @Nullable List debugImages, + final @Nullable ArtContext artContext) { this.type = type; this.dump = dump; this.threads = threads; this.debugImages = debugImages; + this.artContext = artContext; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java new file mode 100644 index 00000000000..af5e2214aba --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java @@ -0,0 +1,149 @@ +package io.sentry.android.core.internal.threaddump; + +import io.sentry.protocol.ArtContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Parses ART runtime memory and GC metrics from ANR thread dump lines. + * + * @see
ART + * Heap::DumpGcCountRateHistogram + */ +final class ArtContextParser { + + private static final long KB = 1024; + private static final long MB = 1024 * KB; + private static final long GB = 1024 * MB; + + private static final String FREE_MEMORY_PREFIX = "Free memory "; + private static final String FREE_MEMORY_UNTIL_GC_PREFIX = "Free memory until GC "; + private static final String FREE_MEMORY_UNTIL_OOME_PREFIX = "Free memory until OOME "; + private static final String TOTAL_MEMORY_PREFIX = "Total memory "; + private static final String MAX_MEMORY_PREFIX = "Max memory "; + private static final String TOTAL_TIME_WAITING_FOR_GC_PREFIX = + "Total time waiting for GC to complete: "; + private static final String TOTAL_GC_COUNT_PREFIX = "Total GC count: "; + private static final String TOTAL_GC_TIME_PREFIX = "Total GC time: "; + private static final String TOTAL_BLOCKING_GC_COUNT_PREFIX = "Total blocking GC count: "; + private static final String TOTAL_BLOCKING_GC_TIME_PREFIX = "Total blocking GC time: "; + private static final String TOTAL_PRE_OOME_GC_COUNT_PREFIX = "Total pre-OOME GC count: "; + + private @Nullable ArtContext artContext; + + @Nullable + ArtContext getArtContext() { + return artContext; + } + + void parseLine(final @NotNull String text) { + if (text.startsWith(FREE_MEMORY_UNTIL_OOME_PREFIX)) { + getOrCreateArtContext() + .setFreeMemoryUntilOome( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_OOME_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_UNTIL_GC_PREFIX)) { + getOrCreateArtContext() + .setFreeMemoryUntilGc( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_GC_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setFreeMemory(parsePrettySize(text.substring(FREE_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setTotalMemory(parsePrettySize(text.substring(TOTAL_MEMORY_PREFIX.length()))); + } else if (text.startsWith(MAX_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setMaxMemory(parsePrettySize(text.substring(MAX_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_TIME_WAITING_FOR_GC_PREFIX)) { + getOrCreateArtContext() + .setGcWaitingTime(parseTimeMs(text.substring(TOTAL_TIME_WAITING_FOR_GC_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_TIME_PREFIX)) { + getOrCreateArtContext() + .setGcTotalTime(parseTimeMs(text.substring(TOTAL_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcTotalCount(parseLongOrNull(text.substring(TOTAL_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_TIME_PREFIX)) { + getOrCreateArtContext() + .setGcBlockingTime(parseTimeMs(text.substring(TOTAL_BLOCKING_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcBlockingCount( + parseLongOrNull(text.substring(TOTAL_BLOCKING_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_PRE_OOME_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcPreOomeCount( + parseLongOrNull(text.substring(TOTAL_PRE_OOME_GC_COUNT_PREFIX.length()))); + } + } + + private @NotNull ArtContext getOrCreateArtContext() { + if (artContext == null) { + artContext = new ArtContext(); + } + return artContext; + } + + /** + * Matches Android's PrettySize output: number followed by unit with no space, e.g. "3107KB". + * + *

Counterpart to + * https://cs.android.com/android/platform/superproject/+/android-latest-release:art/libartbase/base/utils.cc;l=232-251;drc=d0d3deb269b1e14de2ec2707815e38bc95de570c + */ + private @Nullable Long parsePrettySize(final @NotNull String sizeString) { + final String trimmed = sizeString.trim(); + try { + if (trimmed.endsWith("GB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * GB; + } else if (trimmed.endsWith("MB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * MB; + } else if (trimmed.endsWith("KB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * KB; + } else if (trimmed.endsWith("B")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + /** + * Parses ART's PrettyDuration output and converts to milliseconds. Handles "s", "ms", "us", "ns" + * suffixes and the bare "0" special case. + * + * @see ART + * PrettyDuration / FormatDuration + */ + private static @Nullable Double parseTimeMs(final @NotNull String timeString) { + final String trimmed = timeString.trim(); + try { + if (trimmed.equals("0")) { + return 0.0; + } + // Double.parseDouble is locale-independent (always uses '.' as decimal separator), + // which matches the ART runtime output format. + if (trimmed.endsWith("ms")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)); + } else if (trimmed.endsWith("ns")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000_000.0; + } else if (trimmed.endsWith("us")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000.0; + } else if (trimmed.endsWith("s")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 1)) * 1_000.0; + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + private static @Nullable Long parseLongOrNull(final @NotNull String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index 5f70e39f8b8..f5ce8a745ce 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -23,6 +23,7 @@ import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.android.core.internal.util.NativeEventUtils; +import io.sentry.protocol.ArtContext; import io.sentry.protocol.DebugImage; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.SentryStackTrace; @@ -109,6 +110,8 @@ public class ThreadDumpParser { private final @NotNull List threads; + private final @NotNull ArtContextParser artContextParser = new ArtContextParser(); + public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) { this.options = options; this.isBackground = isBackground; @@ -127,6 +130,11 @@ public List getThreads() { return threads; } + @Nullable + public ArtContext getArtContext() { + return artContextParser.getArtContext(); + } + public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); @@ -148,6 +156,8 @@ public void parse(final @NotNull Lines lines) { if (thread != null) { threads.add(thread); } + } else { + artContextParser.parseLine(text); } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt new file mode 100644 index 00000000000..b468127660c --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt @@ -0,0 +1,130 @@ +package io.sentry.android.core.internal.threaddump + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ArtContextParserTest { + + @Test + fun `parses pretty size bytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 0B") + assertEquals(0L, parser.artContext!!.freeMemory) + + val parser2 = ArtContextParser() + parser2.parseLine("Free memory 512B") + assertEquals(512L, parser2.artContext!!.freeMemory) + } + + @Test + fun `parses pretty size kilobytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 3107KB") + assertEquals(3107L * 1024, parser.artContext!!.freeMemory) + } + + @Test + fun `parses pretty size megabytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory until OOME 187MB") + assertEquals(187L * 1024 * 1024, parser.artContext!!.freeMemoryUntilOome) + } + + @Test + fun `parses pretty size gigabytes`() { + val parser = ArtContextParser() + parser.parseLine("Max memory 2GB") + assertEquals(2L * 1024 * 1024 * 1024, parser.artContext!!.maxMemory) + } + + @Test + fun `sets null for invalid pretty size`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 100TB") + assertNull(parser.artContext!!.freeMemory) + } + + @Test + fun `parses time in milliseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 11.807ms") + assertEquals(11.807, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in seconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 2.5s") + assertEquals(2500.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in microseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 500us") + assertEquals(0.5, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in nanoseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 1000000ns") + assertEquals(1.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses zero duration`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 0") + assertEquals(0.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses all memory fields`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 3107KB") + parser.parseLine("Free memory until GC 3107KB") + parser.parseLine("Free memory until OOME 187MB") + parser.parseLine("Total memory 7592KB") + parser.parseLine("Max memory 192MB") + + val info = parser.artContext + assertNotNull(info) + assertEquals(3107L * 1024, info.freeMemory) + assertEquals(3107L * 1024, info.freeMemoryUntilGc) + assertEquals(187L * 1024 * 1024, info.freeMemoryUntilOome) + assertEquals(7592L * 1024, info.totalMemory) + assertEquals(192L * 1024 * 1024, info.maxMemory) + } + + @Test + fun `parses all gc fields`() { + val parser = ArtContextParser() + parser.parseLine("Total time waiting for GC to complete: 8.054ms") + parser.parseLine("Total GC count: 1") + parser.parseLine("Total GC time: 11.807ms") + parser.parseLine("Total blocking GC count: 1") + parser.parseLine("Total blocking GC time: 11.873ms") + parser.parseLine("Total pre-OOME GC count: 0") + + val info = parser.artContext + assertNotNull(info) + assertEquals(8.054, info.gcWaitingTime) + assertEquals(1L, info.gcTotalCount) + assertEquals(11.807, info.gcTotalTime) + assertEquals(1L, info.gcBlockingCount) + assertEquals(11.873, info.gcBlockingTime) + assertEquals(0L, info.gcPreOomeCount) + } + + @Test + fun `ignores unrelated lines`() { + val parser = ArtContextParser() + parser.parseLine("some random line") + parser.parseLine("DALVIK THREADS (29):") + parser.parseLine("") + assertNull(parser.artContext) + } +} 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 604e2e84189..b7db35b63ce 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 @@ -160,6 +160,28 @@ class ThreadDumpParserTest { assertEquals("ba489d4985c0cf173209da67405662f9", image.codeId) } + @Test + fun `parses memory info from thread dump`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + + val artContext = parser.artContext + assertNotNull(artContext) + assertEquals(3107L * 1024, artContext.freeMemory) + assertEquals(3107L * 1024, artContext.freeMemoryUntilGc) + assertEquals(187L * 1024 * 1024, artContext.freeMemoryUntilOome) + assertEquals(7592L * 1024, artContext.totalMemory) + assertEquals(192L * 1024 * 1024, artContext.maxMemory) + assertEquals(1L, artContext.gcTotalCount) + assertEquals(11.807, artContext.gcTotalTime) + assertEquals(1L, artContext.gcBlockingCount) + assertEquals(11.873, artContext.gcBlockingTime) + assertEquals(0L, artContext.gcPreOomeCount) + assertEquals(8.054, artContext.gcWaitingTime) + } + @Test fun `thread dump garbage`() { val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) @@ -168,4 +190,13 @@ class ThreadDumpParserTest { parser.parse(lines) assertTrue(parser.threads.isEmpty()) } + + @Test + fun `garbage thread dump has no memory info`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + assertNull(parser.artContext) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6b8377de3a3..d2fd5f75dbc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5657,6 +5657,59 @@ public final class io/sentry/protocol/App$JsonKeys { public fun ()V } +public final class io/sentry/protocol/ArtContext : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field TYPE Ljava/lang/String; + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getFreeMemory ()Ljava/lang/Long; + public fun getFreeMemoryUntilGc ()Ljava/lang/Long; + public fun getFreeMemoryUntilOome ()Ljava/lang/Long; + public fun getGcBlockingCount ()Ljava/lang/Long; + public fun getGcBlockingTime ()Ljava/lang/Double; + public fun getGcPreOomeCount ()Ljava/lang/Long; + public fun getGcTotalCount ()Ljava/lang/Long; + public fun getGcTotalTime ()Ljava/lang/Double; + public fun getGcWaitingTime ()Ljava/lang/Double; + public fun getMaxMemory ()Ljava/lang/Long; + public fun getTotalMemory ()Ljava/lang/Long; + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setFreeMemory (Ljava/lang/Long;)V + public fun setFreeMemoryUntilGc (Ljava/lang/Long;)V + public fun setFreeMemoryUntilOome (Ljava/lang/Long;)V + public fun setGcBlockingCount (Ljava/lang/Long;)V + public fun setGcBlockingTime (Ljava/lang/Double;)V + public fun setGcPreOomeCount (Ljava/lang/Long;)V + public fun setGcTotalCount (Ljava/lang/Long;)V + public fun setGcTotalTime (Ljava/lang/Double;)V + public fun setGcWaitingTime (Ljava/lang/Double;)V + public fun setMaxMemory (Ljava/lang/Long;)V + public fun setTotalMemory (Ljava/lang/Long;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/protocol/ArtContext$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ArtContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/ArtContext$JsonKeys { + public static final field FREE_MEMORY Ljava/lang/String; + public static final field FREE_MEMORY_UNTIL_GC Ljava/lang/String; + public static final field FREE_MEMORY_UNTIL_OOME Ljava/lang/String; + public static final field GC_BLOCKING_COUNT Ljava/lang/String; + public static final field GC_BLOCKING_TIME Ljava/lang/String; + public static final field GC_PRE_OOME_COUNT Ljava/lang/String; + public static final field GC_TOTAL_COUNT Ljava/lang/String; + public static final field GC_TOTAL_TIME Ljava/lang/String; + public static final field GC_WAITING_TIME Ljava/lang/String; + public static final field MAX_MEMORY Ljava/lang/String; + public static final field TOTAL_MEMORY Ljava/lang/String; + public fun ()V +} + public final class io/sentry/protocol/Browser : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V @@ -5693,6 +5746,7 @@ public class io/sentry/protocol/Contexts : io/sentry/JsonSerializable { public fun equals (Ljava/lang/Object;)Z public fun get (Ljava/lang/Object;)Ljava/lang/Object; public fun getApp ()Lio/sentry/protocol/App; + public fun getArt ()Lio/sentry/protocol/ArtContext; public fun getBrowser ()Lio/sentry/protocol/Browser; public fun getDevice ()Lio/sentry/protocol/Device; public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; @@ -5715,6 +5769,7 @@ public class io/sentry/protocol/Contexts : io/sentry/JsonSerializable { public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public fun setApp (Lio/sentry/protocol/App;)V + public fun setArt (Lio/sentry/protocol/ArtContext;)V public fun setBrowser (Lio/sentry/protocol/Browser;)V public fun setDevice (Lio/sentry/protocol/Device;)V public fun setFeatureFlags (Lio/sentry/protocol/FeatureFlags;)V diff --git a/sentry/src/main/java/io/sentry/protocol/ArtContext.java b/sentry/src/main/java/io/sentry/protocol/ArtContext.java new file mode 100644 index 00000000000..c840f48af91 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/ArtContext.java @@ -0,0 +1,331 @@ +package io.sentry.protocol; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Context containing ART (Android Runtime) specific information. This is only relevant for Android + * and may be null on other platforms. + */ +public final class ArtContext implements JsonUnknown, JsonSerializable { + public static final String TYPE = "art"; + + private @Nullable Long gcTotalCount; + private @Nullable Double gcTotalTime; + private @Nullable Long gcBlockingCount; + private @Nullable Double gcBlockingTime; + private @Nullable Long gcPreOomeCount; + private @Nullable Double gcWaitingTime; + private @Nullable Long freeMemory; + private @Nullable Long freeMemoryUntilGc; + private @Nullable Long freeMemoryUntilOome; + private @Nullable Long totalMemory; + private @Nullable Long maxMemory; + + @SuppressWarnings("unused") + private @Nullable Map unknown; + + public ArtContext() {} + + ArtContext(final @NotNull ArtContext other) { + this.gcTotalCount = other.gcTotalCount; + this.gcTotalTime = other.gcTotalTime; + this.gcBlockingCount = other.gcBlockingCount; + this.gcBlockingTime = other.gcBlockingTime; + this.gcPreOomeCount = other.gcPreOomeCount; + this.gcWaitingTime = other.gcWaitingTime; + this.freeMemory = other.freeMemory; + this.freeMemoryUntilGc = other.freeMemoryUntilGc; + this.freeMemoryUntilOome = other.freeMemoryUntilOome; + this.totalMemory = other.totalMemory; + this.maxMemory = other.maxMemory; + this.unknown = CollectionUtils.newConcurrentHashMap(other.unknown); + } + + /** Total number of GC collections since process start. */ + public @Nullable Long getGcTotalCount() { + return gcTotalCount; + } + + /** Total number of GC collections since process start. */ + public void setGcTotalCount(final @Nullable Long gcTotalCount) { + this.gcTotalCount = gcTotalCount; + } + + /** Total time spent in GC since process start, in milliseconds. */ + public @Nullable Double getGcTotalTime() { + return gcTotalTime; + } + + /** Total time spent in GC since process start, in milliseconds. */ + public void setGcTotalTime(final @Nullable Double gcTotalTime) { + this.gcTotalTime = gcTotalTime; + } + + /** Total number of blocking (stop-the-world) GC collections since process start. */ + public @Nullable Long getGcBlockingCount() { + return gcBlockingCount; + } + + /** Total number of blocking (stop-the-world) GC collections since process start. */ + public void setGcBlockingCount(final @Nullable Long gcBlockingCount) { + this.gcBlockingCount = gcBlockingCount; + } + + /** Total time spent in blocking (stop-the-world) GC since process start, in milliseconds. */ + public @Nullable Double getGcBlockingTime() { + return gcBlockingTime; + } + + /** Total time spent in blocking (stop-the-world) GC since process start, in milliseconds. */ + public void setGcBlockingTime(final @Nullable Double gcBlockingTime) { + this.gcBlockingTime = gcBlockingTime; + } + + /** Total number of GC collections triggered to prevent an OutOfMemoryError. */ + public @Nullable Long getGcPreOomeCount() { + return gcPreOomeCount; + } + + /** Total number of GC collections triggered to prevent an OutOfMemoryError. */ + public void setGcPreOomeCount(final @Nullable Long gcPreOomeCount) { + this.gcPreOomeCount = gcPreOomeCount; + } + + /** Total time threads spent waiting for GC to complete, in milliseconds. */ + public @Nullable Double getGcWaitingTime() { + return gcWaitingTime; + } + + /** Total time threads spent waiting for GC to complete, in milliseconds. */ + public void setGcWaitingTime(final @Nullable Double gcWaitingTime) { + this.gcWaitingTime = gcWaitingTime; + } + + /** Free memory available in the managed heap, in bytes. */ + public @Nullable Long getFreeMemory() { + return freeMemory; + } + + /** Free memory available in the managed heap, in bytes. */ + public void setFreeMemory(final @Nullable Long freeMemory) { + this.freeMemory = freeMemory; + } + + /** Free memory available until the next GC is triggered, in bytes. */ + public @Nullable Long getFreeMemoryUntilGc() { + return freeMemoryUntilGc; + } + + /** Free memory available until the next GC is triggered, in bytes. */ + public void setFreeMemoryUntilGc(final @Nullable Long freeMemoryUntilGc) { + this.freeMemoryUntilGc = freeMemoryUntilGc; + } + + /** Free memory available until an OutOfMemoryError is thrown, in bytes. */ + public @Nullable Long getFreeMemoryUntilOome() { + return freeMemoryUntilOome; + } + + /** Free memory available until an OutOfMemoryError is thrown, in bytes. */ + public void setFreeMemoryUntilOome(final @Nullable Long freeMemoryUntilOome) { + this.freeMemoryUntilOome = freeMemoryUntilOome; + } + + /** Total memory currently allocated for the managed heap, in bytes. */ + public @Nullable Long getTotalMemory() { + return totalMemory; + } + + /** Total memory currently allocated for the managed heap, in bytes. */ + public void setTotalMemory(final @Nullable Long totalMemory) { + this.totalMemory = totalMemory; + } + + /** Maximum memory the managed heap is allowed to grow to, in bytes. */ + public @Nullable Long getMaxMemory() { + return maxMemory; + } + + /** Maximum memory the managed heap is allowed to grow to, in bytes. */ + public void setMaxMemory(final @Nullable Long maxMemory) { + this.maxMemory = maxMemory; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ArtContext that = (ArtContext) o; + return Objects.equals(gcTotalCount, that.gcTotalCount) + && Objects.equals(gcTotalTime, that.gcTotalTime) + && Objects.equals(gcBlockingCount, that.gcBlockingCount) + && Objects.equals(gcBlockingTime, that.gcBlockingTime) + && Objects.equals(gcPreOomeCount, that.gcPreOomeCount) + && Objects.equals(gcWaitingTime, that.gcWaitingTime) + && Objects.equals(freeMemory, that.freeMemory) + && Objects.equals(freeMemoryUntilGc, that.freeMemoryUntilGc) + && Objects.equals(freeMemoryUntilOome, that.freeMemoryUntilOome) + && Objects.equals(totalMemory, that.totalMemory) + && Objects.equals(maxMemory, that.maxMemory); + } + + @Override + public int hashCode() { + return Objects.hash( + gcTotalCount, + gcTotalTime, + gcBlockingCount, + gcBlockingTime, + gcPreOomeCount, + gcWaitingTime, + freeMemory, + freeMemoryUntilGc, + freeMemoryUntilOome, + totalMemory, + maxMemory); + } + + // region JsonSerializable + + public static final class JsonKeys { + public static final String GC_TOTAL_COUNT = "gc.total_count"; + public static final String GC_TOTAL_TIME = "gc.total_time"; + public static final String GC_BLOCKING_COUNT = "gc.blocking_count"; + public static final String GC_BLOCKING_TIME = "gc.blocking_time"; + public static final String GC_PRE_OOME_COUNT = "gc.pre_oome_count"; + public static final String GC_WAITING_TIME = "gc.waiting_time"; + public static final String FREE_MEMORY = "memory.free"; + public static final String FREE_MEMORY_UNTIL_GC = "memory.free_until_gc"; + public static final String FREE_MEMORY_UNTIL_OOME = "memory.free_until_oome"; + public static final String TOTAL_MEMORY = "memory.total"; + public static final String MAX_MEMORY = "memory.max"; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (gcTotalCount != null) { + writer.name(JsonKeys.GC_TOTAL_COUNT).value(gcTotalCount); + } + if (gcTotalTime != null) { + writer.name(JsonKeys.GC_TOTAL_TIME).value(gcTotalTime); + } + if (gcBlockingCount != null) { + writer.name(JsonKeys.GC_BLOCKING_COUNT).value(gcBlockingCount); + } + if (gcBlockingTime != null) { + writer.name(JsonKeys.GC_BLOCKING_TIME).value(gcBlockingTime); + } + if (gcPreOomeCount != null) { + writer.name(JsonKeys.GC_PRE_OOME_COUNT).value(gcPreOomeCount); + } + if (gcWaitingTime != null) { + writer.name(JsonKeys.GC_WAITING_TIME).value(gcWaitingTime); + } + if (freeMemory != null) { + writer.name(JsonKeys.FREE_MEMORY).value(freeMemory); + } + if (freeMemoryUntilGc != null) { + writer.name(JsonKeys.FREE_MEMORY_UNTIL_GC).value(freeMemoryUntilGc); + } + if (freeMemoryUntilOome != null) { + writer.name(JsonKeys.FREE_MEMORY_UNTIL_OOME).value(freeMemoryUntilOome); + } + if (totalMemory != null) { + writer.name(JsonKeys.TOTAL_MEMORY).value(totalMemory); + } + if (maxMemory != null) { + writer.name(JsonKeys.MAX_MEMORY).value(maxMemory); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull ArtContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + ArtContext artContext = new ArtContext(); + Map unknown = null; + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.GC_TOTAL_COUNT: + artContext.gcTotalCount = reader.nextLongOrNull(); + break; + case JsonKeys.GC_TOTAL_TIME: + artContext.gcTotalTime = reader.nextDoubleOrNull(); + break; + case JsonKeys.GC_BLOCKING_COUNT: + artContext.gcBlockingCount = reader.nextLongOrNull(); + break; + case JsonKeys.GC_BLOCKING_TIME: + artContext.gcBlockingTime = reader.nextDoubleOrNull(); + break; + case JsonKeys.GC_PRE_OOME_COUNT: + artContext.gcPreOomeCount = reader.nextLongOrNull(); + break; + case JsonKeys.GC_WAITING_TIME: + artContext.gcWaitingTime = reader.nextDoubleOrNull(); + break; + case JsonKeys.FREE_MEMORY: + artContext.freeMemory = reader.nextLongOrNull(); + break; + case JsonKeys.FREE_MEMORY_UNTIL_GC: + artContext.freeMemoryUntilGc = reader.nextLongOrNull(); + break; + case JsonKeys.FREE_MEMORY_UNTIL_OOME: + artContext.freeMemoryUntilOome = reader.nextLongOrNull(); + break; + case JsonKeys.TOTAL_MEMORY: + artContext.totalMemory = reader.nextLongOrNull(); + break; + case JsonKeys.MAX_MEMORY: + artContext.maxMemory = reader.nextLongOrNull(); + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + artContext.setUnknown(unknown); + reader.endObject(); + return artContext; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 553f4ddbd30..fd1e9b83eb6 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -64,6 +64,8 @@ public Contexts(final @NotNull Contexts contexts) { this.setResponse(new Response((Response) value)); } else if (Spring.TYPE.equals(entry.getKey()) && value instanceof Spring) { this.setSpring(new Spring((Spring) value)); + } else if (ArtContext.TYPE.equals(entry.getKey()) && value instanceof ArtContext) { + this.setArt(new ArtContext((ArtContext) value)); } else { this.put(entry.getKey(), value); } @@ -181,6 +183,14 @@ public void setSpring(final @NotNull Spring spring) { this.put(Spring.TYPE, spring); } + public @Nullable ArtContext getArt() { + return toContextType(ArtContext.TYPE, ArtContext.class); + } + + public void setArt(final @NotNull ArtContext art) { + this.put(ArtContext.TYPE, art); + } + public @Nullable FeatureFlags getFeatureFlags() { return toContextType(FeatureFlags.TYPE, FeatureFlags.class); } @@ -347,6 +357,9 @@ public static final class Deserializer implements JsonDeserializer { case Spring.TYPE: contexts.setSpring(new Spring.Deserializer().deserialize(reader, logger)); break; + case ArtContext.TYPE: + contexts.setArt(new ArtContext.Deserializer().deserialize(reader, logger)); + break; case FeatureFlags.TYPE: contexts.setFeatureFlags(new FeatureFlags.Deserializer().deserialize(reader, logger)); break; diff --git a/sentry/src/test/java/io/sentry/protocol/ArtContextSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ArtContextSerializationTest.kt new file mode 100644 index 00000000000..9825194cd13 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ArtContextSerializationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.protocol + +import io.sentry.ILogger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Test +import org.mockito.kotlin.mock + +class ArtContextSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = + ArtContext().apply { + gcTotalCount = 1L + gcTotalTime = 11.807 + gcBlockingCount = 1L + gcBlockingTime = 11.873 + gcPreOomeCount = 0L + gcWaitingTime = 8.054 + freeMemory = 3181568L + freeMemoryUntilGc = 3181568L + freeMemoryUntilOome = 196083712L + totalMemory = 7774208L + maxMemory = 201326592L + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/art_context.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/art_context.json") + val actual = + SerializationUtils.deserializeJson( + expectedJson, + ArtContext.Deserializer(), + fixture.logger, + ) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + + assertEquals(expectedJson, actualJson) + } + + @Test + fun `deserialize preserves unknown fields`() { + val jsonWithUnknown = + SerializationUtils.sanitizedFile("json/art_context.json") + .removeSuffix("}") + .plus(",\"new_field\":\"test_value\"}") + val actual = + SerializationUtils.deserializeJson( + jsonWithUnknown, + ArtContext.Deserializer(), + fixture.logger, + ) + + assertNotNull(actual.unknown) + assertEquals("test_value", actual.unknown!!["new_field"]) + + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(jsonWithUnknown, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/ArtContextTest.kt b/sentry/src/test/java/io/sentry/protocol/ArtContextTest.kt new file mode 100644 index 00000000000..275123e494e --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ArtContextTest.kt @@ -0,0 +1,54 @@ +package io.sentry.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame + +class ArtContextTest { + @Test + fun `copying art context wont have the same references`() { + val artContext = ArtContext() + val unknown = mapOf(Pair("unknown", "unknown")) + artContext.setUnknown(unknown) + + val clone = ArtContext(artContext) + + assertNotNull(clone) + assertNotSame(artContext, clone) + assertNotSame(artContext.unknown, clone.unknown) + } + + @Test + fun `copying art context will have the same values`() { + val artContext = ArtContext() + artContext.gcTotalCount = 10L + artContext.gcTotalTime = 11.807 + artContext.gcBlockingCount = 2L + artContext.gcBlockingTime = 5.123 + artContext.gcPreOomeCount = 1L + artContext.gcWaitingTime = 8.054 + artContext.freeMemory = 3181568L + artContext.freeMemoryUntilGc = 3181568L + artContext.freeMemoryUntilOome = 196083712L + artContext.totalMemory = 7774208L + artContext.maxMemory = 201326592L + val unknown = mapOf(Pair("unknown", "unknown")) + artContext.setUnknown(unknown) + + val clone = ArtContext(artContext) + + assertEquals(10L, clone.gcTotalCount) + assertEquals(11.807, clone.gcTotalTime) + assertEquals(2L, clone.gcBlockingCount) + assertEquals(5.123, clone.gcBlockingTime) + assertEquals(1L, clone.gcPreOomeCount) + assertEquals(8.054, clone.gcWaitingTime) + assertEquals(3181568L, clone.freeMemory) + assertEquals(3181568L, clone.freeMemoryUntilGc) + assertEquals(196083712L, clone.freeMemoryUntilOome) + assertEquals(7774208L, clone.totalMemory) + assertEquals(201326592L, clone.maxMemory) + assertNotNull(clone.unknown) { assertEquals("unknown", it["unknown"]) } + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt index d7fd3cf9f7f..33db7a2e29d 100644 --- a/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt @@ -22,6 +22,7 @@ class CombinedContextsViewSerializationTest { val combined = CombinedContextsView(global, isolation, current, ScopeType.ISOLATION) current.setApp(AppSerializationTest.Fixture().getSut()) + current.setArt(ArtContextSerializationTest.Fixture().getSut()) current.setBrowser(BrowserSerializationTest.Fixture().getSut()) current.setFeedback(FeedbackTest.Fixture().getSut()) current.setTrace(SpanContextSerializationTest.Fixture().getSut()) diff --git a/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt index 1a5e252a76d..8e17de9c686 100644 --- a/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt @@ -25,6 +25,7 @@ class ContextsSerializationTest { setResponse(ResponseSerializationTest.Fixture().getSut()) setTrace(SpanContextSerializationTest.Fixture().getSut()) setSpring(SpringSerializationTest.Fixture().getSut()) + setArt(ArtContextSerializationTest.Fixture().getSut()) setFeatureFlags(FeatureFlagsSerializationTest.Fixture().getSut()) } } diff --git a/sentry/src/test/resources/json/art_context.json b/sentry/src/test/resources/json/art_context.json new file mode 100644 index 00000000000..f15596574f5 --- /dev/null +++ b/sentry/src/test/resources/json/art_context.json @@ -0,0 +1,13 @@ +{ + "gc.total_count": 1, + "gc.total_time": 11.807, + "gc.blocking_count": 1, + "gc.blocking_time": 11.873, + "gc.pre_oome_count": 0, + "gc.waiting_time": 8.054, + "memory.free": 3181568, + "memory.free_until_gc": 3181568, + "memory.free_until_oome": 196083712, + "memory.total": 7774208, + "memory.max": 201326592 +} diff --git a/sentry/src/test/resources/json/contexts.json b/sentry/src/test/resources/json/contexts.json index 7f4c0c16bc2..0670c8a6e84 100644 --- a/sentry/src/test/resources/json/contexts.json +++ b/sentry/src/test/resources/json/contexts.json @@ -17,6 +17,20 @@ "view_names": ["MainActivity", "SidebarActivity"], "start_type": "cold" }, + "art": + { + "gc.total_count": 1, + "gc.total_time": 11.807, + "gc.blocking_count": 1, + "gc.blocking_time": 11.873, + "gc.pre_oome_count": 0, + "gc.waiting_time": 8.054, + "memory.free": 3181568, + "memory.free_until_gc": 3181568, + "memory.free_until_oome": 196083712, + "memory.total": 7774208, + "memory.max": 201326592 + }, "browser": { "name": "e1c723db-7408-4043-baa7-f4e96234e5dc", From 58e0436e7aa2f8c4d817a0846c5b448b7144aa04 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 26 May 2026 16:20:20 +0200 Subject: [PATCH 055/276] chore(build): Remove IDEA-316081 toolchain workaround (#5465) The Gradle taskGraph workaround for the IntelliJ IDEA toolchain bug (IDEA-316081) is no longer needed. Co-authored-by: Claude Opus 4.6 --- build.gradle.kts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8df6e48fe53..d5b5dfc5d05 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -262,14 +262,6 @@ tasks.register("buildForCodeQL") { } } -// Workaround for https://youtrack.jetbrains.com/issue/IDEA-316081/Gradle-8-toolchain-error-Toolchain-from-executable-property-does-not-match-toolchain-from-javaLauncher-property-when-different -gradle.taskGraph.whenReady { - val task = this.allTasks.find { it.name.endsWith(".main()") } as? JavaExec - task?.let { - it.setExecutable(it.javaLauncher.get().executablePath.asFile.absolutePath) - } -} - /* * Adapted from https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549 * From 14c1d7e752c73c4a62a540ff3db3ce73c126669f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 26 May 2026 18:31:54 +0200 Subject: [PATCH 056/276] feat(replay): Add ReplayFrameObserver for snapshot testing (#5386) * feat(replay): Add beforeStoreFrame callback (JAVA-504) Add an experimental callback that fires right before a replay frame is stored to disk. The callback receives the masked bitmap (via Hint), timestamp, and current screen name. This enables snapshot testing of replay masking without needing to decode stored video segments. Includes a Kotlin extension for ergonomic usage: options.sessionReplay.beforeStoreFrame { bitmap, ts, screen -> ... } Co-Authored-By: Claude Opus 4.6 (1M context) * feat(replay): Add replay snapshot UI test with Sauce Labs collection (JAVA-504) Add ReplaySnapshotTest that uses the beforeStoreFrame callback to capture masked replay frames during a Compose UI test. Frames are written to the Downloads/sauce_labs_custom_screenshots/ directory, which is the standard path Sauce Labs collects screenshots from. CI changes: - Add *.png to Sauce Labs artifact match patterns - Upload collected replay snapshots via sentry-cli build snapshots Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Use Java API in snapshot test to avoid extension dep (JAVA-504) The Kotlin extension `beforeStoreFrame` comes from `sentry-android-replay` which may not resolve in the UI test module. Use the Java callback API directly instead. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Skip snapshot test on GH emulators and add changelog (JAVA-504) GH Actions emulators don't support screenshot capture for replay, so the ReplaySnapshotTest needs the same assumeThat guard used by ReplayTest. Also adds a changelog entry for the beforeStoreFrame callback. Co-Authored-By: Claude Opus 4.6 (1M context) * Apply suggestion from @markushi Co-authored-by: Markus Hintersteiner * refactor(replay): Replace beforeStoreFrame with ReplaySnapshotObserver (JAVA-504) Move the frame observer API from the core sentry module to sentry-android-replay so it can use Bitmap directly instead of the Hint indirection. The new ReplaySnapshotObserver fun interface lives in the replay module and is set on ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Mark ReplaySnapshotObserver as experimental and use Set in test (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Add @ApiStatus.Experimental to ReplaySnapshotObserver (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Make snapshotObserver public for cross-module access (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Exclude ReplaySnapshotTest when integrations disabled (JAVA-504) Move ReplaySnapshotTest to a conditional androidTestReplay source set so it's only compiled when APPLY_SENTRY_INTEGRATIONS is true. The test imports replay classes that aren't on the classpath otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Copy bitmap before passing to ReplaySnapshotObserver (JAVA-504) Consumers of the observer API receive a copy of the bitmap instead of the replay system's shared instance. This eliminates race conditions and crashes when consumers store or use the bitmap asynchronously. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(replay): Move ReplaySnapshotObserver to SentryReplayOptions with Hint API (JAVA-504) Move ReplaySnapshotObserver from the replay module to SentryReplayOptions in the core module and change the callback signature to use Hint instead of Bitmap. The bitmap is now accessible via TypeCheckHint.REPLAY_FRAME_BITMAP. This allows configuring the observer during Sentry.init{} alongside other replay options, removing the need to cast replayController to ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(replay): Remove unnecessary jetbrains-annotations dependency (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(replay): Rename ReplaySnapshotObserver to ReplayFrameObserver (JAVA-504) Rename the interface to ReplayFrameObserver and the callback method to onMaskedFrameCaptured to clarify that frames have masking applied. Also update the changelog with a usage snippet. Co-Authored-By: Claude Opus 4.6 (1M context) * Format code * fix(replay): Call onMaskedFrameCaptured in File-based onScreenshotRecorded (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(changelog): Move replay entry to Unreleased section (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Markus Hintersteiner Co-authored-by: Sentry Github Bot --- .github/workflows/integration-tests-ui.yml | 22 ++++ .sauce/sentry-uitest-android-ui.yml | 1 + CHANGELOG.md | 23 ++++ .../sentry-uitest-android/build.gradle.kts | 4 + .../uitest/android/ReplaySnapshotTest.kt | 71 ++++++++++++ .../android/replay/ReplayIntegration.kt | 38 ++++++- .../android/replay/ReplayIntegrationTest.kt | 103 ++++++++++++++++++ sentry/api/sentry.api | 7 ++ .../java/io/sentry/SentryReplayOptions.java | 48 ++++++++ .../main/java/io/sentry/TypeCheckHint.java | 3 + 10 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 0549577f629..5206a173362 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -73,6 +73,28 @@ jobs: if: env.SAUCE_USERNAME != null + - name: Install Sentry CLI + if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Upload Replay Snapshots to Sentry + if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + run: | + shopt -s globstar nullglob + pngs=(artifacts/**/*.png) + if [ ${#pngs[@]} -gt 0 ]; then + mkdir -p replay-snapshots + cp "${pngs[@]}" replay-snapshots/ + sentry-cli build snapshots ./replay-snapshots \ + --app-id sentry-android-replay + else + echo "No replay snapshot files found, skipping upload" + fi + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android + - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 diff --git a/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml index 8d84f865c95..a00ee10614b 100644 --- a/.sauce/sentry-uitest-android-ui.yml +++ b/.sauce/sentry-uitest-android-ui.yml @@ -32,4 +32,5 @@ artifacts: when: always match: - junit.xml + - "*.png" directory: ./artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index acc07254df0..3c432e62410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased + +### Features + +- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) + + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + if (bitmap != null) { + try { + // Process the masked replay frame + myAnalyzer.processFrame(bitmap, frameTimestamp, screenName) + } finally { + bitmap.recycle() + } + } + } + } + ``` + ## 8.42.0 ### Features 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 5258a33f92a..1d725b0b595 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -83,6 +83,10 @@ android { val applySentryIntegrations = System.getenv("APPLY_SENTRY_INTEGRATIONS")?.toBoolean() ?: true +if (applySentryIntegrations) { + android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java") +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt new file mode 100644 index 00000000000..1d82a3f8bc0 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -0,0 +1,71 @@ +package io.sentry.uitest.android + +import android.graphics.Bitmap +import android.os.Environment +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint +import java.io.File +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertTrue +import org.hamcrest.CoreMatchers.`is` +import org.junit.Assume.assumeThat +import org.junit.Before + +class ReplaySnapshotTest : BaseUiTest() { + + @Before + fun setup() { + // GH Actions emulators don't support capturing screenshots for replay + @Suppress("KotlinConstantConditions") + assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + } + + @Test + fun captureComposeReplayFrameSnapshots() { + val snapshotsDir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "sauce_labs_custom_screenshots", + ) + .apply { + deleteRecursively() + mkdirs() + } + val frameReceived = CountDownLatch(1) + val capturedScreens = CopyOnWriteArraySet() + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + initSentry { + it.sessionReplay.sessionSampleRate = 1.0 + it.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + val bitmap = + hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + ?: return@ReplayFrameObserver + val name = screenName ?: "unknown" + if (capturedScreens.add(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + } + bitmap.recycle() + frameReceived.countDown() + } + } + + assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") + assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured") + + val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList() + assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk") + assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty") + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + } +} 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 d25827e3c7d..07e91d76486 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 @@ -2,11 +2,13 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All import io.sentry.DataCategory.Replay +import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver @@ -17,8 +19,10 @@ import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayState.CLOSED import io.sentry.android.replay.ReplayState.PAUSED import io.sentry.android.replay.ReplayState.RESUMED @@ -308,13 +312,45 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> + val observer = options.sessionReplay.frameObserver + if (observer != null) { + val copy = bitmap.copy(bitmap.config!!, false) + if (copy != null) { + try { + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy) + observer.onMaskedFrameCaptured(hint, frameTimeStamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in ReplayFrameObserver", e) + copy.recycle() + } + } + } addFrame(bitmap, frameTimeStamp, screen) } checkCanRecord() } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { - captureStrategy?.onScreenshotRecorded { _ -> addFrame(screenshot, frameTimestamp) } + var screen: String? = null + scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } + captureStrategy?.onScreenshotRecorded { _ -> + val observer = options.sessionReplay.frameObserver + if (observer != null) { + val bitmap = BitmapFactory.decodeFile(screenshot.absolutePath) + if (bitmap != null) { + try { + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap) + observer.onMaskedFrameCaptured(hint, frameTimestamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in ReplayFrameObserver", e) + bitmap.recycle() + } + } + } + addFrame(screenshot, frameTimestamp, screen) + } checkCanRecord() } 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 7c86a0ad010..4183fad10ed 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 @@ -18,6 +18,8 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint 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_FRAME_RATE @@ -63,6 +65,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -969,6 +972,106 @@ class ReplayIntegrationTest { assertFalse(replay.isDebugMaskingOverlayEnabled) } + @Test + fun `snapshot observer is invoked with bitmap and metadata`() { + var callbackInvoked = false + var receivedTimestamp = 0L + var receivedScreen: String? = null + var receivedBitmap: Bitmap? = null + + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + fixture.scopes.configureScope { it.screen = "MainActivity" } + replay.register(fixture.scopes, fixture.options) + replay.start() + + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + callbackInvoked = true + receivedTimestamp = frameTimestamp + receivedScreen = screenName + receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + } + + val copyBitmap = mock() + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn copyBitmap + } + replay.onScreenshotRecorded(sourceBitmap) + + assertTrue(callbackInvoked) + assertEquals(1720693523997, receivedTimestamp) + assertEquals("MainActivity", receivedScreen) + assertEquals(copyBitmap, receivedBitmap) + } + + @Test + fun `snapshot observer exception does not prevent frame storage`() { + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") } + + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn mock() + } + replay.onScreenshotRecorded(sourceBitmap) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + + @Test + fun `snapshot observer is not invoked when null`() { + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d2fd5f75dbc..cb03d8fe708 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4063,6 +4063,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback; public fun getErrorReplayDuration ()J + public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver; public fun getFrameRate ()I public fun getNetworkDetailAllowUrls ()Ljava/util/List; public fun getNetworkDetailDenyUrls ()Ljava/util/List; @@ -4085,6 +4086,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V + public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V public fun setNetworkCaptureBodies (Z)V @@ -4105,6 +4107,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z } +public abstract interface class io/sentry/SentryReplayOptions$ReplayFrameObserver { + public abstract fun onMaskedFrameCaptured (Lio/sentry/Hint;JLjava/lang/String;)V +} + public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; @@ -4651,6 +4657,7 @@ public final class io/sentry/TypeCheckHint { public static final field OKHTTP_RESPONSE Ljava/lang/String; public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String; + public static final field REPLAY_FRAME_BITMAP Ljava/lang/String; public static final field SENTRY_DART_SDK_NAME Ljava/lang/String; public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String; public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 6eb4a58e1c2..d1da6510cdb 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -36,6 +36,30 @@ public interface BeforeErrorSamplingCallback { boolean execute(@NotNull SentryEvent event, @NotNull Hint hint); } + /** + * Observer that is notified when a masked replay frame is captured. The frame bitmap (with + * masking already applied) is passed via a {@link Hint} using the key {@link + * TypeCheckHint#REPLAY_FRAME_BITMAP}. + * + *

On Android, retrieve the bitmap with: {@code hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, + * Bitmap.class)}. + * + *

The callback runs on a background thread (replay executor). The bitmap is a copy owned by + * the caller. Call {@code Bitmap.recycle()} when done to free native memory. + */ + @ApiStatus.Experimental + public interface ReplayFrameObserver { + /** + * Called when a masked replay frame is captured. + * + * @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP} + * @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured + * @param screenName the current screen name, or {@code null} if unknown + */ + void onMaskedFrameCaptured( + @NotNull Hint hint, long frameTimestamp, @Nullable String screenName); + } + private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; private volatile boolean customMaskingTracked = false; @@ -211,6 +235,8 @@ public enum SentryReplayQuality { */ private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; + @ApiStatus.Experimental private @Nullable ReplayFrameObserver frameObserver; + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { // Add default mask classes directly without setting usingCustomMasking flag @@ -550,4 +576,26 @@ public void setBeforeErrorSampling( final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) { this.beforeErrorSampling = beforeErrorSampling; } + + /** + * Gets the observer that is notified when a masked replay frame is captured. + * + * @return the observer, or {@code null} if not set + */ + @ApiStatus.Experimental + public @Nullable ReplayFrameObserver getFrameObserver() { + return frameObserver; + } + + /** + * Sets the observer that is notified when a masked replay frame is captured. The frame bitmap + * (with masking already applied) is passed via a {@link Hint} using the key {@link + * TypeCheckHint#REPLAY_FRAME_BITMAP}. + * + * @param frameObserver the observer, or {@code null} to clear + */ + @ApiStatus.Experimental + public void setFrameObserver(final @Nullable ReplayFrameObserver frameObserver) { + this.frameObserver = frameObserver; + } } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index 189050570b4..b3b061e847c 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -140,4 +140,7 @@ public final class TypeCheckHint { /** Used for Ktor Request breadcrumbs. */ public static final String KTOR_CLIENT_REQUEST = "ktorClient:request"; + + /** Used for Session Replay frame bitmaps in the ReplayFrameObserver callback. */ + public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap"; } From b8ed47ac7f5486701a89e834a24d8e661ffbbec6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 27 May 2026 17:26:10 +0200 Subject: [PATCH 057/276] chore(build): Apply Develocity build scans plugin (#5469) * chore(build): Apply Develocity build scans plugin Adds the com.gradle.develocity plugin to settings.gradle.kts to publish a build scan on every Gradle invocation. This enables build performance insights and debugging via scans.gradle.com. Co-Authored-By: Claude Opus 4.6 * ref(build): Remove redundant publishingOnlyIf Publishing on every build is the default behavior once the terms of use are accepted. Co-Authored-By: Claude Opus 4.6 * chore(build): Apply common custom user data plugin Adds the com.gradle.common-custom-user-data-gradle-plugin to capture additional build metadata (Git, CI environment) in Develocity build scans. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- settings.gradle.kts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b1c606bc64..c435c382b79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,18 @@ pluginManagement { } } +plugins { + id("com.gradle.develocity") version "4.4.2" + id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0" +} + +develocity { + buildScan { + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + } +} + dependencyResolutionManagement { repositories { google() From a911f6daa9921a54b98a0b5f84e2b3c3d41a5c80 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 27 May 2026 17:28:02 +0200 Subject: [PATCH 058/276] fix(changelog): Move ART memory entry to unreleased (#5470) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c432e62410..684d2b68969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ } } ``` +- Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) ## 8.42.0 @@ -31,7 +32,6 @@ - Enable via `options.isAttachRawTombstone = true` or manifest: `` - Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) -- Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) ### Dependencies From c1702e5dd181f26299ae9251266b039a336e936e Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Wed, 27 May 2026 15:29:25 +0000 Subject: [PATCH 059/276] release: 8.43.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 684d2b68969..c21a8cce7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.43.0 ### Features diff --git a/gradle.properties b/gradle.properties index 2eb795118a0..9739db8a573 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.42.0 +versionName=8.43.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From e5cd1c6063fb69b258319747582f627478860336 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 28 May 2026 10:10:46 +0200 Subject: [PATCH 060/276] ci: Fix Spring Boot matrix version updates (#5372) * ci: Fix Spring Boot matrix version updates Match the TOML version catalog format when overriding Spring Boot versions in matrix jobs. Preserve whitespace around the assignment and replace the quoted version value so the CI jobs actually test the requested matrix version. Co-Authored-By: Claude * ci: Limit Spring Boot matrix to supported versions The matrix jobs now actually update the version catalog. Remove Spring Boot versions that the current sample setup cannot build with the repository's Spring GraphQL integrations and Gradle version. Co-Authored-By: Claude * ci(spring): Restore Spring Boot matrix coverage Expand the Spring Boot 2 and 3 CI matrices to cover supported minor versions. Exclude GraphQL from Spring Boot 2 versions before 2.7 because the starter is unavailable there. Keep the Spring Boot 3 Gradle plugin pinned to a Gradle-compatible version while importing the tested Spring Boot BOM in samples, so the matrix exercises the intended runtime dependencies. Co-Authored-By: Claude * fix(spring): Avoid deprecated Reactor scheduler in sample Remove the explicit elastic scheduler from the Spring Boot WebFlux sample. Mono.delay already schedules the delayed work, and using Schedulers.elastic triggers deprecation warnings that fail CI under -Werror. Co-Authored-By: Claude * fix(spring): Exclude Kafka from old Boot 2 matrix Spring Kafka sample support depends on newer Spring Boot 2 dependency management. Exclude Kafka sources, profile startup, and system tests when the matrix runs Boot 2 versions before 2.7. Keep the system test classpath aligned with the SDK test helpers by importing the OkHttp and Jackson BOMs after the tested Spring Boot BOMs. Co-Authored-By: Claude * fix(spring): Support older Reactor WebFlux APIs Spring Boot 2.1 and 2.2 use Reactor versions without Mono.doFirst or Schedulers.onScheduleHook. Avoid those calls in the Boot 2 WebFlux integration so old matrix jobs can start and serve requests. Co-Authored-By: Claude * ci(spring): Skip OTel no-agent sample on old Boot 2 Spring Boot 2.1 and 2.2 cannot parse newer OpenTelemetry auto-configuration classes during startup. Keep the matrix coverage for supported samples and skip the no-agent OTel sample for those versions. Co-Authored-By: Claude * ci(spring): Drop old Boot 2 matrix versions Remove Spring Boot 2.1 and 2.2 from the matrix instead of carrying WebFlux compatibility changes for their older Reactor and Spring APIs. Restore the WebFlux filter implementation now that those versions are no longer tested. Co-Authored-By: Claude * fix(spring): Restore WebFlux schedule hook registration Revert the compatibility guard for Reactor versions that are no longer covered by the Spring Boot matrix. Co-Authored-By: Claude * fix(spring): Support older Spring GraphQL options API Use the erased Consumer signature in the batch loader registry wrapper so the code compiles with both Spring GraphQL 1.2/1.3 and 1.4. Let the Spring Boot 3 Gradle plugin follow the tested matrix version instead of pinning it separately. Co-Authored-By: Claude * build: Remove redundant test source set config Gradle already includes src/test/java in the test source set by default. Remove explicit duplicate source set configuration from the Spring modules and samples touched by this PR. Co-Authored-By: Claude * style: Import Kotlin JVM target in Gradle scripts Use the JvmTarget import in Spring Gradle scripts touched by this PR instead of repeating the fully qualified class name. Co-Authored-By: Claude * ci: Fail when Spring Boot version update misses Use a replacement command that exits non-zero when the expected Spring Boot version entry is not found. This prevents matrix jobs from silently running against the wrong dependency version. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .github/workflows/spring-boot-2-matrix.yml | 11 +++- .github/workflows/spring-boot-3-matrix.yml | 7 +- .github/workflows/spring-boot-4-matrix.yml | 5 +- gradle/libs.versions.toml | 1 + .../build.gradle.kts | 16 +++-- .../build.gradle.kts | 15 +++-- .../build.gradle.kts | 15 +++-- .../build.gradle.kts | 65 ++++++++++++++----- .../build.gradle.kts | 63 ++++++++++++++---- .../build.gradle.kts | 11 +++- .../build.gradle.kts | 41 ++++++++++-- .../boot/DistributedTracingController.java | 15 +++-- .../samples/spring/boot/PersonService.java | 2 - .../build.gradle.kts | 61 +++++++++++++---- .../build.gradle.kts | 12 ++-- .../sentry-samples-spring/build.gradle.kts | 6 +- sentry-spring-boot/build.gradle.kts | 7 +- .../spring/boot/SentryAutoConfiguration.java | 14 ++-- .../boot/SentrySpringVersionChecker.java | 3 +- .../graphql/SentryBatchLoaderRegistry.java | 4 +- sentry-spring/build.gradle.kts | 7 +- test/system-test-runner.py | 7 +- 22 files changed, 272 insertions(+), 116 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 9a69765657c..48ed0a69665 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '2.1.0', '2.2.5', '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -64,8 +64,13 @@ jobs: - name: Update Spring Boot 2.x version run: | - sed -i 's/^springboot2=.*/springboot2=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 2.x version to ${{ matrix.springboot-version }}" + springboot_version="${{ matrix.springboot-version }}" + if [[ ! "$springboot_version" =~ ^2\.7\. ]]; then + echo "ORG_GRADLE_PROJECT_excludeGraphql=true" >> "$GITHUB_ENV" + echo "ORG_GRADLE_PROJECT_excludeKafka=true" >> "$GITHUB_ENV" + fi + perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 2.x version to $springboot_version" - name: Exclude android modules from build run: | diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index c6a83c597fb..0e00608efe2 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '3.0.0', '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] + springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -64,8 +64,9 @@ jobs: - name: Update Spring Boot 3.x version run: | - sed -i 's/^springboot3=.*/springboot3=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 3.x version to ${{ matrix.springboot-version }}" + springboot_version="${{ matrix.springboot-version }}" + perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 3.x version to $springboot_version" - name: Exclude android modules from build run: | diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 93d314de2e3..c6ae6195f59 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -64,8 +64,9 @@ jobs: - name: Update Spring Boot 4.x version run: | - sed -i 's/^springboot4=.*/springboot4=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 4.x version to ${{ matrix.springboot-version }}" + springboot_version="${{ matrix.springboot-version }}" + perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 4.x version to $springboot_version" - name: Exclude android modules from build run: | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e580db7498..12e24536d7e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -160,6 +160,7 @@ slf4j2-api = { module = "org.slf4j:slf4j-api", version = "2.0.5" } spotlessLib = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotless"} springboot2-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springboot2" } springboot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot2" } +spring-graphql = { module = "org.springframework.graphql:spring-graphql", version = "1.0.6" } springboot-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot2" } springboot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot2" } springboot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot2" } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index ed0af32b031..7966e621ebd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -18,6 +19,13 @@ java.targetCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") + mavenBom(libs.otel.instrumentation.bom.get().toString()) + } +} + // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -27,10 +35,10 @@ configure { } tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 } } @@ -79,10 +87,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } - -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index d3d66c469b7..3c7e00ae552 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.run.BootRun @@ -19,6 +20,12 @@ java.targetCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") + } +} + // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -27,14 +34,12 @@ configure { targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} +tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 } } @@ -83,8 +88,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("bootRunWithAgent").configure { group = "application" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index ae3ef70ad70..d5e4caa595d 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -18,6 +19,12 @@ java.targetCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") + } +} + // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -26,14 +33,12 @@ configure { targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} +tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 } } @@ -85,8 +90,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index f1665f513d1..0b8c5a181e7 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -15,25 +16,35 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 +java.sourceCompatibility = JavaVersion.VERSION_11 -java.targetCompatibility = JavaVersion.VERSION_17 +java.targetCompatibility = JavaVersion.VERSION_11 repositories { mavenCentral() } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 +fun springBoot2SupportsOptionalIntegrations(): Boolean { + val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") + val parts = version.split(".").map { it.toIntOrNull() ?: 0 } + val major = parts.getOrElse(0) { 0 } + val minor = parts.getOrElse(1) { 0 } + return major > 2 || (major == 2 && minor >= 7) } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 +val includeGraphql = + !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() +val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() + +configure { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_11 } + tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_11 } } @@ -43,7 +54,9 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - implementation(libs.springboot.starter.graphql) + if (includeGraphql) { + implementation(libs.springboot.starter.graphql) + } implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -55,14 +68,17 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) + if (includeGraphql) { + implementation(projects.sentryGraphql) + } implementation(projects.sentryQuartz) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) - // kafka - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) + if (includeKafka) { + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) + } // database query tracing implementation(projects.sentryJdbc) @@ -103,7 +119,18 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } +configure { + main { + if (!includeGraphql) { + java.exclude("**/graphql/**") + resources.exclude("graphql/**") + } + if (!includeKafka) { + java.exclude("**/queues/kafka/**") + resources.exclude("application-kafka.properties") + } + } +} tasks.register("systemTest").configure { group = "verification" @@ -121,7 +148,15 @@ tasks.register("systemTest").configure { minHeapSize = "128m" maxHeapSize = "1g" - filter { includeTestsMatching("io.sentry.systemtest*") } + filter { + includeTestsMatching("io.sentry.systemtest*") + if (!includeGraphql) { + excludeTestsMatching("io.sentry.systemtest.Graphql*") + } + if (!includeKafka) { + excludeTestsMatching("io.sentry.systemtest.Kafka*") + } + } } tasks.named("test").configure { diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 7c84875ca07..b78f1f01881 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -15,22 +16,34 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 +java.sourceCompatibility = JavaVersion.VERSION_11 -java.targetCompatibility = JavaVersion.VERSION_17 +java.targetCompatibility = JavaVersion.VERSION_11 repositories { mavenCentral() } +fun springBoot2SupportsOptionalIntegrations(): Boolean { + val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") + val parts = version.split(".").map { it.toIntOrNull() ?: 0 } + val major = parts.getOrElse(0) { 0 } + val minor = parts.getOrElse(1) { 0 } + return major > 2 || (major == 2 && minor >= 7) +} + +val includeGraphql = + !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() +val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() + configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_11 kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_11 } } @@ -39,7 +52,9 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - implementation(libs.springboot.starter.graphql) + if (includeGraphql) { + implementation(libs.springboot.starter.graphql) + } implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -51,14 +66,17 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) + if (includeGraphql) { + implementation(projects.sentryGraphql) + } implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) implementation(libs.otel) - // kafka - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) + if (includeKafka) { + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) + } // database query tracing implementation(projects.sentryJdbc) @@ -99,7 +117,18 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } +configure { + main { + if (!includeGraphql) { + java.exclude("**/graphql/**") + resources.exclude("graphql/**") + } + if (!includeKafka) { + java.exclude("**/queues/kafka/**") + resources.exclude("application-kafka.properties") + } + } +} tasks.register("bootRunWithAgent").configure { group = "application" @@ -141,7 +170,15 @@ tasks.register("systemTest").configure { minHeapSize = "128m" maxHeapSize = "1g" - filter { includeTestsMatching("io.sentry.systemtest*") } + filter { + includeTestsMatching("io.sentry.systemtest*") + if (!includeGraphql) { + excludeTestsMatching("io.sentry.systemtest.Graphql*") + } + if (!includeKafka) { + excludeTestsMatching("io.sentry.systemtest.Kafka*") + } + } } tasks.named("test").configure { diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index d5b04543576..8b2079ddd9c 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -18,6 +19,12 @@ java.targetCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") + } +} + // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -50,12 +57,10 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index b10b30737d8..2127dbfd79f 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -15,22 +16,36 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 +java.sourceCompatibility = JavaVersion.VERSION_11 -java.targetCompatibility = JavaVersion.VERSION_17 +java.targetCompatibility = JavaVersion.VERSION_11 repositories { mavenCentral() } +fun springBoot2SupportsGraphql(): Boolean { + val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") + val parts = version.split(".").map { it.toIntOrNull() ?: 0 } + val major = parts.getOrElse(0) { 0 } + val minor = parts.getOrElse(1) { 0 } + return major > 2 || (major == 2 && minor >= 7) +} + +val includeGraphql = !project.hasProperty("excludeGraphql") && springBoot2SupportsGraphql() + dependencies { implementation(platform(libs.springboot2.bom)) implementation(libs.springboot.starter.actuator) - implementation(libs.springboot.starter.graphql) + if (includeGraphql) { + implementation(libs.springboot.starter.graphql) + } implementation(libs.springboot.starter.webflux) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) + if (includeGraphql) { + implementation(projects.sentryGraphql) + } implementation(projects.sentryAsyncProfiler) testImplementation(kotlin(Config.kotlinStdLib)) @@ -68,12 +83,19 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } +configure { + main { + if (!includeGraphql) { + java.exclude("**/graphql/**") + resources.exclude("graphql/**") + } + } +} tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_11 } } @@ -93,7 +115,12 @@ tasks.register("systemTest").configure { minHeapSize = "128m" maxHeapSize = "1g" - filter { includeTestsMatching("io.sentry.systemtest*") } + filter { + includeTestsMatching("io.sentry.systemtest*") + if (!includeGraphql) { + excludeTestsMatching("io.sentry.systemtest.Graphql*") + } + } } tasks.named("test").configure { diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java index cd69d854006..4bd6bb77bfb 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java @@ -1,6 +1,7 @@ package io.sentry.samples.spring.boot; -import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -17,6 +18,10 @@ @RequestMapping("/tracing/") public class DistributedTracingController { private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private static final String BASIC_AUTH = + "Basic " + + Base64.getEncoder().encodeToString("user:password".getBytes(StandardCharsets.UTF_8)); + private final WebClient webClient; public DistributedTracingController(WebClient webClient) { @@ -28,9 +33,7 @@ Mono person(@PathVariable Long id) { return webClient .get() .uri("http://localhost:8080/person/{id}", id) - .header( - HttpHeaders.AUTHORIZATION, - "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .header(HttpHeaders.AUTHORIZATION, BASIC_AUTH) .retrieve() .bodyToMono(Person.class) .map(response -> response); @@ -41,9 +44,7 @@ Mono create(@RequestBody Person person) { return webClient .post() .uri("http://localhost:8080/person/") - .header( - HttpHeaders.AUTHORIZATION, - "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .header(HttpHeaders.AUTHORIZATION, BASIC_AUTH) .body(Mono.just(person), Person.class) .retrieve() .bodyToMono(Person.class) diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonService.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonService.java index ed7422d9d0b..4a9ae98a447 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonService.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonService.java @@ -4,14 +4,12 @@ import java.time.Duration; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; @Service public class PersonService { Mono create(Person person) { return Mono.delay(Duration.ofMillis(100)) - .publishOn(Schedulers.boundedElastic()) .doOnNext(__ -> Sentry.captureMessage("Creating person")) .map(__ -> person); } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index cc535c725e1..0a2a6f2da57 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -15,21 +16,33 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 +java.sourceCompatibility = JavaVersion.VERSION_11 -java.targetCompatibility = JavaVersion.VERSION_17 +java.targetCompatibility = JavaVersion.VERSION_11 repositories { mavenCentral() } +fun springBoot2SupportsOptionalIntegrations(): Boolean { + val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") + val parts = version.split(".").map { it.toIntOrNull() ?: 0 } + val major = parts.getOrElse(0) { 0 } + val minor = parts.getOrElse(1) { 0 } + return major > 2 || (major == 2 && minor >= 7) +} + +val includeGraphql = + !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() +val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() + configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_11 } } @@ -38,7 +51,9 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - implementation(libs.springboot.starter.graphql) + if (includeGraphql) { + implementation(libs.springboot.starter.graphql) + } implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -48,15 +63,18 @@ dependencies { implementation(libs.springboot.starter.websocket) implementation(libs.caffeine) - // kafka - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) + if (includeKafka) { + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) + } implementation(Config.Libs.aspectj) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) + if (includeGraphql) { + implementation(projects.sentryGraphql) + } implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) @@ -102,7 +120,18 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } +configure { + main { + if (!includeGraphql) { + java.exclude("**/graphql/**") + resources.exclude("graphql/**") + } + if (!includeKafka) { + java.exclude("**/queues/kafka/**") + resources.exclude("application-kafka.properties") + } + } +} tasks.register("systemTest").configure { group = "verification" @@ -120,7 +149,15 @@ tasks.register("systemTest").configure { minHeapSize = "128m" maxHeapSize = "1g" - filter { includeTestsMatching("io.sentry.systemtest*") } + filter { + includeTestsMatching("io.sentry.systemtest*") + if (!includeGraphql) { + excludeTestsMatching("io.sentry.systemtest.Graphql*") + } + if (!includeKafka) { + excludeTestsMatching("io.sentry.systemtest.Kafka*") + } + } } tasks.named("test").configure { diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 319431e71d2..3dec793e5c9 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -1,9 +1,8 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { application - alias(libs.plugins.springboot3) apply false alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) @@ -31,8 +30,9 @@ extra["kotlin-coroutines.version"] = "1.9.0" dependencyManagement { imports { - mavenBom(SpringBootPlugin.BOM_COORDINATES) + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") mavenBom(libs.kotlin.bom.get().toString()) + mavenBom(libs.jackson.bom.get().toString()) } } @@ -57,7 +57,7 @@ dependencies { testImplementation(projects.sentrySystemTestSupport) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.springboot.starter.test) { + testImplementation(libs.springboot3.starter.test) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } } @@ -65,12 +65,10 @@ dependencies { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.jvmTarget = JvmTarget.JVM_17 } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 446baf3a696..02e7f632450 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -33,6 +34,7 @@ dependencyManagement { mavenBom(libs.springboot2.bom.get().toString()) mavenBom(libs.kotlin.bom.get().toString()) mavenBom(libs.jackson.bom.get().toString()) + mavenBom(libs.okhttp.bom.get().toString()) } } @@ -64,12 +66,10 @@ dependencies { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 74f5d7c87bb..e54112ae54c 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -1,4 +1,5 @@ import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -12,7 +13,7 @@ plugins { } tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.jvmTarget = 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 } @@ -35,7 +36,7 @@ dependencies { compileOnly(libs.servlet.api) compileOnly(libs.springboot.starter) compileOnly(libs.springboot.starter.aop) - compileOnly(libs.springboot.starter.graphql) + compileOnly(libs.spring.graphql) compileOnly(libs.springboot.starter.quartz) compileOnly(libs.springboot.starter.security) compileOnly(libs.spring.kafka2) @@ -84,8 +85,6 @@ dependencies { testImplementation(projects.sentryAsyncProfiler) } -configure { test { java.srcDir("src/test/java") } } - jacoco { toolVersion = libs.versions.jacoco.get() } tasks.jacocoTestReport { diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index c7d5a892e9f..f89f5c5bb31 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -1,7 +1,6 @@ package io.sentry.spring.boot; import com.jakewharton.nopen.annotation.Open; -import graphql.GraphQLError; import io.sentry.EventProcessor; import io.sentry.IScopes; import io.sentry.ISpanFactory; @@ -12,7 +11,6 @@ import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; -import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.protocol.SdkVersion; import io.sentry.quartz.SentryJobListener; import io.sentry.spring.ContextTagsEventProcessor; @@ -75,7 +73,6 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; -import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; import org.springframework.scheduling.quartz.SchedulerFactoryBean; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.client.RestTemplate; @@ -203,11 +200,12 @@ static class ContextTagsEventProcessorConfiguration { @Configuration(proxyBeanMethods = false) @Import(SentryGraphqlAutoConfiguration.class) @Open - @ConditionalOnClass({ - SentryGraphqlExceptionHandler.class, - DataFetcherExceptionResolverAdapter.class, - GraphQLError.class - }) + @ConditionalOnClass( + name = { + "io.sentry.graphql.SentryGraphqlExceptionHandler", + "org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter", + "graphql.GraphQLError" + }) static class GraphqlConfiguration {} @Configuration(proxyBeanMethods = false) diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentrySpringVersionChecker.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentrySpringVersionChecker.java index 1cbcb4f090c..2da1a3dd8d9 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentrySpringVersionChecker.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentrySpringVersionChecker.java @@ -14,7 +14,8 @@ final class SentrySpringVersionChecker @Override public void onApplicationEvent(ApplicationContextInitializedEvent event) { - if (!SpringBootVersion.getVersion().startsWith("2")) { + String springBootVersion = SpringBootVersion.getVersion(); + if (springBootVersion != null && !springBootVersion.startsWith("2")) { logger.warn("############################### WARNING ###############################"); logger.warn("## ##"); logger.warn("## !Incompatible Spring Boot Version detected! ##"); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java index a75aa281349..4e7c3665aae 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java @@ -75,8 +75,8 @@ public BatchLoaderRegistry.RegistrationSpec withName(String name) { } @Override - public BatchLoaderRegistry.RegistrationSpec withOptions( - Consumer optionsConsumer) { + @SuppressWarnings({"rawtypes", "unchecked"}) + public BatchLoaderRegistry.RegistrationSpec withOptions(Consumer optionsConsumer) { return delegate.withOptions(optionsConsumer); } diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index c4c75cb5f07..64380f7e7f4 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -1,4 +1,5 @@ import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -12,7 +13,7 @@ plugins { } tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.jvmTarget = 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 } @@ -34,7 +35,7 @@ dependencies { compileOnly(libs.otel) compileOnly(libs.servlet.api) compileOnly(libs.slf4j.api) - compileOnly(libs.springboot.starter.graphql) + compileOnly(libs.spring.graphql) compileOnly(libs.springboot.starter.quartz) compileOnly(libs.spring.kafka2) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) @@ -63,8 +64,6 @@ dependencies { testImplementation(libs.springboot.starter.webflux) } -configure { test { java.srcDir("src/test/java") } } - jacoco { toolVersion = libs.versions.jacoco.get() } tasks.jacocoTestReport { diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 784448715e9..7dd7530c8bd 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -224,11 +224,14 @@ def kill_process(self, pid: int, name: str) -> None: except (OSError, ProcessLookupError): print(f"Process {pid} was already dead") + def exclude_kafka(self) -> bool: + return os.environ.get("ORG_GRADLE_PROJECT_excludeKafka") == "true" + def module_requires_kafka(self, sample_module: str) -> bool: - return sample_module in KAFKA_BROKER_REQUIRED_MODULES + return not self.exclude_kafka() and sample_module in KAFKA_BROKER_REQUIRED_MODULES def module_requires_kafka_profile(self, sample_module: str) -> bool: - return sample_module in KAFKA_PROFILE_REQUIRED_MODULES + return not self.exclude_kafka() and sample_module in KAFKA_PROFILE_REQUIRED_MODULES def wait_for_port(self, host: str, port: int, max_attempts: int = 20) -> bool: for _ in range(max_attempts): From ca6b6d88192958c95ad9494d044c45dcc460c8e2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 28 May 2026 10:11:26 +0200 Subject: [PATCH 061/276] fix(skill): Detect stacked PR context from branch (#5223) * fix(skill): Detect stacked PR context from branch Update the create-java-pr skill to infer standalone vs stacked PR mode\nfrom git branch and existing PR relationships.\n\nWhen running on main/master, default to standalone PR mode and only\nenter stack mode when explicitly requested by the user.\n\nCo-Authored-By: Claude * fix(create-java-pr): Detect collection branches with downstream PRs Check for downstream PRs even when the current branch already has a PR targeting main/master. This prevents collection branches in a stacked PR flow from being misclassified as standalone PR context. Co-Authored-By: Claude * fix(create-java-pr): Map stack base detection to defined PR type When downstream PRs are found for a branch, classify the result as an existing stack flow instead of an undefined "stack base context". Clarify that the next PR in an existing stack can target either the previous stack PR branch or the collection branch, so all detection outcomes map to actionable PR types. Co-Authored-By: Claude * fix(skills): Add missing standalone PR fallback for fresh feature branches The decision tree in create-java-pr Step 0 had a gap: when a non-main branch has no existing PR and no downstream PRs target it, no outcome was specified. This is the most common case (fresh feature branch). Add explicit fallback to standalone PR context, matching the behavior of the parallel branch where a PR exists with base main and no downstream PRs. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .claude/skills/create-java-pr/SKILL.md | 45 ++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/.claude/skills/create-java-pr/SKILL.md b/.claude/skills/create-java-pr/SKILL.md index 6d5bb34edb3..e2a9b9bc785 100644 --- a/.claude/skills/create-java-pr/SKILL.md +++ b/.claude/skills/create-java-pr/SKILL.md @@ -9,15 +9,48 @@ Prepare local changes and create a pull request for the sentry-java repo. **Required reading:** Before proceeding, read `.cursor/rules/pr.mdc` for the full PR and stacked PR workflow details. That file is the source of truth for PR conventions, stack comment format, branch naming, and merge strategy. -## Step 0: Determine PR Type +## Step 0: Determine PR Type From Git Branch Context -Ask the user (or infer from context) whether this is: +Infer PR type from the current branch before asking the user. -- **Standalone PR** — a regular PR targeting `main`. Follow Steps 1–6 as written. -- **First PR of a new stack** — ask for a topic name (e.g. "Global Attributes"). Create a collection branch from `main`, then branch the first PR off it. The first PR targets the collection branch. -- **Next PR in an existing stack** — identify the previous stack branch and topic. This PR targets the previous stack branch. +1. Get current branch: -If the user mentions "stack", "stacked PR", or provides a topic name with a number (e.g. `[Topic 2]`), treat it as a stacked PR. See `.cursor/rules/pr.mdc` § "Stacked PRs" for full details. +```bash +git branch --show-current +``` + +2. Apply these rules: + +- **If branch is `main` or `master`**: default to a **standalone PR**. + - Do **not** assume stack mode from `main`. + - Only use stack mode if the user explicitly asks for a stacked PR. +- **If branch is not `main`/`master`**: + - Check whether that branch already has a PR and what its base is: + ```bash + gh pr list --head "$(git branch --show-current)" --json number,baseRefName,title --jq '.[0]' + ``` + - If that branch PR exists and `baseRefName` is **not** `main`/`master`, treat the work as a **stacked PR context**. + - If that branch PR exists and `baseRefName` **is** `main`/`master`, also check whether other PRs target the current branch: + ```bash + gh pr list --base "$(git branch --show-current)" --json number,headRefName,title + ``` + - If there are downstream PRs, treat this as **next PR in an existing stack** with the current branch as the stack base (collection branch). + - If there are no downstream PRs, treat it as **standalone PR context**. + - If no PR exists for the current branch, check whether other PRs target it: + ```bash + gh pr list --base "$(git branch --show-current)" --json number,headRefName,title + ``` + - If there are downstream PRs, treat this as **next PR in an existing stack** with the current branch as the stack base (collection branch). + - If there are no downstream PRs either, treat it as **standalone PR context** (fresh feature branch). + +3. If signals are mixed or ambiguous, ask one focused question to confirm. + +PR types: +- **Standalone PR** — regular PR targeting `main`. +- **First PR of a new stack** — create collection branch from `main`, then first PR off it. +- **Next PR in an existing stack** — target the current stack base branch (usually the previous stack PR branch, or the collection branch if creating the first follow-up PR from the collection branch). + +If the user explicitly says "stack", "stacked PR", or provides numbered stack titles (e.g. `[Topic 2]`), honor that even if branch heuristics are inconclusive. ## Step 1: Ensure Feature Branch From 9404243e2540f4ae8b6d734afb446c64bb976076 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 29 May 2026 08:33:54 +0200 Subject: [PATCH 062/276] chore(ci): Update gradle/actions from v5.0.2 to v6.1.0 (#5471) * chore(ci): Update gradle/actions from v5.0.2 to v6.1.0 Remove stale workaround comments for gradle/actions#21 (now closed). Co-Authored-By: Claude Opus 4.6 * Restore workaround comments Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.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 | 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, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 7ef34ea563e..f3ad7240438 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -39,7 +39,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5e89b2be40..6817ce53337 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae8d78d305e..34bf6241fd6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -31,7 +31,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 1d1493bb7bf..23dd0134203 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Set up Java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index c338400f958..4109c2a2947 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -19,7 +19,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index b50d42f7d1d..090a6360745 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 2f5a63f747a..bbe8c709587 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -88,7 +88,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 1fd6c5c2c09..6d0aefab386 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -36,7 +36,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 4d6c952a161..85731127f36 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 5206a173362..fbb8018da06 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -33,7 +33,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 3ba2d299e54..3fb0b162750 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Build artifacts run: make publish diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 48ed0a69665..91154a2c7b3 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 0e00608efe2..f0b3fbe279b 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index c6ae6195f59..68bdd38f2ec 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index ea6a53a8750..fc66f6744b5 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -118,7 +118,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From 4e3e79da82431a9fd3081a829281371c79709f19 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 29 May 2026 16:37:03 +0200 Subject: [PATCH 063/276] fix(replay): Associate trace IDs with replay segments (#5473) * fix(replay): Populate trace_ids in replay events for trace search When a transaction is captured while replay is recording, the trace ID is now registered with the replay controller and included in the next replay segment. This enables searching for replays by trace ID in the Sentry UI. Fixes #5346 Slack thread: https://sentry.slack.com/archives/CP4UUUF1S/p1779889727948439?thread_ts=1777385469.860819&cid=CP4UUUF1S https://claude.ai/code/session_012wjHQtsEPzcrxSMCufxrDY * docs: Add changelog entry for trace_ids fix https://claude.ai/code/session_012wjHQtsEPzcrxSMCufxrDY * Format code * api dump --------- Co-authored-by: Claude Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 6 ++ .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 7 ++ .../replay/capture/BaseCaptureStrategy.kt | 29 ++++++- .../android/replay/capture/CaptureStrategy.kt | 6 ++ .../android/replay/ReplayIntegrationTest.kt | 32 ++++++++ .../capture/SessionCaptureStrategyTest.kt | 79 +++++++++++++++++++ sentry/api/sentry.api | 2 + .../java/io/sentry/NoOpReplayController.java | 3 + .../main/java/io/sentry/ReplayController.java | 8 ++ .../src/main/java/io/sentry/SentryClient.java | 7 ++ .../test/java/io/sentry/SentryClientTest.kt | 17 ++++ 12 files changed, 195 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c21a8cce7d7..1bbbede5794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473)) + ## 8.43.0 ### Features diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index aeabe9c05c1..12fe214176d 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 registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()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 07e91d76486..116ab45af06 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 @@ -280,6 +280,13 @@ public class ReplayIntegration( override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled + override fun registerTraceId(traceId: SentryId) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerTraceId(traceId) + } + 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 2277f6c33a1..dab98ec4e24 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,6 +54,8 @@ internal abstract class BaseCaptureStrategy( ) : CaptureStrategy { 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 val persistingExecutor: ScheduledExecutorService by lazy { @@ -96,6 +98,8 @@ internal abstract class BaseCaptureStrategy( override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) protected val currentEvents: Deque = ConcurrentLinkedDeque() + private val traceIdsLock = Any() + private val currentTraceIds: MutableList = mutableListOf() override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) { cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) @@ -135,8 +139,14 @@ internal abstract class BaseCaptureStrategy( screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents, - ): ReplaySegment = - createSegment( + ): ReplaySegment { + val traceIds = + synchronized(traceIdsLock) { + val ids = currentTraceIds.toList() + currentTraceIds.clear() + ids + } + return createSegment( scopes, options, duration, @@ -152,7 +162,9 @@ internal abstract class BaseCaptureStrategy( screenAtStart, breadcrumbs, events, + traceIds, ) + } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { this.recorderConfig = recorderConfig @@ -167,6 +179,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) + } + } + } + } + } + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 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 8e078161c15..6dc391a15ec 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 @@ -53,6 +53,8 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy + fun registerTraceId(traceId: SentryId) + companion object { private fun Breadcrumb?.isNetworkAvailable(): Boolean = this != null && @@ -84,6 +86,7 @@ internal interface CaptureStrategy { screenAtStart: String?, breadcrumbs: List?, events: Deque, + traceIds: List = emptyList(), ): ReplaySegment { val generatedVideo = cache?.createVideoOf( @@ -122,6 +125,7 @@ internal interface CaptureStrategy { screenAtStart, replayBreadcrumbs, events, + traceIds, ) } @@ -141,6 +145,7 @@ internal interface CaptureStrategy { screenAtStart: String?, breadcrumbs: List, events: Deque, + traceIds: List, ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = @@ -152,6 +157,7 @@ internal interface CaptureStrategy { this.replayStartTimestamp = segmentTimestamp this.replayType = replayType this.videoFile = video + this.traceIds = traceIds } val recordingPayload = mutableListOf() 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 4183fad10ed..3df0c9f005f 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 @@ -1072,6 +1072,38 @@ class ReplayIntegrationTest { verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) } + @Test + fun `registerTraceId does nothing when replay is not started`() { + val replay = fixture.getSut(context) + + replay.register(fixture.scopes, fixture.options) + // Don't call start() + + // Should not throw + replay.registerTraceId(SentryId()) + } + + @Test + fun `registerTraceId forwards to capture strategy when recording`() { + var traceIdRegistered: SentryId? = null + val captureStrategy = + mock { + on { currentReplayId }.thenReturn(SentryId()) + doAnswer { traceIdRegistered = it.arguments[0] as SentryId } + .whenever(mock) + .registerTraceId(any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val traceId = SentryId() + replay.registerTraceId(traceId) + + assertEquals(traceId, traceIdRegistered) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, 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 9982c6623b2..b5a00bc624b 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 @@ -475,4 +475,83 @@ class SessionCaptureStrategyTest { }, ) } + + @Test + fun `registerTraceId includes trace IDs in next segment`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + val traceId1 = SentryId() + val traceId2 = SentryId() + strategy.registerTraceId(traceId1) + strategy.registerTraceId(traceId2) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.traceIds?.size == 2 && + event.traceIds!!.contains(traceId1.toString()) && + event.traceIds!!.contains(traceId2.toString()) + }, + any(), + ) + } + + @Test + fun `registerTraceId clears trace IDs after segment is created`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + val traceId = SentryId() + strategy.registerTraceId(traceId) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.traceIds?.contains(traceId.toString()) == true + }, + any(), + ) + + // trigger another segment, trace IDs should be cleared + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.traceIds.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerTraceId ignores empty trace ID`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerTraceId(SentryId.EMPTY_ID) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.traceIds.isNullOrEmpty() }, + any(), + ) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index cb03d8fe708..4757be4894a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1703,6 +1703,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z public fun pause ()V + public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V @@ -2344,6 +2345,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 registerTraceId (Lio/sentry/protocol/SentryId;)V public abstract fun resume ()V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public abstract fun start ()V diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index fec95b5d66d..2f6de9740d2 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -57,4 +57,7 @@ public void enableDebugMaskingOverlay() {} @Override public void disableDebugMaskingOverlay() {} + + @Override + public void registerTraceId(@NotNull SentryId traceId) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index dd40bfc9732..f4baba40c9d 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -28,4 +28,12 @@ public interface ReplayController extends IReplayApi { ReplayBreadcrumbConverter getBreadcrumbConverter(); boolean isDebugMaskingOverlayEnabled(); + + /** + * Registers a trace ID to be associated with the current replay. This is called when a + * transaction is captured while replay is recording, to enable searching for replays by trace ID. + * + * @param traceId the trace ID to associate with the current replay + */ + void registerTraceId(@NotNull SentryId traceId); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 6f328d0fd58..5ac81c44936 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1043,6 +1043,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint sentryId = SentryId.EMPTY_ID; } + if (!sentryId.equals(SentryId.EMPTY_ID)) { + final @Nullable SpanContext trace = transaction.getContexts().getTrace(); + if (trace != null) { + options.getReplayController().registerTraceId(trace.getTraceId()); + } + } + return sentryId; } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 663b1f9bdee..d5b2f0f82a0 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1958,6 +1958,23 @@ class SentryClientTest { assertEquals("abc", transaction.platform) } + @Test + fun `captureTransaction registers trace ID with replay controller`() { + var registeredTraceId: SentryId? = null + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun registerTraceId(traceId: SentryId) { + registeredTraceId = traceId + } + } + ) + val sut = fixture.getSut() + val sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + val transaction = SentryTransaction(sentryTracer) + sut.captureTransaction(transaction, sentryTracer.traceContext()) + assertEquals(sentryTracer.spanContext.traceId, registeredTraceId) + } + @Test fun `when exception type is ignored, capturing event does not send it`() { fixture.sentryOptions.addIgnoredExceptionForType(IllegalStateException::class.java) From b0aa73ebff17166a1a13d27e88b2dcdc13adf68c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jun 2026 23:43:24 +0200 Subject: [PATCH 064/276] fix(replay): Keep replay recording during animations (#5489) * fix(android): Keep replay capturing during animations Skip only the first unstable PixelCopy capture, then continue emitting frames while the screen keeps invalidating. This prevents animated screens from freezing Session Replay visuals while preserving the existing debounce for one-off redraws. Fixes GH-5404 Co-Authored-By: Codex * test(android): Add replay animation sample screens Add separate Android sample screens for Lottie, Compose canvas, and classic View animations so replay capture behavior can be tested manually. Keep the sample app on the Canvas replay screenshot strategy while exercising these animations. Refs GH-5404 Co-Authored-By: Codex * changelog * fix(android): Make replay animation sample colors API-safe Use ContextCompat.getColor in ReplayAnimationsActivity so release lint passes with the sample app minSdk. Refs GH-5489 Co-Authored-By: Codex * docs(android): Explain unstable replay captures Document why PixelCopyStrategy caps skipped unstable captures so continuous animations keep producing replay frames. Refs GH-5489 Co-Authored-By: Codex --------- Co-authored-by: Codex --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 2 +- .../replay/screenshot/PixelCopyStrategy.kt | 63 +++- .../screenshot/PixelCopyStrategyTest.kt | 114 +++++++ .../sentry-samples-android/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 4 + .../io/sentry/samples/android/MainActivity.kt | 12 + .../android/ReplayAnimationsActivity.kt | 302 ++++++++++++++++++ .../src/main/res/raw/replay_lottie_pulse.json | 181 +++++++++++ 9 files changed, 669 insertions(+), 11 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbbede5794..71bf01b7bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Session Replay: Fix replay recording freezing on screens with continuous animations ([#5489](https://github.com/getsentry/sentry-java/pull/5489)) - Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473)) ## 8.43.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12e24536d7e..7ee39d75ede 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -126,6 +126,7 @@ launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk" log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } @@ -248,4 +249,3 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } - 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 81dd7c5cee5..4b9618df6ec 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 @@ -40,6 +40,14 @@ internal class PixelCopyStrategy( private val markContentChanged: () -> Unit = {}, ) : ScreenshotStrategy { + private companion object { + /** + * An unstable capture means the view hierarchy changed while PixelCopy was in flight. Cap + * skipped unstable captures so continuous animations don't stop replay recording. + */ + const val MAX_UNSTABLE_CAPTURES_TO_SKIP = 1 + } + private val executor = executorProvider.getExecutor() private val mainLooperHandler = executorProvider.getMainLooperHandler() private val screenshot = @@ -49,6 +57,7 @@ internal class PixelCopyStrategy( private val lastCaptureSuccessful = AtomicBoolean(false) private val maskRenderer = MaskRenderer() private val contentChanged = AtomicBoolean(false) + private val unstableCaptures = AtomicInteger(0) private val isClosed = AtomicBoolean(false) private val dstOverPaint by lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } @@ -86,15 +95,13 @@ internal class PixelCopyStrategy( if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + unstableCaptures.set(0) lastCaptureSuccessful.set(false) return@request } - // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times - // in a row, we should capture) - if (contentChanged.get()) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - lastCaptureSuccessful.set(false) + val changedDuringCapture = contentChanged.get() + if (changedDuringCapture && shouldSkipUnstableCapture()) { return@request } @@ -111,25 +118,48 @@ internal class PixelCopyStrategy( if (surfaceViewNodes.isNullOrEmpty()) { executor.submit( ReplayRunnable("screenshot_recorder.mask") { - applyMaskingAndNotify(root, viewHierarchy) + applyMaskingAndNotify( + root, + 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) + captureSurfaceViews( + root, + surfaceViewNodes, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) } }, mainLooperHandler.handler, ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) + unstableCaptures.set(0) lastCaptureSuccessful.set(false) } } - private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) { + private fun shouldSkipUnstableCapture(): Boolean { + if (unstableCaptures.incrementAndGet() <= MAX_UNSTABLE_CAPTURES_TO_SKIP) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + lastCaptureSuccessful.set(false) + return true + } + return false + } + + private fun applyMaskingAndNotify( + root: View, + viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, + ) { if (isClosed.get() || screenshot.isRecycled) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") return @@ -149,6 +179,9 @@ internal class PixelCopyStrategy( screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastCaptureSuccessful.set(true) contentChanged.set(false) + if (resetUnstableCaptures) { + unstableCaptures.set(0) + } } @SuppressLint("NewApi") @@ -156,6 +189,7 @@ internal class PixelCopyStrategy( root: View, surfaceViewNodes: List, viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, ) { // Snapshot the window location into locals so the executor-side compositor reads stable // values even if a new capture cycle starts and overwrites the field. @@ -168,7 +202,14 @@ internal class PixelCopyStrategy( fun onCaptureComplete() { if (remaining.decrementAndGet() == 0) { - compositeSurfaceViewsAndMask(root, captures, viewHierarchy, windowX, windowY) + compositeSurfaceViewsAndMask( + root, + captures, + viewHierarchy, + windowX, + windowY, + resetUnstableCaptures, + ) } } @@ -229,6 +270,7 @@ internal class PixelCopyStrategy( viewHierarchy: ViewHierarchyNode, windowX: Int, windowY: Int, + resetUnstableCaptures: Boolean, ) { executor.submit( ReplayRunnable("screenshot_recorder.composite") { @@ -258,7 +300,7 @@ internal class PixelCopyStrategy( capture.bitmap.recycle() } - applyMaskingAndNotify(root, viewHierarchy) + applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) } ) } @@ -287,6 +329,7 @@ internal class PixelCopyStrategy( override fun close() { isClosed.set(true) + unstableCaptures.set(0) executor.submit( ReplayRunnable( "PixelCopyStrategy.close", 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 277ad941a14..779cf7d4311 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 @@ -12,7 +12,10 @@ import android.graphics.RectF import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.PixelCopy import android.view.SurfaceView +import android.view.View +import android.view.Window import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams @@ -36,12 +39,16 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer 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 import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowPixelCopy @Config(shadows = [ShadowPixelCopy::class], sdk = [30]) @@ -92,6 +99,7 @@ class PixelCopyStrategyTest { fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + DeferredWindowPixelCopyShadow.reset() } @Test @@ -132,6 +140,68 @@ class PixelCopyStrategyTest { if (failure.get() != null) throw failure.get() } + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture skips the first unstable PixelCopy result`() { + 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()) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture emits the second consecutive unstable PixelCopy result`() { + 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()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture keeps emitting after entering continuous instability mode`() { + 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()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `stable capture resets the unstable PixelCopy counter`() { + 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()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureStableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + @Test fun `capture does not call markContentChanged when option is disabled`() { val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() @@ -250,6 +320,50 @@ class PixelCopyStrategyTest { assertEquals(0, dest.getPixel(4, 4)) assertEquals(0, dest.getPixel(25, 25)) } + + private fun captureUnstableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + strategy.onContentChanged() + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } + + private fun captureStableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } +} + +@Implements(PixelCopy::class) +class DeferredWindowPixelCopyShadow { + companion object { + private val pendingCallbacks = mutableListOf<() -> Unit>() + + fun reset() { + pendingCallbacks.clear() + } + + fun flush() { + val callbacks = pendingCallbacks.toList() + pendingCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + @JvmStatic + @Implementation + @Suppress("UNUSED_PARAMETER") + fun request( + _source: Window, + _dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler, + ) { + pendingCallbacks.add { + listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + } + } } private class SimpleActivity : Activity() { diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index bb2c3954ca6..ed8cea25661 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,6 +150,7 @@ dependencies { implementation(libs.androidx.browser) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) + implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 26f526124b4..e5b5ed2250b 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ android:name=".PermissionsActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index e000b54e4cc..86f1aace82e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -498,6 +498,18 @@ fun SessionReplayScreen() { } } } + item { + SentryTraced("open_replay_animations") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, ReplayAnimationsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Animations", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("show_dialog") { OutlinedButton(onClick = { showDialog = true }, modifier = Modifier) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt new file mode 100644 index 00000000000..0fe6cda581f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt @@ -0,0 +1,302 @@ +package io.sentry.samples.android + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color as AndroidColor +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +class ReplayAnimationsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val primaryColor = Color(ContextCompat.getColor(this, R.color.colorPrimary)) + val accentColor = Color(ContextCompat.getColor(this, R.color.colorAccent)) + val colorScheme = + if (isSystemInDarkTheme()) + darkColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + else + lightColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + + MaterialTheme(colorScheme = colorScheme) { ReplayAnimationsScreen(onClose = { finish() }) } + } + } +} + +@Composable +private fun ReplayAnimationsScreen(onClose: () -> Unit) { + var selectedSample by remember { mutableStateOf(null) } + + BackHandler(enabled = selectedSample != null) { selectedSample = null } + + selectedSample?.let { sample -> + ReplayAnimationDetailScreen(sample = sample, onBack = { selectedSample = null }) + return + } + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onClose, modifier = Modifier.align(Alignment.End)) { Text("Close") } + Text( + text = "Replay animations", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + ReplayAnimationSample.entries.forEach { sample -> + Button(onClick = { selectedSample = sample }, modifier = Modifier.fillMaxWidth()) { + Text(sample.title) + } + } + } +} + +@Composable +private fun ReplayAnimationDetailScreen(sample: ReplayAnimationSample, onBack: () -> Unit) { + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onBack, modifier = Modifier.align(Alignment.End)) { Text("Back") } + Text( + text = sample.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Surface( + modifier = Modifier.fillMaxWidth().height(420.dp), + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + when (sample) { + ReplayAnimationSample.LOTTIE -> LottieReplayAnimation() + ReplayAnimationSample.COMPOSE_CANVAS -> ComposeCanvasAnimation() + ReplayAnimationSample.ANDROID_VIEWS -> + AndroidView( + factory = { context -> ClassicAnimationLayout(context) }, + modifier = Modifier.fillMaxWidth().height(360.dp), + ) + } + } + } + } +} + +private enum class ReplayAnimationSample(val title: String) { + LOTTIE("Lottie"), + COMPOSE_CANVAS("Compose canvas"), + ANDROID_VIEWS("Android views"), +} + +@Composable +private fun LottieReplayAnimation() { + val composition by + rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.replay_lottie_pulse)) + val progress by + animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) + + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +private fun ComposeCanvasAnimation() { + val transition = rememberInfiniteTransition() + val angle by + transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val pulse by + transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + + Canvas( + modifier = + Modifier.fillMaxWidth().height(160.dp).background(Color(0xFF101820), RoundedCornerShape(8.dp)) + ) { + val center = Offset(size.width / 2f, size.height / 2f) + val orbitRadius = min(size.width, size.height) * 0.32f + val ballRadius = min(size.width, size.height) * 0.1f + val radians = angle / 180f * PI.toFloat() + + drawCircle( + color = Color(0xFF8BE9FD), + radius = orbitRadius * pulse, + center = center, + style = Stroke(width = 5.dp.toPx()), + alpha = 0.55f, + ) + drawCircle( + color = Color(0xFFFF6B6B), + radius = ballRadius, + center = Offset(center.x + cos(radians) * orbitRadius, center.y + sin(radians) * orbitRadius), + ) + drawCircle( + color = Color(0xFFFFD166), + radius = ballRadius * 0.75f, + center = + Offset( + center.x + cos(radians + PI.toFloat()) * orbitRadius, + center.y + sin(radians + PI.toFloat()) * orbitRadius, + ), + ) + } +} + +private class ClassicAnimationLayout(context: Context) : FrameLayout(context) { + private val movingDot = + View(context).apply { background = ovalDrawable(AndroidColor.rgb(255, 107, 107)) } + private val rotatingSquare = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(139, 233, 253), dp(8)) } + private val scalingBar = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(255, 209, 102), dp(6)) } + private val animators: List + + init { + setBackgroundColor(AndroidColor.rgb(16, 24, 32)) + clipChildren = false + clipToPadding = false + + addView(scalingBar, LayoutParams(dp(180), dp(18), Gravity.CENTER).apply { topMargin = dp(116) }) + addView(rotatingSquare, LayoutParams(dp(64), dp(64), Gravity.CENTER)) + addView(movingDot, LayoutParams(dp(48), dp(48), Gravity.CENTER)) + + animators = + listOf( + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_X, -dp(92).toFloat(), dp(92).toFloat()) + .repeatable(durationMillis = 900, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_Y, -dp(28).toFloat(), dp(28).toFloat()) + .repeatable(durationMillis = 650, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(rotatingSquare, View.ROTATION, 0f, 360f) + .repeatable(durationMillis = 1200), + ObjectAnimator.ofFloat(scalingBar, View.SCALE_X, 0.25f, 1f) + .repeatable(durationMillis = 800, mode = ValueAnimator.REVERSE), + ) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + animators.forEach { animator -> + if (!animator.isStarted) { + animator.start() + } + } + } + + override fun onDetachedFromWindow() { + animators.forEach { it.cancel() } + super.onDetachedFromWindow() + } + + private fun ObjectAnimator.repeatable( + durationMillis: Long, + mode: Int = ValueAnimator.RESTART, + ): ObjectAnimator = apply { + duration = durationMillis + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + repeatMode = mode + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + private fun ovalDrawable(color: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + } + + private fun roundedRectDrawable(color: Int, radius: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = radius.toFloat() + setColor(color) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json b/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json new file mode 100644 index 00000000000..e09afc9c5e5 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json @@ -0,0 +1,181 @@ +{ + "v": "5.7.4", + "fr": 60, + "ip": 0, + "op": 120, + "w": 256, + "h": 256, + "nm": "Replay pulse", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Rotating ring", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "e": [360], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [360] } + ] + }, + "p": { "a": 0, "k": [128, 128, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [132, 132] }, + "nm": "Ring path" + }, + { + "ty": "tm", + "s": { "a": 0, "k": 18 }, + "e": { "a": 0, "k": 86 }, + "o": { "a": 0, "k": 0 }, + "m": 1, + "nm": "Trim ring" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.545, 0.914, 0.992, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 16 }, + "lc": 2, + "lj": 2, + "nm": "Ring stroke" + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0] }, + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 }, + "sk": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "nm": "Ring transform" + } + ], + "nm": "Ring", + "np": 4, + "cix": 2, + "bm": 0 + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Pulse", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + { + "t": 0, + "s": [35], + "e": [95], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { + "t": 60, + "s": [95], + "e": [35], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [35] } + ] + }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [128, 128, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { + "a": 1, + "k": [ + { + "t": 0, + "s": [70, 70, 100], + "e": [115, 115, 100], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { + "t": 60, + "s": [115, 115, 100], + "e": [70, 70, 100], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [70, 70, 100] } + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [96, 96] }, + "nm": "Pulse path" + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 0.82, 0.4, 1] }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Pulse fill" + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0] }, + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 }, + "sk": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "nm": "Pulse transform" + } + ], + "nm": "Pulse", + "np": 3, + "cix": 2, + "bm": 0 + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + } + ] +} From caa40f2853580011b123daf1c4485914716519b2 Mon Sep 17 00:00:00 2001 From: romtsn <4999776+romtsn@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:48:54 +0000 Subject: [PATCH 065/276] release: 8.43.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71bf01b7bf0..d6a3b55b403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.43.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 9739db8a573..eee4b292bff 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.43.0 +versionName=8.43.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From d128fe99a5714c92b776f221d8e2778c9cf562a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:40:47 +0200 Subject: [PATCH 066/276] chore(deps): bump the github-actions group across 1 directory with 7 updates (#5494) Bumps the github-actions group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `6.0.3` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` | | [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) | `2.26.3` | `2.26.6` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.35.4` | `4.36.1` | | [getsentry/github-workflows](https://github.com/getsentry/github-workflows) | `3.3.0` | `3.4.0` | | [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.1.1` | `3.2.0` | | [getsentry/craft](https://github.com/getsentry/craft) | `2.26.3` | `2.26.6` | Updates `actions/checkout` from 6.0.2 to 6.0.3 - [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/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) Updates `codecov/codecov-action` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354) Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.3 to 2.26.6 - [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/bae212ca7aec50bb716eafd387c80bcfb28da937...3e6a0f477702864bb5854384b390a0db3325428e) Updates `github/codeql-action` from 4.35.4 to 4.36.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/68bde559dea0fdcac2102bfdf6230c5f70eb485e...87557b9c84dde89fdd9b10e88954ac2f4248e463) Updates `getsentry/github-workflows` from 3.3.0 to 3.4.0 - [Release notes](https://github.com/getsentry/github-workflows/releases) - [Commits](https://github.com/getsentry/github-workflows/compare/3.3.0...3.4.0) Updates `actions/create-github-app-token` from 3.1.1 to 3.2.0 - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/1b10c78c7865c340bc4f6099eb2f838309f1e8c3...bcd2ba49218906704ab6c1aa796996da409d3eb1) Updates `getsentry/craft` from 2.26.3 to 2.26.6 - [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/bae212ca7aec50bb716eafd387c80bcfb28da937...3e6a0f477702864bb5854384b390a0db3325428e) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: codecov/codecov-action dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: getsentry/github-workflows dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/create-github-app-token dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.6 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 | 4 ++-- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/changes-in-high-risk-code.yml | 2 +- .github/workflows/check-tombstone-proto-schema.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/danger.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 | 6 +++--- .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 +- .github/workflows/update-deps.yml | 2 +- .github/workflows/validate-pr.yml | 2 +- 22 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index f3ad7240438..aebcbf87d5e 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6817ce53337..2d9e2a3ba38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' @@ -58,7 +58,7 @@ jobs: SENTRY_PROJECT: sentry-android - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v4 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v4 with: name: sentry-java fail_ci_if_error: false diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 4d5a78a4114..23daafa1a05 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@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3e6a0f477702864bb5854384b390a0db3325428e # v2 secrets: inherit diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 4ecc23619a4..028b4217ef2 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Get changed files id: changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index f4dd5f2f957..535b2170fae 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - 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 34bf6241fd6..1276f2bd715 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # pin@v2 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # pin@v2 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # pin@v2 diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 77fe824701a..e40b4563b00 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -8,4 +8,4 @@ jobs: danger: runs-on: ubuntu-latest steps: - - uses: getsentry/github-workflows/danger@26f565c05d0dd49f703d238706b775883037d76b # v3 + - uses: getsentry/github-workflows/danger@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 23dd0134203..01ee3db1584 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # 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 4109c2a2947..28cb78df4e3 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 090a6360745..af0b44ddadd 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index bbe8c709587..65cfcf242fc 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' @@ -77,7 +77,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 6d0aefab386..19598699165 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Java Version uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 85731127f36..8973148cadd 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Java 17 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 @@ -77,7 +77,7 @@ jobs: arch: x86_64 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Enable KVM run: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index fbb8018da06..4af564cd2c3 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 3fb0b162750..16cfe4531a0 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8464e8d0399..88cac7c6754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,18 +23,18 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 + uses: getsentry/craft@3e6a0f477702864bb5854384b390a0db3325428e # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 91154a2c7b3..bbcb3cfc0bc 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index f0b3fbe279b..781d8a876f9 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 68bdd38f2ec..bc1b1686692 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index fc66f6744b5..b1884cd4a7a 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: submodules: 'recursive' diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index bfcf9ccfa85..5b8d3d11628 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -18,7 +18,7 @@ jobs: native: runs-on: ubuntu-latest steps: - - uses: getsentry/github-workflows/updater@26f565c05d0dd49f703d238706b775883037d76b # v3 + - uses: getsentry/github-workflows/updater@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 with: path: scripts/update-sentry-native-ndk.sh name: Native SDK diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 10fe894067a..313a4611145 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@71588ddf95134f804e82c5970a8098588e2eaecd + - uses: getsentry/github-workflows/validate-pr@26f565c05d0dd49f703d238706b775883037d76b with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 65aff4f61d87bda0ca21a6093854008c39377946 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 4 Jun 2026 16:44:03 +0200 Subject: [PATCH 067/276] ci: Update getsentry/github-workflows to 3.4.0 for validate-pr (#5496) The validate-pr action was pinned to 3.3.0 while the other workflows in this repo already use 3.4.0. Pin it to the same SHA and add the matching version comment. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 313a4611145..ca5108943de 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@26f565c05d0dd49f703d238706b775883037d76b + - uses: getsentry/github-workflows/validate-pr@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From b93642593067222cc2b5a8ab4f9a63a38cde8ae3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:04:18 +0200 Subject: [PATCH 068/276] chore(deps): bump the github-actions group with 3 updates (#5498) Bumps the github-actions group with 3 updates: [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft), [github/codeql-action](https://github.com/github/codeql-action) and [getsentry/craft](https://github.com/getsentry/craft). Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.6 to 2.26.8 - [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/3e6a0f477702864bb5854384b390a0db3325428e...4468eb9e399655a61c770534dacc03139d98aa18) Updates `github/codeql-action` from 4.36.1 to 4.36.2 - [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/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e) Updates `getsentry/craft` from 2.26.6 to 2.26.8 - [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/3e6a0f477702864bb5854384b390a0db3325428e...4468eb9e399655a61c770534dacc03139d98aa18) --- updated-dependencies: - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: github/codeql-action dependency-version: 4.36.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.8 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/changelog-preview.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 23daafa1a05..612cc5b52f3 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@3e6a0f477702864bb5854384b390a0db3325428e # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@4468eb9e399655a61c770534dacc03139d98aa18 # v2 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1276f2bd715..6aa197d6625 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # pin@v2 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # pin@v2 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # pin@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88cac7c6754..a6964cec4ba 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@3e6a0f477702864bb5854384b390a0db3325428e # v2 + uses: getsentry/craft@4468eb9e399655a61c770534dacc03139d98aa18 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From abcd8895429bce96de12618799bf303bb34d2312 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Jun 2026 17:37:59 +0200 Subject: [PATCH 069/276] fix(replay): Fix Compose masking on obfuscated/minified builds (#5503) * try to run replay tests on gh emulators * Format code * feat(replay): fail fast on swallowed Compose masking errors in CI Add an internal SentryReplayDebug.failFast switch (gated on the io.sentry.replay.compose.fail-fast system property) that re-throws the exceptions ComposeViewHierarchyNode normally swallows in fromComposeNode and fromView. Enabled in the sentry-samples-android app and the on-device ReplayTest/ReplaySnapshotTest so our release/obfuscated builds running on real devices in CI crash instead of silently degrading masking. Defaults off, so customers are unaffected. Also add consumer proguard keep rules for the LayoutNode internals (getChildren/getOuterCoordinator/getCollapsedSemantics) that are looked up via reflection on Compose < 1.10, so R8 doesn't strip or rename them. Also guard the on-device tests against GitHub-hosted emulators, which can't capture screenshots reliably. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Sentry Github Bot Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 +++++ .../io/sentry/uitest/android/ReplayTest.kt | 3 +++ .../uitest/android/ReplaySnapshotTest.kt | 3 +++ sentry-android-replay/proguard-rules.pro | 6 +++++ .../android/replay/util/SentryReplayDebug.kt | 26 +++++++++++++++++++ .../viewhierarchy/ComposeViewHierarchyNode.kt | 12 +++++++++ .../sentry/samples/android/MyApplication.java | 5 ++++ 7 files changed, 61 insertions(+) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..06c0b8bab55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Session Replay: Fix Compose view masking not working on obfuscated/minified builds ([#5503](https://github.com/getsentry/sentry-java/pull/5503)) + ## 8.43.1 ### Fixes 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 5ea12ddbbc8..3827561e37c 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 @@ -21,6 +21,9 @@ class ReplayTest : BaseUiTest() { // we can't run on GH actions emulator, because they don't allow capturing screenshots properly @Suppress("KotlinConstantConditions") assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") } @Test diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 1d82a3f8bc0..6d45b2d1f9c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -23,6 +23,9 @@ class ReplaySnapshotTest : BaseUiTest() { // GH Actions emulators don't support capturing screenshots for replay @Suppress("KotlinConstantConditions") assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") } @Test diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 42e3cb30a42..6ce45c1ef5d 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -29,3 +29,9 @@ # Rules to detect a PreviewView view to later mask it -dontwarn androidx.camera.view.PreviewView -keepnames class androidx.camera.view.PreviewView +# Rules to walk the Compose Node tree. +-keep class androidx.compose.ui.node.LayoutNode { + *** getChildren*(...); + *** getOuterCoordinator*(...); + *** getCollapsedSemantics*(...); +} \ No newline at end of file diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt new file mode 100644 index 00000000000..966428f84c2 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt @@ -0,0 +1,26 @@ +package io.sentry.android.replay.util + +/** + * Internal, undocumented escape hatch used to make Session Replay fail fast instead of silently + * degrading masking when an exception is swallowed (e.g. unsupported/obfuscated Compose internals). + * + * It is intended to be enabled only in our own sample/UI-test apps that run on real devices in CI + * (which are release/obfuscated builds, so [io.sentry.android.replay.BuildConfig.DEBUG] can't be + * used), so that regressions surface as crashes rather than under-masked replays. Customers should + * never set this. + * + * Enable via: + * ``` + * System.setProperty("io.sentry.replay.compose.fail-fast", "true") + * ``` + */ +internal object SentryReplayDebug { + private const val FAIL_FAST_PROPERTY = "io.sentry.replay.compose.fail-fast" + + /** + * Read live (not cached) so it's only evaluated on the error path and unit tests can toggle it + * between cases. + */ + val failFast: Boolean + get() = "true".equals(System.getProperty(FAIL_FAST_PROPERTY), ignoreCase = true) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index a0312b69cd0..2b6bc3fc08e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -22,6 +22,7 @@ import io.sentry.SentryLevel import io.sentry.SentryMaskingOptions import io.sentry.android.replay.SentryReplayModifiers import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.SentryReplayDebug import io.sentry.android.replay.util.boundsInWindow import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextColor @@ -147,6 +148,12 @@ internal object ComposeViewHierarchyNode { ) } + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently degrading masking + if (SentryReplayDebug.failFast) { + throw t + } + // If we're unable to retrieve the semantics configuration // we should play safe and mask the whole node. return GenericViewHierarchyNode( @@ -291,6 +298,11 @@ internal object ComposeViewHierarchyNode { """ .trimIndent(), ) + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently skipping the whole Compose subtree (i.e. not masking it) + if (SentryReplayDebug.failFast) { + throw e + } return false } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 572c4cdba72..f074901f4f0 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -9,6 +9,11 @@ public class MyApplication extends Application { @Override public void onCreate() { + // Make Session Replay fail fast instead of silently degrading masking when an exception is + // swallowed (e.g. unsupported/obfuscated Compose internals). This way regressions surface as + // crashes in our release/obfuscated builds that run on real devices in CI. Only meant for our + // own sample/UI-test apps, customers should never set this. + System.setProperty("io.sentry.replay.compose.fail-fast", "true"); Sentry.startProfiler(); strictMode(); super.onCreate(); From 8c7718c4199deb7d801a643ba768d97f499ec156 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Jun 2026 17:51:12 +0200 Subject: [PATCH 070/276] fix(replay): Fix VerifyError in Compose masking under DexGuard/R8 obfuscation (#5507) * fix(replay): Fix VerifyError in Compose masking under DexGuard/R8 obfuscation ComposeViewHierarchyNode.boundsInWindow returned an android.graphics.Rect while the surrounding code carried it as androidx.compose.ui.geometry.Rect, mixing the two Rect types in the same method. Under aggressive obfuscation (DexGuard 9.13.2 / R8 full mode) this could be rejected at class load with a VerifyError, crashing Replay when traversing the Compose tree. Make boundsInWindow return androidx.compose.ui.geometry.Rect throughout and add a Rect.toRect() extension to convert to android.graphics.Rect only at the boundary where the view-hierarchy node needs it. Fixes #5497 Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * fix(replay): Round Compose mask bounds outward to avoid zero-area masks isVisible/shouldMask are derived from the sub-pixel float bounds, but the android.graphics.Rect stored on the node (and drawn by MaskRenderer) used truncating toInt(). A sub-pixel node could be marked visible+maskable yet store a zero-width/height rect, so the mask wasn't drawn and sensitive content leaked. Round outward (floor min, ceil max) so a non-empty float rect always yields a non-empty integer rect, biasing toward over-masking. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../io/sentry/android/replay/util/Nodes.kt | 21 ++++++++++-- .../viewhierarchy/ComposeViewHierarchyNode.kt | 33 ++++++++++--------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c0b8bab55..d80037414db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) - Session Replay: Fix Compose view masking not working on obfuscated/minified builds ([#5503](https://github.com/getsentry/sentry-java/pull/5503)) ## 8.43.1 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 2882b2113b8..028f681d96b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -2,8 +2,8 @@ package io.sentry.android.replay.util -import android.graphics.Rect import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter @@ -11,6 +11,8 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToInt internal class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout { @@ -176,7 +178,7 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) if (boundsLeft == boundsRight || boundsTop == boundsBottom) { - return Rect() + return Rect(0.0f, 0.0f, 0.0f, 0.0f) } val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) @@ -200,5 +202,18 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) - return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + return Rect(left, top, right, bottom) +} + +internal fun Rect.toRect(): android.graphics.Rect { + // Round outward (floor min edges, ceil max edges) so that a sub-pixel but non-empty Rect doesn't + // collapse to a zero-width/height android.graphics.Rect. Otherwise a node could be marked visible + // and maskable based on the float bounds, while the integer rect the MaskRenderer draws has zero + // area, leaving sensitive content unmasked. Rounding outward also biases toward over-masking. + return android.graphics.Rect( + floor(left).toInt(), + floor(top).toInt(), + ceil(right).toInt(), + ceil(bottom).toInt(), + ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 2b6bc3fc08e..2e40144e2de 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -28,6 +28,7 @@ import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextColor import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -157,8 +158,8 @@ internal object ComposeViewHierarchyNode { // If we're unable to retrieve the semantics configuration // we should play safe and mask the whole node. return GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -168,17 +169,17 @@ internal object ComposeViewHierarchyNode { isImportantForContentCapture = false, // will be set by children isVisible = !SentryLayoutNodeHelper.isTransparent(node) && - visibleRect.height() > 0 && - visibleRect.width() > 0, - visibleRect = visibleRect, + visibleRect.height > 0 && + visibleRect.width > 0, + visibleRect = visibleRect.toRect(), ) } val isVisible = !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && - visibleRect.height() > 0 && - visibleRect.width() > 0 + visibleRect.height > 0 && + visibleRect.width > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true || semantics?.contains(SemanticsProperties.EditableText) == true @@ -213,8 +214,8 @@ internal object ComposeViewHierarchyNode { null }, dominantColor = textColor?.toArgb()?.toOpaque(), - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -223,7 +224,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else -> { @@ -233,8 +234,8 @@ internal object ComposeViewHierarchyNode { parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -243,7 +244,7 @@ internal object ComposeViewHierarchyNode { isVisible = isVisible, isImportantForContentCapture = true, shouldMask = shouldMask && painter.isMaskable(), - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else { val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) @@ -252,8 +253,8 @@ internal object ComposeViewHierarchyNode { // TODO: traverse the ViewHierarchyNode here again. For now we can recommend // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -262,7 +263,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = false, // will be set by children isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } } From 80199f8effacbd46531006e0c25645ef0c24d518 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 9 Jun 2026 09:13:40 +0200 Subject: [PATCH 071/276] docs(ai): Refresh AGENTS.md module list and fix coding.mdc command (#5517) * docs(ai): Refresh AGENTS.md module list and fix coding.mdc command The Module Architecture section omitted several product areas that now exist as modules and already have dedicated .cursor/rules: Session Replay, Feature Flags, Queues (Kafka), and JVM continuous profiling. Add them alongside the other previously-unlisted modules, and add a pointer to the repository's task-specific skills. Also fix a typo in coding.mdc where the per-file test command used ./gradle instead of ./gradlew. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(ai): Note moving changelog entries to Unreleased on rebase A rebase onto main can land a branch after a release was cut, leaving a new changelog entry under an already-released version heading. Document that the entry should be moved back into an Unreleased section at the top of CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(ai): Add changelog rebase note to AGENTS.md AGENTS.md is the always-loaded entrypoint, so the rebase reminder reaches agents more reliably here than in an on-demand .cursor rule. Keep the detailed workflow in pr.mdc and point to it from here. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .cursor/rules/coding.mdc | 4 ++-- .cursor/rules/pr.mdc | 2 ++ AGENTS.md | 23 +++++++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc index e7af7273f15..4e6fd2538a3 100644 --- a/.cursor/rules/coding.mdc +++ b/.cursor/rules/coding.mdc @@ -24,13 +24,13 @@ sentry-java is the Java and Android SDK for Sentry. This repository contains the ./gradlew check # Run unit tests for a specific file -./gradle '::testDebugUnitTest' --tests="**" --info +./gradlew '::testDebugUnitTest' --tests="**" --info ``` ## Contributing Guidelines 1. Follow existing code style and language -2. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them +2. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them 3. Write comprehensive tests 4. New features should always be opt-in by default, extend `SentryOptions` or similar Option classes with getters and setters to enable/disable a new feature 5. Consider backwards compatibility diff --git a/.cursor/rules/pr.mdc b/.cursor/rules/pr.mdc index e15c0a0a563..3a37ecc15f8 100644 --- a/.cursor/rules/pr.mdc +++ b/.cursor/rules/pr.mdc @@ -93,6 +93,8 @@ Entry format: - ([#](https://github.com/getsentry/sentry-java/pull/)) ``` +**When rebasing:** A rebase onto `main` can land your branch after a release was cut, where the `## Unreleased` heading your entry lived under has since been renamed to that version number. If that happens, move your new entry into an `## Unreleased` section at the top of `CHANGELOG.md` (create the section if it no longer exists) so it is not left under an already-released version. + Commit changelog separately: ```bash diff --git a/AGENTS.md b/AGENTS.md index ff50727c662..8d0cccabbc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,14 @@ make systemTest 6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply apiDump` 7. **Propose commit**: As final step, git stage relevant files and propose (but not execute) a single git commit command +## Repository Skills + +This repo ships task-specific skills (declared in `agents.toml`, sources under `.agents/skills`). Prefer them over performing the steps manually: +- **`create-java-pr`**: Branch, format, `apiDump`, commit, push, open PR, and add the changelog entry (automates the PR workflow above) +- **`test`**: Run unit or system tests for a module or a specific class +- **`check-code-attribution`**: Verify third-party code attribution on the current branch (see Third-Party Code Attribution below) +- **`btrace-perfetto`**: Capture and compare Perfetto traces for Android performance work + ## Module Architecture The repository is organized into multiple modules: @@ -100,15 +108,22 @@ The repository is organized into multiple modules: - **`sentry`** - Core Java SDK implementation - **`sentry-android-core`** - Core Android SDK implementation - **`sentry-android`** - High-level Android SDK +- **`sentry-android-ndk`** - Native (NDK) crash handling ### Integration Modules - **Spring Framework**: `sentry-spring*`, `sentry-spring-boot*` -- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul` -- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-apache-http-client-5` +- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul`, `sentry-android-timber` +- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-openfeign`, `sentry-apache-http-client-5` - **GraphQL**: `sentry-graphql*`, `sentry-apollo*` - **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-compose` +- **Session Replay**: `sentry-android-replay` +- **Database**: `sentry-jdbc`, `sentry-android-sqlite`, `sentry-jcache` - **Reactive**: `sentry-reactor`, `sentry-ktor-client` +- **Feature Flags**: `sentry-launchdarkly-android`, `sentry-launchdarkly-server`, `sentry-openfeature` +- **Queues**: `sentry-kafka` +- **Profiling**: `sentry-async-profiler` (JVM continuous profiling) - **Monitoring**: `sentry-opentelemetry*`, `sentry-quartz` +- **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` ### Utility Modules - **`sentry-test-support`** - Shared test utilities @@ -171,6 +186,10 @@ gh pr view --json number -q '.number' gh pr view --json url -q '.url' ``` +### Changelog + +User-facing changes get an entry under the `## Unreleased` section of `CHANGELOG.md`. When rebasing onto `main`, a release may have renamed the `## Unreleased` heading your entry was under to a version number — if so, move your entry back into an `## Unreleased` section at the top of the file (create it if it no longer exists). See `.cursor/rules/pr.mdc` for the full changelog and PR workflow. + ## Useful Resources - Main SDK documentation: https://develop.sentry.dev/sdk/overview/ From 105d667ed4ed37d897fe8e9199de23357896e550 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Jun 2026 09:42:26 +0200 Subject: [PATCH 072/276] fix(license): Attribute vendored AndroidX Compose UI code in Session Replay (#5516) sentry-android-replay's Nodes.kt vendors code from AndroidX Compose UI (Apache 2.0, The Android Open Source Project) without attribution: - boundsInWindow is a faster copy of LayoutCoordinates.boundsInWindow - fastMinOf/fastMaxOf/fastCoerceIn/fastCoerceAtLeast/fastCoerceAtMost are copied from androidx.compose.ui.util.MathHelpers Add the required source-file attribution header and a THIRD_PARTY_NOTICES.md entry covering both source files. Co-authored-by: Claude Opus 4.8 (1M context) --- THIRD_PARTY_NOTICES.md | 29 +++++++++++++++++++ .../io/sentry/android/replay/util/Nodes.kt | 22 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5a48d567fac..c1fa7e8f65b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -315,6 +315,35 @@ limitations under the License. --- +## Android Open Source Project — Jetpack Compose UI (Apache 2.0) + +**Source:** https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187
+**Source:** https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2019, 2020 The Android Open Source Project + +### Scope + +The Sentry Android Replay SDK includes code adapted from Jetpack Compose UI, used to compute Compose node bounds while traversing the view hierarchy for masking. The code resides in `io.sentry.android.replay.util.Nodes`: the `boundsInWindow` extension function (a faster copy of `LayoutCoordinates.boundsInWindow`) and the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast`, and `fastCoerceAtMost` numeric helpers (copied from `androidx.compose.ui.util.MathHelpers`). + +``` +Copyright (C) 2019, 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + ## OpenTelemetry (Apache 2.0) **Source:** https://github.com/open-telemetry/opentelemetry-java (Commit: 0aacc55d1e3f5cc6dbb4f8fa26bcb657b01a7bc9)
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 028f681d96b..704260cf311 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -1,3 +1,25 @@ +/* + * Portions of this file are adapted from AndroidX Compose UI: + * - the `boundsInWindow` extension is a faster copy of `LayoutCoordinates.boundsInWindow` + * - the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast` and `fastCoerceAtMost` + * helpers are copied from `androidx.compose.ui.util.MathHelpers` + * + * Adapted from: + * https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt + * https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt + * + * Copyright (C) 2019, 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes package io.sentry.android.replay.util From 29f120b097b4bd6c870491f5dc35caf7b37a7609 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 9 Jun 2026 10:22:41 +0200 Subject: [PATCH 073/276] perf: Replace java.net.URI with custom string parsing in Dsn (#5448) * perf: Replace java.net.URI with custom string parsing in Dsn The Dsn constructor used `new URI(dsnString).normalize()` to parse the DSN string, which is known to be slow on Android. Since `retrieveParsedDsn()` is called on the main thread during `Sentry.init()` via `preInitConfigurations()`, this directly impacts app startup time. Replace the URI-based parsing with manual indexOf/substring operations. The only remaining URI construction is from pre-parsed components (`new URI(scheme, null, host, port, path, null, null)`), which is significantly cheaper since the JDK doesn't need to re-parse a string. Co-Authored-By: Claude Opus 4.6 * test: Add tests for custom DSN string parsing Cover edge cases specific to the manual indexOf/substring parser: null input, missing scheme separator, no slash after host, multiple path segments, port with path, multiple double slashes, query string with port, empty secret key, and a realistic Sentry DSN with org id. Co-Authored-By: Claude Opus 4.6 * changelog: Add entry for custom DSN parser Co-Authored-By: Claude Opus 4.6 * fix(dsn): Strip URI fragments and support IPv6 hosts Harden the custom DSN parser and convert its tests to Google Truth. - Strip URI fragments (#...) alongside query strings so they no longer leak into the project id and corrupt the constructed Sentry URI. - Detect bracketed IPv6 literal hosts when locating the port separator, restoring behavior that java.net.URI handled. - Narrow the parse error handling from catch (Throwable) to the expected exceptions, which stops swallowing Error and removes the doubled exception message. - Extract the parsing steps into focused private helpers. - Convert DsnTest to Google Truth assertions. - Move the changelog entry to the Unreleased section, since 8.43.0 and 8.43.1 have already been released. Co-Authored-By: Claude Opus 4.8 * test(dsn): Assert exception messages via Truth hasMessageThat Follow Truth's recommended pattern for exception testing: catch with assertFailsWith, then assert on the caught throwable with assertThat(ex).hasMessageThat(). Also assert the message in the previously bare throw-only cases so they can no longer pass on an unrelated exception. Co-Authored-By: Claude Opus 4.8 * fix(dsn): Give a clear error message for a malformed port Parse the port in a dedicated helper that reports the offending value ("Invalid DSN: Invalid port 'abc'.") instead of leaking the raw NumberFormatException text. Narrow the catch to URISyntaxException now that the port is the only parseInt, and add a test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 4 + gradle/libs.versions.toml | 1 + sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Dsn.java | 161 ++++++++++++------- sentry/src/test/java/io/sentry/DsnTest.kt | 182 +++++++++++++++++----- 5 files changed, 255 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80037414db..be4a5f7628d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Improvements + +- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) + ### Fixes - Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ee39d75ede..e653069e2b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -238,6 +238,7 @@ camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "ca camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +google-truth = { module = "com.google.truth:truth", version = "1.4.5" } hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 25e700995b4..4c237803a51 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.javafaker) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry/src/main/java/io/sentry/Dsn.java b/sentry/src/main/java/io/sentry/Dsn.java index 0d21499b5fc..15aea2a064c 100644 --- a/sentry/src/main/java/io/sentry/Dsn.java +++ b/sentry/src/main/java/io/sentry/Dsn.java @@ -2,6 +2,7 @@ import io.sentry.util.Objects; import java.net.URI; +import java.net.URISyntaxException; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; @@ -17,98 +18,148 @@ final class Dsn { private final @NotNull URI sentryUri; private final @Nullable String orgId; - /* - / The project ID which the authenticated user is bound to. - */ + /** The project ID which the authenticated user is bound to. */ public @NotNull String getProjectId() { return projectId; } - /* - / An optional path of which Sentry is hosted - */ + /** An optional path of which Sentry is hosted. */ public @Nullable String getPath() { return path; } - /* - / The optional secret key to authenticate the SDK. - */ + /** The optional secret key to authenticate the SDK. */ public @Nullable String getSecretKey() { return secretKey; } - /* - / The required public key to authenticate the SDK. - */ + /** The required public key to authenticate the SDK. */ public @NotNull String getPublicKey() { return publicKey; } - /* - / The URI used to communicate with Sentry - */ + /** The org ID extracted from the host, or {@code null} when the host has no org prefix. */ + public @Nullable String getOrgId() { + return orgId; + } + + /** The URI used to communicate with Sentry. */ @NotNull URI getSentryUri() { return sentryUri; } + // Avoids java.net.URI for DSN parsing, which is slow on Android. Dsn(@Nullable String dsn) throws IllegalArgumentException { + final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim(); + if (dsnString.isEmpty()) { + throw new IllegalArgumentException("The DSN is empty."); + } + try { - final String dsnString = Objects.requireNonNull(dsn, "The DSN is required.").trim(); - if (dsnString.isEmpty()) { - throw new IllegalArgumentException("The DSN is empty."); + final int schemeEnd = dsnString.indexOf("://"); + if (schemeEnd < 0) { + throw new IllegalArgumentException("Invalid DSN: Missing scheme."); } - final URI uri = new URI(dsnString).normalize(); - final String scheme = uri.getScheme(); - if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { - throw new IllegalArgumentException("Invalid DSN scheme: " + scheme); + final String scheme = dsnString.substring(0, schemeEnd); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("Invalid DSN: Invalid scheme '" + scheme + "'."); } - String userInfo = uri.getUserInfo(); - if (userInfo == null || userInfo.isEmpty()) { + final int authStart = schemeEnd + 3; + final int atIndex = dsnString.indexOf('@', authStart); + if (atIndex < 0) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } - String[] keys = userInfo.split(":", -1); - publicKey = keys[0]; - if (publicKey == null || publicKey.isEmpty()) { + final String userInfo = dsnString.substring(authStart, atIndex); + final int colonIndex = userInfo.indexOf(':'); + publicKey = colonIndex < 0 ? userInfo : userInfo.substring(0, colonIndex); + secretKey = colonIndex < 0 ? null : userInfo.substring(colonIndex + 1); + if (publicKey.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: No public key provided."); } - secretKey = keys.length > 1 ? keys[1] : null; - String uriPath = uri.getPath(); - if (uriPath.endsWith("/")) { - uriPath = uriPath.substring(0, uriPath.length() - 1); - } - int projectIdStart = uriPath.lastIndexOf("/") + 1; - String path = uriPath.substring(0, projectIdStart); - if (!path.endsWith("/")) { - path += "/"; + + final String hostAndPath = stripQueryAndFragment(dsnString, atIndex + 1); + final int firstSlash = hostAndPath.indexOf('/'); + if (firstSlash < 0) { + throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); } - this.path = path; - projectId = uriPath.substring(projectIdStart); + + final String hostPort = hostAndPath.substring(0, firstSlash); + final int portColon = portSeparatorIndex(hostPort); + final String host = portColon < 0 ? hostPort : hostPort.substring(0, portColon); + final int port = portColon < 0 ? -1 : parsePort(hostPort.substring(portColon + 1)); + + final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash))); + final int projectIdStart = rawPath.lastIndexOf('/') + 1; + path = ensureTrailingSlash(rawPath.substring(0, projectIdStart)); + projectId = rawPath.substring(projectIdStart); if (projectId.isEmpty()) { throw new IllegalArgumentException("Invalid DSN: A Project Id is required."); } - sentryUri = - new URI( - scheme, null, uri.getHost(), uri.getPort(), path + "api/" + projectId, null, null); - - // Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123") - String extractedOrgId = null; - final String host = uri.getHost(); - if (host != null) { - final Matcher matcher = ORG_ID_PATTERN.matcher(host); - if (matcher.find()) { - extractedOrgId = matcher.group(1); - } + + sentryUri = new URI(scheme, null, host, port, path + "api/" + projectId, null, null); + orgId = extractOrgId(host); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid DSN: " + e.getMessage(), e); + } + } + + private static int parsePort(final @NotNull String portString) { + try { + return Integer.parseInt(portString); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid DSN: Invalid port '" + portString + "'.", e); + } + } + + // Drops the query string and/or fragment, whichever appears first, from the host onwards. + private static @NotNull String stripQueryAndFragment( + final @NotNull String dsn, final int fromIndex) { + int cut = dsn.indexOf('?', fromIndex); + final int fragment = dsn.indexOf('#', fromIndex); + if (fragment >= 0 && (cut < 0 || fragment < cut)) { + cut = fragment; + } + return cut < 0 ? dsn.substring(fromIndex) : dsn.substring(fromIndex, cut); + } + + // IPv6 literals are bracketed and contain colons, so the port separator follows the ']'. + private static int portSeparatorIndex(final @NotNull String hostPort) { + return hostPort.startsWith("[") + ? hostPort.indexOf(':', hostPort.indexOf(']')) + : hostPort.indexOf(':'); + } + + // Collapses runs of slashes into a single slash, like URI.normalize(). + private static @NotNull String collapseSlashes(final @NotNull String path) { + if (!path.contains("//")) { + return path; + } + final StringBuilder sb = new StringBuilder(path.length()); + char previous = 0; + for (int i = 0; i < path.length(); i++) { + final char c = path.charAt(i); + if (c == '/' && previous == '/') { + continue; } - orgId = extractedOrgId; - } catch (Throwable e) { - throw new IllegalArgumentException(e); + sb.append(c); + previous = c; } + return sb.toString(); } - public @Nullable String getOrgId() { - return orgId; + private static @NotNull String stripTrailingSlash(final @NotNull String path) { + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + private static @NotNull String ensureTrailingSlash(final @NotNull String path) { + return path.endsWith("/") ? path : path + "/"; + } + + // Extracts the org ID from a host such as "o123.ingest.sentry.io" -> "123". + private static @Nullable String extractOrgId(final @NotNull String host) { + final Matcher matcher = ORG_ID_PATTERN.matcher(host); + return matcher.find() ? matcher.group(1) : null; } } diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index 7e2982073f1..f8195d16af6 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -1,21 +1,20 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import java.lang.IllegalArgumentException import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertNull class DsnTest { @Test fun `dsn parsed with path, sets all properties`() { val dsn = Dsn("https://publicKey:secretKey@host/path/id") - assertEquals("https://host/path/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("publicKey", dsn.publicKey) - assertEquals("secretKey", dsn.secretKey) - assertEquals("/path/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/api/id") + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("secretKey") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") } @Test @@ -23,94 +22,90 @@ class DsnTest { // query strings were once a feature, but no more val dsn = Dsn("https://publicKey:secretKey@host/path/id?sample.rate=0.1") - assertEquals("https://host/path/api/id", dsn.sentryUri.toURL().toString()) - assertEquals("publicKey", dsn.publicKey) - assertEquals("secretKey", dsn.secretKey) - assertEquals("/path/", dsn.path) - assertEquals("id", dsn.projectId) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/api/id") + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("secretKey") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") } @Test fun `dsn parsed without path`() { val dsn = Dsn("https://key@host/id") - assertEquals("https://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/id") } @Test fun `dsn parsed with port number`() { val dsn = Dsn("http://key@host:69/id") - assertEquals("http://host:69/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host:69/api/id") } @Test fun `dsn parsed with trailing slash`() { val dsn = Dsn("http://key@host/id/") - assertEquals("http://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/api/id") } @Test fun `dsn parsed with no delimiter for key`() { val dsn = Dsn("https://publicKey@host/id") - assertEquals("publicKey", dsn.publicKey) - assertNull(dsn.secretKey) + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isNull() } @Test fun `when no project id exists, throws exception`() { val ex = assertFailsWith { Dsn("http://key@host/") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: A Project Id is required.", - ex.message, - ) + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: A Project Id is required.") } @Test fun `when no key exists, throws exception`() { val ex = assertFailsWith { Dsn("http://host/id") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: No public key provided.", - ex.message, - ) + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: No public key provided.") } @Test fun `when only passing secret key, throws exception`() { val ex = assertFailsWith { Dsn("https://:secret@host/path/id") } - assertEquals( - "java.lang.IllegalArgumentException: Invalid DSN: No public key provided.", - ex.message, - ) + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: No public key provided.") } @Test fun `dsn is normalized`() { val dsn = Dsn("http://key@host//id") - assertEquals("http://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/api/id") } @Test fun `dsn parsed with leading and trailing whitespace`() { val dsn = Dsn(" https://key@host/id ") - assertEquals("https://host/api/id", dsn.sentryUri.toURL().toString()) + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/id") } @Test fun `when dsn is empty, throws exception`() { val ex = assertFailsWith { Dsn("") } - assertEquals("java.lang.IllegalArgumentException: The DSN is empty.", ex.message) + assertThat(ex).hasMessageThat().isEqualTo("The DSN is empty.") } @Test fun `when dsn is only whitespace, throws exception`() { val ex = assertFailsWith { Dsn(" ") } - assertEquals("java.lang.IllegalArgumentException: The DSN is empty.", ex.message) + assertThat(ex).hasMessageThat().isEqualTo("The DSN is empty.") } @Test fun `non http protocols are not accepted`() { - assertFailsWith { Dsn("ftp://publicKey:secretKey@host/path/id") } - assertFailsWith { Dsn("jar://publicKey:secretKey@host/path/id") } + val ftp = + assertFailsWith { Dsn("ftp://publicKey:secretKey@host/path/id") } + assertThat(ftp).hasMessageThat().isEqualTo("Invalid DSN: Invalid scheme 'ftp'.") + + val jar = + assertFailsWith { Dsn("jar://publicKey:secretKey@host/path/id") } + assertThat(jar).hasMessageThat().isEqualTo("Invalid DSN: Invalid scheme 'jar'.") } @Test @@ -125,24 +120,133 @@ class DsnTest { @Test fun `extracts org id from host`() { val dsn = Dsn("https://key@o123.ingest.sentry.io/456") - assertEquals("123", dsn.orgId) + assertThat(dsn.orgId).isEqualTo("123") } @Test fun `extracts single digit org id from host`() { val dsn = Dsn("https://key@o1.ingest.us.sentry.io/456") - assertEquals("1", dsn.orgId) + assertThat(dsn.orgId).isEqualTo("1") } @Test fun `returns null org id when host has no org prefix`() { val dsn = Dsn("https://key@sentry.io/456") - assertNull(dsn.orgId) + assertThat(dsn.orgId).isNull() } @Test fun `returns null org id for non-standard host`() { val dsn = Dsn("http://key@localhost:9000/456") - assertNull(dsn.orgId) + assertThat(dsn.orgId).isNull() + } + + @Test + fun `when dsn is null, throws exception`() { + val ex = assertFailsWith { Dsn(null) } + assertThat(ex).hasMessageThat().isEqualTo("The DSN is required.") + } + + @Test + fun `when dsn has no scheme separator, throws exception`() { + val ex = assertFailsWith { Dsn("httpspublicKey@host/id") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: Missing scheme.") + } + + @Test + fun `when dsn has no slash after host, throws exception`() { + val ex = assertFailsWith { Dsn("https://key@host") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: A Project Id is required.") + } + + @Test + fun `when port is not a number, throws exception`() { + val ex = assertFailsWith { Dsn("http://key@host:abc/1") } + assertThat(ex).hasMessageThat().isEqualTo("Invalid DSN: Invalid port 'abc'.") + } + + @Test + fun `dsn parsed with multiple path segments`() { + val dsn = Dsn("https://key@host/path/to/sentry/id") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/path/to/sentry/api/id") + assertThat(dsn.publicKey).isEqualTo("key") + assertThat(dsn.path).isEqualTo("/path/to/sentry/") + assertThat(dsn.projectId).isEqualTo("id") + } + + @Test + fun `dsn parsed with port and path`() { + val dsn = Dsn("http://key:secret@host:8080/path/id") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host:8080/path/api/id") + assertThat(dsn.publicKey).isEqualTo("key") + assertThat(dsn.secretKey).isEqualTo("secret") + assertThat(dsn.path).isEqualTo("/path/") + assertThat(dsn.projectId).isEqualTo("id") + } + + @Test + fun `dsn with multiple double slashes in path is normalized`() { + val dsn = Dsn("http://key@host//path//id") + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("http://host/path/api/id") + } + + @Test + fun `dsn with query string and port`() { + val dsn = Dsn("https://key@host:443/id?foo=bar&baz=1") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host:443/api/id") + assertThat(dsn.projectId).isEqualTo("id") + } + + @Test + fun `dsn with fragment is stripped from project id`() { + val dsn = Dsn("https://key@host/123#frag") + + assertThat(dsn.projectId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/123") + } + + @Test + fun `dsn with both query string and fragment is stripped from project id`() { + val dsn = Dsn("https://key@host/123?foo=bar#frag") + + assertThat(dsn.projectId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://host/api/123") + } + + @Test + fun `dsn with ipv6 host and port`() { + val dsn = Dsn("https://key@[2001:db8::1]:9000/1") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://[2001:db8::1]:9000/api/1") + assertThat(dsn.projectId).isEqualTo("1") + } + + @Test + fun `dsn with ipv6 host and no port`() { + val dsn = Dsn("https://key@[::1]/1") + + assertThat(dsn.sentryUri.toURL().toString()).isEqualTo("https://[::1]/api/1") + assertThat(dsn.projectId).isEqualTo("1") + } + + @Test + fun `dsn with empty secret key after colon`() { + val dsn = Dsn("https://publicKey:@host/id") + + assertThat(dsn.publicKey).isEqualTo("publicKey") + assertThat(dsn.secretKey).isEqualTo("") + } + + @Test + fun `dsn with numeric project id`() { + val dsn = Dsn("https://key@o123.ingest.sentry.io/1234567") + + assertThat(dsn.projectId).isEqualTo("1234567") + assertThat(dsn.orgId).isEqualTo("123") + assertThat(dsn.sentryUri.toURL().toString()) + .isEqualTo("https://o123.ingest.sentry.io/api/1234567") } } From 887fd58186a83c5a1120c69813ba5eb7f1c097da Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 9 Jun 2026 11:25:49 +0200 Subject: [PATCH 074/276] ci(spring-matrix): Replace sed hacks with targeted Gradle builds (#5397) * ci(spring-matrix): Replace sed hacks with targeted Gradle builds Remove the sed-based Android module exclusion from settings.gradle.kts and build.gradle.kts in the Spring Boot matrix workflows. This is unnecessary because `org.gradle.configureondemand=true` ensures Gradle only configures projects needed for the requested tasks. Replace the broad `./gradlew assemble --parallel` with a single targeted Gradle invocation that builds only the specific artifacts needed (shadowJar/bootJar/war + OTel agent). Remove redundant `--build "true"` from test runner invocations since artifacts are already built. Co-Authored-By: Claude Opus 4.6 * ci(spring-matrix): Include testClasses in initial build Add testClasses tasks to the single Gradle invocation so test sources are pre-compiled. The subsequent systemTest Gradle calls then only execute tests without needing to compile anything. Co-Authored-By: Claude Opus 4.6 * ci(spring-matrix): Remove redundant --parallel flag Already set in gradle.properties. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/spring-boot-2-matrix.yml | 64 +++++++--------------- .github/workflows/spring-boot-3-matrix.yml | 64 +++++++--------------- .github/workflows/spring-boot-4-matrix.yml | 64 +++++++--------------- 3 files changed, 57 insertions(+), 135 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index bbcb3cfc0bc..32eeef2442d 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -72,88 +72,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 2.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot:shadowJar \ + :sentry-samples:sentry-samples-spring-boot:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring:war \ + :sentry-samples:sentry-samples-spring:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 781d8a876f9..8614e2ca69d 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -68,88 +68,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 3.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-jakarta:war \ + :sentry-samples:sentry-samples-spring-jakarta:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index bc1b1686692..e82b120ec24 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -68,88 +68,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-4:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-7:war \ + :sentry-samples:sentry-samples-spring-7:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Run sentry-samples-spring-boot-4 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-7 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-7" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() From 0456f5cda95b2b91962216f24628a5ecfbebd594 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:06:41 +0200 Subject: [PATCH 075/276] chore(deps): bump the github-actions group across 1 directory with 3 updates (#5519) Bumps the github-actions group with 3 updates in the / directory: [codecov/codecov-action](https://github.com/codecov/codecov-action), [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) and [getsentry/craft](https://github.com/getsentry/craft). Updates `codecov/codecov-action` from 6.0.1 to 7.0.0 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/e79a6962e0d4c0c17b229090214935d2e33f8354...fb8b3582c8e4def4969c97caa2f19720cb33a72f) Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.8 to 2.26.9 - [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/4468eb9e399655a61c770534dacc03139d98aa18...6143e76379c342e247687c4ab5c83d8b900cc273) Updates `getsentry/craft` from 2.26.8 to 2.26.9 - [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/4468eb9e399655a61c770534dacc03139d98aa18...6143e76379c342e247687c4ab5c83d8b900cc273) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.9 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/build.yml | 2 +- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d9e2a3ba38..bb1f45dd60d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,7 +58,7 @@ jobs: SENTRY_PROJECT: sentry-android - name: Upload coverage to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v4 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v4 with: name: sentry-java fail_ci_if_error: false diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 612cc5b52f3..ad0c577b29f 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@4468eb9e399655a61c770534dacc03139d98aa18 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@6143e76379c342e247687c4ab5c83d8b900cc273 # v2 secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6964cec4ba..36732d3874d 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@4468eb9e399655a61c770534dacc03139d98aa18 # v2 + uses: getsentry/craft@6143e76379c342e247687c4ab5c83d8b900cc273 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 3594cd9accff22d8fc383bafc87965ec3b2f8d83 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 10 Jun 2026 14:25:15 +0200 Subject: [PATCH 076/276] ref(core): Reduce unnecessary boxing and redundant null checks (JAVA-554) (#5520) * ref(core): Use static compare and drop redundant null checks Replace boxed Long.valueOf(...).compareTo(...) with Long.compare(...), which avoids the unnecessary boxing. Also remove the redundant != null checks that precede an instanceof, since instanceof already returns false for null. Co-Authored-By: Claude Opus 4.8 * ref(core): Remove unnecessary boxing (JAVA-554) Replace Integer.valueOf/Double.valueOf boxing with primitives or the appropriate parse method. String.format takes the primitives directly, the double conversions only need a cast, and the version check parses straight to a primitive double via Double.parseDouble. Co-Authored-By: Claude Opus 4.8 * changelog * ref(core): Drop redundant StringBuilder in hashing helper (JAVA-554) Return the hex string directly instead of wrapping it in a StringBuilder only to immediately call toString(). Co-Authored-By: Claude Opus 4.8 * ref(core): Use StandardCharsets.UTF_8 and tidy comments (JAVA-554) Replace Charset.forName("UTF-8") with the StandardCharsets constant, which avoids the lookup and cannot throw a checked exception. Also collapse the leftover hashing comments into one. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/CircularFifoQueue.java | 3 +-- sentry/src/main/java/io/sentry/DateUtils.java | 4 ++-- .../src/main/java/io/sentry/ScopesStorageFactory.java | 2 +- sentry/src/main/java/io/sentry/SentryDate.java | 2 +- sentry/src/main/java/io/sentry/SentryNanotimeDate.java | 6 +++--- sentry/src/main/java/io/sentry/SpanFactoryFactory.java | 2 +- .../eventprocessor/EventProcessorAndOrder.java | 2 +- sentry/src/main/java/io/sentry/protocol/Contexts.java | 2 +- .../src/main/java/io/sentry/util/LifecycleHelper.java | 2 +- sentry/src/main/java/io/sentry/util/Platform.java | 2 +- sentry/src/main/java/io/sentry/util/StringUtils.java | 10 ++++------ 12 files changed, 18 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4a5f7628d..6a813a8e7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Improvements - Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) +- Remove unnecessary boxing to improve performance ([#5520](https://github.com/getsentry/sentry-java/pull/5520)) ### Fixes diff --git a/sentry/src/main/java/io/sentry/CircularFifoQueue.java b/sentry/src/main/java/io/sentry/CircularFifoQueue.java index 8fa72e39d56..4c6a123d512 100644 --- a/sentry/src/main/java/io/sentry/CircularFifoQueue.java +++ b/sentry/src/main/java/io/sentry/CircularFifoQueue.java @@ -258,8 +258,7 @@ public boolean add(final @NotNull E element) { if (index < 0 || index >= sz) { throw new NoSuchElementException( String.format( - "The specified index (%1$d) is outside the available range [0, %2$d)", - Integer.valueOf(index), Integer.valueOf(sz))); + "The specified index (%1$d) is outside the available range [0, %2$d)", index, sz)); } final int idx = (start + index) % maxElements; diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 31a8dcd76ea..f7c46844edc 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -115,7 +115,7 @@ public static double nanosToMillis(final double nanos) { * @return date rounded down to milliseconds */ public static Date nanosToDate(final long nanos) { - final Double millis = nanosToMillis(Double.valueOf(nanos)); + final Double millis = nanosToMillis((double) nanos); return getDateTime(millis.longValue()); } @@ -137,7 +137,7 @@ public static Date nanosToDate(final long nanos) { * @return seconds */ public static double nanosToSeconds(final long nanos) { - return Double.valueOf(nanos) / (1000.0 * 1000.0 * 1000.0); + return (double) nanos / (1000.0 * 1000.0 * 1000.0); } /** diff --git a/sentry/src/main/java/io/sentry/ScopesStorageFactory.java b/sentry/src/main/java/io/sentry/ScopesStorageFactory.java index 89fa6389072..37c0acf2314 100644 --- a/sentry/src/main/java/io/sentry/ScopesStorageFactory.java +++ b/sentry/src/main/java/io/sentry/ScopesStorageFactory.java @@ -29,7 +29,7 @@ public final class ScopesStorageFactory { try { final @Nullable Object otelScopesStorage = otelScopesStorageClazz.getDeclaredConstructor().newInstance(); - if (otelScopesStorage != null && otelScopesStorage instanceof IScopesStorage) { + if (otelScopesStorage instanceof IScopesStorage) { return (IScopesStorage) otelScopesStorage; } } catch (InstantiationException e) { diff --git a/sentry/src/main/java/io/sentry/SentryDate.java b/sentry/src/main/java/io/sentry/SentryDate.java index d2620ab3024..03ea596b07a 100644 --- a/sentry/src/main/java/io/sentry/SentryDate.java +++ b/sentry/src/main/java/io/sentry/SentryDate.java @@ -47,6 +47,6 @@ public final boolean isAfter(final @NotNull SentryDate otherDate) { @Override public int compareTo(@NotNull SentryDate otherDate) { - return Long.valueOf(nanoTimestamp()).compareTo(otherDate.nanoTimestamp()); + return Long.compare(nanoTimestamp(), otherDate.nanoTimestamp()); } } diff --git a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java index 2993eeed6c6..98c46ad5325 100644 --- a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java +++ b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java @@ -46,7 +46,7 @@ public long nanoTimestamp() { @Override public long laterDateNanosTimestampByDiff(final @Nullable SentryDate otherDate) { - if (otherDate != null && otherDate instanceof SentryNanotimeDate) { + if (otherDate instanceof SentryNanotimeDate) { final @NotNull SentryNanotimeDate otherNanoDate = (SentryNanotimeDate) otherDate; if (compareTo(otherDate) < 0) { return nanotimeDiff(this, otherNanoDate); @@ -66,9 +66,9 @@ public int compareTo(@NotNull SentryDate otherDate) { final long thisDateMillis = date.getTime(); final long otherDateMillis = otherNanoDate.date.getTime(); if (thisDateMillis == otherDateMillis) { - return Long.valueOf(nanos).compareTo(otherNanoDate.nanos); + return Long.compare(nanos, otherNanoDate.nanos); } else { - return Long.valueOf(thisDateMillis).compareTo(otherDateMillis); + return Long.compare(thisDateMillis, otherDateMillis); } } else { return super.compareTo(otherDate); diff --git a/sentry/src/main/java/io/sentry/SpanFactoryFactory.java b/sentry/src/main/java/io/sentry/SpanFactoryFactory.java index 7dbb9f1f588..f0e3fcbb3c7 100644 --- a/sentry/src/main/java/io/sentry/SpanFactoryFactory.java +++ b/sentry/src/main/java/io/sentry/SpanFactoryFactory.java @@ -21,7 +21,7 @@ public final class SpanFactoryFactory { try { final @Nullable Object otelSpanFactory = otelSpanFactoryClazz.getDeclaredConstructor().newInstance(); - if (otelSpanFactory != null && otelSpanFactory instanceof ISpanFactory) { + if (otelSpanFactory instanceof ISpanFactory) { return (ISpanFactory) otelSpanFactory; } } catch (InstantiationException e) { diff --git a/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java b/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java index 1f504f25557..1ca5f70df8f 100644 --- a/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java +++ b/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java @@ -29,6 +29,6 @@ public EventProcessorAndOrder( @Override public int compareTo(@NotNull EventProcessorAndOrder o) { - return order.compareTo(o.order); + return Long.compare(order, o.order); } } diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index fd1e9b83eb6..35168e5bcc2 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -282,7 +282,7 @@ public void putAll(final @Nullable Contexts contexts) { @Override public boolean equals(final @Nullable Object obj) { - if (obj != null && obj instanceof Contexts) { + if (obj instanceof Contexts) { final @NotNull Contexts otherContexts = (Contexts) obj; return internalStorage.equals(otherContexts.internalStorage); } diff --git a/sentry/src/main/java/io/sentry/util/LifecycleHelper.java b/sentry/src/main/java/io/sentry/util/LifecycleHelper.java index 4a029f620cc..fc6e9e74120 100644 --- a/sentry/src/main/java/io/sentry/util/LifecycleHelper.java +++ b/sentry/src/main/java/io/sentry/util/LifecycleHelper.java @@ -7,7 +7,7 @@ public final class LifecycleHelper { public static void close(final @Nullable Object tokenObject) { - if (tokenObject != null && tokenObject instanceof ISentryLifecycleToken) { + if (tokenObject instanceof ISentryLifecycleToken) { final @NotNull ISentryLifecycleToken token = (ISentryLifecycleToken) tokenObject; token.close(); } diff --git a/sentry/src/main/java/io/sentry/util/Platform.java b/sentry/src/main/java/io/sentry/util/Platform.java index b08b6e584fb..cc924fb2815 100644 --- a/sentry/src/main/java/io/sentry/util/Platform.java +++ b/sentry/src/main/java/io/sentry/util/Platform.java @@ -23,7 +23,7 @@ public final class Platform { try { final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); if (javaStringVersion != null) { - final @NotNull double javaVersion = Double.valueOf(javaStringVersion); + final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); isJavaNinePlus = javaVersion >= 9.0; } else { isJavaNinePlus = false; diff --git a/sentry/src/main/java/io/sentry/util/StringUtils.java b/sentry/src/main/java/io/sentry/util/StringUtils.java index 66e3a95ddb7..02d7d4636a4 100644 --- a/sentry/src/main/java/io/sentry/util/StringUtils.java +++ b/sentry/src/main/java/io/sentry/util/StringUtils.java @@ -4,6 +4,7 @@ import io.sentry.SentryLevel; import java.math.BigInteger; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.CharacterIterator; @@ -18,7 +19,7 @@ @ApiStatus.Internal public final class StringUtils { - private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final Charset UTF_8 = StandardCharsets.UTF_8; public static final String PROPER_NIL_UUID = "00000000-0000-0000-0000-000000000000"; private static final String CORRUPTED_NIL_UUID = "0000-0000"; @@ -142,11 +143,8 @@ private StringUtils() {} // Convert byte array into signum representation final BigInteger no = new BigInteger(1, messageDigest); - // Convert message digest into hex value - final StringBuilder stringBuilder = new StringBuilder(no.toString(16)); - - // return the HashText - return stringBuilder.toString(); + // Convert message digest into hex value and return the HashText + return no.toString(16); } // For specifying wrong message digest algorithms From b88ded98ebe81fc24dde6129f8d397da9b1dcf61 Mon Sep 17 00:00:00 2001 From: markushi <1411808+markushi@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:38:25 +0000 Subject: [PATCH 077/276] release: 8.43.2 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a813a8e7b6..1f7529b728f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.43.2 ### Improvements diff --git a/gradle.properties b/gradle.properties index eee4b292bff..35641a00053 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.43.1 +versionName=8.43.2 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From a28ff1255339a733196c2151b2b56742aae1211e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:23:15 +0200 Subject: [PATCH 078/276] chore(deps): bump the github-actions group with 2 updates (#5526) Bumps the github-actions group with 2 updates: [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) and [getsentry/craft](https://github.com/getsentry/craft). Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.9 to 2.26.10 - [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/6143e76379c342e247687c4ab5c83d8b900cc273...acdb88019720182caf57293360d7cdc8db9e75ac) Updates `getsentry/craft` from 2.26.9 to 2.26.10 - [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/6143e76379c342e247687c4ab5c83d8b900cc273...acdb88019720182caf57293360d7cdc8db9e75ac) --- updated-dependencies: - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.10 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/changelog-preview.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index ad0c577b29f..d814ca72002 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@6143e76379c342e247687c4ab5c83d8b900cc273 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@acdb88019720182caf57293360d7cdc8db9e75ac # v2 secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36732d3874d..eddeaa24cd9 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@6143e76379c342e247687c4ab5c83d8b900cc273 # v2 + uses: getsentry/craft@acdb88019720182caf57293360d7cdc8db9e75ac # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 8330a1be3553835c8c54fa993396afea48b0ef98 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 11 Jun 2026 09:33:11 +0200 Subject: [PATCH 079/276] ref(core): Avoid boxing in DateUtils.nanosToDate (#5523) * ref(core): Avoid boxing in DateUtils.nanosToDate nanosToMillis already returns a primitive double, but the result was stored in a boxed Double and then unboxed again via longValue(). Keep the value primitive to drop the redundant allocation and unboxing on this conversion, which runs whenever a SentryDate is turned into a java.util.Date. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ sentry/src/main/java/io/sentry/DateUtils.java | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7529b728f..ba25b7d588a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improvements + +- Reduce unboxing in `DateUtils.nanosToDate` ([#5523](https://github.com/getsentry/sentry-java/pull/5523)) + ## 8.43.2 ### Improvements diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index f7c46844edc..5e55512ae70 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -115,8 +115,8 @@ public static double nanosToMillis(final double nanos) { * @return date rounded down to milliseconds */ public static Date nanosToDate(final long nanos) { - final Double millis = nanosToMillis((double) nanos); - return getDateTime(millis.longValue()); + final double millis = nanosToMillis((double) nanos); + return getDateTime((long) millis); } public static @Nullable Date toUtilDate(final @Nullable SentryDate sentryDate) { From b988b37098f9350428c60c0d5ad8f8b11b872b84 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 11 Jun 2026 14:30:33 +0200 Subject: [PATCH 080/276] perf(core): Use fixed-delay scheduling for performance collector (JAVA-555) (#5524) * perf(core): Use fixed-delay scheduling for performance collector Switch the transaction collection timer from scheduleAtFixedRate to schedule. Fixed-rate scheduling fires rapid catch-up executions after a delay or GC pause, which the old code guarded against with a 10ms skip check. Fixed-delay scheduling spaces each collection 100ms after the previous one finishes, so the catch-up bursts cannot happen and the guard, its timestamp field, and the stale comment are no longer needed. Co-Authored-By: Claude Opus 4.8 * changelog * test(core): Verify schedule instead of scheduleAtFixedRate The performance collector now uses fixed-delay scheduling, so the timer verifications assert schedule(...) rather than scheduleAtFixedRate(...). Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ .../DefaultCompositePerformanceCollector.java | 11 +---------- .../DefaultCompositePerformanceCollectorTest.kt | 16 ++++++++-------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba25b7d588a..407eb12d201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Reduce unboxing in `DateUtils.nanosToDate` ([#5523](https://github.com/getsentry/sentry-java/pull/5523)) +### Fixes + +- Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) + ## 8.43.2 ### Improvements diff --git a/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java b/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java index 1861381a853..4736ab8fac5 100644 --- a/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java +++ b/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java @@ -27,7 +27,6 @@ public final class DefaultCompositePerformanceCollector implements CompositePerf private final @NotNull SentryOptions options; private final @NotNull AtomicBoolean isStarted = new AtomicBoolean(false); - private long lastCollectionTimestamp = 0; public DefaultCompositePerformanceCollector(final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "The options object is required."); @@ -112,16 +111,8 @@ public void run() { new TimerTask() { @Override public void run() { - long now = System.currentTimeMillis(); - // The timer is scheduled to run every 100ms on average. In case it takes longer, - // subsequent tasks are executed more quickly. If two tasks are scheduled to run in - // less than 10ms, the measurement that we collect is not meaningful, so we skip it - if (now - lastCollectionTimestamp <= 10) { - return; - } timedOutTransactions.clear(); - lastCollectionTimestamp = now; final @NotNull PerformanceCollectionData tempData = new PerformanceCollectionData(options.getDateProvider().now().nanoTimestamp()); @@ -147,7 +138,7 @@ public void run() { } } }; - timer.scheduleAtFixedRate( + timer.schedule( timerTask, TRANSACTION_COLLECTION_INTERVAL_MILLIS, TRANSACTION_COLLECTION_INTERVAL_MILLIS); diff --git a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt index 46c304358df..ceec3571ebd 100644 --- a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt +++ b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt @@ -86,7 +86,7 @@ class DefaultCompositePerformanceCollectorTest { val collector = fixture.getSut(null, null) assertTrue(fixture.options.performanceCollectors.isEmpty()) collector.start(fixture.transaction1) - verify(fixture.mockTimer, never())!!.scheduleAtFixedRate(any(), any(), any()) + verify(fixture.mockTimer, never())!!.schedule(any(), any(), any()) } @Test @@ -104,14 +104,14 @@ class DefaultCompositePerformanceCollectorTest { fun `when start, timer is scheduled every 100 milliseconds`() { val collector = fixture.getSut() collector.start(fixture.transaction1) - verify(fixture.mockTimer)!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer)!!.schedule(any(), any(), eq(100)) } @Test fun `when start with a string, timer is scheduled every 100 milliseconds`() { val collector = fixture.getSut() collector.start(fixture.id1) - verify(fixture.mockTimer)!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer)!!.schedule(any(), any(), eq(100)) } @Test @@ -119,7 +119,7 @@ class DefaultCompositePerformanceCollectorTest { val collector = fixture.getSut() collector.start(fixture.transaction1) collector.stop(fixture.transaction1) - verify(fixture.mockTimer)!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer)!!.schedule(any(), any(), eq(100)) verify(fixture.mockTimer)!!.cancel() } @@ -128,7 +128,7 @@ class DefaultCompositePerformanceCollectorTest { val collector = fixture.getSut() collector.start(fixture.id1) collector.stop(fixture.id1) - verify(fixture.mockTimer)!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer)!!.schedule(any(), any(), eq(100)) verify(fixture.mockTimer)!!.cancel() } @@ -136,7 +136,7 @@ class DefaultCompositePerformanceCollectorTest { fun `stopping a not collected transaction return null`() { val collector = fixture.getSut() val data = collector.stop(fixture.transaction1) - verify(fixture.mockTimer, never())!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer, never())!!.schedule(any(), any(), eq(100)) verify(fixture.mockTimer, never())!!.cancel() assertNull(data) } @@ -145,7 +145,7 @@ class DefaultCompositePerformanceCollectorTest { fun `stopping a not collected id return null`() { val collector = fixture.getSut() val data = collector.stop(fixture.id1) - verify(fixture.mockTimer, never())!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer, never())!!.schedule(any(), any(), eq(100)) verify(fixture.mockTimer, never())!!.cancel() assertNull(data) } @@ -316,7 +316,7 @@ class DefaultCompositePerformanceCollectorTest { collector.close() // Timer was canceled - verify(fixture.mockTimer)!!.scheduleAtFixedRate(any(), any(), eq(100)) + verify(fixture.mockTimer)!!.schedule(any(), any(), eq(100)) verify(fixture.mockTimer)!!.cancel() // Data was cleared From 46b442bde01e6922493733151c6bb41106764cff Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 11 Jun 2026 16:49:14 +0200 Subject: [PATCH 081/276] ref(core): Use primitive long for EventProcessorAndOrder.order (#5527) * ref(core): Use primitive long for EventProcessorAndOrder.order Avoid boxing by storing the order as a primitive long instead of a boxed Long. The constructor already normalizes a null order to System.nanoTime(), so the field never needs to represent null. Co-Authored-By: Claude Opus 4.8 * changelog * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 2 +- .../sentry/internal/eventprocessor/EventProcessorAndOrder.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407eb12d201..9b42d4bd09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Improvements -- Reduce unboxing in `DateUtils.nanosToDate` ([#5523](https://github.com/getsentry/sentry-java/pull/5523)) +- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) ### Fixes diff --git a/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java b/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java index 1ca5f70df8f..38ff7802f58 100644 --- a/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java +++ b/sentry/src/main/java/io/sentry/internal/eventprocessor/EventProcessorAndOrder.java @@ -7,7 +7,7 @@ public final class EventProcessorAndOrder implements Comparable { private final @NotNull EventProcessor eventProcessor; - private final @NotNull Long order; + private final long order; public EventProcessorAndOrder( final @NotNull EventProcessor eventProcessor, final @Nullable Long order) { From 85fd8b11a0c380fa02410ab8ac09a87d38a16e62 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 12 Jun 2026 14:42:12 +0200 Subject: [PATCH 082/276] feat(android): Add standalone app start tracing (#5342) * feat: Add standalone app start transaction (happy path) Introduce experimental `enableStandaloneAppStartTracing` option that creates a separate app start transaction instead of attaching app start as a child span of the first activity transaction. This is the happy path only (foreground importance, activity launch, first frame drawn as end time). The standalone transaction shares the same trace ID as the activity transaction but is not bound to the scope. App start measurements and child spans (process init, content providers, application.onCreate) are attached to the standalone transaction instead of the activity transaction. Includes foreground importance check branching to prepare for the non-activity launch path (next PR). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Add non-activity app start path with end time resolution When the app starts without launching an activity (service, broadcast receiver, content provider), create a standalone app start transaction with the end time determined by priority: 1. onApplicationPostCreate (Gradle plugin bytecode instrumentation) 2. ApplicationStartInfo timestamps (API 35+) 3. firstIdle - main thread idle handler (pre-API 35 fallback) The non-activity app start transaction stores its trace ID so that if an activity is later launched, the activity transaction reuses the same trace ID to keep both in the same trace. Adds OnNoActivityStartedListener callback from AppStartMetrics to ActivityLifecycleIntegration, triggered by checkCreateTimeOnMain() when no activity was created after Application.onCreate(). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Support non-activity app start tracing without bytecode instrumentation When an app is launched via broadcast receiver, service, or content provider (no activity), detect this via Handler.post() and create a standalone app start transaction. Resolves app start end time with priority: Gradle plugin > ApplicationStartInfo (API 35+) > process init time. Also attaches child spans (process init, content providers, Application.onCreate) to standalone transactions. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: Consolidate non-activity app start time-span resolution Extract the "try appStartSpan, fall back to sdkInitTimeSpan" logic used for standalone (non-activity) app start transactions into a new AppStartMetrics.getAppStartTimeSpanDirect() helper, removing the duplicated inline fallback in ActivityLifecycleIntegration and the private helper in PerformanceAndroidEventProcessor. Also cache the API 35+ ApplicationStartInfo on registerLifecycleCallbacks so onAppStartSpansSent no longer re-queries ActivityManager, and simplify the non-activity detection path to always use the main-thread IdleHandler. Regenerates the sentry-android-core API to include method additions missed in prior commits on this branch (standalone-app-start options, trace id accessors, OnNoActivityStartedListener). Co-Authored-By: Claude Opus 4.7 (1M context) * chore(samples): Register TestBroadcastReceiver in manifest Wires up the TestBroadcastReceiver added earlier so the sample app can trigger a non-activity cold start via `adb shell am broadcast`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app-start): resolve standalone tracing misclassification and duplicate emission Two pre-merge fixes for the standalone app-start tracing path introduced on this branch (issue #5046): - AppStartMetrics.checkCreateTimeOnMain() now defaults appStartType to COLD when UNKNOWN with no active activities. On API < 35 (where ApplicationStartInfo is unavailable) non-activity cold starts were stuck at UNKNOWN, which both misclassified the standalone transaction as App Start Warm and caused PerformanceAndroidEventProcessor.attachAppStartSpans to early-return (dropping process.load / application.load / contentprovider.load phase spans). - ActivityLifecycleIntegration.onActivityPreCreated() now skips emitting a second standalone App Start transaction when the non-activity path has already reported the process's app start (detected via the stashed appStartTraceId). Previously a broadcast followed by an activity launch produced two standalone transactions (a spurious App Start Warm in addition to the broadcast's App Start Cold), violating one-per-process semantics. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(android): refine standalone app start tracing * chore: Update generated files * style(core): Apply spotless formatting * changelog * fix(android): Use stable app start transaction name Rename the standalone app-start transaction to a single App Start name so cold and warm starts group consistently while preserving the app.start op. Co-authored-by: Cursor * feat(android): Add standalone app start tracing Co-authored-by: Cursor * fix(android): Handle non-activity app starts below API 24 Co-authored-by: Cursor * fix(android): Guard app start timestamp clock base Co-authored-by: Cursor * ref(android): Remove app start reason plumbing Co-authored-by: Cursor * ref(android): Clarify no-activity app start handling Rename the private app start helper to reflect that it conditionally handles non-activity starts. Keep comments and tests focused on behavior. Co-Authored-By: Claude Co-authored-by: Cursor * docs(android): Clarify non-activity app start fallback Explain why unresolved non-activity starts default to cold when Activity signals or ApplicationStartInfo classification are unavailable. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Preserve legacy no-activity app start guard Only run the no-activity startup check for unresolved app starts or when standalone app start tracing registered a listener. This keeps API 35 ApplicationStartInfo classifications from triggering legacy side effects. Co-Authored-By: Claude Co-authored-by: Cursor * test(android): Opt into standalone no-activity API 35 tests Register a no-op no-activity listener for API 35 end-time resolution tests so they exercise the standalone path under the restored legacy guard. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Schedule no-activity idle check when standalone listener is set on API 35+ On API 35+, ApplicationStartInfo resolves appStartType before the standalone app start listener is installed, causing the idle handler condition to be false and skipping the no-activity detection entirely. Register the idle handler from setOnNoActivityStartedListener when the type is already resolved, ensuring onNoActivityStarted() fires for standalone app start tracing on API 35+ devices. Co-authored-by: Cursor * ref(android): Remove dead foregroundImportance check in standalone app start path The foregroundImportance guard was always true at that point because appStartTime is only set to non-null inside the foregroundImportance branch. Remove the redundant check and the misleading else comment that described an unreachable code path. Co-authored-by: Cursor * fix(android): Prevent duplicate standalone app start measurements Require the app-start pending flag even when standalone app-start transactions bypass foreground checks. Preserve completed non-activity app-start timings so fallback resolution does not overwrite stopped spans. Co-authored-by: Cursor * ref(android): Remove unused app start application context Drop dead AppStartMetrics state that was assigned during lifecycle callback registration but never read. Co-authored-by: Cursor * ref(android): Rename getAppStartTimeSpanDirect to getAppStartTimeSpanForStandalone Co-authored-by: Cursor * fix(android): Do not set TTID/TTFD contributing flags on standalone app start spans Co-authored-by: Cursor * fix(android): Add volatile to noActivityStartedListener for cross-thread visibility The field is written by setOnNoActivityStartedListener (called during Sentry.init(), potentially on a background thread) and read on the main thread in handleNoActivityStartIfNeededOnMain. Without volatile, the JMM permits the main thread to see a stale null, silently skipping the listener and preventing standalone app-start transaction creation. Co-authored-by: Cursor * fix(android): Clear stale app start sampling decision in non-activity start path onNoActivityStarted() did not clear the appStartSamplingDecision, which could leak to the first ui.load transaction when an activity eventually starts after a non-activity process launch. Co-authored-by: Cursor * fix: Format adb test commands in TestBroadcastReceiver JavaDoc Co-authored-by: Cursor * ref(android): Rename headless app start handling Use headless terminology for app starts that do not reach an Activity and schedule the headless check from lifecycle callback registration. This removes listener setter side effects while preserving standalone app-start behavior. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Align foreground app start measurements Use the foreground app start fallback for foreground standalone app start transactions so measurements match the transaction timestamp. Keep the headless-only span source limited to true headless starts. Co-authored-by: Cursor * fix(android): Gate headless app start end time Resolve the headless app start end timestamp only when standalone headless tracing is active. This avoids stopping legacy app start spans before a later foreground Activity can finish them. Co-authored-by: Cursor * test(android): Update API 35 headless app start expectation Make the ApplicationStartInfo headless test install the listener that now gates headless end-time resolution, matching the standalone path. Co-authored-by: Cursor * Fix headless app-start idle scheduling * ref(android): Clarify headless app start state names Rename private headless app start flags to distinguish the pending main-thread check from the one-shot listener invocation guard. No behavior change. Co-Authored-By: Claude Co-authored-by: Cursor * ref(android): Use app.start origin for headless app start transaction Set the standalone headless app start transaction origin to `auto.app.start` instead of `auto.ui.activity`, which was semantically incorrect for non-activity (broadcast/service/content provider) starts. Also simplify the API 35+ ApplicationStartInfo onCreate timestamp resolution by using the reported nanos directly as the uptime base. Co-authored-by: Cursor * ref(android): refine standalone app start trace continuation Drop the redundant trace-id sharing TransactionContext constructor; the ui.load now shares the app.start trace solely through continueTrace. Don't connect a headless app.start and a following activity's ui.load into the same trace when they are more than 1 minute apart, since such a large gap means they no longer belong to the same launch. Co-authored-by: Cursor * fix(android): align headless app start tests with uptime-based onCreate timestamp ApplicationStartInfo's START_TIMESTAMP_APPLICATION_ONCREATE is captured via SystemClock.uptimeNanos(), the same base as TimeSpan, so no clock re-anchoring is needed. Add the missing headless test setup (foreground-importance stubbing) and fix the API 35 timestamp test to use uptime semantics. Co-authored-by: Cursor * test(android): add standalone app start E2E harness Wire the Android sample app for manual standalone app-start validation and add a reusable harness plus notes for the scenarios verified locally. Trim redundant comments around app-start trace continuation while keeping the non-obvious sampling and parentage details. Co-Authored-By: Claude Co-authored-by: Cursor * chore(android): remove standalone app start report Co-authored-by: Cursor * test(android): clarify app start transaction shapes Co-authored-by: Cursor * chore(android): remove standalone app start harness Co-authored-by: Cursor * fix(android): Preserve app start activity counter Keep the foreground headless guard from faking an observed activity so late standalone app start init still lets the first real activity classify startup and reset warm-start state correctly. Co-authored-by: Cursor * fix(android): Finish app start after activity spans Keep standalone app-start transactions open until activity lifecycle spans are attached so early app-start completion does not drop activity spans. Co-Authored-By: Cursor * feat(samples): enable standalone app start tracing and add headless-start broadcast receiver Co-authored-by: Cursor * fix(changelog): resolve merge conflict and keep standalone app start entry under Unreleased Co-authored-by: Cursor * docs(options): Clarify standalone app start javadoc per review - Use plain quotes for the "App Start" transaction name instead of {@code} - Clarify that the API 35 gate refers to the device's runtime OS version Co-Authored-By: Claude Fable 5 * fix(android): Clarify ApplicationStartInfo onCreate timestamp marks onCreate start START_TIMESTAMP_APPLICATION_ONCREATE is captured right before Application.onCreate is invoked (ActivityThread.handleBindApplication), so it is the onCreate start, not its end. Rename locals, fix comments and javadoc accordingly, and drop the applicationOnCreate.setStoppedAt branch which could have recorded a zero-length application.load span. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cursor --- CHANGELOG.md | 8 + .../api/sentry-android-core.api | 17 + .../core/ActivityLifecycleIntegration.java | 293 +++++++-- .../android/core/ManifestMetadataReader.java | 10 + .../PerformanceAndroidEventProcessor.java | 56 +- .../android/core/SentryAndroidOptions.java | 49 ++ .../core/performance/AppStartMetrics.java | 209 ++++++- .../core/ActivityLifecycleIntegrationTest.kt | 554 +++++++++++++++++- .../core/ManifestMetadataReaderTest.kt | 30 + .../PerformanceAndroidEventProcessorTest.kt | 183 +++++- .../android/core/SentryAndroidOptionsTest.kt | 6 + .../core/SentryShadowActivityManager.kt | 13 + .../android/core/SentryShadowProcess.kt | 16 +- .../core/performance/AppStartMetricsTest.kt | 195 +++++- .../performance/AppStartMetricsTestApi35.kt | 130 ++++ .../src/main/AndroidManifest.xml | 13 + .../android/TestBroadcastReceiver.java | 26 + 17 files changed, 1695 insertions(+), 113 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42d4bd09d..cd876f3c57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `options.isEnableStandaloneAppStartTracing = true` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) + ### Improvements - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 249549f8366..0500ba44990 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -392,6 +392,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z @@ -423,6 +424,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V @@ -740,11 +742,16 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; + public fun getAppStartBaggageHeader ()Ljava/lang/String; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; + public fun getAppStartEndTime ()Lio/sentry/SentryDate; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -765,12 +772,18 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V + public fun setAppStartBaggageHeader (Ljava/lang/String;)V public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V + public fun setAppStartEndTime (Lio/sentry/SentryDate;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z + public fun shouldSendStartMeasurements (Z)Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -781,6 +794,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { + public abstract fun onHeadlessAppStart ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..19cee7fcce5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,6 +9,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -18,6 +20,7 @@ import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; @@ -33,6 +36,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -40,6 +44,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.Date; import java.util.Map; import java.util.WeakHashMap; @@ -55,12 +60,19 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; static final long TTFD_TIMEOUT_MILLIS = 25000; + // If a headless app start and the following activity's ui.load are more than this far apart, they + // are treated as unrelated and not connected into the same trace. + static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,6 +89,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -124,6 +137,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +153,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setHeadlessAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +258,93 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + + final boolean isAppStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + // Foreground starts create app.start first; ui.load then shares its trace. + final boolean createStandaloneAppStart = + isAppStart + && options.isEnableStandaloneAppStartTracing() + && !isFollowingHeadlessAppStart; + + if (createStandaloneAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + } + + // Continue either the foreground app.start above or an earlier headless app.start. + final @Nullable String continueSentryTrace; + final @Nullable String continueBaggage; + if (createStandaloneAppStart) { + continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); + final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); + continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); + } else if (isFollowingHeadlessAppStart + && isWithinAppStartContinuationWindow(ttidStartTime)) { + continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); + continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); + } else { + continueSentryTrace = null; + continueBaggage = null; + } + + final @Nullable TransactionContext continuedContext = + continueSentryTrace == null + ? null + : continueUiLoadTrace(continueSentryTrace, continueBaggage, activityName); + + final ITransaction transaction; + if (continuedContext != null) { + transaction = scopes.startTransaction(continuedContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } + + if (isFollowingHeadlessAppStart) { + // Consume the stored headless app-start trace so it isn't reused by another activity. + AppStartMetrics.getInstance().setAppStartTraceId(null); + AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); + AppStartMetrics.getInstance().setAppStartBaggageHeader(null); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (isAppStart) { + if (!createStandaloneAppStart && !options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -316,6 +395,61 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { spanOptions.setOrigin(TRACE_ORIGIN); } + /** + * Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless + * app start to belong to the same trace. If they are more than {@link + * #APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS} apart, they are treated as unrelated. When + * the headless end time is unknown, we keep the previous behaviour and continue the trace. + */ + private boolean isWithinAppStartContinuationWindow(final @NotNull SentryDate uiLoadStartTime) { + final @Nullable SentryDate appStartEndTime = AppStartMetrics.getInstance().getAppStartEndTime(); + if (appStartEndTime == null) { + return true; + } + return uiLoadStartTime.diff(appStartEndTime) <= APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS; + } + + /** + * Builds a {@link TransactionContext} for the ui.load transaction that shares the standalone + * app.start trace (same traceId and sampleRand) while staying a sibling (no parentSpanId), rather + * than a child. The continued baggage keeps sampling decisions on the same sampleRand. Returns + * null if the trace cannot be continued, so callers can fall back. + */ + private @Nullable TransactionContext continueUiLoadTrace( + final @NotNull String sentryTrace, + final @Nullable String baggage, + final @NotNull String activityName) { + if (options == null || !options.isTracingEnabled()) { + return null; + } + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + options.getLogger(), + sentryTrace, + baggage == null ? null : Collections.singletonList(baggage), + options); + final @Nullable Boolean parentSampled = propagationContext.isSampled(); + final @NotNull Baggage continuedBaggage = propagationContext.getBaggage(); + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSampled == null + ? null + : new TracesSamplingDecision( + parentSampled, + continuedBaggage.getSampleRate(), + propagationContext.getSampleRand()); + final @NotNull TransactionContext context = + new TransactionContext( + propagationContext.getTraceId(), + propagationContext.getSpanId(), + null, + parentSamplingDecision, + continuedBaggage); + context.setName(activityName); + context.setTransactionNameSource(TransactionNameSource.COMPONENT); + context.setOperation(UI_LOAD_OP); + return context; + } + @VisibleForTesting void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( @@ -440,8 +574,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,11 +612,11 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } + finishAppStartSpan(); } @Override @@ -559,6 +692,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +711,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -637,22 +774,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +802,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +815,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +928,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +947,70 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } } } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + // Persist trace headers so a later ui.load can share traceId and sampleRand. + metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); + final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); + metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); + // Persist the end time so a later activity can decide whether its ui.load is close enough in + // time to continue this trace. + metrics.setAppStartEndTime(endTime); + + transaction.finish(SpanStatus.OK, endTime); + } } 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 e16d4b312fc..c34ee0dbfa9 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 @@ -108,6 +108,9 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; @@ -502,6 +505,13 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..0b50b5080f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,7 +1,9 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -84,9 +86,21 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -216,9 +230,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +257,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +274,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +289,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +302,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +312,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bb9ec17aabd..ed07c4edaaf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -246,6 +246,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -677,6 +679,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

The SDK reports app start through these paths: + * + *

    + *
  • With an Activity: the SDK sends an "App Start" transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
      + *
    • On devices running Android 15 (API level 35) or newer, the SDK can use {@code + * ApplicationStartInfo} to classify cold versus warm starts and anchor the end time + * at the {@code Application.onCreate} start. + *
    • On devices running older Android versions, headless launches are treated as cold + * once {@code Application.onCreate} finishes without an Activity. The end time falls + * back to the best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 746805fcfdc..d8cb0827ba4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -10,7 +10,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.MessageQueue; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -19,12 +18,14 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpLogger; +import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,15 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + // Captures a headless app.start so a later ui.load can share its trace. + private @Nullable SentryId appStartTraceId; + private @Nullable String appStartSentryTraceHeader; + private @Nullable String appStartBaggageHeader; + private @Nullable SentryDate appStartEndTime; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -161,6 +175,48 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + public @Nullable String getAppStartSentryTraceHeader() { + return appStartSentryTraceHeader; + } + + public void setAppStartSentryTraceHeader(final @Nullable String appStartSentryTraceHeader) { + this.appStartSentryTraceHeader = appStartSentryTraceHeader; + } + + public @Nullable String getAppStartBaggageHeader() { + return appStartBaggageHeader; + } + + public void setAppStartBaggageHeader(final @Nullable String appStartBaggageHeader) { + this.appStartBaggageHeader = appStartBaggageHeader; + } + + public @Nullable SentryDate getAppStartEndTime() { + return appStartEndTime; + } + + public void setAppStartEndTime(final @Nullable SentryDate appStartEndTime) { + this.appStartEndTime = appStartEndTime; + } + /** * Provides all collected content provider onCreate time spans * @@ -188,14 +244,30 @@ public void onAppStartSpansSent() { activityLifecycles.clear(); } + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements + && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + } + public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -258,6 +330,14 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + appStartSentryTraceHeader = null; + appStartBaggageHeader = null; + appStartEndTime = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -346,6 +426,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { activityManager.getHistoricalProcessStartReasons(1); if (!historicalProcessStartReasons.isEmpty()) { final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { appStartType = AppStartType.COLD; @@ -357,41 +438,61 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( - new MessageQueue.IdleHandler() { - @Override - public boolean queueIdle() { - firstIdle = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - return false; - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; }); - } else if (appStartType == AppStartType.UNKNOWN) { - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. + } else { final Handler handler = new Handler(Looper.getMainLooper()); handler.post( - new Runnable() { - @Override - public void run() { - // not technically correct, but close enough for pre-M - firstIdle = SystemClock.uptimeMillis(); - handler.post(() -> checkCreateTimeOnMain()); - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); }); } } - private void checkCreateTimeOnMain() { - // if no activity has ever been created, app was launched in background + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { if (activeActivitiesCounter.get() == 0) { + // SDK init happened after Application.onCreate (e.g. deferred/late init inside an Activity): + // we missed the Activity's onActivityCreated, but a foreground process means it was a real + // launch, not a headless start. Gated on the listener so only the standalone-app-start path + // (which is what could emit a headless transaction) is affected. + if (headlessAppStartListener != null && ContextUtils.isForegroundImportance()) { + return; + } + appLaunchedInForeground.setValue(false); + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); @@ -401,6 +502,56 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateStartNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateStartNanos != null) { + // The framework captures this timestamp with SystemClock.uptimeNanos() right *before* + // invoking Application.onCreate (see ActivityThread.handleBindApplication), so it marks + // the onCreate start, not its end. Without plugin instrumentation there is no onCreate + // end signal, so this is the best available lower bound for the app start end time. + // Same clock base as TimeSpan, so it can be used directly without re-anchoring. + final long onCreateStartUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateStartNanos); + stopHeadlessAppStartAt(onCreateStartUptimeMs); + return; + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); } } @@ -413,7 +564,9 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, consider it a warm start + // If the app (process) was launched more than 1 minute ago, consider it a warm start. + // NOTE: meaningless in standalone app start mode, where a headless start is already its own + // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground.getValue() || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { @@ -472,7 +625,11 @@ public void onActivityStopped(@NonNull Activity activity) { public void onActivityDestroyed(@NonNull Activity activity) { CurrentActivityHolder.getInstance().clearActivity(activity); - final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + int remainingActivities = activeActivitiesCounter.decrementAndGet(); + if (remainingActivities < 0) { + activeActivitiesCounter.set(0); + remainingActivities = 0; + } // if the app is moving into background // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { 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 9e94d7b9905..f2ffb4b4b96 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 @@ -7,6 +7,7 @@ import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -22,17 +23,21 @@ import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider import io.sentry.SentryNanotimeDate +import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.Span +import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.SpanStatus.OK import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty @@ -52,6 +57,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -64,6 +70,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +90,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +112,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +240,192 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("auto.app.start", options.origin) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @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 + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + 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() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +729,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @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 + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1105,282 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @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 + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertEquals("auto.app.start", fixture.capturedOptions[appStartIndex].origin) + assertEquals("auto.ui.activity", fixture.capturedOptions[uiLoadIndex].origin) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val appStartEndDate = SentryNanotimeDate(Date(499), 0) + setAppStartTime(SentryNanotimeDate(Date(1), 0), appStartEndDate) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val appStartTransaction = fixture.createdTransactions[appStartIndex] + assertFalse(appStartTransaction.isFinished) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + val activityLoadSpans = appStartTransaction.children.filter { it.operation == "activity.load" } + assertEquals(2, activityLoadSpans.size) + assertTrue(activityLoadSpans.all { it.isFinished }) + assertTrue(appStartTransaction.isFinished) + assertEquals(appStartEndDate.nanoTimestamp(), appStartTransaction.finishDate!!.nanoTimestamp()) + } + + @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 + } + 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. + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @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 + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended right before the activity opens + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + } + + @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 + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended at epoch, but the activity opens more than a minute later + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(TimeUnit.MINUTES.toMillis(2)), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + // too far apart: the ui.load gets its own fresh trace, not the stored one + assertNotEquals(storedTraceId, context.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + val metrics = AppStartMetrics.getInstance() + val sentryTraceHeader = metrics.appStartSentryTraceHeader + val baggageHeader = metrics.appStartBaggageHeader + assertNotNull(sentryTraceHeader) + assertNotNull(baggageHeader) + // sentry-trace carries the standalone app.start trace id so a later ui.load txn can continue it + assertTrue(sentryTraceHeader.startsWith(transaction.spanContext.traceId.toString())) + } + + @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 + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + // the app-start sampling decision carries the sampleRand the whole trace should share + AppStartMetrics.getInstance() + .setAppStartSamplingDecision(TracesSamplingDecision(true, 1.0, 0.42)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + // app.start is created first so it roots the trace; ui.load shares it + assertTrue(appStartIndex < uiLoadIndex) + + val appStartContext = fixture.capturedContexts[appStartIndex] + val uiLoadContext = fixture.capturedContexts[uiLoadIndex] + assertEquals(appStartContext.traceId, uiLoadContext.traceId) + // both share the same sampleRand + assertEquals(0.42, appStartContext.baggage?.sampleRand) + assertEquals(0.42, uiLoadContext.baggage?.sampleRand) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + } + + @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 + } + sut.register(fixture.scopes, fixture.options) + + // 1) a headless start emits the standalone app.start and stores its trace headers + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + driveHeadlessAppStart() + val appStartTransaction = fixture.createdTransactions.single() + + // 2) an activity opens and shares the stored trace instead of emitting a second standalone + setAppStartTime() + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + assertFalse( + fixture.capturedContexts.drop(1).any { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + ) + assertEquals(appStartTransaction.spanContext.traceId, uiLoadContext.traceId) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + + // stored continuation state is consumed + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(AppStartMetrics.getInstance().appStartSentryTraceHeader) + assertNull(AppStartMetrics.getInstance().appStartBaggageHeader) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2236,59 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + // A headless start (broadcast/service) runs in a non-foreground-importance process. The + // foreground guard in AppStartMetrics suppresses the headless path for foreground processes + // (deferred init inside an Activity), so headless scenarios must simulate background + // importance. + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null, 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 d8ac959601a..3b12e6489a7 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 @@ -1492,6 +1492,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..173b4e3d999 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -13,7 +13,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -87,7 +89,7 @@ class PerformanceAndroidEventProcessorTest { fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -95,11 +97,106 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = createStandaloneAppStartTransaction() + setStandaloneColdAppStartMetrics() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -111,7 +208,7 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.WARM) + var tr = createUiLoadTransactionWithAppStartChildSpan(coldStart = false) setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -123,7 +220,7 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -136,23 +233,40 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction(AppStartType.COLD) + var tr1 = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction(AppStartType.UNKNOWN) + var tr2 = createUiLoadTransaction() tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = createStandaloneAppStartTransaction() + tr1 = sut.process(tr1, Hint()) + + var tr2 = createStandaloneAppStartTransaction() + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -163,7 +277,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -174,7 +288,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -184,7 +298,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createTransaction("custom.op") tr = sut.process(tr, Hint()) @@ -194,7 +308,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -464,10 +578,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,13 +981,44 @@ class PerformanceAndroidEventProcessorTest { } } - private fun getTransaction(type: AppStartType): SentryTransaction { - val op = - when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } } + } + } + + private fun createUiLoadTransactionWithAppStartChildSpan( + coldStart: Boolean = true + ): SentryTransaction = + createUiLoadTransaction().also { txn -> + txn.spans.add(createAppStartSpan(txn.contexts.trace!!.traceId, coldStart)) + } + + private fun createUiLoadTransaction(): SentryTransaction = createTransaction(UI_LOAD_OP) + + private fun createStandaloneAppStartTransaction( + appStartScreen: String? = null + ): SentryTransaction = + createTransaction(STANDALONE_APP_START_OP).also { txn -> + if (appStartScreen != null) { + txn.contexts.trace!!.setData(APP_START_SCREEN_DATA, appStartScreen) + } + } + + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..0eec9502702 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index e7079bd46d0..a959c5dd865 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.app.ApplicationStartInfo import android.os.Build import org.robolectric.annotation.Implementation @@ -10,13 +11,25 @@ import org.robolectric.annotation.Implements class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setImportance(importance: Int) { + this.importance = importance + } + fun reset() { historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..ab0013a8c75 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -13,11 +13,14 @@ import io.sentry.DateUtils import io.sentry.IContinuousProfiler import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -27,6 +30,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -44,6 +48,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +70,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +84,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -167,10 +174,10 @@ class AppStartMetricsTest { // when the looper runs waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -180,7 +187,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -194,7 +201,7 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched + // when the handler callback is executed and the start is headless waitForMainLooperIdle() // isAppLaunchedInForeground should be false @@ -208,11 +215,170 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() = headlessProcess { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `foreground process does not fire HeadlessAppStartListener`() { + // Deferred/late SDK init inside an already-running Activity: we missed onActivityCreated, but + // the process is foreground (Robolectric default importance), so this is a real launch, not a + // headless start. The listener must not fire and the headless reclassification must not run. + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val activity = mock() + whenever(activity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(activity, null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityDestroyed(activity) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() } + // Simulates a real headless start (broadcast/service), i.e. a non-foreground-importance process. + // The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so headless + // scenarios must opt into a background importance explicitly. + private fun headlessProcess(block: () -> T): T = + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + block() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -331,12 +497,12 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created + // Main thread performs the check and sets the flag to false if the start is headless waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -369,11 +535,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -387,18 +553,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -585,7 +751,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +956,7 @@ class AppStartMetricsTest { whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() waitForMainLooperIdle() @@ -804,7 +969,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(secondActivity, null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() // Third activity - should still be warm @@ -812,7 +977,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..30686852156 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -1,18 +1,25 @@ package io.sentry.android.core.performance +import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -25,7 +32,9 @@ class AppStartMetricsTestApi35 { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -42,6 +51,22 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +106,109 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses ApplicationStartInfo onCreate uptime timestamp`() { + val appStartUptimeMs = 100L + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (the same + // base as TimeSpan) right before Application.onCreate is invoked, so it is used directly as + // an uptime value marking the onCreate start, without any clock re-anchoring. + val onCreateStartUptimeMs = 350L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateStartUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(appStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e5b5ed2250b..14c8b595fd3 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ android:exported="true" android:foregroundServiceType="remoteMessaging" /> + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..10b4fd4d94e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,26 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing standalone app starts. + * + *

Test with: + * + *

{@code
+ * adb shell am force-stop io.sentry.samples.android && \
+ * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
+ *   -n io.sentry.samples.android/.TestBroadcastReceiver
+ * }
+ */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} From 26ddd89873d8d1bd9b1e0147fe177fd4178b1f1e Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Mon, 15 Jun 2026 11:10:07 +0200 Subject: [PATCH 083/276] Upgrade to asyncProfiler 4.4 (#5418) --- CHANGELOG.md | 4 ++++ gradle/libs.versions.toml | 2 +- .../JfrAsyncProfilerToSentryProfileConverter.java | 9 +-------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd876f3c57d..648533ec18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) +### Dependencies + +- Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) + ### Fixes - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e653069e2b3..f6edafdc17f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" -asyncProfiler = "4.2" +asyncProfiler = "4.4" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index b7b5662a8e5..718fae422f7 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -26,7 +26,6 @@ @ApiStatus.Internal public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { - private static final double NANOS_PER_SECOND = 1_000_000_000.0; private static final long UNKNOWN_THREAD_ID = -1; private final @NotNull SentryProfile sentryProfile = new SentryProfile(); @@ -83,7 +82,6 @@ private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull JfrReader jfr; private final @NotNull Arguments args; - private final double ticksPerNanosecond; public ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @@ -94,7 +92,6 @@ public ProfileEventVisitor( this.stackTraceFactory = stackTraceFactory; this.jfr = jfr; this.args = args; - ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; } @Override @@ -150,11 +147,7 @@ private void processSampleWithStack(Event event, long threadId, StackTrace stack } private double calculateTimestamp(Event event) { - long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); - - long timeNs = jfr.chunkStartNanos + nanosFromStart; - - return DateUtils.nanosToSeconds(timeNs); + return DateUtils.nanosToSeconds(jfr.eventTimeToNanos(event.time)); } private int addStackTrace(StackTrace stackTrace) { From 77e5b0a977f40709f429b7507b8b97fc46bcbdc4 Mon Sep 17 00:00:00 2001 From: arb Date: Mon, 15 Jun 2026 11:17:04 +0200 Subject: [PATCH 084/276] chore(samples-android): Add optional SAGP build mode (#5538) Adds an optional useSagp flag to Android sample app builds that, when true, applies the Sentry Android Gradle Plugin. (Defaults to false, which matches existing build behavior.) ``` ./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true ``` See the Android sample app README for more details. --- .../sentry-samples-android/README.md | 57 +++++++++++++++++++ .../sentry-samples-android/build.gradle.kts | 32 +++++++++++ 2 files changed, 89 insertions(+) create mode 100644 sentry-samples/sentry-samples-android/README.md diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 00000000000..c7c7ad0d89c --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,57 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, +profiling) and integrations (Compose, OkHttp, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true +``` + +In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gradle project property. + +## Build modes + +### With or without SAGP + +The sample app can be built with or without the SAGP. + +| Gradle Property | Required | Purpose | +|-----------------|--------------------------|----------------------------------------------------------------------------------------------------------------| +| `useSagp` | No (defaults to `false`) | When `true`, apply SAGP when building the sample app. When false or absent, build the sample app without SAGP. | + +You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's +`build.gradle.kts` file. + +### Builds against your local sentry-java branch + +Regardless of `useSagp`, the sample always depends on sentry-java modules from this monorepo (e.g., `projects.sentryAndroid`). SAGP's SDK +auto-installation is disabled, so the sample never pulls a separate SDK version from Maven. Local SDK changes in your branch are picked up +directly. + +## Viewing SDK output + +### Locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +### On Sentry UI + +By default, SDK output produced by the sample app appears under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value in `src/main/AndroidManifest.xml` +with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index ed8cea25661..44d930975ec 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,5 +1,7 @@ import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.impl.VariantImpl +import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.extensions.SentryPluginExtension import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.internal.extensions.stdlib.capitalized @@ -7,6 +9,36 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.sentry) apply false +} + +val useSagp = + providers.gradleProperty("useSagp").map { it.equals("true", ignoreCase = true) }.orElse(false) + +if (useSagp.get()) { + apply(plugin = "io.sentry.android.gradle") +} + +plugins.withId("io.sentry.android.gradle") { + // Extension configs match non-SAGP builds. Update locally to test your feature. + extensions.configure("sentry") { + autoInstallation.enabled.set(false) + includeProguardMapping.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + tracingInstrumentation { + features.set( + setOf( + InstrumentationFeature.COMPOSE, + InstrumentationFeature.DATABASE, + InstrumentationFeature.FILE_IO, + InstrumentationFeature.OKHTTP, + ) + ) + logcat.enabled.set(false) + appStart.enabled.set(false) + } + } } android { From aab75f770bf249460a877aeacdca4902d0ad8929 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 15 Jun 2026 12:18:09 +0200 Subject: [PATCH 085/276] fix(samples): allow leak canary for non-debug builds, so the sample app doesn't crash when using AS profiler (#5545) --- .../sentry-samples-android/src/main/res/values/bools.xml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 sentry-samples/sentry-samples-android/src/main/res/values/bools.xml diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml b/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml new file mode 100644 index 00000000000..de1623ff077 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml @@ -0,0 +1,4 @@ + + + true + From 9d2f4e3096958e67c6acc73926f7fde9e1bf2925 Mon Sep 17 00:00:00 2001 From: arb Date: Mon, 15 Jun 2026 21:51:29 +0200 Subject: [PATCH 086/276] chore(samples-android): Support mavenLocal for builds that apply the SAGP (#5539) chore(samples-android): Support mavenLocal for builds that apply the SAGP Adds wiring that lets us prefer mavenLocal SAGP artifacts, when present, for Android sample app builds that set -PuseSagp=true. If no local artifact is found, we fall back to libs.versions.toml. --- gradle/libs.versions.toml | 3 +- .../sentry-samples-android/README.md | 33 ++++++++++++++----- .../sentry-samples-android/build.gradle.kts | 5 +-- settings.gradle.kts | 15 +++++++-- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6edafdc17f..e7fcababd6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +sagp = "6.10.0" slf4j = "1.7.30" springboot2 = "2.7.18" springboot3 = "3.5.0" @@ -66,7 +67,7 @@ springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } -sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} +sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index c7c7ad0d89c..f5c8caf8685 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -16,10 +16,10 @@ or simply open the project in Android Studio and run the `sentry-samples-android You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): ``` -./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp ``` -In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gradle project property. +In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. ## Build modes @@ -27,18 +27,33 @@ In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gra The sample app can be built with or without the SAGP. -| Gradle Property | Required | Purpose | -|-----------------|--------------------------|----------------------------------------------------------------------------------------------------------------| -| `useSagp` | No (defaults to `false`) | When `true`, apply SAGP when building the sample app. When false or absent, build the sample app without SAGP. | +| Gradle Property | Required | Purpose | +|-----------------|----------|-------------------------------------------------------------------------------------------------| +| `useSagp` | No | When present, apply SAGP when building the sample app. Omit the property to build without SAGP. | You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's `build.gradle.kts` file. -### Builds against your local sentry-java branch +### Testing an unpublished SAGP build -Regardless of `useSagp`, the sample always depends on sentry-java modules from this monorepo (e.g., `projects.sentryAndroid`). SAGP's SDK -auto-installation is disabled, so the sample never pulls a separate SDK version from Maven. Local SDK changes in your branch are picked up -directly. +`-PuseSagp` builds check `mavenLocal()` first when resolving SAGP. To test a local SAGP branch: + +1. In your `sentry-android-gradle-plugin` checkout, temporarily set a unique local version in `plugin-build/gradle.properties` (e.g. + `6.10.0-LOCAL`) and publish to Maven Local: + +``` +./gradlew -p plugin-build publishToMavenLocal +``` + +Re-run `publishToMavenLocal` after each SAGP change. + +2. Temporarily bump the `sagp` pin in `gradle/libs.versions.toml` to match that version. + +Then build from sentry-java: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` ## Viewing SDK output diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 44d930975ec..e19c02700fb 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -12,10 +12,7 @@ plugins { alias(libs.plugins.sentry) apply false } -val useSagp = - providers.gradleProperty("useSagp").map { it.equals("true", ignoreCase = true) }.orElse(false) - -if (useSagp.get()) { +if (providers.gradleProperty("useSagp").isPresent) { apply(plugin = "io.sentry.android.gradle") } diff --git a/settings.gradle.kts b/settings.gradle.kts index c435c382b79..51dd84abb4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,10 +1,19 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() + repositories { + // Prefer local SAGP artifact if one exists; otherwise fall back to libs.versions.toml. + if (providers.gradleProperty("useSagp").isPresent) { + mavenLocal { + content { + includeGroup("io.sentry") + includeGroup("io.sentry.android.gradle") + } + } } + mavenCentral() + gradlePluginPortal() + } } plugins { From 3eb7173bf3fbd040000b90276e47f41aed8b05db Mon Sep 17 00:00:00 2001 From: arb Date: Tue, 16 Jun 2026 08:09:41 +0200 Subject: [PATCH 087/276] feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) (#5466) feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper. SentrySQLiteDriver automatically creates spans for each SQL statement it executes. Its data scheme closely tracks that of SentrySupportSQLiteOpenHelper, which it's designed to replace. (Span duration is an important exception; see the SentrySQLiteStatement KDoc for more details.) A key motivation behind Google's use of SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We're careful to keep the SentrySQLiteDriver KMP-compatible as well, should we one day want to lift it into sentry-kotlin-multiplatform. --- Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- sentry-android-sqlite/README.md | 21 ++ .../android/sqlite/SQLiteSpanManager.kt | 43 +-- .../main/java/io/sentry/sqlite/DbMetadata.kt | 49 +++ .../sqlite/SQLiteSpanInstrumentation.kt | 99 ++++++ .../sentry/sqlite/SentrySQLiteConnection.kt | 15 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 79 +++++ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 80 +++++ .../src/test/AndroidManifest.xml | 13 + .../java/io/sentry/sqlite/DbMetadataTest.kt | 87 ++++++ .../sqlite/SQLiteSpanInstrumentationTest.kt | 193 ++++++++++++ .../sqlite/SentrySQLiteConnectionTest.kt | 63 ++++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 145 +++++++++ .../sqlite/SentrySQLiteStatementTest.kt | 291 ++++++++++++++++++ 14 files changed, 1144 insertions(+), 36 deletions(-) create mode 100644 sentry-android-sqlite/README.md create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt create mode 100644 sentry-android-sqlite/src/test/AndroidManifest.xml create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7fcababd6c..1ebcb8e0e38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,7 +95,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..7bf9edf3474 --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -0,0 +1,21 @@ +# sentry-android-sqlite + +SQLite instrumentation for AndroidX APIs. + +Two instrumentation paths are supported: + +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. +- **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. + +To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. + +## Package layout + +The module is organized as two separate packages: + +- **`io.sentry.android.sqlite`**: Android-specific code. Depends on `android.database.*` and/or on `androidx.sqlite.db.*`. +- **`io.sentry.sqlite`**: No Android-specific code. Depends only on multiplatform `androidx.sqlite.*`. + +The split anticipates future Kotlin Multiplatform support. The `androidx.sqlite.*` interfaces are defined in KMP's `commonMain` source set and are used by Room in non-JVM environments. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets. + +Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout. diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 1bdeb7d369c..3495d3a71f0 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -3,21 +3,17 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes -import io.sentry.ISpan -import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage -import io.sentry.SentryStackTraceFactory -import io.sentry.SpanDataConvention import io.sentry.SpanStatus - -private const val TRACE_ORIGIN = "auto.db.sqlite" +import io.sentry.sqlite.SQLiteSpanInstrumentation internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - private val databaseName: String? = null, + databaseName: String? = null, ) { - private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -33,8 +29,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = scopes.getOptions().dateProvider.now() - var span: ISpan? = null + val startTimestamp = spans.startTimestamp() + return try { val result = operation() /* @@ -45,34 +41,11 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN - span?.status = SpanStatus.OK + spans.recordSpan(sql, startTimestamp, SpanStatus.OK) result } catch (e: Throwable) { - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN - span?.status = SpanStatus.INTERNAL_ERROR - span?.throwable = e + spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) throw e - } finally { - span?.apply { - val isMainThread: Boolean = scopes.options.threadChecker.isMainThread - setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) - if (isMainThread) { - setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) - } - // if db name is null, then it's an in-memory database as per - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42 - if (databaseName != null) { - setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite") - setData(SpanDataConvention.DB_NAME_KEY, databaseName) - } else { - setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory") - } - - finish() - } } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt new file mode 100644 index 00000000000..aa3c186b6d9 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -0,0 +1,49 @@ +package io.sentry.sqlite + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for in-memory databases. */ +internal const val DB_SYSTEM_IN_MEMORY = "in-memory" + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for SQLite databases. */ +internal const val DB_SYSTEM_SQLITE = "sqlite" + +/** + * Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an + * in-memory database (see docs + * [here](https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver)). + */ +private const val IN_MEMORY_DB_FILENAME = ":memory:" + +/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */ +private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\') + +internal data class DbMetadata(val name: String?, val system: String) + +/** + * Returns metadata based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ +internal fun dbMetadataFromFileName(fileName: String): DbMetadata { + if (fileName == IN_MEMORY_DB_FILENAME) { + return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } + + val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS } + if (trimmed.isEmpty()) { + return DbMetadata(name = null, system = DB_SYSTEM_SQLITE) + } + + val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS) + val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed + return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) +} + +/** + * Returns metadata based on + * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. + */ +internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata = + if (databaseName == null) { + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } else { + DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE) + } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt new file mode 100644 index 00000000000..4c925198bd5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -0,0 +1,99 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLongDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** Shared span instrumentation for SQLite. */ +internal class SQLiteSpanInstrumentation( + private val scopes: IScopes, + private val dbMetadata: DbMetadata, +) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + /** + * Returns a start timestamp for a `db.sql.query` span. + * + * Exposed so callers can capture a wall-clock start before accumulating database time. + * Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace + * timeline, which is less desirable. + */ + fun startTimestamp(): SentryDate = scopes.options.dateProvider.now() + + /** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + status: SpanStatus, + throwable: Throwable? = null, + ) { + recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable) + } + + /** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) + recordSpan(sql, startTimestamp, endTimestamp, status, throwable) + } + + private fun recordSpan( + sql: String, + startTimestamp: SentryDate, + endTimestamp: SentryDate?, + status: SpanStatus, + throwable: Throwable?, + ) { + scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + throwable?.let { this.throwable = it } + + val isMainThread = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) } + setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + finish(status, endTimestamp) + } + } + + companion object { + + /** + * Returns [SQLiteSpanInstrumentation] based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ + fun fromFileName( + fileName: String, + scopes: IScopes = ScopesAdapter.getInstance(), + ): SQLiteSpanInstrumentation = + SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + + /** + * Returns [SQLiteSpanInstrumentation] based on + * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. + */ + fun fromDatabaseName( + databaseName: String?, + scopes: IScopes = ScopesAdapter.getInstance(), + ): SQLiteSpanInstrumentation = + SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName)) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..45ee9a39b27 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -0,0 +1,15 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val spans: SQLiteSpanInstrumentation, +) : SQLiteConnection by delegate { + + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..9a619c418a5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import io.sentry.ScopesAdapter +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel + +/** + * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. + * + * Example usage: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .build() + * ``` + * + * **Warning:** Do not use [SentrySQLiteDriver] together with + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the + * same database file. Both wrappers instrument at different layers and combining them will produce + * duplicate spans. + * + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ +internal class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : + SQLiteDriver { + + init { + SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") + } + + override val hasConnectionPool: Boolean + get() = + try { + delegate.hasConnectionPool + } catch (_: LinkageError) { + // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. + false + } + + @Suppress("TooGenericExceptionCaught") + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + + return try { + val spans = SQLiteSpanInstrumentation.fromFileName(fileName) + // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping + // the connection. + SentrySQLiteConnection(connection, spans) + } catch (t: Throwable) { + ScopesAdapter.getInstance() + .options + .logger + .log( + SentryLevel.ERROR, + "Failed to instrument SQLite connection; returning uninstrumented connection.", + t, + ) + connection + } + } + + companion object { + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already + * wrapped. + */ + @JvmStatic + fun create(delegate: SQLiteDriver): SQLiteDriver = + delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..41df37444b5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -0,0 +1,80 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryDate +import io.sentry.SpanStatus + +/** + * Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the + * statement's lifetime (until [step] iteration is complete or the statement is [reset] or + * [closed][close]). + * + * Span duration is restricted to accumulated database time, i.e., each [step] call is individually + * timed and the durations are summed. Time the application spends between steps (e.g., processing + * rows, sleeping, or doing I/O) is intentionally excluded. + * + * Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage). + */ +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val spans: SQLiteSpanInstrumentation, + private val sql: String, + private val nanoTimeProvider: () -> Long = { System.nanoTime() }, +) : SQLiteStatement by delegate { + + private var firstStepTimestamp: SentryDate? = null + private var accumulatedDbNanos: Long = 0L + private var stepsComplete = false + private var closed = false + + @Suppress("TooGenericExceptionCaught") + override fun step(): Boolean { + if (stepsComplete || closed) { + return delegate.step() + } + + val beforeNanos = nanoTimeProvider() + return try { + if (firstStepTimestamp == null) { + firstStepTimestamp = spans.startTimestamp() + } + + stepsComplete = !delegate.step() + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + if (stepsComplete) { + recordSpan(SpanStatus.OK) + } + !stepsComplete + } catch (e: Throwable) { + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + recordSpan(SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + override fun reset() { + if (closed) { + return delegate.reset() + } + + try { + recordSpan(SpanStatus.OK) + } finally { + delegate.reset() + stepsComplete = false + } + } + + override fun close() { + closed = true + delegate.use { recordSpan(SpanStatus.OK) } + } + + private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { + val start = firstStepTimestamp ?: return + val duration = accumulatedDbNanos + firstStepTimestamp = null + accumulatedDbNanos = 0L + spans.recordSpan(sql, start, duration, status, throwable) + } +} diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..967265a1f16 --- /dev/null +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt new file mode 100644 index 00000000000..227b9d9558c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -0,0 +1,87 @@ +package io.sentry.sqlite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbMetadataTest { + + @Test + fun `dbMetadataFromFileName returns in-memory system with no db name for in-memory sentinel`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromFileName(":memory:"), + ) + } + + @Test + fun `dbMetadataFromDatabaseName returns in-memory system with no db name when databaseName is null`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromDatabaseName(null), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName has no separator`() { + assertEquals( + DbMetadata(name = "tracks", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks"), + ) + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for relative path with forward slashes`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("databases/myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for windows-style path`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("C:\\Users\\app\\databases\\myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName uses last separator when both slash types are present`() { + assertEquals( + DbMetadata(name = "db.sqlite", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data\\mixed/path\\db.sqlite"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName ends with separator`() { + assertEquals( + DbMetadata(name = "databases", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name when fileName contains only separators`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("/")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("///")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("\\\\")) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name for empty fileName`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("")) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt new file mode 100644 index 00000000000..ead123a190b --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -0,0 +1,193 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SQLiteSpanInstrumentationTest { + + private class Fixture { + + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut( + isTransactionActive: Boolean = true, + fileName: String = ":memory:", + ): SQLiteSpanInstrumentation { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isTransactionActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SQLiteSpanInstrumentation.fromFileName(fileName, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `recordSpan records a span if a transaction is active`() { + val sut = fixture.getSut(isTransactionActive = true) + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan does not record a span if no transaction is active`() { + val sut = fixture.getSut(isTransactionActive = false) + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan creates a span with correct properties`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `recordSpan sets finishDate equal to startDate + durationNanos`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val durationNanos = 42_000_000L + + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals(start, span.startDate) + assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `recordSpan attaches throwable when provided`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val exception = RuntimeException("disk I/O error") + + sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `recordSpan sets db system and db name when fileName is not the in-memory sentinel`() { + val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets db system only when fileName is the in-memory sentinel`() { + val sut = fixture.getSut(fileName = ":memory:") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan sets blocked_main_thread to false and does not attach a call stack on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan without a duration finishes the span at the time of invocation`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + + sut.recordSpan("SELECT 1", start, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + // Unlike the duration overload, no synthetic end timestamp is supplied; the span finishes at + // "now", i.e. at or after its start. + assertTrue(span.finishDate!!.nanoTimestamp() >= start.nanoTimestamp()) + } + + @Test + fun `fromFileName sets db name from fileName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromFileName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `fromDatabaseName sets db name from databaseName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromDatabaseName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..b405d054f03 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,63 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spans) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { + val sut = fixture.getSut() + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut() + + sut.prepare("SELECT 1") + verify(fixture.mockConnection).prepare("SELECT 1") + + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..9b2345a975f --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,145 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + + val mockDriver = mock() + val mockConnection = mock() + + fun getSut(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `create registers SQLiteDriver integration`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + SentrySQLiteDriver.create(fixture.mockDriver) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance without re-wrapping`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `hasConnectionPool forwards delegate value when supported`() { + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertTrue(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool returns false when delegate throws LinkageError`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(AbstractMethodError()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFalse(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool does not catch non-LinkageErrors`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(IllegalStateException()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFailsWith { sut.hasConnectionPool } + } + + @Test + fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { + val driver = fixture.getSut("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open returns the unwrapped delegate if wrapping fails`() { + val brokenScopes = mock() + val validOptions = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(brokenScopes.options) + .thenThrow(RuntimeException("Sentry options unavailable")) + .thenReturn(validOptions) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(brokenScopes) + + val driver = fixture.getSut("myapp.db") + val result = driver.open("myapp.db") + + assertSame(fixture.mockConnection, result) + verify(fixture.mockDriver).open("myapp.db") + } + } + + // Smoke test ensuring all layers are properly wired up. + @Test + fun `full stack produces a span with correct metadata`() { + val scopes = mock() + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + val tracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(tracer) + + val mockStatement = mock() + whenever(fixture.mockConnection.prepare("SELECT * FROM users")).thenReturn(mockStatement) + whenever(mockStatement.step()).thenReturn(true, false) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val driver = fixture.getSut("/data/data/com.example/databases/myapp.db") + val connection = driver.open("/data/data/com.example/databases/myapp.db") + val statement = connection.prepare("SELECT * FROM users") + + assertIs(connection) + assertIs(statement) + + statement.step() + statement.step() + + val span = tracer.children.firstOrNull() + assertNotNull(span) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..6691910e358 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,291 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryLongDate +import io.sentry.SpanStatus +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val mockStatement = mock() + val mockSpans = mock() + val startDate = SentryLongDate(1_000_000_000_000L) + val fakeClock = AtomicLong(0L) + + fun getSut(sql: String): SentrySQLiteStatement { + whenever(mockSpans.startTimestamp()).thenReturn(startDate) + return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) + } + } + + private val fixture = Fixture() + + @Test + fun `step calls recordSpan once after iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + sut.step() + verify(fixture.mockSpans) + .recordSpan( + eq("SELECT * FROM users"), + eq(fixture.startDate), + any(), + eq(SpanStatus.OK), + anyOrNull(), + ) + } + + @Test + fun `step that throws an exception calls recordSpan with INTERNAL_ERROR and exception`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + + assertFailsWith { sut.step() } + + verify(fixture.mockSpans) + .recordSpan( + eq("BAD SQL"), + eq(fixture.startDate), + any(), + eq(SpanStatus.INTERNAL_ERROR), + eq(exception), + ) + } + + @Test + fun `step after exception calls recordSpan once new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + + assertFailsWith { sut.step() } + verifyCalledRecordSpan(times = 1) + + sut.step() + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `step after step iteration completes does not call recordSpan again`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true, false, false) + + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.step() + + verifyCalledRecordSpan(times = 1) + verify(fixture.mockStatement, times(3)).step() + } + + @Test + fun `reset calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.reset() + + verifyCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.reset() + verifyNeverCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after reset calls recordSpan when new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + sut.step() + + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `close calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.close() + + verifyCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + verifyNeverCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + sut.step() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `reset after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.close() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `recorded duration captures step time but excludes time between steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenAnswer { + fixture.fakeClock.addAndGet(10) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(20) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(30) + false + } + + sut.step() + // Simulate work done between steps. + fixture.fakeClock.addAndGet(1_000_000) + sut.step() + fixture.fakeClock.addAndGet(2_000_000) + sut.step() + + val durationCaptor = argumentCaptor() + verify(fixture.mockSpans).recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + // Each step contributes its internal time (10 + 20 + 30) plus one unit from + // fakeClock::getAndIncrement between before/after reads, so total is 63. + assertEquals(63L, durationCaptor.firstValue) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.step() + verify(fixture.mockStatement).step() + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + + sut.close() + verify(fixture.mockStatement).close() + } + + private fun verifyNeverCalledRecordSpan() { + verifyCalledRecordSpan(times = 0) + } + + private fun verifyCalledRecordSpan(times: Int = 1) { + verify(fixture.mockSpans, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + } +} From 773a0df13db7654c534b8775902021af234fa3d5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 16 Jun 2026 09:31:06 +0200 Subject: [PATCH 088/276] ci: Remove Codecov and code coverage tooling (JAVA-560) (#5547) * ci: Remove Codecov and code coverage tooling (JAVA-560) Remove the Codecov service integration (codecov.yml, the README badge, and the upload steps across all CI workflows) along with the JaCoCo and Kover coverage tooling that only existed to feed it: the plugins, report and verification tasks across all modules, the version catalog entries, the Config.kt coverage threshold, and the createCoverageReports Makefile target. No SDK code or public API is affected. Co-Authored-By: Claude Opus 4.8 (1M context) * test(sentry): Restore java.lang open after jacoco removal (JAVA-560) SentryTest reflectively rewrites a class's name to fake the Android environment, which requires --add-opens java.base/java.lang=ALL-UNNAMED. The jacoco test agent was implicitly providing this open; now that jacoco is removed, declare it explicitly so the sentry unit tests keep passing. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/agp-matrix.yml | 7 ---- .github/workflows/build.yml | 9 +--- .github/workflows/integration-tests-ui.yml | 7 ---- .github/workflows/spring-boot-2-matrix.yml | 7 ---- .github/workflows/spring-boot-3-matrix.yml | 7 ---- .github/workflows/spring-boot-4-matrix.yml | 7 ---- AGENTS.md | 4 -- Makefile | 13 ++---- README.md | 1 - build.gradle.kts | 42 ------------------- buildSrc/src/main/java/Config.kt | 7 ---- codecov.yml | 23 ---------- gradle/libs.versions.toml | 3 -- sentry-android-core/build.gradle.kts | 2 - sentry-android-fragment/build.gradle.kts | 2 - sentry-android-navigation/build.gradle.kts | 2 - sentry-android-ndk/build.gradle.kts | 2 - sentry-android-replay/build.gradle.kts | 2 - sentry-android-sqlite/build.gradle.kts | 2 - sentry-android-timber/build.gradle.kts | 2 - sentry-apache-http-client-5/build.gradle.kts | 20 --------- sentry-apollo-3/build.gradle.kts | 21 +--------- sentry-apollo-4/build.gradle.kts | 21 +--------- sentry-apollo/build.gradle.kts | 21 +--------- sentry-async-profiler/build.gradle.kts | 20 --------- sentry-compose/build.gradle.kts | 1 - sentry-graphql-22/build.gradle.kts | 20 --------- sentry-graphql-core/build.gradle.kts | 20 --------- sentry-graphql/build.gradle.kts | 20 --------- sentry-jcache/build.gradle.kts | 20 --------- sentry-jdbc/build.gradle.kts | 20 --------- sentry-jul/build.gradle.kts | 17 -------- sentry-kafka/build.gradle.kts | 20 --------- sentry-kotlin-extensions/build.gradle.kts | 21 +--------- sentry-ktor-client/build.gradle.kts | 21 +--------- sentry-launchdarkly-android/build.gradle.kts | 2 - sentry-launchdarkly-server/build.gradle.kts | 20 --------- sentry-log4j2/build.gradle.kts | 20 --------- sentry-logback/build.gradle.kts | 20 --------- sentry-okhttp/build.gradle.kts | 21 +--------- sentry-openfeature/build.gradle.kts | 20 --------- sentry-openfeign/build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- sentry-quartz/build.gradle.kts | 20 --------- sentry-reactor/build.gradle.kts | 20 --------- sentry-servlet-jakarta/build.gradle.kts | 20 --------- sentry-servlet/build.gradle.kts | 20 --------- sentry-spotlight/build.gradle.kts | 21 +--------- sentry-spring-7/build.gradle.kts | 20 --------- sentry-spring-boot-4-starter/build.gradle.kts | 20 --------- sentry-spring-boot-4/build.gradle.kts | 20 --------- sentry-spring-boot-jakarta/build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- sentry-spring-boot-starter/build.gradle.kts | 20 --------- sentry-spring-boot/build.gradle.kts | 20 --------- sentry-spring-jakarta/build.gradle.kts | 20 --------- sentry-spring/build.gradle.kts | 20 --------- sentry-system-test-support/build.gradle.kts | 1 - sentry-test-support/build.gradle.kts | 1 - sentry/build.gradle.kts | 28 ++++--------- 63 files changed, 20 insertions(+), 928 deletions(-) delete mode 100644 codecov.yml diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index aebcbf87d5e..cc9c153f252 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -112,10 +112,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: build/outputs/androidTest-results/**/*.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb1f45dd60d..9a8a7e6138d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Tests with coverage and Lint + - name: Run Tests and Lint run: make preMerge - name: Install Sentry CLI @@ -57,13 +57,6 @@ jobs: SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v4 - with: - name: sentry-java - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 4af564cd2c3..102951a6d40 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -94,10 +94,3 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./artifacts/*.xml diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 32eeef2442d..b9eb217d578 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -150,10 +150,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 8614e2ca69d..82f379c141c 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -146,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index e82b120ec24..d2ec6c096bf 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -146,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/AGENTS.md b/AGENTS.md index 8d0cccabbc7..a05d9386607 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,6 @@ The project uses **Gradle** with Kotlin DSL. Key build files: # Build entire project ./gradlew build -# Create coverage reports -./gradlew jacocoTestReport koverXmlReportRelease - # Generate documentation ./gradlew aggregateJavadocs ``` @@ -149,7 +146,6 @@ The repository is organized into multiple modules: - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable - System tests validate end-to-end functionality with sample applications -- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules) ### Contributing Guidelines 1. Follow existing code style and language diff --git a/Makefile b/Makefile index c9eca8b8b7e..3967ff856ad 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease createCoverageReports runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish -all: stop clean javadocs compile createCoverageReports +all: stop clean javadocs compile assembleBenchmarks: assembleBenchmarkTestRelease assembleUiTests: assembleUiTestRelease -preMerge: check createCoverageReports +preMerge: check publish: clean dryRelease # deep clean @@ -51,13 +51,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create coverage reports -# - Jacoco for Java & Android modules -# - Kover for KMP modules e.g sentry-compose -createCoverageReports: - ./gradlew jacocoTestReport - ./gradlew koverXmlReportRelease - # Create the Python virtual environment for system tests, and install the necessary dependencies setupPython: @test -d .venv || python3 -m venv .venv diff --git a/README.md b/README.md index 9aaf7aca4d8..0aab8a4e75d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he Sentry SDK for Java and Android =========== [![GH Workflow](https://img.shields.io/github/actions/workflow/status/getsentry/sentry-java/build.yml?branch=main)](https://github.com/getsentry/sentry-java/actions) -[![codecov](https://codecov.io/gh/getsentry/sentry-java/branch/main/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-java) [![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) diff --git a/build.gradle.kts b/build.gradle.kts index d5b5dfc5d05..93c82cd8c9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,19 +3,15 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension import groovy.util.Node import io.gitlab.arturbosch.detekt.extensions.DetektExtension -import kotlinx.kover.gradle.plugin.dsl.KoverReportExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `java-library` alias(libs.plugins.spotless) apply false - jacoco alias(libs.plugins.detekt) `maven-publish` alias(libs.plugins.binary.compatibility.validator) - alias(libs.plugins.jacoco.android) apply false - alias(libs.plugins.kover) apply false alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false @@ -121,44 +117,6 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } - val jacocoAndroidModules = listOf( - "sentry-android-core", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-ndk", - "sentry-android-sqlite", - "sentry-android-replay", - "sentry-android-timber" - ) - if (jacocoAndroidModules.contains(name)) { - afterEvaluate { - jacoco { - toolVersion = "0.8.10" - } - - tasks.withType().configureEach { - configure { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") - } - } - } - } - - val koverKmpModules = listOf("sentry-compose") - if (koverKmpModules.contains(name)) { - afterEvaluate { - configure { - androidReports("release") { - xml { - // Change the report file name so the Codecov Github action can find it - setReportFile(project.layout.buildDirectory.file("reports/kover/report.xml").get().asFile) - } - } - } - } - } - plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 3410d9601d3..f0e2e9baf86 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,6 +1,4 @@ -import java.math.BigDecimal - object Config { val AGP = System.getenv("VERSION_AGP") ?: "8.13.1" val kotlinStdLib = "stdlib-jdk8" @@ -37,11 +35,6 @@ object Config { } object QualityPlugins { - object Jacoco { - // TODO [POTEL] add tests and restore - val minimumCoverage = BigDecimal.valueOf(0.1) - } - // this can be removed when we upgrade to Gradle 8, which allows us to use a getter for the plugin ID val detektPlugin = "io.gitlab.arturbosch.detekt" } diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 3a53b1f7b3f..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,23 +0,0 @@ -comment: no -codecov: - require_ci_to_pass: no - max_report_age: off - -coverage: - status: - project: - default: - target: 78% - threshold: 4% - patch: off - range: 78...100 - precision: 3 - round: down - -ignore: - - "**/src/test/*" - - "sentry-android-integration-tests/*" - - "sentry-system-test-support/*" - - "sentry-test-support/*" - - "sentry-samples/*" - - "sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/**" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ebcb8e0e38..a305275a118 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,6 @@ composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" -jacoco = "0.8.7" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" @@ -59,8 +58,6 @@ errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" } -kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index abcca4f8833..f7440b19494 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -5,8 +5,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 7a4178b0652..1bd182d618c 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 7f5d1017ec3..eaa204b3860 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 413fd3a7b77..c2d0a33d823 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -3,8 +3,6 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 60d38c0ae0a..8d0f63797aa 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -5,8 +5,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) // TODO: enable it later // alias(libs.plugins.detekt) diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 07fa7ad343f..dd28252665e 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 16083b43f1b..d8f8431bef1 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 4c9aba6e31b..df93fbe8823 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -36,25 +35,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 8819e0993d4..1eb71bc217a 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -45,25 +44,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 85ea2c3b52b..144297ddb9d 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -52,25 +51,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 909d52aa127..c115e6b8fe3 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -46,25 +45,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 5af2f0bef45..ef000b465a1 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 3385d0328e2..c45a431b1b3 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) id("com.android.library") - alias(libs.plugins.kover) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) alias(libs.plugins.dokka) diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index a8256ca8a27..c36ca09856d 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index cb8c9f49493..d625c31dea6 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -43,25 +42,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 46bef6e4b9d..68efbc7389e 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index a9393a7d905..2c476dbd007 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 0415fd8ccff..8a7808530b1 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -37,25 +36,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 13bee6418d6..b59a1481d19 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -36,23 +35,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } test { // used to test io.sentry.jul.SentryHandler systemProperty( diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index ee3ba0d4a60..603014f9af9 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -36,25 +35,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 55aca007130..5092976de32 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) @@ -40,25 +39,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 2965e81ebd3..745acaa11fb 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -47,25 +46,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index bf59c256ed1..427ec473676 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -1,8 +1,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index ee273fa5a9c..207400676a0 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,25 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 68ebd90b1e8..7d406076e2f 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 385209e8c49..d2084e95467 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -35,25 +34,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index f7178cf1dfe..ea831f174cc 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -46,25 +45,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 632d16b55cf..5847f48e7b5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,25 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 40119987f72..e9e3a2b18de 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -37,25 +36,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index b4a84300efd..ed6605f8da4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -43,25 +42,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 64db4096bb9..503c92c95f0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 2ab3d4988d5..5b3b9d97ff4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -48,25 +47,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index f039b3c95ef..21e75c0ed7d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index f81254f110f..69c0e72ee07 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 9e8b6e74be9..4d389b0a334 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -46,25 +45,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.reactor") diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index ec079b6d65f..728e147dc9b 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index ceaa160695a..142a1cd2f20 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index dbab6237b12..b034c8267db 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.animalsniffer) @@ -38,25 +37,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index ae8269e7825..ec90aedcbeb 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -85,25 +84,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring7") diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index 2c8eab0ba66..c0f655e965f 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot4) apply false @@ -41,25 +40,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 3b0b3be8630..43e105ad8db 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -5,7 +5,6 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - jacoco alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) alias(libs.plugins.errorprone) @@ -111,25 +110,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot4") diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 36b7dad3cc6..edd2d605916 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -103,25 +102,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot.jakarta") diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 60ac812b013..d7d10b73b8c 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot3) apply false @@ -41,25 +40,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 6b5bcdf5752..3ef4ac59379 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -33,25 +32,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index e54112ae54c..3ed6199fbc5 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -85,25 +84,6 @@ dependencies { testImplementation(projects.sentryAsyncProfiler) } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot") diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index cbf2e5346b5..b4a61129df7 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -80,25 +79,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.jakarta") diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 64380f7e7f4..fced2220f02 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -64,25 +63,6 @@ dependencies { testImplementation(libs.springboot.starter.webflux) } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring") diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index b8e4a283c87..4d4c7d5bb6e 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) id("com.apollographql.apollo") version "4.1.1" diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index 29b2083a0a9..f108915d463 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 4c237803a51..a2ecd281296 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,15 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - animalsniffer { ignore = listOf( @@ -63,16 +53,16 @@ tasks.animalsnifferMain { } tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } + check { dependsOn(animalsnifferMain) } test { - jvmArgs("--add-opens", "java.base/java.util.concurrent=ALL-UNNAMED") + // 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. + jvmArgs( + "--add-opens", + "java.base/java.util.concurrent=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + ) environment["SENTRY_TEST_PROPERTY"] = "\"some-value\"" environment["SENTRY_TEST_MAP_KEY1"] = "\"value1\"" environment["SENTRY_TEST_MAP_KEY2"] = "value2" From f36e6e37ec1517fa6e3c06ab79abc6775e2ef1bf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 16 Jun 2026 13:40:05 +0200 Subject: [PATCH 089/276] fix(android): Stop duplicating attachments on native events (JAVA-559) (#5548) * fix(android): Stop duplicating attachments on native events (JAVA-559) Scope attachments are synced to the native SDK, so native events already carry them as envelope items in the outbox. When re-ingesting those cached envelopes, SentryClient re-applied the scope attachments on top, sending each attachment twice. Skip re-applying scope attachments for cached envelopes. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 4 +++- .../test/java/io/sentry/SentryClientTest.kt | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648533ec18f..93cd3765da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixes +- Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) ## 8.43.2 diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 5ac81c44936..78225f05d19 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -112,7 +112,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul hint = new Hint(); } - if (shouldApplyScopeData(event, hint)) { + // Cached envelopes (e.g. native crashes from the outbox) already carry their attachments as + // envelope items. Re-applying scope attachments here would duplicate them. + if (shouldApplyScopeData(event, hint) && !HintUtils.hasType(hint, Cached.class)) { addScopeAttachmentsToHint(scope, hint); } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index d5b2f0f82a0..ab6fd2075a3 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -870,6 +870,25 @@ class SentryClientTest { assertEquals(scope.level, event.level) } + @Test + fun `when hint is Cached, scope attachments are not added to avoid duplication`() { + val sut = fixture.getSut() + + val event = createEvent() + val scope = createScopeWithAttachments() + + val hints = HintUtils.createWithTypeCheckHint(CustomCachedApplyScopeDataHint()) + sut.captureEvent(event, scope, hints) + + verify(fixture.transport) + .send( + check { actual -> + assertEquals(0, actual.items.count { it.header.type == SentryItemType.Attachment }) + }, + anyOrNull(), + ) + } + @Test fun `when transport factory is NoOp, it should initialize it`() { fixture.sentryOptions.setTransportFactory(NoOpTransportFactory.getInstance()) From 6dff1c9970ad612ac431980c08abb138218465e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:52:26 +0000 Subject: [PATCH 090/276] chore: update scripts/update-sentry-native-ndk.sh to 0.15.0 (#5528) Co-authored-by: GitHub --- CHANGELOG.md | 3 +++ gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cd3765da8..71c8d990122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ ### Dependencies - Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) +- Bump Native SDK from v0.14.2 to v0.15.0 ([#5528](https://github.com/getsentry/sentry-java/pull/5528)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0150) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.2...0.15.0) ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a305275a118..c16a87ad9b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,7 +150,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.14.2" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.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 e52b4e44a1eb195adcd0fbe2761d33e708ac5d49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:49:21 +0200 Subject: [PATCH 091/276] chore(deps): bump actions/setup-java in the github-actions group (#5554) Bumps the github-actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java). Updates `actions/setup-java` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.3.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/agp-matrix.yml | 2 +- .github/workflows/build.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 | 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, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index cc9c153f252..40f8509fee4 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a8a7e6138d..375e94e7499 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6aa197d6625..e24b7c96c14 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 01ee3db1584..e5e4530933b 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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 28cb78df4e3..ec427af3564 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index af0b44ddadd..2e82024077a 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 65cfcf242fc..4d323f0394a 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' @@ -82,7 +82,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 19598699165..e2fa42ddc16 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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 8973148cadd..18809c060e1 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Java 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 102951a6d40..f7b95a26d12 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 16cfe4531a0..9fecaf32b5e 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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 b9eb217d578..7628a0bbba0 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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 82f379c141c..40670eaf258 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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 d2ec6c096bf..128051ed03e 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b1884cd4a7a..62a1b7665c0 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' From ba010111864967003758a5e4d750dfe04f995c18 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 17 Jun 2026 13:17:06 +0200 Subject: [PATCH 092/276] perf: Avoid boxing in doubleToBigDecimal timestamp serialization (#5551) * perf: Avoid boxing in doubleToBigDecimal timestamp serialization Change DateUtils.doubleToBigDecimal to take a primitive double instead of a boxed Double, and route the four duplicated private copies (ProfileChunk, ProfileMeasurementValue, SentrySample, SentrySpan) through it. Callers that hold a primitive double timestamp no longer autobox on every serialization, and the duplicated helpers are consolidated into one. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 2 +- sentry/src/main/java/io/sentry/ProfileChunk.java | 8 ++------ .../profilemeasurements/ProfileMeasurementValue.java | 8 ++------ sentry/src/main/java/io/sentry/protocol/SentrySpan.java | 8 ++------ .../java/io/sentry/protocol/profiling/SentrySample.java | 8 ++------ 7 files changed, 11 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c8d990122..5dbcde58f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Improvements -- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) +- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) ### Dependencies diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4757be4894a..22f9366f738 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -384,7 +384,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public final class io/sentry/DateUtils { public static fun dateToNanos (Ljava/util/Date;)J public static fun dateToSeconds (Ljava/util/Date;)D - public static fun doubleToBigDecimal (Ljava/lang/Double;)Ljava/math/BigDecimal; + public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; public static fun getCurrentDateTime ()Ljava/util/Date; public static fun getDateTime (J)Ljava/util/Date; public static fun getDateTime (Ljava/lang/String;)Ljava/util/Date; diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 5e55512ae70..b86bddeaad8 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -166,7 +166,7 @@ public static long secondsToNanos(final @NotNull long seconds) { return seconds * (1000L * 1000L * 1000L); } - public static @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { + public static @NotNull BigDecimal doubleToBigDecimal(final double value) { return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); } } diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index a6145ca8e9a..1d159030c1d 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.SdkVersion; @@ -8,8 +10,6 @@ import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -264,10 +264,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java index 2f9ba5e1312..d27114c66ef 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -1,5 +1,7 @@ package io.sentry.profilemeasurements; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; @@ -10,8 +12,6 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -92,10 +92,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index 6274c8b00d7..58930ec1a87 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -1,5 +1,7 @@ package io.sentry.protocol; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; @@ -16,8 +18,6 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -230,10 +230,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java index 8f1c95641d5..af9053742d3 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java @@ -1,5 +1,7 @@ package io.sentry.protocol.profiling; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; @@ -8,8 +10,6 @@ import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -78,10 +78,6 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { From 06b0d8089c88819b4be74dd8eeff4aba34e9877b Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 17 Jun 2026 14:39:28 +0200 Subject: [PATCH 093/276] chore(android-sqlite): Repair start times of spans generated by SentrySQLiteDriver (#5543) chore(android-sqlite): Repair start times of spans generated by SentrySQLiteDriver (JAVA-275) Repairs the nanoTimetamp of the SentryNanotimeDates used as start times for the spans generated by SentrySQLiteDriver. Without those repairs, all spans within a given wall clock millisecond are displayed by Sentry UI as starting at that same millisecond and are re-ordered arbitrarily. Often that's quite confusing as actual BEGIN -> EXECUTE STATEMENT -> END sequences can appear as EXECUTE STATEMENT -> END -> BEGIN (etc.). For more details, see the discussion [here](https://github.com/getsentry/sentry-java/pull/5504#issuecomment-4679631245). --- .../android/sqlite/SQLiteSpanManager.kt | 43 +++++-- .../main/java/io/sentry/sqlite/DbMetadata.kt | 11 -- .../sqlite/SQLiteSpanInstrumentation.kt | 101 ++++++++++------ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 13 +- .../ComputeNanoStartTimestampForChildTest.kt | 100 ++++++++++++++++ .../java/io/sentry/sqlite/DbMetadataTest.kt | 8 -- .../sqlite/SQLiteSpanInstrumentationTest.kt | 112 +++++++++++------- .../sqlite/SentrySQLiteStatementTest.kt | 9 +- 8 files changed, 279 insertions(+), 118 deletions(-) create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 3495d3a71f0..1bdeb7d369c 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -3,17 +3,21 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention import io.sentry.SpanStatus -import io.sentry.sqlite.SQLiteSpanInstrumentation + +private const val TRACE_ORIGIN = "auto.db.sqlite" internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - databaseName: String? = null, + private val databaseName: String? = null, ) { - - private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -29,8 +33,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = spans.startTimestamp() - + val startTimestamp = scopes.getOptions().dateProvider.now() + var span: ISpan? = null return try { val result = operation() /* @@ -41,11 +45,34 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - spans.recordSpan(sql, startTimestamp, SpanStatus.OK) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + span?.status = SpanStatus.OK result } catch (e: Throwable) { - spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + span?.status = SpanStatus.INTERNAL_ERROR + span?.throwable = e throw e + } finally { + span?.apply { + val isMainThread: Boolean = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + // if db name is null, then it's an in-memory database as per + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42 + if (databaseName != null) { + setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite") + setData(SpanDataConvention.DB_NAME_KEY, databaseName) + } else { + setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory") + } + + finish() + } } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt index aa3c186b6d9..598dc524ed1 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -36,14 +36,3 @@ internal fun dbMetadataFromFileName(fileName: String): DbMetadata { val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) } - -/** - * Returns metadata based on - * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. - */ -internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata = - if (databaseName == null) { - DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) - } else { - DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE) - } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt index 4c925198bd5..5099f38f691 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -1,17 +1,26 @@ package io.sentry.sqlite import io.sentry.IScopes +import io.sentry.ISpan import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryDate import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention import io.sentry.SpanStatus +import java.util.Date private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" -/** Shared span instrumentation for SQLite. */ +/** + * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via + * [SentryDate.diff]. + */ +private val EMPTY_NANO_TIME = SentryNanotimeDate(Date(0), 0L) + +/** Span instrumentation for [SentrySQLiteDriver]. */ internal class SQLiteSpanInstrumentation( private val scopes: IScopes, private val dbMetadata: DbMetadata, @@ -20,44 +29,32 @@ internal class SQLiteSpanInstrumentation( private val stackTraceFactory = SentryStackTraceFactory(scopes.options) /** - * Returns a start timestamp for a `db.sql.query` span. + * Returns a timestamp in nanoseconds for use with [recordSpan]. Timestamp is ns-precise if the + * active parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. * - * Exposed so callers can capture a wall-clock start before accumulating database time. - * Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace - * timeline, which is less desirable. + * Note: Internalizing the start time in [recordSpan] would shift spans to end-of-work on the + * trace timeline, which is less desirable; callers capture the start before doing database work + * and pass it back to [recordSpan]. */ - fun startTimestamp(): SentryDate = scopes.options.dateProvider.now() - - /** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */ - fun recordSpan( - sql: String, - startTimestamp: SentryDate, - status: SpanStatus, - throwable: Throwable? = null, - ) { - recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable) - } + fun startTimestamp(): Long = + // Try to retain nanosecond precision + avoid SentryDate allocation... + scopes.span?.computeNanoStartTimestampForChild() + // ...otherwise fall back to millisecond precision + allocate. + ?: scopes.options.dateProvider.now().nanoTimestamp() - /** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */ + /** Records a `db.sql.query` span. */ fun recordSpan( sql: String, - startTimestamp: SentryDate, + startTimestampNanos: Long, durationNanos: Long, status: SpanStatus, throwable: Throwable? = null, ) { - val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) - recordSpan(sql, startTimestamp, endTimestamp, status, throwable) - } + val parent = scopes.span ?: return + val startTimestamp = SentryLongDate(startTimestampNanos) + val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) - private fun recordSpan( - sql: String, - startTimestamp: SentryDate, - endTimestamp: SentryDate?, - status: SpanStatus, - throwable: Throwable?, - ) { - scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { spanContext.origin = SQLITE_TRACE_ORIGIN throwable?.let { this.throwable = it } @@ -85,15 +82,43 @@ internal class SQLiteSpanInstrumentation( scopes: IScopes = ScopesAdapter.getInstance(), ): SQLiteSpanInstrumentation = SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + } +} - /** - * Returns [SQLiteSpanInstrumentation] based on - * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. - */ - fun fromDatabaseName( - databaseName: String?, - scopes: IScopes = ScopesAdapter.getInstance(), - ): SQLiteSpanInstrumentation = - SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName)) +/** + * Computes a start timestamp with nanosecond precision for the child of the receiver span. Returns + * null if nanosecond precision isn't possible. + * + * Lets us improve the display of spans in the Sentry UI. If timestamps are only ms-precise, the + * Sentry UI will left-align and arbitrarily reorder spans that share the same wall clock ms: + * ``` + * (Relative start times out of order) + * ↓ + * Parent span ├█████████████┤ + * END TRANSACTION ├███┤ 0.33 ms + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * ↑ + * (All spans share the same ms baseline + * even though their execution was staggered) + * ``` + * + * Nanosecond precision ensures proper ordering and lets the spans stagger: + * ``` + * Parent span ├█████████████┤ + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * END TRANSACTION ├███┤ 0.33 ms + * ``` + */ +internal fun ISpan.computeNanoStartTimestampForChild(): Long? { + if (startDate !is SentryNanotimeDate) { + return null } + + val parentWallClockNanos = startDate.nanoTimestamp() + val parentMonotonicNanos = startDate.diff(EMPTY_NANO_TIME) + val elapsedSinceParentStart = System.nanoTime() - parentMonotonicNanos + // Return the child's absolute start time. + return parentWallClockNanos + elapsedSinceParentStart } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt index 41df37444b5..a739a396bcb 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -1,7 +1,6 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteStatement -import io.sentry.SentryDate import io.sentry.SpanStatus /** @@ -22,7 +21,7 @@ internal class SentrySQLiteStatement( private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { - private var firstStepTimestamp: SentryDate? = null + private var firstStepTimestampNanos: Long? = null private var accumulatedDbNanos: Long = 0L private var stepsComplete = false private var closed = false @@ -35,8 +34,8 @@ internal class SentrySQLiteStatement( val beforeNanos = nanoTimeProvider() return try { - if (firstStepTimestamp == null) { - firstStepTimestamp = spans.startTimestamp() + if (firstStepTimestampNanos == null) { + firstStepTimestampNanos = spans.startTimestamp() } stepsComplete = !delegate.step() @@ -71,10 +70,10 @@ internal class SentrySQLiteStatement( } private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { - val start = firstStepTimestamp ?: return + val startNanos = firstStepTimestampNanos ?: return val duration = accumulatedDbNanos - firstStepTimestamp = null + firstStepTimestampNanos = null accumulatedDbNanos = 0L - spans.recordSpan(sql, start, duration, status, throwable) + spans.recordSpan(sql, startNanos, duration, status, throwable) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt new file mode 100644 index 00000000000..92a98b6e56d --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -0,0 +1,100 @@ +package io.sentry.sqlite + +import io.sentry.DateUtils +import io.sentry.ISpan +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComputeNanoStartTimestampForChildTest { + + @Test + fun `returns parent wall clock plus elapsed monotonic time since parent started`() { + val wallClockMillis = 1_000_000L + val elapsedNanos = 500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val timestamp = span.computeNanoStartTimestampForChild()!! + + val elapsedSinceParentStart = timestamp - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `same millisecond wall clocks with different monotonic offsets produce distinct ordered timestamps`() { + val wallClockMillis = 1_000_000L + val wallClockNanos = DateUtils.millisToNanos(wallClockMillis) + val earlierParentMonotonicNanos = System.nanoTime() - 200_000L + val laterParentMonotonicNanos = System.nanoTime() - 800_000L + val earlierSpan = spanWithNanotimeStart(wallClockMillis, earlierParentMonotonicNanos) + val laterSpan = spanWithNanotimeStart(wallClockMillis, laterParentMonotonicNanos) + + assertEquals( + earlierSpan.startDate.nanoTimestamp(), + laterSpan.startDate.nanoTimestamp(), + "Raw parent timestamps share the same ms-quantized value", + ) + + val earlier = earlierSpan.computeNanoStartTimestampForChild()!! + val later = laterSpan.computeNanoStartTimestampForChild()!! + + assertTrue(earlier > wallClockNanos) + assertTrue(later > wallClockNanos) + assertTrue(earlier < later) + assertTrue(later - earlier >= 500_000L) + } + + @Test + fun `returns parent wall clock when no monotonic time has elapsed since parent started`() { + val wallClockMillis = 1_000_000L + val parentMonotonicNanos = System.nanoTime() + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= 0L) + assertTrue(elapsedSinceParentStart < TEST_SLACK_NANOS) + } + + @Test + fun `works when parent wall clock differs from millisecond baseline`() { + val wallClockMillis = 1_000_001L + val elapsedNanos = 1_500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `returns null when start date is not SentryNanotimeDate`() { + val span = mock() + whenever(span.startDate).thenReturn(SentryLongDate(DateUtils.millisToNanos(1_000_000L))) + + assertNull(span.computeNanoStartTimestampForChild()) + } + + private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { + val startDate = SentryNanotimeDate(Date(wallClockMillis), parentMonotonicNanos) + val span = mock() + whenever(span.startDate).thenReturn(startDate) + return span + } + + companion object { + + // Upper bound for monotonic drift while the test body runs. + private const val TEST_SLACK_NANOS = 50_000_000L + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt index 227b9d9558c..09d80793ed2 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -13,14 +13,6 @@ class DbMetadataTest { ) } - @Test - fun `dbMetadataFromDatabaseName returns in-memory system with no db name when databaseName is null`() { - assertEquals( - DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), - dbMetadataFromDatabaseName(null), - ) - } - @Test fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { assertEquals( diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt index ead123a190b..a38be242ec5 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -1,15 +1,21 @@ package io.sentry.sqlite import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryDateProvider +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.util.thread.IThreadChecker +import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -40,6 +46,63 @@ class SQLiteSpanInstrumentationTest { private val fixture = Fixture() + @Test + fun `startTimestamp is ns-precise and skips date provider when parent uses SentryNanotimeDate`() { + // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), + // the queue would underflow and the test would fail loudly — this is what verifies the + // optimization is in effect. + val parentDate = SentryNanotimeDate(Date(1_000_000L), 100_000_000L) + val sut = setUpWithNanotimeDates(parentDate) + + val start = sut.startTimestamp() + + val durationNanos = 42_000_000L + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + + // startTimestamp returns an already-ns-precise value, anchored to the parent's wall clock and + // offset by elapsed System.nanoTime(). The exact ns-math is unit-tested in + // ChildStartTimestampOrNullTest; here we verify the integration shape. + assertIs(span.startDate) + assertEquals(start, span.startDate.nanoTimestamp()) + assertEquals(start + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { + val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val parentSpan = mock() + whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(parentSpan) + + val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when no transaction is active`() { + val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(null) + + val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + @Test fun `recordSpan records a span if a transaction is active`() { val sut = fixture.getSut(isTransactionActive = true) @@ -79,7 +142,6 @@ class SQLiteSpanInstrumentationTest { sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() - assertEquals(start, span.startDate) assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) } @@ -146,48 +208,16 @@ class SQLiteSpanInstrumentationTest { assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) } - @Test - fun `recordSpan without a duration finishes the span at the time of invocation`() { - val sut = fixture.getSut() - val start = sut.startTimestamp() - - sut.recordSpan("SELECT 1", start, SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertTrue(span.isFinished) - assertEquals(SpanStatus.OK, span.status) - // Unlike the duration overload, no synthetic end timestamp is supplied; the span finishes at - // "now", i.e. at or after its start. - assertTrue(span.finishDate!!.nanoTimestamp() >= start.nanoTimestamp()) - } - - @Test - fun `fromFileName sets db name from fileName`() { - val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } - whenever(fixture.scopes.options).thenReturn(options) - fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) - whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - - val sut = SQLiteSpanInstrumentation.fromFileName("tracks.db", fixture.scopes) - sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) - } - - @Test - fun `fromDatabaseName sets db name from databaseName`() { - val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): SQLiteSpanInstrumentation { + val dateQueue = ArrayDeque(dates.toList()) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { dateQueue.removeFirst() } + } whenever(fixture.scopes.options).thenReturn(options) fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - - val sut = SQLiteSpanInstrumentation.fromDatabaseName("tracks.db", fixture.scopes) - sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + return SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt index 6691910e358..ce2c3f00cd5 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -1,7 +1,6 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteStatement -import io.sentry.SentryLongDate import io.sentry.SpanStatus import java.util.concurrent.atomic.AtomicLong import kotlin.test.Test @@ -21,11 +20,11 @@ class SentrySQLiteStatementTest { private class Fixture { val mockStatement = mock() val mockSpans = mock() - val startDate = SentryLongDate(1_000_000_000_000L) + val startTimestampNanos = 1_000_000_000_000L val fakeClock = AtomicLong(0L) fun getSut(sql: String): SentrySQLiteStatement { - whenever(mockSpans.startTimestamp()).thenReturn(startDate) + whenever(mockSpans.startTimestamp()).thenReturn(startTimestampNanos) return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) } } @@ -43,7 +42,7 @@ class SentrySQLiteStatementTest { verify(fixture.mockSpans) .recordSpan( eq("SELECT * FROM users"), - eq(fixture.startDate), + eq(fixture.startTimestampNanos), any(), eq(SpanStatus.OK), anyOrNull(), @@ -61,7 +60,7 @@ class SentrySQLiteStatementTest { verify(fixture.mockSpans) .recordSpan( eq("BAD SQL"), - eq(fixture.startDate), + eq(fixture.startTimestampNanos), any(), eq(SpanStatus.INTERNAL_ERROR), eq(exception), From 3a7603a26335e1f1aafb78c75b4f1079819b25b3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 17 Jun 2026 15:45:35 +0200 Subject: [PATCH 094/276] perf(android): Replace Date with unix timestamp in SentryNanotimeDate (JAVA-533) (#5550) * perf(android): Replace Date with unix timestamp in SentryNanotimeDate (JAVA-533) SentryNanotimeDate stored a java.util.Date but only ever read its epoch millis. Storing the millis directly avoids a Calendar allocation on every timestamp, which on Android backs every span/transaction timestamp. The default constructor now uses System.currentTimeMillis() instead of DateUtils.getCurrentDateTime() (Calendar with UTC). This is behavior- preserving: the UTC TimeZone only affects calendar field access, not the epoch-millis value the class used. BREAKING: the public SentryNanotimeDate(Date, long) constructor is replaced by SentryNanotimeDate(long unixDate, long nanos). Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * ref(android): Mark SentryNanotimeDate as @ApiStatus.Internal SentryNanotimeDate is the legacy Date+nanoTime precision workaround and is not intended for direct use by consumers. Marking it @ApiStatus.Internal signals this and means the constructor change in this PR is not a public API break per the repo's API policy. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(sentry): Rename unixDate field to unixDateMillis Name the long field for its unit so it is clear it holds the unix timestamp in milliseconds since the epoch. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): Reword SentryNanotimeDate entry Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sentry): Restore deprecated SentryNanotimeDate Date constructor (JAVA-533) The previous change replaced the (Date, long) constructor with a (long, long) constructor, which was a breaking API change. Add the Date constructor back, delegating to the millis-based one, and mark it deprecated to steer callers toward the new constructor. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sentry): Suppress InlineMeSuggester on deprecated constructor (JAVA-533) Error Prone flagged the deprecated (Date, long) constructor as inlineable, failing the build. Suppress the suggestion to match the existing convention in Sentry.java, keeping the constructor available for backwards compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sentry): Suppress JavaUtilDate on deprecated constructor (JAVA-533) Error Prone's JavaUtilDate check flagged date.getTime() in the deprecated constructor, failing the build. Suppress it, matching the existing suppression used elsewhere in this class. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../core/ActivityLifecycleIntegration.java | 5 +- .../core/SpanFrameMetricsCollector.java | 3 +- .../core/ActivityLifecycleIntegrationTest.kt | 67 +++++++++---------- .../core/NetworkBreadcrumbsIntegrationTest.kt | 6 +- .../core/SpanFrameMetricsCollectorTest.kt | 15 +++-- .../ActivityLifecycleSpanHelperTest.kt | 9 ++- .../core/performance/AppStartMetricsTest.kt | 3 +- .../apache/ApacheHttpClientTransportTest.kt | 4 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 11 --- .../java/io/sentry/SentryNanotimeDate.java | 29 +++++--- ...efaultCompositePerformanceCollectorTest.kt | 21 ++---- .../java/io/sentry/SentryNanotimeDateTest.kt | 29 ++++---- .../test/java/io/sentry/SentryTracerTest.kt | 5 +- .../transport/AsyncHttpTransportTest.kt | 4 +- 16 files changed, 99 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbcde58f10..0de5fc72452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Improvements - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) +- Replace `Date` with a unix timestamp in `SentryNanotimeDate` to improve performance ([#5550](https://github.com/getsentry/sentry-java/pull/5550)) + - `SentryNanotimeDate` is now marked `@ApiStatus.Internal`. A new `(long unixDateMillis, long nanos)` constructor was added, where `unixDateMillis` is milliseconds since the epoch. The existing `(Date, long)` constructor is retained but deprecated. ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 19cee7fcce5..8a891926341 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -45,7 +45,6 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.util.Collections; -import java.util.Date; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -94,7 +93,7 @@ public final class ActivityLifecycleIntegration private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); - private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(0, 0); private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -729,7 +728,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + lastPausedTime = new SentryNanotimeDate(0, 0); activitySpanHelpers.clear(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index a83454d29b7..074a4a6ea51 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -13,7 +13,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; import io.sentry.util.AutoClosableReentrantLock; -import java.util.Date; import java.util.Iterator; import java.util.SortedSet; import java.util.TreeSet; @@ -33,7 +32,7 @@ public class SpanFrameMetricsCollector // grow indefinitely in case of a long running span private static final int MAX_FRAMES_COUNT = 3600; private static final long ONE_SECOND_NANOS = TimeUnit.SECONDS.toNanos(1); - private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(new Date(0), 0); + private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(0, 0); private final boolean enabled; protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); 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 f2ffb4b4b96..19f43432bef 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 @@ -41,7 +41,6 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import java.util.Date import java.util.concurrent.Future import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -936,7 +935,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) fixture.options.dateProvider = SentryDateProvider { date } @@ -961,7 +960,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -984,8 +983,8 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) setAppStartTime(date) val activity = mock() @@ -1011,7 +1010,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -1030,8 +1029,8 @@ class ActivityLifecycleIntegrationTest { sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) val activity = mock() // Activity onCreate date will be used @@ -1056,7 +1055,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -1080,7 +1079,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.appStartType = AppStartType.WARM @@ -1113,9 +1112,9 @@ class ActivityLifecycleIntegrationTest { it.isEnableStandaloneAppStartTracing = true } sut.register(fixture.scopes, fixture.options) - val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + val firstFrameDate = SentryNanotimeDate(1499, 0) fixture.options.dateProvider = SentryDateProvider { firstFrameDate } - setAppStartTime(SentryNanotimeDate(Date(1), 0)) + setAppStartTime(SentryNanotimeDate(1, 0)) val activity = mock() sut.onActivityPreCreated(activity, fixture.bundle) @@ -1168,8 +1167,8 @@ class ActivityLifecycleIntegrationTest { it.isEnableStandaloneAppStartTracing = true } sut.register(fixture.scopes, fixture.options) - val appStartEndDate = SentryNanotimeDate(Date(499), 0) - setAppStartTime(SentryNanotimeDate(Date(1), 0), appStartEndDate) + val appStartEndDate = SentryNanotimeDate(499, 0) + setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) val activity = mock() sut.onActivityPreCreated(activity, fixture.bundle) @@ -1230,9 +1229,9 @@ class ActivityLifecycleIntegrationTest { AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value // headless start ended right before the activity opens - AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) sut.register(fixture.scopes, fixture.options) - setAppStartTime(date = SentryNanotimeDate(Date(1), 0)) + setAppStartTime(date = SentryNanotimeDate(1, 0)) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1254,9 +1253,9 @@ class ActivityLifecycleIntegrationTest { AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value // headless start ended at epoch, but the activity opens more than a minute later - AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) sut.register(fixture.scopes, fixture.options) - setAppStartTime(date = SentryNanotimeDate(Date(TimeUnit.MINUTES.toMillis(2)), 0)) + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1389,7 +1388,7 @@ class ActivityLifecycleIntegrationTest { // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -1415,7 +1414,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(1234) @@ -1439,7 +1438,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -1474,7 +1473,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime() val activity = mock() @@ -1988,14 +1987,14 @@ class ActivityLifecycleIntegrationTest { @Test fun `When sentry is initialized mid activity lifecycle, last paused time should be used in favor of app start time`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) - val now = SentryNanotimeDate(Date(1234), 456) + val now = SentryNanotimeDate(1234, 456) fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(5678), 910) + val startDate = SentryNanotimeDate(5678, 910) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.COLD @@ -2019,7 +2018,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) assertTrue(sut.activitySpanHelpers.isEmpty()) @@ -2036,8 +2035,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2076,7 +2075,7 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans even when no app start span is available`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val startDate = SentryNanotimeDate(Date(2), 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2134,8 +2133,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans on API lower than 29`() { val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2187,8 +2186,8 @@ class ActivityLifecycleIntegrationTest { fun `Does not add activity lifecycle spans when firstActivityCreated is true`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2209,7 +2208,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start span has stopped, restart app start to current date`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) + val appStartDate = SentryNanotimeDate(1, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() setAppStartTime(appStartDate) @@ -2290,7 +2289,7 @@ class ActivityLifecycleIntegrationTest { } private fun setAppStartTime( - date: SentryDate = SentryNanotimeDate(Date(1), 0), + date: SentryDate = SentryNanotimeDate(1, 0), stopDate: SentryDate? = null, ) { // set by SentryPerformanceProvider so forcing it here diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 4f6ba7fc5f0..711f5f7fe0b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -5,7 +5,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import io.sentry.Breadcrumb -import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider @@ -54,8 +53,9 @@ class NetworkBreadcrumbsIntegrationTest { executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) - SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) + val nowMillis = nowMs ?: System.currentTimeMillis() + val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMillis) + SentryNanotimeDate(nowMillis, nowNanos) } } return NetworkBreadcrumbsIntegration(context, buildInfo) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index e5d7349d37c..2b6f19a8d31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -8,7 +8,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SpanContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.protocol.MeasurementValue -import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -50,11 +49,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -69,11 +69,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -438,8 +439,8 @@ class SpanFrameMetricsCollectorTest { @Test fun `SentryNanoDate diff does nano precision`() { // having this in here, as SpanFrameMetricsCollector relies on this behavior - val a = SentryNanotimeDate(Date(1234), 567) - val b = SentryNanotimeDate(Date(1234), 0) + val a = SentryNanotimeDate(1234, 567) + val b = SentryNanotimeDate(1234, 0) assertEquals(567, a.diff(b)) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt index 710fc835acd..ef048978795 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt @@ -12,7 +12,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanOptions import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext -import java.util.Date import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -31,8 +30,8 @@ class ActivityLifecycleSpanHelperTest { val appStartSpan: ISpan val scopes = mock() val options = SentryOptions() - val date = SentryNanotimeDate(Date(1), 1000000) - val endDate = SentryNanotimeDate(Date(3), 3000000) + val date = SentryNanotimeDate(1, 1000000) + val endDate = SentryNanotimeDate(3, 3000000) init { whenever(scopes.options).thenReturn(options) @@ -59,7 +58,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnCreateSpan creates and finishes onCreate span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnCreateStartTimestamp(date) helper.createAndStopOnCreateSpan(fixture.appStartSpan) @@ -99,7 +98,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnStartSpan creates and finishes onStart span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnStartStartTimestamp(date) helper.createAndStopOnStartSpan(fixture.appStartSpan) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index ab0013a8c75..2737785349f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -18,7 +18,6 @@ import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import io.sentry.protocol.SentryId -import java.util.Date import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test @@ -639,7 +638,7 @@ class AppStartMetricsTest { @Test fun `createProcessInitSpan creates a span`() { val appStartMetrics = AppStartMetrics.getInstance() - val startDate = SentryNanotimeDate(Date(1), 1000000) + val startDate = SentryNanotimeDate(1, 1000000) appStartMetrics.classLoadedUptimeMs = 10 val startMillis = DateUtils.nanosToMillis(startDate.nanoTimestamp().toDouble()).toLong() appStartMetrics.appStartTimeSpan.setStartedAt(1) diff --git a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt index 639dd4e0513..9f5c9b910ad 100644 --- a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt +++ b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt @@ -213,7 +213,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope) @@ -226,7 +226,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope, Hint()) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 22f9366f738..e9083350349 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -382,7 +382,6 @@ public final class io/sentry/DataCategory : java/lang/Enum { } public final class io/sentry/DateUtils { - public static fun dateToNanos (Ljava/util/Date;)J public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; public static fun getCurrentDateTime ()Ljava/util/Date; @@ -3558,6 +3557,7 @@ public final class io/sentry/SentryMetricsEvents$JsonKeys { public final class io/sentry/SentryNanotimeDate : io/sentry/SentryDate { public fun ()V + public fun (JJ)V public fun (Ljava/util/Date;J)V public fun compareTo (Lio/sentry/SentryDate;)I public synthetic fun compareTo (Ljava/lang/Object;)I diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index b86bddeaad8..e407391c394 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -151,17 +151,6 @@ public static double dateToSeconds(final @NotNull Date date) { return millisToSeconds(date.getTime()); } - /** - * Convert {@link Date} to nanoseconds represented as {@link Long}. - * - * @param date - date - * @return nanoseconds - */ - @SuppressWarnings("JavaUtilDate") - public static long dateToNanos(final @NotNull Date date) { - return millisToNanos(date.getTime()); - } - public static long secondsToNanos(final @NotNull long seconds) { return seconds * (1000L * 1000L * 1000L); } diff --git a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java index 98c46ad5325..f3abf3518f7 100644 --- a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java +++ b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java @@ -1,32 +1,43 @@ package io.sentry; import java.util.Date; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** - * Uses {@link Date} in combination with System.nanoTime(). + * Uses a unix timestamp (milliseconds since the epoch) in combination with System.nanoTime(). * - *

A single date only offers millisecond precision but diff can be calculated with up to + *

The unix timestamp only offers millisecond precision but diff can be calculated with up to * nanosecond precision. This increased precision can also be used to calculate a new end date for a * transaction where start date is sent with ms precision and end date is added to it with ns * precision leading to an end timestamp with ns precision that can be used to gain ns precision * transaction durations. * *

This is a workaround for older versions of Java (before 9) and Android API (lower than 26) - * that allows for higher precision than {@link Date} alone would. + * that allows for higher precision than a millisecond timestamp alone would. */ +@ApiStatus.Internal public final class SentryNanotimeDate extends SentryDate { - private final @NotNull Date date; + private final long unixDateMillis; private final long nanos; public SentryNanotimeDate() { - this(DateUtils.getCurrentDateTime(), System.nanoTime()); + this(System.currentTimeMillis(), System.nanoTime()); } + /** + * @deprecated use {@link SentryNanotimeDate#SentryNanotimeDate(long, long)} instead. + */ + @Deprecated + @SuppressWarnings({"InlineMeSuggester", "JavaUtilDate"}) public SentryNanotimeDate(final @NotNull Date date, final long nanos) { - this.date = date; + this(date.getTime(), nanos); + } + + public SentryNanotimeDate(final long unixDateMillis, final long nanos) { + this.unixDateMillis = unixDateMillis; this.nanos = nanos; } @@ -41,7 +52,7 @@ public long diff(final @NotNull SentryDate otherDate) { @Override public long nanoTimestamp() { - return DateUtils.dateToNanos(date); + return DateUtils.millisToNanos(unixDateMillis); } @Override @@ -63,8 +74,8 @@ public long laterDateNanosTimestampByDiff(final @Nullable SentryDate otherDate) public int compareTo(@NotNull SentryDate otherDate) { if (otherDate instanceof SentryNanotimeDate) { final @NotNull SentryNanotimeDate otherNanoDate = (SentryNanotimeDate) otherDate; - final long thisDateMillis = date.getTime(); - final long otherDateMillis = otherNanoDate.date.getTime(); + final long thisDateMillis = unixDateMillis; + final long otherDateMillis = otherNanoDate.unixDateMillis; if (thisDateMillis == otherDateMillis) { return Long.compare(nanos, otherNanoDate.nanos); } else { diff --git a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt index ceec3571ebd..f8e3a8f9f98 100644 --- a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt +++ b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt @@ -4,7 +4,6 @@ import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField import io.sentry.util.thread.ThreadChecker -import java.util.Date import java.util.Timer import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -188,14 +187,8 @@ class DefaultCompositePerformanceCollectorTest { val mockCollector = mock() val dates = listOf( - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(100) }, - TimeUnit.SECONDS.toNanos(100), - ), - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(131) }, - TimeUnit.SECONDS.toNanos(131), - ), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(131), TimeUnit.SECONDS.toNanos(131)), ) whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) val collector = @@ -226,14 +219,8 @@ class DefaultCompositePerformanceCollectorTest { val mockDateProvider = mock() val dates = listOf( - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(100) }, - TimeUnit.SECONDS.toNanos(100), - ), - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(130) }, - TimeUnit.SECONDS.toNanos(130), - ), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(130), TimeUnit.SECONDS.toNanos(130)), ) whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) val collector = fixture.getSut { it.dateProvider = mockDateProvider } diff --git a/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt b/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt index 86464bcedba..3f7a5dca8b6 100644 --- a/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt +++ b/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt @@ -1,20 +1,19 @@ package io.sentry -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals class SentryNanotimeDateTest { @Test fun `doubleValue only offers ms precision`() { - val date = SentryNanotimeDate(Date(1672742031123), 123456789) + val date = SentryNanotimeDate(1672742031123, 123456789) assertEquals(1672742031123000000L, date.nanoTimestamp()) } @Test fun `laterDateNanosByDiff offers ns precision`() { - val startDate = SentryNanotimeDate(Date(1672742031123), 456788) - val finishDate = SentryNanotimeDate(Date(1672742031123), 456789) + val startDate = SentryNanotimeDate(1672742031123, 456788) + val finishDate = SentryNanotimeDate(1672742031123, 456789) val dateInSeconds = startDate.laterDateNanosTimestampByDiff(finishDate) assertEquals(1672742031123000001L, dateInSeconds) } @@ -26,7 +25,7 @@ class SentryNanotimeDateTest { */ @Test fun `laterDateNanosByDiff with SentryLongDate gives ms precision`() { - val startDate = SentryNanotimeDate(Date(1672742031123), 456789) + val startDate = SentryNanotimeDate(1672742031123, 456789) val finishDate = SentryLongDate(61633553039) val dateInSeconds = startDate.laterDateNanosTimestampByDiff(finishDate) assertEquals(1672742031123000000L, dateInSeconds) @@ -36,36 +35,36 @@ class SentryNanotimeDateTest { @Test fun `compareTo() with equal dates returns 0`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(0, date1.compareTo(date2)) } @Test fun `compareTo() returns -1 for earlier ns`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456788) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456788) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(-1, date1.compareTo(date2)) } @Test fun `compareTo() returns 1 for later ns`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456788) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456788) assertEquals(1, date1.compareTo(date2)) } @Test fun `compareTo() returns -1 for earlier date`() { - val date1 = SentryNanotimeDate(Date(1672742030123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742030123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(-1, date1.compareTo(date2)) } @Test fun `compareTo() returns 1 for later date`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742030123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742030123, 456789) assertEquals(1, date1.compareTo(date2)) } } diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 1ccbcf2f318..3b808dd2220 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -8,7 +8,6 @@ import io.sentry.test.getProperty import io.sentry.util.thread.IThreadChecker import java.time.LocalDateTime import java.time.ZoneOffset -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -184,7 +183,7 @@ class SentryTracerTest { val tracer = fixture.getSut() val date = SentryNanotimeDate( - Date.from(LocalDateTime.of(2022, 12, 24, 23, 59, 58, 0).toInstant(ZoneOffset.UTC)), + LocalDateTime.of(2022, 12, 24, 23, 59, 58, 0).toInstant(ZoneOffset.UTC).toEpochMilli(), 0, ) tracer.finish(SpanStatus.ABORTED, date) @@ -643,7 +642,7 @@ class SentryTracerTest { @Test fun `when startTimestamp is given, use it as startTimestamp`() { - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(0, 0) val transaction = fixture.getSut(startTimestamp = date) assertSame(date, transaction.startDate) diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index 70092ffa7ba..6f711cfedbf 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -367,7 +367,7 @@ class AsyncHttpTransportTest { // given val now = Date(9001) fixture.sentryOptions.dateProvider = mock() - whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.sentryOptions.serializer, createSession(), null) whenever(fixture.transportGate.isConnected).thenReturn(true) @@ -387,7 +387,7 @@ class AsyncHttpTransportTest { // given val now = Date(9001) fixture.sentryOptions.dateProvider = mock() - whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.sentryOptions.serializer, createSession(), null) whenever(fixture.transportGate.isConnected).thenReturn(true) From f944a75eb71cc82131a624d4fd91edeaa42bab7b Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:53:20 +0000 Subject: [PATCH 095/276] release: 8.44.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de5fc72452..4cf56d17cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.44.0 ### Features diff --git a/gradle.properties b/gradle.properties index 35641a00053..19127ac9832 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.43.2 +versionName=8.44.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 5dc86e8f233e15687c3a51ce4d05226196638f14 Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 17 Jun 2026 17:05:40 +0200 Subject: [PATCH 096/276] chore(android-sqlite): Remove calls to deprecated SentryNanotimeDate constructor (#5562) --- .../java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt | 3 +-- .../sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt | 3 +-- .../java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt | 7 +++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt index 5099f38f691..f0998dfdc23 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -10,7 +10,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention import io.sentry.SpanStatus -import java.util.Date private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" @@ -18,7 +17,7 @@ private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via * [SentryDate.diff]. */ -private val EMPTY_NANO_TIME = SentryNanotimeDate(Date(0), 0L) +private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) /** Span instrumentation for [SentrySQLiteDriver]. */ internal class SQLiteSpanInstrumentation( diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt index 92a98b6e56d..13ae1389b77 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -4,7 +4,6 @@ import io.sentry.DateUtils import io.sentry.ISpan import io.sentry.SentryLongDate import io.sentry.SentryNanotimeDate -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -86,7 +85,7 @@ class ComputeNanoStartTimestampForChildTest { } private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { - val startDate = SentryNanotimeDate(Date(wallClockMillis), parentMonotonicNanos) + val startDate = SentryNanotimeDate(wallClockMillis, parentMonotonicNanos) val span = mock() whenever(span.startDate).thenReturn(startDate) return span diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt index a38be242ec5..74bd1c7f882 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -11,7 +11,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.util.thread.IThreadChecker -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -51,7 +50,7 @@ class SQLiteSpanInstrumentationTest { // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), // the queue would underflow and the test would fail loudly — this is what verifies the // optimization is in effect. - val parentDate = SentryNanotimeDate(Date(1_000_000L), 100_000_000L) + val parentDate = SentryNanotimeDate(1_000_000L, 100_000_000L) val sut = setUpWithNanotimeDates(parentDate) val start = sut.startTimestamp() @@ -71,7 +70,7 @@ class SQLiteSpanInstrumentationTest { @Test fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { - val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) val parentSpan = mock() whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) val options = @@ -89,7 +88,7 @@ class SQLiteSpanInstrumentationTest { @Test fun `startTimestamp falls back to date provider when no transaction is active`() { - val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" From 10a0bc2fdb190596413bf6d105438474c6663445 Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 11:04:24 +0200 Subject: [PATCH 097/276] feat(android-sqlite): Make SentrySQLiteDriver experimental (JAVA-275) (#5563) Makes SentrySQLiteDriver public + experimental during development. In particular, lets us access the driver via the Sentry Android sample app. --- CHANGELOG.md | 8 ++++++++ sentry-android-sqlite/api/sentry-android-sqlite.api | 12 ++++++++++++ sentry-android-sqlite/build.gradle.kts | 5 +++++ .../main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 10 +++++++--- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf56d17cee..8428f033b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Features + +- Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) + - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` + - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) + ## 8.44.0 ### Features diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..7b9f633b46a 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -21,3 +21,15 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; } +public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver { + public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion; + public synthetic fun (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; + public fun hasConnectionPool ()Z + public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; +} + +public final class io/sentry/sqlite/SentrySQLiteDriver$Companion { + public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index dd28252665e..6e0275b29b8 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -47,6 +47,10 @@ android { buildFeatures { buildConfig = true } + // Needed b/c Kotlin 1.4.x would otherwise pull in an older version without the annotations we + // want. + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } + androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -65,6 +69,7 @@ dependencies { api(projects.sentry) compileOnly(libs.androidx.sqlite) + compileOnly(libs.jetbrains.annotations) implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 9a619c418a5..e869778b811 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteDriver import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel +import org.jetbrains.annotations.ApiStatus /** * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. @@ -28,13 +29,16 @@ import io.sentry.SentryLevel * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ -internal class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : +@ApiStatus.Experimental +public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : SQLiteDriver { init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") } + @Suppress("INAPPLICABLE_JVM_NAME") + @get:JvmName("hasConnectionPool") override val hasConnectionPool: Boolean get() = try { @@ -66,14 +70,14 @@ internal class SentrySQLiteDriver private constructor(private val delegate: SQLi } } - companion object { + public companion object { /** * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already * wrapped. */ @JvmStatic - fun create(delegate: SQLiteDriver): SQLiteDriver = + public fun create(delegate: SQLiteDriver): SQLiteDriver = delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) } } From f6192aacb057496dd89e4fdb72ed2741e50e03ee Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 11:38:27 +0200 Subject: [PATCH 098/276] chore(android-sqlite): Add SQLite samples to sentry-samples-android (#5504) Adds our SQLite integrations to sentry-android-samples (`SentrySQLiteDriver` and `SentrySupportOpenSQLiteHelper`). The entry point is `SQLiteActivity`. Example SQL statements are identical across integrations so we can observe similarities / differences in how they handle spans. Users can exercise the integrations directly or via Room or SQLDelight. --- gradle/libs.versions.toml | 31 +- .../sentry-samples-android/README.md | 2 +- .../sentry-samples-android/build.gradle.kts | 42 +- .../src/main/AndroidManifest.xml | 8 + .../io/sentry/samples/android/MainActivity.kt | 14 + .../sentry/samples/android/MyApplication.java | 3 + .../samples/android/sqlite/DisplayInfo.kt | 106 +++ .../sentry/samples/android/sqlite/Room2Dao.kt | 42 ++ .../sentry/samples/android/sqlite/Room3Dao.kt | 42 ++ .../samples/android/sqlite/SQLiteActivity.kt | 621 ++++++++++++++++++ .../samples/android/sqlite/SampleDatabases.kt | 222 +++++++ .../io/sentry/samples/android/sqlite/Song.sq | 17 + .../samples/android/sqlite/SqlStatements.kt | 226 +++++++ .../samples/android/sqlite/UiLoadActivity.kt | 69 ++ .../samples/android/sqlite/UiLoadScreen.kt | 110 ++++ 15 files changed, 1542 insertions(+), 13 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c16a87ad9b6..91a7669194f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,15 +5,18 @@ androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" asyncProfiler = "4.4" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" +gummyBears = "0.12.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" kotlinSpring7 = "2.2.0" kotlin-compatible-version = "1.9" +ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -21,6 +24,7 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" +openfeature = "1.18.2" otel = "1.60.1" otelInstrumentation = "2.26.0" otelInstrumentationAlpha = "2.26.0-alpha" @@ -28,19 +32,22 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-alpha06" sagp = "6.10.0" +sqlite = "2.6.2" +sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* slf4j = "1.7.30" +spotless = "8.4.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.0.0" +sqldelight = "2.3.2" + # Android targetSdk = "36" compileSdk = "36" minSdk = "21" -spotless = "8.4.0" -gummyBears = "0.12.0" -camerax = "1.4.0" -openfeature = "1.18.2" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -50,6 +57,7 @@ kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpr kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -62,6 +70,7 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.3 springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } 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" } sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} @@ -92,7 +101,14 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } @@ -205,6 +221,7 @@ springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature @@ -248,3 +265,7 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index f5c8caf8685..99d0edcd1c3 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -1,7 +1,7 @@ # Sentry Sample Android App Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, -profiling) and integrations (Compose, OkHttp, etc.). +profiling) and integrations (Compose, OkHttp, SQLite, etc.). ## How to run it? diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index e19c02700fb..74e3c3a57b8 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -9,7 +9,9 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) alias(libs.plugins.sentry) apply false + alias(libs.plugins.sqldelight) } if (providers.gradleProperty("useSagp").isPresent) { @@ -26,9 +28,9 @@ plugins.withId("io.sentry.android.gradle") { tracingInstrumentation { features.set( setOf( + // FILE_IO is disabled for non-SAGP builds. InstrumentationFeature.COMPOSE, InstrumentationFeature.DATABASE, - InstrumentationFeature.FILE_IO, InstrumentationFeature.OKHTTP, ) ) @@ -44,7 +46,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -119,7 +122,13 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -145,6 +154,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -152,6 +172,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) @@ -177,17 +198,24 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 14c8b595fd3..1150dd5ef2e 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -101,6 +101,14 @@ android:name=".TriggerHttpRequestActivity" android:exported="false" /> + + + + ) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 00000000000..145e12d3897 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt new file mode 100644 index 00000000000..1ff6828a757 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,621 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 + +private val SECTION_HEADER_HEIGHT = 28.dp + +/** Which sentry-android-sqlite integration the demo buttons currently target. */ +private enum class Integration(val color: Color, val apiName: String) { + DRIVER(SentryPurple, "SQLiteDriver"), + OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; + * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why + * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + */ +private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + private var shareScreenTrace by mutableStateOf(false) + + /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ + private var integration by mutableStateOf(Integration.DRIVER) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(titleGap)) + + SectionHeader("Configure it") + + val openHelper = integration == Integration.OPEN_HELPER + val integrationSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = SentryPink, + checkedBorderColor = SentryPink, + uncheckedTrackColor = SentryPurple, + uncheckedBorderColor = SentryPurple, + uncheckedThumbColor = Color.White, + ) + val controlSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = Color.Black, + checkedBorderColor = Color.Black, + ) + ToggleRow( + label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", + checked = openHelper, + labelColor = if (openHelper) SentryPink else SentryPurple, + switchColors = integrationSwitchColors, + ) { + integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER + // Switching integration starts a fresh comparison: clear the detail box and result. + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + } + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // integration's variant; a row that doesn't apply explains why via a toast (see + // [DemoRowButton]). + DEMO_ROWS.forEach { row -> + val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + disabledReason = "${row.label} doesn't use the ${integration.apiName}", + ) + } + + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + // The latest run result (row counts, errors). Hidden until the first run. + if (latestResult.isNotEmpty()) { + Text( + text = latestResult, + style = MaterialTheme.typography.bodyMedium, + color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + dbOperationInFlight = true + try { + latestResult = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + } finally { + dbOperationInFlight = false + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + /** + * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the + * label inherits the default text color; the integration toggle passes its pink/purple instead. + */ + @androidx.compose.runtime.Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @androidx.compose.runtime.Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @androidx.compose.runtime.Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null + val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (variant != null) onTap(variant) else explain() }, + onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @androidx.compose.runtime.Composable + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + if (dbOperationInFlight) return@Button + lifecycleScope.launch { + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @androidx.compose.runtime.Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + "$transactionName failed: ${t.message}" + } finally { + transaction.finish() + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private suspend fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + return "Dropped tables: cleared $cleared database file(s)." + } + + private companion object { + + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt new file mode 100644 index 00000000000..63f217fcfbb --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,222 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.room.Room +import androidx.room3.Room as Room3 +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + private val sqlAccess = Mutex() + + val driverDirectLock = Any() + val openHelperDirectLock = Any() + + /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ + suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) + .open(databaseFile(context, "driver_direct.db")) + .also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("openhelper_direct.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. + CoroutineScope(Dispatchers.IO).launch { + runCatching { driverConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { directHelper(appContext).writableDatabase } + runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. Waits for any in-flight demo SQL (including [UiLoadActivity]) to finish first. + */ + suspend fun reset(context: Context): Int = withSqlAccess { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "driver_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 00000000000..345e55a3582 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt new file mode 100644 index 00000000000..543f1169294 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,226 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + SampleDatabases.withSqlAccess { + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt new file mode 100644 index 00000000000..b32811e8c91 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,69 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = + SqlDemo.entries.find { it.name == intent.getStringExtra(EXTRA_DEMO_ID) } + ?: run { + finish() + return + } + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + "Load failed: ${t.message}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 00000000000..6495726448d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +} From 7c1a728e8bd2faa42b8f1c25c9f16a145baab60f Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 12:38:54 +0200 Subject: [PATCH 099/276] chore(android-sqlite): Skip wrapping SupportSQLiteDriver bridge to avoid duplicate spans (#5514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SentrySQLiteDriver.create() now recognizes the Room 2.7+ androidx.sqlite.driver.SupportSQLiteDriver bridge adapter and returns it unwrapped. That lets us protect against the one known vector where using both SentrySQLiteDriver and SentrySupportSQLiteOpenHelper with the same db table is allowed under either the Room or SQLDelight APIs: ```kotlin // AVOID — this configuration produces duplicate spans for every SQL statement. // Step 1: Developer wraps their open helper with Sentry, either manually or // via the Sentry Android Gradle Plugin. val sentryWrappedHelper: SupportSQLiteOpenHelper = SentrySupportSQLiteOpenHelper.create( FrameworkSQLiteOpenHelperFactory().create(configuration) ) // Step 2: Developer builds the compat driver around that wrapped helper. val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper) // Step 3: Developer (wrongly!) wraps the driver with Sentry as well. All // spans will now be duplicated. val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver) Room.databaseBuilder(context, MyDb::class.java, "mydb") .setDriver(sentryWrappedDriver) .build() ``` This commit lets us avoid step 3 by no-op'ing if a developer tries to pass a SupportSQLiteDriver to SentrySQLiteDriver.create(). --- sentry-android-sqlite/proguard-rules.pro | 4 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 32 ++- .../sqlite/driver/SupportSQLiteDriver.kt | 18 ++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 11 ++ .../samples/android/sqlite/DisplayInfo.kt | 5 + .../samples/android/sqlite/SQLiteActivity.kt | 183 ++++++++++++++---- .../samples/android/sqlite/SampleDatabases.kt | 181 +++++++++++++++-- .../samples/android/sqlite/SqlStatements.kt | 35 ++++ .../samples/android/sqlite/UiLoadActivity.kt | 5 +- 9 files changed, 412 insertions(+), 62 deletions(-) create mode 100644 sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro index 02ab589d3bd..13fa4bf9dea 100644 --- a/sentry-android-sqlite/proguard-rules.pro +++ b/sentry-android-sqlite/proguard-rules.pro @@ -4,4 +4,8 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# SentrySQLiteDriver.create() uses a runtime class-name check to skip wrapping the Room 2.7+ +# SupportSQLiteDriver bridge adapter and avoid duplicate spans. +-keepnames class androidx.sqlite.driver.SupportSQLiteDriver + ##---------------End: proguard configuration for SQLite ---------- diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index e869778b811..f0f41782c22 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -22,11 +22,6 @@ import org.jetbrains.annotations.ApiStatus * .build() * ``` * - * **Warning:** Do not use [SentrySQLiteDriver] together with - * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the - * same database file. Both wrappers instrument at different layers and combining them will produce - * duplicate spans. - * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @ApiStatus.Experimental @@ -73,11 +68,32 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite public companion object { /** - * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already - * wrapped. + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. + * + * To avoid duplicate spans, returns the delegate as-is if: + * 1. it's already wrapped, or + * 2. it's an `androidx.sqlite.driver.SupportSQLiteDriver`. + * + * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via + * `SentrySupportSQLiteOpenHelper` instead. */ @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = - delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } } } diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 00000000000..2de7f1d38f5 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,18 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt index 9b2345a975f..5816f3d859c 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -3,6 +3,7 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage @@ -64,6 +65,16 @@ class SentrySQLiteDriverTest { assertSame(wrapped, doubleWrapped) } + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + @Test fun `hasConnectionPool forwards delegate value when supported`() { whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt index 14582fe305e..fd80a5aae1e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -87,6 +87,11 @@ internal val OPENHELPER_ROOM = .trimIndent(), ) +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + internal val OPENHELPER_SQLDELIGHT = DisplayInfo( sql = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 1ff6828a757..9a27ecda353 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -1,6 +1,7 @@ package io.sentry.samples.android.sqlite import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -33,6 +34,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchColors @@ -73,6 +77,7 @@ import kotlinx.coroutines.withContext private val SentryPink = Color(0xFFC85B9C) private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) private val SentryRed = Color(0xFFF55459) /** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ @@ -88,10 +93,33 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp -/** Which sentry-android-sqlite integration the demo buttons currently target. */ -private enum class Integration(val color: Color, val apiName: String) { - DRIVER(SentryPurple, "SQLiteDriver"), - OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, + val subtitle: String, +) { + DRIVER( + SentryPurple, + "SQLiteDriver", + "SQLiteDriver", + "SentrySQLiteDriver.create(BundledSQLiteDriver)", + ), + OPEN_HELPER( + SentryPink, + "OpenHelper", + "SupportSQLiteOpenHelper", + "SentrySupportSQLiteOpenHelper.create(...)", + ), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE( + SentryOrange, + "Bridge", + "SupportSQLiteDriver bridge", + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", + ), } /** @@ -107,11 +135,24 @@ private class DemoVariant( ) /** - * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; - * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why - * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). */ -private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} // The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual // SQL lives in SqlStatements, dispatched by id. @@ -133,6 +174,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-direct", displayInfo = OPENHELPER_DIRECT, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), ), DemoRow( label = "Room 2", @@ -150,6 +198,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-room", displayInfo = OPENHELPER_ROOM, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), ), DemoRow( label = "Room 3", @@ -161,6 +216,7 @@ private val DEMO_ROWS = displayInfo = DRIVER_ROOM3, ), openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, ), DemoRow( label = "SQLDelight", @@ -172,6 +228,7 @@ private val DEMO_ROWS = op = "db.sql.openhelper-sqldelight", displayInfo = OPENHELPER_SQLDELIGHT, ), + bridge = null, ), ) @@ -187,6 +244,7 @@ private val DEMO_ROWS = class SQLiteActivity : ComponentActivity() { private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) private var heavyWork by mutableStateOf(false) @@ -198,8 +256,8 @@ class SQLiteActivity : ComponentActivity() { */ private var shareScreenTrace by mutableStateOf(false) - /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ - private var integration by mutableStateOf(Integration.DRIVER) + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ private var runTick by mutableStateOf(0) @@ -265,31 +323,19 @@ class SQLiteActivity : ComponentActivity() { SectionHeader("Configure it") - val openHelper = integration == Integration.OPEN_HELPER - val integrationSwitchColors = - SwitchDefaults.colors( - checkedTrackColor = SentryPink, - checkedBorderColor = SentryPink, - uncheckedTrackColor = SentryPurple, - uncheckedBorderColor = SentryPurple, - uncheckedThumbColor = Color.White, - ) val controlSwitchColors = SwitchDefaults.colors( checkedTrackColor = Color.Black, checkedBorderColor = Color.Black, ) - ToggleRow( - label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", - checked = openHelper, - labelColor = if (openHelper) SentryPink else SentryPurple, - switchColors = integrationSwitchColors, - ) { - integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER - // Switching integration starts a fresh comparison: clear the detail box and result. - sqlDetail = SQL_DETAIL_HINT - latestResult = "" - } + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) ToggleRow( label = if (heavyWork) "Heavy app-level work" else "No app-level work", checked = heavyWork, @@ -313,12 +359,12 @@ class SQLiteActivity : ComponentActivity() { // integration's variant; a row that doesn't apply explains why via a toast (see // [DemoRowButton]). DEMO_ROWS.forEach { row -> - val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + val variant = row.variantFor(integration) DemoRowButton( label = row.label, color = integration.color, variant = variant, - disabledReason = "${row.label} doesn't use the ${integration.apiName}", + disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", ) } @@ -330,12 +376,26 @@ class SQLiteActivity : ComponentActivity() { // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from // the detail output. SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } // The latest run result (row counts, errors). Hidden until the first run. if (latestResult.isNotEmpty()) { Text( text = latestResult, style = MaterialTheme.typography.bodyMedium, - color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, ) } DetailField("SQL run", sqlDetail, borderColor = detailOutline) @@ -361,12 +421,13 @@ class SQLiteActivity : ComponentActivity() { lifecycleScope.launch { dbOperationInFlight = true try { - latestResult = + val result = withContext(Dispatchers.IO) { runInTransaction(variant.transactionName, variant.op) { SqlStatements.execute(applicationContext, variant.demo, heavyWork) } } + latestResult = result } finally { dbOperationInFlight = false } @@ -385,9 +446,41 @@ class SQLiteActivity : ComponentActivity() { startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) } + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the - * label inherits the default text color; the integration toggle passes its pink/purple instead. + * label inherits the default text color. */ @androidx.compose.runtime.Composable private fun ToggleRow( @@ -533,7 +626,11 @@ class SQLiteActivity : ComponentActivity() { try { val message = withContext(Dispatchers.IO) { resetDatabases() } latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" } finally { this@SQLiteActivity.dbOperationInFlight = false this@SQLiteActivity.resetInProgress = false @@ -595,7 +692,8 @@ class SQLiteActivity : ComponentActivity() { result } catch (t: Throwable) { transaction.status = SpanStatus.INTERNAL_ERROR - "$transactionName failed: ${t.message}" + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" } finally { transaction.finish() } @@ -604,11 +702,20 @@ class SQLiteActivity : ComponentActivity() { /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) - return "Dropped tables: cleared $cleared database file(s)." + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } } private companion object { + private const val TAG = "SQLiteActivity" + /** Demo SQL shorter than this won't visibly disable the reset button. */ private const val RESET_DISABLE_DEBOUNCE_MS = 300L @@ -619,3 +726,5 @@ class SQLiteActivity : ComponentActivity() { private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" } } + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 63f217fcfbb..19b292cd91e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -1,12 +1,14 @@ package io.sentry.samples.android.sqlite import android.content.Context +import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver @@ -18,6 +20,7 @@ import io.sentry.samples.android.sqlite.SampleDatabases.warmUp import io.sentry.sqlite.SentrySQLiteDriver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,18 +45,40 @@ import kotlinx.coroutines.sync.withLock */ object SampleDatabases { + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + private val sqlAccess = Mutex() val driverDirectLock = Any() + val bridgeDirectLock = Any() val openHelperDirectLock = Any() /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null @Volatile private var driverRoom3Db: SampleRoom3Database? = null @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null @Volatile private var openHelperRoomDb: SampleRoom2Database? = null @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null @@ -68,6 +93,45 @@ object SampleDatabases { } } + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + SentrySQLiteDriver.create( + SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) + ) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + fun driverRoom2Db(context: Context): SampleRoom2Database = synchronized(this) { driverRoom2Db @@ -133,10 +197,50 @@ object SampleDatabases { .also { sqlDelightDriver = it } } - private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { val configuration = SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) - .name("openhelper_direct.db") + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) .callback( object : SupportSQLiteOpenHelper.Callback(1) { override fun onCreate(db: SupportSQLiteDatabase) { @@ -156,22 +260,50 @@ object SampleDatabases { /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext + val generation = ++warmUpGeneration + warmUpComplete = false + warmUpErrors = "" // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. - CoroutineScope(Dispatchers.IO).launch { - runCatching { driverConnection(appContext) } - // primeWriter() + count() opens both Room pool connections (writer + reader), so the first - // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its - // transaction. - runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { directHelper(appContext).writableDatabase } - runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { - SampleSQLDelightDatabase(sqlDelightDriver(appContext)) - .songQueries - .countSongs() - .executeAsOne() + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") } } @@ -185,7 +317,9 @@ object SampleDatabases { val names = listOf( "driver_direct.db", + "bridge_direct.db", "driver_room2.db", + "bridge_room2.db", "driver_room3.db", "openhelper_direct.db", "openhelper_room.db", @@ -201,6 +335,12 @@ object SampleDatabases { driverConnection?.close() driverConnection = null } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } synchronized(openHelperDirectLock) { directHelper?.close() directHelper = null @@ -208,6 +348,8 @@ object SampleDatabases { synchronized(this) { driverRoom2Db?.close() driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null driverRoom3Db?.close() driverRoom3Db = null openHelperRoomDb?.close() @@ -219,4 +361,11 @@ object SampleDatabases { private fun databaseFile(context: Context, name: String): String = context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } } + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index 543f1169294..9bd2d624694 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -18,6 +18,8 @@ enum class SqlDemo { DRIVER_DIRECT, DRIVER_ROOM2, DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, OPENHELPER_DIRECT, OPENHELPER_ROOM, OPENHELPER_SQLDELIGHT, @@ -64,6 +66,8 @@ object SqlStatements { SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) @@ -115,6 +119,37 @@ object SqlStatements { if (statement.step()) statement.getLong(0) else 0 } + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt index b32811e8c91..3cc6d394daa 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -3,6 +3,7 @@ package io.sentry.samples.android.sqlite import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.getValue @@ -48,7 +49,8 @@ class UiLoadActivity : ComponentActivity() { withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } "$result\n\nRan under the auto ui.load transaction." } catch (t: Throwable) { - "Load failed: ${t.message}" + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" } finally { // Close the TTFD window so the ui.load transaction finishes with the db spans attached. Sentry.reportFullyDisplayed() @@ -57,6 +59,7 @@ class UiLoadActivity : ComponentActivity() { } companion object { + private const val TAG = "UiLoadActivity" private const val EXTRA_DEMO_ID = "demo_id" private const val EXTRA_HEAVY = "heavy" From 547d3e463dec24c1b99586f85e0cd5f2d73b9022 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:04:12 +0200 Subject: [PATCH 100/276] chore: update scripts/update-sentry-native-ndk.sh to 0.15.1 (#5570) 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 8428f033b78..76b2d974e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) +### Dependencies + +- Bump Native SDK from v0.15.0 to v0.15.1 ([#5570](https://github.com/getsentry/sentry-java/pull/5570)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0151) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.0...0.15.1) + ## 8.44.0 ### Features diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91a7669194f..68521efdfcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -166,7 +166,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.0" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.1" } 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 9c501bbbe56976ba47b467455062992eac005b07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:07:08 +0000 Subject: [PATCH 101/276] chore(deps): bump actions/checkout in the github-actions group (#5569) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6.0.3 to 7.0.0 - [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/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout 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/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 40f8509fee4..8ddb961ec96 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 375e94e7499..f2ffd96f9c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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 028b4217ef2..78918167207 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get changed files id: changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index 535b2170fae..3e30f97e45e 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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 e24b7c96c14..ccc9cc04a85 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index e5e4530933b..38680fe0a23 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # 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 ec427af3564..2892df16701 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 2e82024077a..fabd36736aa 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 4d323f0394a..45b063705dc 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' @@ -77,7 +77,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index e2fa42ddc16..5c212d5895a 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Java Version uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 18809c060e1..7d0b74b4329 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Java 17 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 @@ -77,7 +77,7 @@ jobs: arch: x86_64 steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Enable KVM run: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index f7b95a26d12..92e29ecbef7 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 9fecaf32b5e..050782006f0 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eddeaa24cd9..dd266d948c2 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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 7628a0bbba0..6e0b1366c9f 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 40670eaf258..00e93f5442b 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 128051ed03e..450dbd8c98d 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 62a1b7665c0..67f81f2fb64 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' From 8da852cc8e39d8246ba5a712c88d38b64618b074 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 19 Jun 2026 11:11:14 +0200 Subject: [PATCH 102/276] fix(android): Make FirstDrawDoneListener cleanup OnGlobalLayoutListener after use (#5567) * fix(android): Make FirstDrawDoneListener cleanup OnGlobalLayoutListener after use The OnGlobalLayoutListener registered in onDraw() to defer removal of the OnDrawListener was never itself removed. In single-Activity apps (e.g. React Native), this caused an unbounded per-navigation leak on the ViewTreeObserver, accumulating one listener per registerForNextDraw call. Make the OnGlobalLayoutListener remove itself after firing. Fixes JAVA-545 Co-Authored-By: Claude Opus 4.6 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++ .../internal/util/FirstDrawDoneListener.java | 9 +++++- .../util/FirstDrawDoneListenerTest.kt | 31 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b2d974e5a..395b1f2e2b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix `FirstDrawDoneListener` leaking an `OnGlobalLayoutListener` per registration ([#5567](https://github.com/getsentry/sentry-java/pull/5567)) + ### Features - Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index f2612b4aa84..0629b7a4908 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -112,7 +112,14 @@ public void onDraw() { // OnDrawListeners cannot be removed within onDraw, so we remove it with a // GlobalLayoutListener view.getViewTreeObserver() - .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 008a036cbfc..44d6d9fd03a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -128,6 +128,37 @@ class FirstDrawDoneListenerTest { assertTrue(fixture.onDrawListeners.isEmpty()) } + @Test + fun `OnGlobalLayoutListener is removed after cleanup`() { + val view = fixture.getSut() + + // Initialize mOnGlobalLayoutListeners via a dummy add/remove + val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} + view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) + view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) + + // CopyOnWriteArray wraps an internal ArrayList called mData + val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") + val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") + mDataField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> + + assertTrue(globalLayoutListeners().isEmpty()) + + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + + // onDraw registers a cleanup OnGlobalLayoutListener + view.viewTreeObserver.dispatchOnDraw() + assertFalse(globalLayoutListeners().isEmpty()) + + // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(globalLayoutListeners().isEmpty()) + assertTrue(fixture.onDrawListeners.isEmpty()) + } + @Test fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { val view = fixture.getSut() From f4269fd1cb8cbeef665b2e1316819fc632e2e338 Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:51:59 +0000 Subject: [PATCH 103/276] release: 8.44.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395b1f2e2b1..b3bdcd38bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.44.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 19127ac9832..f2e3da3ca09 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.44.0 +versionName=8.44.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 69943b840653f4dde405bdc32c4fde5732dcc43f Mon Sep 17 00:00:00 2001 From: arb Date: Fri, 19 Jun 2026 16:00:18 +0200 Subject: [PATCH 104/276] refactor(android-sqlite): Rename classes instrumenting SQLite spans for consistency (#5555) --- ...QLiteSpanManager.kt => OpenHelperSpans.kt} | 3 +- .../sqlite/SentryCrossProcessCursor.kt | 8 +-- .../sqlite/SentrySupportSQLiteDatabase.kt | 21 ++++--- .../sqlite/SentrySupportSQLiteOpenHelper.kt | 6 +- .../sqlite/SentrySupportSQLiteStatement.kt | 17 +++--- ...eSpanInstrumentation.kt => DriverSpans.kt} | 26 ++++----- .../sentry/sqlite/SentrySQLiteConnection.kt | 2 +- .../io/sentry/sqlite/SentrySQLiteDriver.kt | 2 +- .../io/sentry/sqlite/SentrySQLiteStatement.kt | 4 +- ...nManagerTest.kt => OpenHelperSpansTest.kt} | 6 +- .../sqlite/SentryCrossProcessCursorTest.kt | 4 +- .../sqlite/SentrySupportSQLiteDatabaseTest.kt | 4 +- .../SentrySupportSQLiteStatementTest.kt | 4 +- ...trumentationTest.kt => DriverSpansTest.kt} | 55 +++++++++---------- .../sqlite/SentrySQLiteConnectionTest.kt | 4 +- .../sqlite/SentrySQLiteStatementTest.kt | 10 ++-- 16 files changed, 82 insertions(+), 94 deletions(-) rename sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/{SQLiteSpanManager.kt => OpenHelperSpans.kt} (96%) rename sentry-android-sqlite/src/main/java/io/sentry/sqlite/{SQLiteSpanInstrumentation.kt => DriverSpans.kt} (81%) rename sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/{SQLiteSpanManagerTest.kt => OpenHelperSpansTest.kt} (97%) rename sentry-android-sqlite/src/test/java/io/sentry/sqlite/{SQLiteSpanInstrumentationTest.kt => DriverSpansTest.kt} (77%) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt similarity index 96% rename from sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt rename to sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 1bdeb7d369c..059eb1bb1b5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -13,7 +13,8 @@ import io.sentry.SpanStatus private const val TRACE_ORIGIN = "auto.db.sqlite" -internal class SQLiteSpanManager( +/** Span instrumentation for [SentrySupportSQLiteOpenHelper]. */ +internal class OpenHelperSpans( private val scopes: IScopes = ScopesAdapter.getInstance(), private val databaseName: String? = null, ) { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt index 1f3796a8975..f5f8424aca3 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -13,7 +13,7 @@ import android.database.CursorWindow */ internal class SentryCrossProcessCursor( private val delegate: CrossProcessCursor, - private val spanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : CrossProcessCursor by delegate { // We have to start the span only the first time, regardless of how many times its methods get @@ -25,7 +25,7 @@ internal class SentryCrossProcessCursor( return delegate.count } isSpanStarted = true - return spanManager.performSql(sql) { delegate.count } + return spans.performSql(sql) { delegate.count } } override fun onMove(oldPosition: Int, newPosition: Int): Boolean { @@ -33,7 +33,7 @@ internal class SentryCrossProcessCursor( return delegate.onMove(oldPosition, newPosition) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.onMove(oldPosition, newPosition) } + return spans.performSql(sql) { delegate.onMove(oldPosition, newPosition) } } override fun fillWindow(position: Int, window: CursorWindow?) { @@ -41,6 +41,6 @@ internal class SentryCrossProcessCursor( return delegate.fillWindow(position, window) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.fillWindow(position, window) } + return spans.performSql(sql) { delegate.fillWindow(position, window) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt index bfe3265f89b..458203a232f 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -14,11 +14,11 @@ import androidx.sqlite.db.SupportSQLiteStatement * and it's created automatically by the [SentrySupportSQLiteOpenHelper]. * * @param delegate The [SupportSQLiteDatabase] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. */ internal class SentrySupportSQLiteDatabase( private val delegate: SupportSQLiteDatabase, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, ) : SupportSQLiteDatabase by delegate { /** * Compiles the given SQL statement. It will return Sentry's wrapper around @@ -28,35 +28,34 @@ internal class SentrySupportSQLiteDatabase( * @return Compiled statement. */ override fun compileStatement(sql: String): SupportSQLiteStatement = - SentrySupportSQLiteStatement(delegate.compileStatement(sql), sqLiteSpanManager, sql) + SentrySupportSQLiteStatement(delegate.compileStatement(sql), spans, sql) @Suppress("AcronymName") // To keep consistency with framework method name. override fun execPerConnectionSQL( sql: String, @SuppressLint("ArrayReturn") bindArgs: Array?, ) { - sqLiteSpanManager.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } } - override fun query(query: String): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query) } + override fun query(query: String): Cursor = spans.performSql(query) { delegate.query(query) } override fun query(query: String, bindArgs: Array): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query, bindArgs) } + spans.performSql(query) { delegate.query(query, bindArgs) } override fun query(query: SupportSQLiteQuery): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query) } + spans.performSql(query.sql) { delegate.query(query) } override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query, cancellationSignal) } + spans.performSql(query.sql) { delegate.query(query, cancellationSignal) } @Throws(SQLException::class) override fun execSQL(sql: String) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql) } + spans.performSql(sql) { delegate.execSQL(sql) } } @Throws(SQLException::class) override fun execSQL(sql: String, bindArgs: Array) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execSQL(sql, bindArgs) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt index 76b405d9f11..12b63cfa128 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -33,14 +33,14 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper public class SentrySupportSQLiteOpenHelper private constructor(private val delegate: SupportSQLiteOpenHelper) : SupportSQLiteOpenHelper by delegate { - private val sqLiteSpanManager = SQLiteSpanManager(databaseName = delegate.databaseName) + private val spans = OpenHelperSpans(databaseName = delegate.databaseName) private val sentryWritableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.writableDatabase, spans) } private val sentryReadableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.readableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.readableDatabase, spans) } override val writableDatabase: SupportSQLiteDatabase diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt index 1a364dc27ba..3df6d287b28 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -9,25 +9,22 @@ import androidx.sqlite.db.SupportSQLiteStatement * [SentrySupportSQLiteDatabase.compileStatement]. * * @param delegate The [SupportSQLiteStatement] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. * @param sql The query string. */ internal class SentrySupportSQLiteStatement( private val delegate: SupportSQLiteStatement, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : SupportSQLiteStatement by delegate { - override fun execute() = sqLiteSpanManager.performSql(sql) { delegate.execute() } + override fun execute() = spans.performSql(sql) { delegate.execute() } - override fun executeUpdateDelete(): Int = - sqLiteSpanManager.performSql(sql) { delegate.executeUpdateDelete() } + override fun executeUpdateDelete(): Int = spans.performSql(sql) { delegate.executeUpdateDelete() } - override fun executeInsert(): Long = - sqLiteSpanManager.performSql(sql) { delegate.executeInsert() } + override fun executeInsert(): Long = spans.performSql(sql) { delegate.executeInsert() } - override fun simpleQueryForLong(): Long = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForLong() } + override fun simpleQueryForLong(): Long = spans.performSql(sql) { delegate.simpleQueryForLong() } override fun simpleQueryForString(): String? = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForString() } + spans.performSql(sql) { delegate.simpleQueryForString() } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt similarity index 81% rename from sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt rename to sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt index f0998dfdc23..b3c0eb7c713 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -20,20 +20,17 @@ private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) /** Span instrumentation for [SentrySQLiteDriver]. */ -internal class SQLiteSpanInstrumentation( - private val scopes: IScopes, - private val dbMetadata: DbMetadata, -) { +internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: DbMetadata) { private val stackTraceFactory = SentryStackTraceFactory(scopes.options) /** - * Returns a timestamp in nanoseconds for use with [recordSpan]. Timestamp is ns-precise if the - * active parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. + * Returns a timestamp in nanoseconds for use with [record]. Timestamp is ns-precise if the active + * parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. * - * Note: Internalizing the start time in [recordSpan] would shift spans to end-of-work on the - * trace timeline, which is less desirable; callers capture the start before doing database work - * and pass it back to [recordSpan]. + * Note: Internalizing the start time in [record] would shift spans to end-of-work on the trace + * timeline, which is less desirable; callers capture the start before doing database work and + * pass it back to [record]. */ fun startTimestamp(): Long = // Try to retain nanosecond precision + avoid SentryDate allocation... @@ -42,7 +39,7 @@ internal class SQLiteSpanInstrumentation( ?: scopes.options.dateProvider.now().nanoTimestamp() /** Records a `db.sql.query` span. */ - fun recordSpan( + fun record( sql: String, startTimestampNanos: Long, durationNanos: Long, @@ -73,14 +70,11 @@ internal class SQLiteSpanInstrumentation( companion object { /** - * Returns [SQLiteSpanInstrumentation] based on the [fileName] argument passed to + * Returns [DriverSpans] based on the [fileName] argument passed to * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. */ - fun fromFileName( - fileName: String, - scopes: IScopes = ScopesAdapter.getInstance(), - ): SQLiteSpanInstrumentation = - SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()): DriverSpans = + DriverSpans(scopes, dbMetadataFromFileName(fileName)) } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt index 45ee9a39b27..e01544b0523 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -5,7 +5,7 @@ import androidx.sqlite.SQLiteStatement internal class SentrySQLiteConnection( private val delegate: SQLiteConnection, - private val spans: SQLiteSpanInstrumentation, + private val spans: DriverSpans, ) : SQLiteConnection by delegate { override fun prepare(sql: String): SQLiteStatement { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index f0f41782c22..22f6353d883 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -48,7 +48,7 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite val connection = delegate.open(fileName) return try { - val spans = SQLiteSpanInstrumentation.fromFileName(fileName) + val spans = DriverSpans.fromFileName(fileName) // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping // the connection. SentrySQLiteConnection(connection, spans) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt index a739a396bcb..e220a74cd1e 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -16,7 +16,7 @@ import io.sentry.SpanStatus */ internal class SentrySQLiteStatement( private val delegate: SQLiteStatement, - private val spans: SQLiteSpanInstrumentation, + private val spans: DriverSpans, private val sql: String, private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { @@ -74,6 +74,6 @@ internal class SentrySQLiteStatement( val duration = accumulatedDbNanos firstStepTimestampNanos = null accumulatedDbNanos = 0L - spans.recordSpan(sql, startNanos, duration, status, throwable) + spans.record(sql, startNanos, duration, status, throwable) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt similarity index 97% rename from sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 6fd6fa51bb3..0552094838e 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -21,13 +21,13 @@ import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanManagerTest { +class OpenHelperSpansTest { private class Fixture { private val scopes = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): SQLiteSpanManager { + fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): OpenHelperSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) @@ -35,7 +35,7 @@ class SQLiteSpanManagerTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanManager(scopes, databaseName) + return OpenHelperSpans(scopes, databaseName) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt index 44836dd0c97..27eff29c9f3 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -20,7 +20,7 @@ import org.mockito.kotlin.whenever class SentryCrossProcessCursorTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockCursor = mock() lateinit var options: SentryOptions lateinit var sentryTracer: SentryTracer @@ -33,7 +33,7 @@ class SentryCrossProcessCursorTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentryCrossProcessCursor(mockCursor, spanManager, sql) + return SentryCrossProcessCursor(mockCursor, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt index 81bd964cc87..6a47eb6fa92 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -23,7 +23,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteDatabaseTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockDatabase = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -41,7 +41,7 @@ class SentrySupportSQLiteDatabaseTest { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteDatabase(mockDatabase, spanManager) + return SentrySupportSQLiteDatabase(mockDatabase, spans) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt index b2b4998ace8..c4d810adbcd 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteStatementTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockStatement = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -31,7 +31,7 @@ class SentrySupportSQLiteStatementTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) + return SentrySupportSQLiteStatement(mockStatement, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt similarity index 77% rename from sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt index 74bd1c7f882..319fc20d7ce 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanInstrumentationTest { +class DriverSpansTest { private class Fixture { @@ -29,17 +29,14 @@ class SQLiteSpanInstrumentationTest { lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut( - isTransactionActive: Boolean = true, - fileName: String = ":memory:", - ): SQLiteSpanInstrumentation { + fun getSut(isTransactionActive: Boolean = true, fileName: String = ":memory:"): DriverSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isTransactionActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanInstrumentation.fromFileName(fileName, scopes) + return DriverSpans.fromFileName(fileName, scopes) } } @@ -56,7 +53,7 @@ class SQLiteSpanInstrumentationTest { val start = sut.startTimestamp() val durationNanos = 42_000_000L - sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() @@ -81,7 +78,7 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) whenever(fixture.scopes.span).thenReturn(parentSpan) - val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) } @@ -97,31 +94,31 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) whenever(fixture.scopes.span).thenReturn(null) - val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) } @Test - fun `recordSpan records a span if a transaction is active`() { + fun `record method records a span if a transaction is active`() { val sut = fixture.getSut(isTransactionActive = true) - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) assertEquals(1, fixture.sentryTracer.children.size) } @Test - fun `recordSpan does not record a span if no transaction is active`() { + fun `record method does not record a span if no transaction is active`() { val sut = fixture.getSut(isTransactionActive = false) val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) assertEquals(0, fixture.sentryTracer.children.size) } @Test - fun `recordSpan creates a span with correct properties`() { + fun `record method creates a span with correct properties`() { val sut = fixture.getSut() val start = sut.startTimestamp() - sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.firstOrNull() assertNotNull(span) @@ -133,24 +130,24 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets finishDate equal to startDate + durationNanos`() { + fun `record method sets finishDate equal to startDate + durationNanos`() { val sut = fixture.getSut() val start = sut.startTimestamp() val durationNanos = 42_000_000L - sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) } @Test - fun `recordSpan attaches throwable when provided`() { + fun `record method attaches throwable when provided`() { val sut = fixture.getSut() val start = sut.startTimestamp() val exception = RuntimeException("disk I/O error") - sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + sut.record("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) val span = fixture.sentryTracer.children.first() assertEquals(SpanStatus.INTERNAL_ERROR, span.status) @@ -158,10 +155,10 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets db system and db name when fileName is not the in-memory sentinel`() { + fun `record method sets db system and db name when fileName is not the in-memory sentinel`() { val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) @@ -169,10 +166,10 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets db system only when fileName is the in-memory sentinel`() { + fun `record method sets db system only when fileName is the in-memory sentinel`() { val sut = fixture.getSut(fileName = ":memory:") val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) @@ -180,13 +177,13 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + fun `record method sets blocked_main_thread to true and attaches call stack on main thread`() { val sut = fixture.getSut() fixture.options.threadChecker = mock() whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) @@ -194,20 +191,20 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets blocked_main_thread to false and does not attach a call stack on background thread`() { + fun `record method sets blocked_main_thread to false and does not attach a call stack on background thread`() { val sut = fixture.getSut() fixture.options.threadChecker = mock() whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) } - private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): SQLiteSpanInstrumentation { + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): DriverSpans { val dateQueue = ArrayDeque(dates.toList()) val options = SentryOptions().apply { @@ -217,6 +214,6 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - return SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + return DriverSpans.fromFileName(":memory:", fixture.scopes) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt index b405d054f03..212e3b032e4 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -24,7 +24,7 @@ class SentrySQLiteConnectionTest { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) - val spans = SQLiteSpanInstrumentation.fromFileName("test.db", scopes) + val spans = DriverSpans.fromFileName("test.db", scopes) return SentrySQLiteConnection(mockConnection, spans) } } @@ -41,7 +41,7 @@ class SentrySQLiteConnectionTest { @Test fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { val sut = fixture.getSut() - val spans = SQLiteSpanInstrumentation.fromFileName("test.db", fixture.scopes) + val spans = DriverSpans.fromFileName("test.db", fixture.scopes) val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt index ce2c3f00cd5..bc6b074545a 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -19,7 +19,7 @@ class SentrySQLiteStatementTest { private class Fixture { val mockStatement = mock() - val mockSpans = mock() + val mockSpans = mock() val startTimestampNanos = 1_000_000_000_000L val fakeClock = AtomicLong(0L) @@ -40,7 +40,7 @@ class SentrySQLiteStatementTest { verifyNeverCalledRecordSpan() sut.step() verify(fixture.mockSpans) - .recordSpan( + .record( eq("SELECT * FROM users"), eq(fixture.startTimestampNanos), any(), @@ -58,7 +58,7 @@ class SentrySQLiteStatementTest { assertFailsWith { sut.step() } verify(fixture.mockSpans) - .recordSpan( + .record( eq("BAD SQL"), eq(fixture.startTimestampNanos), any(), @@ -224,7 +224,7 @@ class SentrySQLiteStatementTest { sut.step() val durationCaptor = argumentCaptor() - verify(fixture.mockSpans).recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + verify(fixture.mockSpans).record(any(), any(), durationCaptor.capture(), any(), anyOrNull()) // Each step contributes its internal time (10 + 20 + 30) plus one unit from // fakeClock::getAndIncrement between before/after reads, so total is 63. assertEquals(63L, durationCaptor.firstValue) @@ -285,6 +285,6 @@ class SentrySQLiteStatementTest { } private fun verifyCalledRecordSpan(times: Int = 1) { - verify(fixture.mockSpans, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + verify(fixture.mockSpans, times(times)).record(any(), any(), any(), any(), anyOrNull()) } } From 05aa61daa3d25b2c82424779b5dec47c2c37556b Mon Sep 17 00:00:00 2001 From: arb Date: Fri, 19 Jun 2026 17:20:19 +0200 Subject: [PATCH 105/276] chore(samples-android): Adapt SQLite demo screen to SAGP build mode (#5568) Exposes a `BuildConfig.USE_SAGP` property from the recently introduced -PuseSagp flag ([#5538](https://github.com/getsentry/sentry-java/pull/5538)). Lets us update the SQLite screen in the Android sample app so that it swizzles between auto-instrumenting vs manually wrapping `SQLiteDriver`, depending on the whether SAGP was applied to the build. --- .../sentry-samples-android/build.gradle.kts | 12 ++ .../samples/android/sqlite/SQLiteActivity.kt | 111 +++++++++++++----- .../samples/android/sqlite/SampleDatabases.kt | 45 ++++--- 3 files changed, 112 insertions(+), 56 deletions(-) diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 74e3c3a57b8..96ded862f95 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,4 +1,5 @@ import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.impl.VariantImpl import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.extensions.SentryPluginExtension @@ -135,6 +136,17 @@ android { } androidComponents.onVariants { variant -> + variant.buildConfigFields?.put( + "USE_SAGP", + providers.provider { + BuildConfigField( + type = "boolean", + value = providers.gradleProperty("useSagp").isPresent.toString(), + comment = "Whether the Sentry Android Gradle Plugin was applied", + ) + }, + ) + val taskName = "toggle${variant.name.capitalized()}NativeLogging" val toggleNativeLoggingTask = project.tasks.register(taskName) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 9a27ecda353..54334b6e407 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.HelpOutline @@ -45,6 +46,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -70,6 +72,7 @@ import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions import io.sentry.protocol.SentryId +import io.sentry.samples.android.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -93,33 +96,45 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp +private const val SAGP_DIRECT_DRIVER_MESSAGE = + "SAGP doesn't auto-instrument SQLiteDriver for direct use" + /** Which sentry-android-sqlite integration the demo currently targets. */ private enum class IntegrationMode( val color: Color, val segmentLabel: String, val apiName: String, - val subtitle: String, ) { - DRIVER( - SentryPurple, - "SQLiteDriver", - "SQLiteDriver", - "SentrySQLiteDriver.create(BundledSQLiteDriver)", - ), - OPEN_HELPER( - SentryPink, - "OpenHelper", - "SupportSQLiteOpenHelper", - "SentrySupportSQLiteOpenHelper.create(...)", - ), + + DRIVER(SentryPurple, "SQLiteDriver", "SQLiteDriver"), + OPEN_HELPER(SentryPink, "OpenHelper", "SupportSQLiteOpenHelper"), // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER // integrations are used together via the SupportSQLiteDriver bridge. - BRIDGE( - SentryOrange, - "Bridge", - "SupportSQLiteDriver bridge", - "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", - ), + BRIDGE(SentryOrange, "Bridge", "SupportSQLiteDriver bridge"); + + fun subtitle(): String = + when (this) { + DRIVER -> + if (BuildConfig.USE_SAGP) { + "BundledSQLiteDriver (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(BundledSQLiteDriver)" + } + + OPEN_HELPER -> + if (BuildConfig.USE_SAGP) { + "FrameworkSQLiteOpenHelperFactory (SAGP auto-wrap)" + } else { + "SentrySupportSQLiteOpenHelper.create(...)" + } + + BRIDGE -> + if (BuildConfig.USE_SAGP) { + "SupportSQLiteDriver(open helper) (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))" + } + } } /** @@ -318,6 +333,7 @@ class SQLiteActivity : ComponentActivity() { lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + SagpBuildPill() Spacer(Modifier.height(titleGap)) @@ -364,6 +380,7 @@ class SQLiteActivity : ComponentActivity() { label = row.label, color = integration.color, variant = variant, + sagpDisabledReason = sagpDisabledReason(integration, row), disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", ) } @@ -447,7 +464,7 @@ class SQLiteActivity : ComponentActivity() { } @OptIn(ExperimentalMaterial3Api::class) - @androidx.compose.runtime.Composable + @Composable private fun IntegrationModeSelector( selected: IntegrationMode, onSelected: (IntegrationMode) -> Unit, @@ -471,18 +488,36 @@ class SQLiteActivity : ComponentActivity() { } Text( - text = selected.subtitle, + text = selected.subtitle(), style = MaterialTheme.typography.bodySmall, color = Color.Gray, modifier = Modifier.padding(top = 6.dp), ) } + @Composable + private fun SagpBuildPill() { + val useSagp = BuildConfig.USE_SAGP + + Surface( + shape = RoundedCornerShape(percent = 50), + color = if (useSagp) SentryPurple.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.2f), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + text = if (useSagp) "Built with SAGP" else "Built without SAGP", + style = MaterialTheme.typography.labelSmall, + color = if (useSagp) SentryPurple else Color.DarkGray, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the * label inherits the default text color. */ - @androidx.compose.runtime.Composable + @Composable private fun ToggleRow( label: String, checked: Boolean, @@ -509,11 +544,11 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun SectionHeader( title: String, topPadding: Dp = 8.dp, - trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, ) { Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -529,7 +564,7 @@ class SQLiteActivity : ComponentActivity() { * tooltip that auto-dismisses after a few seconds. */ @OptIn(ExperimentalMaterial3Api::class) - @androidx.compose.runtime.Composable + @Composable private fun HelpTooltip() { val tooltipState = rememberTooltipState(isPersistent = true) val scope = rememberCoroutineScope() @@ -565,16 +600,19 @@ class SQLiteActivity : ComponentActivity() { * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. */ @OptIn(ExperimentalFoundationApi::class) - @androidx.compose.runtime.Composable + @Composable private fun DemoRowButton( label: String, color: Color, variant: DemoVariant?, + sagpDisabledReason: String?, disabledReason: String, ) { val context = LocalContext.current - val enabled = variant != null - val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + val enabled = variant != null && sagpDisabledReason == null + val explain = { + Toast.makeText(context, sagpDisabledReason ?: disabledReason, Toast.LENGTH_SHORT).show() + } Surface( modifier = Modifier.fillMaxWidth(), @@ -585,8 +623,8 @@ class SQLiteActivity : ComponentActivity() { Box( modifier = Modifier.combinedClickable( - onClick = { if (variant != null) onTap(variant) else explain() }, - onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + onClick = { if (enabled) onTap(variant) else explain() }, + onLongClick = { if (enabled) onLongPress(variant) else explain() }, ) .fillMaxWidth() .heightIn(min = 44.dp) @@ -598,7 +636,7 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. @@ -642,7 +680,7 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun DetailField(label: String, value: String, borderColor: Color) { OutlinedTextField( value = value, @@ -699,6 +737,15 @@ class SQLiteActivity : ComponentActivity() { } } + private fun sagpDisabledReason(mode: IntegrationMode, row: DemoRow): String? { + if (!BuildConfig.USE_SAGP) return null + val demo = row.variantFor(mode)?.demo ?: return null + return when (demo) { + SqlDemo.DRIVER_DIRECT -> SAGP_DIRECT_DRIVER_MESSAGE + else -> null + } + } + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 19b292cd91e..f01a529499d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory @@ -13,6 +14,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.BuildConfig import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock import io.sentry.samples.android.sqlite.SampleDatabases.reset @@ -85,12 +87,10 @@ object SampleDatabases { fun driverConnection(context: Context): SQLiteConnection = synchronized(driverDirectLock) { driverConnection - ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) - .open(databaseFile(context, "driver_direct.db")) - .also { - it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open - driverConnection = it - } + ?: wrapDriver(BundledSQLiteDriver()).open(databaseFile(context, "driver_direct.db")).also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } } /** @@ -104,7 +104,7 @@ object SampleDatabases { // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); // use the absolute path Room and the direct driver path both pass to open(). val dbPath = databaseFile(context, "bridge_direct.db") - SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + wrapDriver(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) .open(dbPath) .also { it.execSQL(SqlStatements.CREATE_SONG) @@ -122,9 +122,7 @@ object SampleDatabases { "bridge_room2.db", ) .setDriver( - SentrySQLiteDriver.create( - SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) - ) + wrapDriver(SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext))) ) .setQueryCoroutineContext(Dispatchers.IO) .fallbackToDestructiveMigration(true) @@ -140,7 +138,7 @@ object SampleDatabases { SampleRoom2Database::class.java, "driver_room2.db", ) - .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setDriver(wrapDriver(BundledSQLiteDriver())) .setQueryCoroutineContext(Dispatchers.IO) .fallbackToDestructiveMigration(true) .build() @@ -151,7 +149,7 @@ object SampleDatabases { synchronized(this) { driverRoom3Db ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") - .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setDriver(wrapDriver(BundledSQLiteDriver())) .setQueryCoroutineContext(Dispatchers.IO) .build() .also { driverRoom3Db = it } @@ -171,9 +169,7 @@ object SampleDatabases { "openhelper_room.db", ) .openHelperFactory { configuration -> - SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } .fallbackToDestructiveMigration(true) .build() @@ -189,9 +185,7 @@ object SampleDatabases { name = "openhelper_sqldelight.db", factory = SupportSQLiteOpenHelper.Factory { configuration -> - SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) }, ) .also { sqlDelightDriver = it } @@ -232,9 +226,7 @@ object SampleDatabases { } ) .build() - return SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { @@ -252,17 +244,22 @@ object SampleDatabases { } ) .build() - return SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } + private fun wrapDriver(driver: SQLiteDriver): SQLiteDriver = + if (BuildConfig.USE_SAGP) driver else SentrySQLiteDriver.create(driver) + + private fun wrapOpenHelper(delegate: SupportSQLiteOpenHelper): SupportSQLiteOpenHelper = + if (BuildConfig.USE_SAGP) delegate else SentrySupportSQLiteOpenHelper.create(delegate) + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext val generation = ++warmUpGeneration warmUpComplete = false warmUpErrors = "" + Log.i(TAG, "Warm-up starting (USE_SAGP=${BuildConfig.USE_SAGP})") // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. warmUpJob = CoroutineScope(Dispatchers.IO).launch { From 0c118e902e8632b9fc107e8063613ea19fd11e70 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 22 Jun 2026 11:02:52 +0200 Subject: [PATCH 106/276] feat(android): Report app start reason as `app.vitals.start.reason` on standalone app start transaction (#5552) * feat(android): Report app start reason on standalone app start transaction Read ApplicationStartInfo.getReason() (API 35+) and attach it as app.start.reason trace data on the standalone app.start transaction in both the foreground and headless paths. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(android): Register StandaloneAppStart SDK integration marker Advertise that standalone app start tracing is active by adding a StandaloneAppStart marker to the SDK metadata integrations when the feature is enabled. Internal SDK metadata only. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): Note app.start.reason is searchable in Trace Explorer Address review feedback to mention that customers can search and group by the app.vitals.start.reason attribute. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 2 + .../core/ActivityLifecycleIntegration.java | 10 +++ .../core/performance/AppStartMetrics.java | 45 ++++++++++++ .../core/ActivityLifecycleIntegrationTest.kt | 71 +++++++++++++++++++ .../performance/AppStartMetricsTestApi35.kt | 42 +++++++++++ 6 files changed, 171 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bdcd38bc4..5d9d4dddac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view - Also covers non-activity starts (broadcast receivers, services, content providers) + - On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) ### Improvements diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0500ba44990..58325d08b5b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -746,6 +746,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartEndTime ()Lio/sentry/SentryDate; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; @@ -780,6 +781,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V public fun setClassLoadedUptimeMs (J)V public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 8a891926341..d70ff837178 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -71,6 +71,7 @@ public final class ActivityLifecycleIntegration static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_REASON_DATA = "app.vitals.start.reason"; static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; @@ -139,6 +140,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + addIntegrationToSdkVersion("StandaloneAppStart"); } this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); @@ -285,6 +287,10 @@ private void startTracing(final @NotNull Activity activity) { appStartSamplingDecision), appStartTransactionOptions); appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason(); + if (appStartReason != null) { + appStartTransaction.setData(APP_START_REASON_DATA, appStartReason); + } } // Continue either the foreground app.start above or an earlier headless app.start. @@ -1001,6 +1007,10 @@ private void onHeadlessAppStart() { null); final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); // Persist trace headers so a later ui.load can share traceId and sampleRand. metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index d8cb0827ba4..36cae8686ca 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -166,6 +166,45 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + /** + * The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}. + * Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null} + * otherwise or for an unmapped reason. + */ + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + switch (cachedStartInfo.getReason()) { + case ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + return "launcher_recents"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -372,6 +411,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) { CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs; } + @TestOnly + @ApiStatus.Internal + public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) { + this.cachedStartInfo = cachedStartInfo; + } + /** * Called by instrumentation * 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 19f43432bef..8b842a0cfa9 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application +import android.app.ApplicationStartInfo import android.content.Context import android.os.Build import android.os.Bundle @@ -274,6 +275,76 @@ 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 + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `Standalone app start transaction has no app start reason when unavailable`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertNull(appStartTransaction.getData("app.vitals.start.reason")) + } + + @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 + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertEquals("broadcast", transaction.getData("app.vitals.start.reason")) + } + @Test fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { val sut = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index 30686852156..b5d87ab77cb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -207,6 +208,47 @@ class AppStartMetricsTestApi35 { assertEquals(1, listenerCalls.get()) } + @Test + fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals("broadcast", metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null when no ApplicationStartInfo is available`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null for an unmapped reason`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() From f037273666b1f288823a4d6a135dcf517dd61468 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 22 Jun 2026 14:00:00 +0200 Subject: [PATCH 107/276] fix(replay): Release MediaMuxer when no frames are encoded (#5583) * fix(replay): Release MediaMuxer when no frames are encoded The MediaMuxer is created when the video encoder is constructed, but its release() was reachable only on the happy path. Two cases leaked it: - createVideoOf returned early when frameCount was 0 without releasing the encoder. - SimpleMp4FrameMuxer.release() called muxer.stop() before muxer.release(). stop() throws if the muxer was never started (no frame ever muxed), so release() was skipped. This surfaced as a CloseGuard "resource was acquired but never released" warning. Guard stop() behind the started flag so release() is always reached, and release the encoder on the no-frames return path. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 4 ++++ .../io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9d4dddac0..8d66d755a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) + ## 8.44.1 ### Fixes 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 32e42dafac1..b3b9edae055 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 @@ -199,6 +199,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (frameCount == 0) { options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment") + encoderLock.acquire().use { + encoder?.release() + encoder = null + } deleteFile(videoFile) return null } 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 36741686701..e32af9bb44b 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,7 +67,11 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - muxer.stop() + // 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) { + muxer.stop() + } muxer.release() } From 57d359a2dee07eb48c5b2f6fad04d540af7fe407 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 22 Jun 2026 14:21:24 +0200 Subject: [PATCH 108/276] docs(replay): Add THIRD_PARTY_NOTICES entry for SimpleMp4FrameMuxer (#5586) * docs(replay): Add THIRD_PARTY_NOTICES entry for SimpleMp4FrameMuxer SimpleMp4FrameMuxer is adapted from the flutter_screen_recorder library and carries a complete attribution header, but the corresponding entry in THIRD_PARTY_NOTICES.md was never added. Warden's check-code-attribution flags the missing entry as independently required regardless of header completeness. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(replay): Cover all adapted flutter_screen_recorder and Curtains files SimpleFrameMuxer and SimpleVideoEncoder are adapted from the same flutter_screen_recorder library as SimpleMp4FrameMuxer; fold all three into one notice entry. Also extend the existing Square Curtains scope to list io.sentry.android.replay.Windows, which is adapted from Curtains but was not mentioned. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(replay): Correct adapted-from URL in SimpleVideoEncoder header The attribution header pointed at the upstream SimpleFrameMuxer.kt instead of SimpleVideoEncoder.kt. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- THIRD_PARTY_NOTICES.md | 39 ++++++++++++++++++- .../replay/video/SimpleVideoEncoder.kt | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c1fa7e8f65b..925add4a71a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -154,7 +154,7 @@ limitations under the License. ### Scope -The Sentry Java SDK includes an adapted version of Square's Curtains library for null-safe `Window.Callback` handling. The code resides in `io.sentry.android.replay.util.FixedWindowCallback`. +The Sentry Java SDK includes adapted versions of Square's Curtains library for null-safe `Window.Callback` handling and for tracking attached window roots. The code resides in `io.sentry.android.replay.util.FixedWindowCallback` and `io.sentry.android.replay.Windows`. ``` Copyright 2021 Square Inc. @@ -513,3 +513,40 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +--- + +## fzyzcjy — Flutter Screen Recorder (MIT) + +**Source:** https://github.com/fzyzcjy/flutter_screen_recorder (Commit: dce41cec25c66baf42c6bac4198e95874ce3eb9d)
+**License:** MIT License
+**Copyright:** Copyright (c) 2021 fzyzcjy + +### Scope + +The Sentry Android Replay SDK includes adapted versions of the video encoding and muxing classes from the flutter_screen_recorder library, used to encode and mux replay video frames into an MP4 file. The code resides in the `io.sentry.android.replay.video` package and includes `SimpleFrameMuxer`, `SimpleMp4FrameMuxer`, and `SimpleVideoEncoder`. + +``` +Copyright (c) 2021 fzyzcjy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +In addition to the standard MIT license, this library requires the following: The recorder itself +only saves data on user's phone locally, thus it does not have any privacy problem. However, if +you are going to get the records out of the local storage (e.g. upload the records to your +server), please explicitly ask the user for permission, and promise to only use the records to +debug your app. This is a part of the license of this library. +``` 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 a400be865e7..de14aadaaab 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 @@ -1,6 +1,6 @@ /** * Adapted from - * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt * * Copyright (c) 2021 fzyzcjy * From f982bad2175a3302c67624af6ec2bd27d72a549f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 23 Jun 2026 11:13:24 +0200 Subject: [PATCH 109/276] build(samples): Remove outputs.upToDateWhen { false } from systemTest tasks (#5522) * build(samples): Remove outputs.upToDateWhen { false } from systemTest tasks The systemTest tasks in the sample modules forced Gradle to always treat their outputs as out of date, disabling up-to-date checks and build cache reuse. Removing this lets Gradle rely on its normal input/output tracking for the Test tasks. Co-Authored-By: Claude Opus 4.8 (1M context) * build(samples): Track systemTest app archive via convention plugin The system tests launch the packaged sample (war/shadowJar/bootJar) from build/libs as a separate process, so the archive is a real input to the systemTest task even though it is not on the test classpath. Without it, removing outputs.upToDateWhen { false } would let Gradle mark systemTest up-to-date while a separate jar build refreshed the artifact, skipping verification against the rebuilt sample. Move that wiring into a single io.sentry.systemtest convention plugin in build-logic instead of repeating it in every sample build file. The plugin auto-detects the packaging task (war, else shadowJar, else bootJar), mirroring the selection in test/system-test-runner.py, and declares its archive as an input and dependency. Each sample just applies the plugin. Co-Authored-By: Claude Opus 4.8 (1M context) * build(samples): Track OpenTelemetry agent jar as systemTest input The agent-based OpenTelemetry samples are launched by the runner with -javaagent:, started outside the test JVM. That jar is not on the test classpath nor one of the app archives, so without tracking it systemTest could stay up-to-date and be skipped while the runner launches a newer agent. Add a usesOpenTelemetryAgent opt-in to the io.sentry.systemtest plugin; the three agent samples enable it and the agent jar is then tracked as a content input. The runner already builds and launches the agent before invoking the task, so it is tracked by path without a cross-project task dependency, which keeps it configuration-on-demand and configuration cache compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../kotlin/io.sentry.systemtest.gradle.kts | 38 +++++++++++++++++++ .../io/sentry/gradle/SystemTestExtension.kt | 17 +++++++++ .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../sentry-samples-console/build.gradle.kts | 3 +- .../sentry-samples-jul/build.gradle.kts | 3 +- .../sentry-samples-log4j2/build.gradle.kts | 3 +- .../sentry-samples-logback/build.gradle.kts | 3 +- .../sentry-samples-spring-7/build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../sentry-samples-spring/build.gradle.kts | 3 +- 24 files changed, 86 insertions(+), 44 deletions(-) create mode 100644 build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts new file mode 100644 index 00000000000..a21079e1336 --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts @@ -0,0 +1,38 @@ +import io.sentry.gradle.SystemTestExtension +import org.gradle.api.tasks.ClasspathNormalizer + +val systemTest = extensions.create("sentrySystemTest") + +// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a +// separate process, so the archive is a real input even though it is not on the test classpath. +// Agent-based samples are additionally launched with -javaagent:, another runtime +// input not on the classpath. See test/system-test-runner.py. +tasks.matching { it.name == "systemTest" }.configureEach { + val archiveTask = + listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } + ?: throw GradleException( + "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + + "exist to provide the launched app archive for the systemTest task" + ) + // Declaring the archive as an input also wires the dependency on its producing task. + inputs + .files(tasks.named(archiveTask)) + .withPropertyName("appArchive") + .withNormalizer(ClasspathNormalizer::class.java) + + if (systemTest.usesOpenTelemetryAgent.get()) { + // The runner builds the agent and launches the app with -javaagent before invoking this task, + // so the agent jar is tracked for content only (by path, no cross-project task dependency): a + // change to it makes systemTest out of date even though it runs outside the test JVM. + val version = providers.gradleProperty("versionName").get() + inputs + .files( + rootProject.layout.projectDirectory.file( + "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + + "sentry-opentelemetry-agent-$version.jar" + ) + ) + .withPropertyName("openTelemetryAgent") + .withNormalizer(ClasspathNormalizer::class.java) + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt new file mode 100644 index 00000000000..9111ce17b1f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt @@ -0,0 +1,17 @@ +package io.sentry.gradle + +import org.gradle.api.provider.Property + +/** Configuration for the `io.sentry.systemtest` convention plugin. */ +abstract class SystemTestExtension { + /** + * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry + * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task + * re-runs when the agent changes, even though it is started outside the test JVM. + */ + abstract val usesOpenTelemetryAgent: Property + + init { + usesOpenTelemetryAgent.convention(false) + } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index f5d14dc2c38..9db90129958 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -71,8 +72,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 483f6bea799..261894baaa0 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -74,8 +75,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 79878ab9a08..3e70e79ae71 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -75,8 +76,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 01e6a95f13d..310af1e7bce 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.jul.Main") } @@ -66,8 +67,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 005e1116528..962fd56a839 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.log4j2.Main") } @@ -72,8 +73,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 05f96c346a8..1a7f3a23875 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.logback.Main") } @@ -66,8 +67,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index e3300cd2841..3e108aabd1e 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring7.Main") } @@ -77,8 +78,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index 64ef57692c3..722788830f1 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -90,8 +91,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index e12b960e0fd..b9551ffcf74 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -110,6 +111,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -118,8 +122,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index 7329d5cc0ea..d793201d4c0 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-otlp" @@ -91,8 +92,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index a311b8a972e..6d8d3c81e09 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-webflux" @@ -70,8 +71,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index cdb33ecc675..4e463671a78 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -92,8 +93,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 7966e621ebd..553affc3620 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -95,8 +96,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 3c7e00ae552..e4fefab7de7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -120,6 +121,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -128,8 +132,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index d5e4caa595d..65850a6f2bd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -98,8 +99,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index 0b8c5a181e7..e32eec82ac8 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -140,8 +141,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index b78f1f01881..085d6e362af 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -154,6 +155,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -162,8 +166,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 8b2079ddd9c..3e462517ded 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-webflux-jakarta" @@ -72,8 +73,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 2127dbfd79f..8dc51e07a53 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -107,8 +108,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 0a2a6f2da57..54fe99d56d4 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -141,8 +142,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 3dec793e5c9..5fe0334a629 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.jakarta.Main") } @@ -77,8 +78,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 02e7f632450..3ab6610d96d 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.Main") } @@ -78,8 +79,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test From ec5e3a55656fea8e4eec6aeff74a211df4894a6d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Jun 2026 20:09:38 +0200 Subject: [PATCH 110/276] fix(android): Fix crash when getHistoricalProcessStartReasons is called from a wrong process (#5597) * fix(android): Fix crash when getHistoricalProcessStartReasons is called from a wrong process * test(android): Add test and changelog for getHistoricalProcessStartReasons crash fix Co-Authored-By: Claude Opus 4.6 (1M context) * SecurityException -> RuntimeException --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../core/performance/AppStartMetrics.java | 31 +++++++++++++------ .../core/SentryShadowActivityManager.kt | 7 +++++ .../performance/AppStartMetricsTestApi35.kt | 14 +++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d66d755a7c..0c3574f1fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) ## 8.44.1 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 36cae8686ca..828e103e8b6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -11,6 +11,7 @@ import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -467,18 +468,28 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { final @Nullable ActivityManager activityManager = (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { - final List historicalProcessStartReasons = - activityManager.getHistoricalProcessStartReasons(1); - if (!historicalProcessStartReasons.isEmpty()) { - final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); - cachedStartInfo = info; - if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { - if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { - appStartType = AppStartType.COLD; - } else { - appStartType = AppStartType.WARM; + try { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; + if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { + if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + appStartType = AppStartType.COLD; + } else { + appStartType = AppStartType.WARM; + } } } + } catch (RuntimeException ignored) { + // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: + // - SecurityException when called from an isolated process + // - IllegalArgumentException when called with a wrong userId + // - others + // See impl: + // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 + Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index a959c5dd865..93cb4759e99 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -12,11 +12,16 @@ class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + private var historicalProcessStartReasonsException: RuntimeException? = null fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setHistoricalProcessStartReasonsException(exception: RuntimeException) { + historicalProcessStartReasonsException = exception + } + fun setImportance(importance: Int) { this.importance = importance } @@ -24,6 +29,7 @@ class SentryShadowActivityManager { fun reset() { historicalProcessStartReasons = emptyList() importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + historicalProcessStartReasonsException = null } @Implementation @@ -35,6 +41,7 @@ class SentryShadowActivityManager { @Implementation fun getHistoricalProcessStartReasons(maxNum: Int): List { + historicalProcessStartReasonsException?.let { throw it } return historicalProcessStartReasons.take(maxNum) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index b5d87ab77cb..0624e70b898 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -249,6 +249,20 @@ class AppStartMetricsTestApi35 { assertNull(metrics.appStartReason) } + @Test + fun `does not crash when getHistoricalProcessStartReasons throws RuntimeException`() { + SentryShadowActivityManager.setHistoricalProcessStartReasonsException( + RuntimeException("isolated process") + ) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + assertNull(metrics.appStartReason) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() From 818350078b0238d8db99964f3464614643490fa5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 24 Jun 2026 13:23:23 +0200 Subject: [PATCH 111/276] fix(replay): Fix flaky ComposeMaskingOptionsTest (#5613) The `when sentry-unmask modifier is set unmasks the node` test intermittently failed because Robolectric can report zero bounds for some nodes when running the full test class, making them invisible (shouldMask = isVisible && ...). Restructure the test to: - Explicitly find the "Make Request" node and assert it IS visible and unmasked - Assert other visible nodes remain masked, with a guard against empty iteration - Tolerate intermittent zero-bounds on non-identifiable nodes (Robolectric artifact) Validated with the repro from getsentry/repro#51: 20/20 passes (vs ~10% flake rate before the fix). Fixes #5585 Co-authored-by: Claude Opus 4.6 (1M context) --- .../ComposeMaskingOptionsTest.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) 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 e043b035668..fe3fbc1ba67 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 @@ -228,18 +228,24 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - textNodes.forEach { - if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should not be masked", - ) - } else { - assertTrue( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", - ) + + 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") + + // Robolectric may intermittently report zero bounds for some nodes when running + // the full test class, making them invisible (shouldMask = isVisible && ...). + // Assert that all other visible nodes remain masked. + val otherVisibleNodes = textNodes.filter { it !== unmaskNode && it.isVisible } + assertTrue(otherVisibleNodes.isNotEmpty(), "Expected at least one other visible text node") + otherVisibleNodes.forEach { + assertTrue( + it.shouldMask, + "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", + ) } } From 3c89fa4c79a2af40618acffcdc84f9a98eb4aca8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 24 Jun 2026 14:33:54 +0200 Subject: [PATCH 112/276] ci(replay): Skip snapshot upload on PRs from forks (#5621) Fork PRs don't have access to the SENTRY_AUTH_TOKEN secret, so the sentry-cli snapshot upload would fail anyway. Guard the step to run only on pushes and same-repo PRs. Co-authored-by: Claude Opus 4.8 --- .github/workflows/integration-tests-ui.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 92e29ecbef7..e271227b97e 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -78,7 +78,8 @@ jobs: run: curl -sL https://sentry.io/get-cli/ | bash - name: Upload Replay Snapshots to Sentry - if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} run: | shopt -s globstar nullglob pngs=(artifacts/**/*.png) From 477b848f9ad9a2eac9efa22553ca3da49cf0ab68 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 24 Jun 2026 16:44:37 +0200 Subject: [PATCH 113/276] ci(build): Skip snapshot upload on PRs from forks (#5622) Fork PRs don't have access to SENTRY_AUTH_TOKEN, so the upload step would attempt to run without credentials. Guard it the same way the replay snapshot upload is guarded so fork PRs cleanly skip it. --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2ffd96f9c5..6cba7e07e0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,8 @@ jobs: run: curl -sL https://sentry.io/get-cli/ | bash - name: Upload Snapshots to Sentry + # 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 \ --app-id sentry-android-core From 693fc159de6b16dff56436c11b55543111b4d207 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:01:18 -0700 Subject: [PATCH 114/276] fix: use System.nanoTime() for cron check-in duration measurement (#5611) * fix: use System.nanoTime() for cron check-in duration measurement System.currentTimeMillis() is a wall-clock value and is subject to NTP adjustments and DST transitions. For long-running cron jobs this can produce incorrect or even negative durations in the check-in payload. Switch the start/end capture in CheckInUtils.withCheckIn() and the three SentryCheckInAdvice implementations (sentry-spring, sentry-spring-jakarta, sentry-spring-7) to System.nanoTime(), which is guaranteed monotonic. Use DateUtils.nanosToSeconds() (already present) to convert the delta. Fixes #5579 * changelog --------- Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 1 + .../java/io/sentry/spring7/checkin/SentryCheckInAdvice.java | 4 ++-- .../io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java | 4 ++-- .../java/io/sentry/spring/checkin/SentryCheckInAdvice.java | 4 ++-- sentry/src/main/java/io/sentry/util/CheckInUtils.java | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3574f1fa6..5a01e722486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) - Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java index 274c20ac89a..d2c164b9a6e 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java @@ -91,7 +91,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -105,7 +105,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java index d2b93471f1c..fa64ac0e3e4 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -91,7 +91,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -105,7 +105,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java index 719ead46b51..a96e9e29808 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -94,7 +94,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -108,7 +108,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry/src/main/java/io/sentry/util/CheckInUtils.java b/sentry/src/main/java/io/sentry/util/CheckInUtils.java index 7b44fffbc35..3deea093142 100644 --- a/sentry/src/main/java/io/sentry/util/CheckInUtils.java +++ b/sentry/src/main/java/io/sentry/util/CheckInUtils.java @@ -37,7 +37,7 @@ public static U withCheckIn( try (final @NotNull ISentryLifecycleToken ignored = Sentry.forkedScopes("CheckInUtils").makeCurrent()) { final @NotNull IScopes scopes = Sentry.getCurrentScopes(); - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; TracingUtils.startNewTrace(scopes); @@ -61,7 +61,7 @@ public static U withCheckIn( if (environment != null) { checkIn.setEnvironment(environment); } - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } From 0499903a71e617bd84b2f748b25f7ca2db71f134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:26:29 +0200 Subject: [PATCH 115/276] chore: update scripts/update-sentry-native-ndk.sh to 0.15.2 (#5610) 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 5a01e722486..596c36b3320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) +### Dependencies + +- Bump Native SDK from v0.15.1 to v0.15.2 ([#5610](https://github.com/getsentry/sentry-java/pull/5610)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0152) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.1...0.15.2) + ## 8.44.1 ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68521efdfcc..24064703ca1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -166,7 +166,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.1" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.2" } 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 f8e292e692fee3774289917409f05080b443bec3 Mon Sep 17 00:00:00 2001 From: 0xadam-brown <281682121+0xadam-brown@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:30:08 +0000 Subject: [PATCH 116/276] release: 8.45.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596c36b3320..e1a4d05122c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.45.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index f2e3da3ca09..f83b851f8d9 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.44.1 +versionName=8.45.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 2c01eff3d05e76446bc1264d9235077ce183fee6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 24 Jun 2026 18:50:01 +0200 Subject: [PATCH 117/276] fix(changelog): Move app start reason to 8.45.0 (#5625) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a4d05122c..48a1115f8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 8.45.0 +### Features + +- On Android 15+ (API 35), the standalone `app.start` transaction now reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) + ### Fixes - Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) @@ -41,7 +45,6 @@ - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view - Also covers non-activity starts (broadcast receivers, services, content providers) - - On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) ### Improvements From 6424f21f3573988056d194317e654ef11d605426 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 10:50:50 +0200 Subject: [PATCH 118/276] build: Remove redundant test source set declarations (#5624) The line configure { test { java.srcDir("src/test/java") } } re-added Gradle's default test source directory, which is a no-op. Remove it from all 51 build files. --- sentry-apache-http-client-5/build.gradle.kts | 2 -- sentry-apollo-3/build.gradle.kts | 2 -- sentry-apollo-4/build.gradle.kts | 2 -- sentry-apollo/build.gradle.kts | 2 -- sentry-async-profiler/build.gradle.kts | 2 -- sentry-graphql-22/build.gradle.kts | 2 -- sentry-graphql-core/build.gradle.kts | 2 -- sentry-graphql/build.gradle.kts | 2 -- sentry-jcache/build.gradle.kts | 2 -- sentry-jdbc/build.gradle.kts | 2 -- sentry-jul/build.gradle.kts | 2 -- sentry-kafka/build.gradle.kts | 2 -- sentry-kotlin-extensions/build.gradle.kts | 2 -- sentry-ktor-client/build.gradle.kts | 2 -- sentry-launchdarkly-server/build.gradle.kts | 2 -- sentry-log4j2/build.gradle.kts | 2 -- sentry-logback/build.gradle.kts | 2 -- sentry-okhttp/build.gradle.kts | 2 -- sentry-openfeature/build.gradle.kts | 2 -- sentry-openfeign/build.gradle.kts | 2 -- .../sentry-opentelemetry-agentcustomization/build.gradle.kts | 2 -- .../sentry-opentelemetry-bootstrap/build.gradle.kts | 2 -- sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts | 2 -- sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts | 2 -- sentry-quartz/build.gradle.kts | 2 -- sentry-reactor/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- sentry-samples/sentry-samples-console-otlp/build.gradle.kts | 2 -- sentry-samples/sentry-samples-console/build.gradle.kts | 2 -- sentry-samples/sentry-samples-jul/build.gradle.kts | 2 -- sentry-samples/sentry-samples-log4j2/build.gradle.kts | 2 -- sentry-samples/sentry-samples-logback/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-7/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-otlp/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts | 2 -- sentry-servlet-jakarta/build.gradle.kts | 2 -- sentry-servlet/build.gradle.kts | 2 -- sentry-spotlight/build.gradle.kts | 2 -- sentry-spring-7/build.gradle.kts | 2 -- sentry-spring-boot-4-starter/build.gradle.kts | 2 -- sentry-spring-boot-4/build.gradle.kts | 2 -- sentry-spring-boot-jakarta/build.gradle.kts | 2 -- sentry-spring-boot-starter-jakarta/build.gradle.kts | 2 -- sentry-spring-boot-starter/build.gradle.kts | 2 -- sentry-spring-jakarta/build.gradle.kts | 2 -- sentry-system-test-support/build.gradle.kts | 2 -- sentry-test-support/build.gradle.kts | 2 -- sentry/build.gradle.kts | 2 -- 51 files changed, 102 deletions(-) diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index df93fbe8823..00916258b8f 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 1eb71bc217a..d70085e27bd 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -42,8 +42,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 144297ddb9d..d9f41891dc1 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -49,8 +49,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index c115e6b8fe3..0fc853886df 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index ef000b465a1..17093fe6a09 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index c36ca09856d..3c0667fd0d4 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index d625c31dea6..62635ded34e 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 68efbc7389e..30000655079 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index 2c476dbd007..1cc3b6e0e3d 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 8a7808530b1..1e86048053e 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -34,8 +34,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index b59a1481d19..66c46bcee21 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.slf4j.api) } -configure { test { java.srcDir("src/test/java") } } - tasks { test { // used to test io.sentry.jul.SentryHandler diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 603014f9af9..ef1ff252468 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.kafka.clients) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 5092976de32..8c4312641a8 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 745acaa11fb..647563cc1d1 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -44,8 +44,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 207400676a0..370252c2154 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { testImplementation(libs.launchdarkly.server) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 7d406076e2f..1c5cf94e8eb 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index d2084e95467..1c42a4e1c03 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -32,8 +32,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index ea831f174cc..d547720c174 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 5847f48e7b5..fbabcb81aa5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { testImplementation(libs.openfeature) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index e9e3a2b18de..9b1ac2bbc29 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -34,8 +34,6 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index ed6605f8da4..71f31ce2afb 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 503c92c95f0..d4bd1af9ede 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 5b3b9d97ff4..91ec023e178 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -45,8 +45,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index 21e75c0ed7d..d63c8a5c451 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { // testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 69c0e72ee07..6e227abafe6 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 4d389b0a334..07024b3a23b 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.reactor") diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 9db90129958..23df981060f 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -62,8 +62,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 261894baaa0..9bb0678bf65 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -65,8 +65,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 3e70e79ae71..8fdef6ef70e 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -66,8 +66,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 310af1e7bce..5381f3ff2f0 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -57,8 +57,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 962fd56a839..07df6703c85 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -63,8 +63,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 1a7f3a23875..bb37638d8c5 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -57,8 +57,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 3e108aabd1e..6de7ed62e9f 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -68,8 +68,6 @@ tasks.withType().configureEach { } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index 722788830f1..afdb92e5c5b 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -81,8 +81,6 @@ dependencies { dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index b9551ffcf74..f0e2d468fec 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -84,8 +84,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("bootRunWithAgent").configure { group = "application" diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index d793201d4c0..d7c2c009bc9 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -82,8 +82,6 @@ dependencies { dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index 6d8d3c81e09..20ccf2d662c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -49,8 +49,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { kotlin { explicitApi() diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 4e463671a78..2cc1f34b9eb 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -83,8 +83,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 728e147dc9b..3cdc4772f18 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.servlet.jakarta.api) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index 142a1cd2f20..9f12d4ee177 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.springboot.starter.web) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index b034c8267db..71498aecd92 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index ec90aedcbeb..4e5ea54d294 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -82,8 +82,6 @@ dependencies { testImplementation(projects.sentryReactor) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring7") diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index c0f655e965f..bffe53aab01 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -38,8 +38,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 43e105ad8db..2a6634b257f 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -108,8 +108,6 @@ dependencies { testImplementation(libs.springboot4.resttestclient) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot4") diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index edd2d605916..1ed9373f4bf 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -100,8 +100,6 @@ dependencies { testImplementation(projects.sentryAsyncProfiler) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot.jakarta") diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index d7d10b73b8c..c6fe511073e 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -38,8 +38,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 3ef4ac59379..f4da56179cb 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -30,8 +30,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index b4a61129df7..f103bfcbe0a 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -77,8 +77,6 @@ dependencies { testImplementation(projects.sentryReactor) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.jakarta") diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index 4d4c7d5bb6e..7f08bf6d01b 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { implementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - apollo { service("service") { srcDir("src/main/graphql") diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index f108915d463..a0b508c9715 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -31,5 +31,3 @@ dependencies { implementation(libs.kotlin.test.junit) implementation(libs.mockito.kotlin) } - -configure { test { java.srcDir("src/test/java") } } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index a2ecd281296..9717cb176ae 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - animalsniffer { ignore = listOf( From d735888152fb47be1e04654e453e37febe3a0b9d Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 25 Jun 2026 12:04:33 +0200 Subject: [PATCH 119/276] chore(android-sqlite): Update SQLite instrumentation documentation after 8.45.0 release (#5572) We'll be coordinating the 8.45.0 release with SAGP auto-instrumentation for the SentrySQLiteDriver. Commit contains related documentation updates. --- sentry-android-sqlite/README.md | 4 +++- .../main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md index 7bf9edf3474..307beb51f0e 100644 --- a/sentry-android-sqlite/README.md +++ b/sentry-android-sqlite/README.md @@ -4,11 +4,13 @@ SQLite instrumentation for AndroidX APIs. Two instrumentation paths are supported: -- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. Applied automatically by the Sentry Android Gradle Plugin. - **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. +See the [SQLite integration docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for more details. + ## Package layout The module is organized as two separate packages: diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 22f6353d883..4a616ba3abe 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -22,6 +22,9 @@ import org.jetbrains.annotations.ApiStatus * .build() * ``` * + * If you're using the Sentry Android Gradle Plugin (SAGP) 6.13.0+, wrapping will be performed + * automatically. + * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @ApiStatus.Experimental @@ -87,9 +90,16 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite * * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via * `SentrySupportSQLiteOpenHelper` instead. + * + * Note that wrapping will be performed if the delegate isn't a `SupportSQLiteDriver` itself but + * wraps or subclasses one. In that case, ensure the open helper passed to the support driver + * constructor is *not* wrapped. */ + // Warning! The SAGP depends on this method's ABI. @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = + // FQN check simplifies our SAGP implementation, allowing it to naively instrument all + // RoomDatabase.Builder.setDriver() call sites. if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { delegate } else { From 6bbdfbea7809761ded2735fc5a81acbab5182dc2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Jun 2026 12:48:15 +0200 Subject: [PATCH 120/276] chore(changelog): Add 8.43.3 hotfix (#5620) Add release notes for version 8.43.3 with fixes. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a1115f8ae..2049c2b2543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,12 @@ - Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) +## 8.43.3 + +### Fixes + +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) + ## 8.43.2 ### Improvements From fa825503d1a24bca46aa0f7a71b9d1a06ee00351 Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 25 Jun 2026 13:42:36 +0200 Subject: [PATCH 121/276] chore(deps): Bump dependencies associated with SentrySQLiteDriver (#5630) Bumps SAGP to 6.13.0, Room 3 to 3.0.0-rc01, and androidx.sqlite to 2.7.0-rc01. Lets us ensure the Android sample app runs against the latest Room build + picks up the SQLiteDriver auto-instrumentation introduced in SAGP 6.13.0. --- gradle/libs.versions.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24064703ca1..3984cb7115b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,10 +33,10 @@ otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" room2 = "2.8.4" -room3 = "3.0.0-alpha06" -sagp = "6.10.0" +room3 = "3.0.0-rc01" +sagp = "6.13.0" sqlite = "2.6.2" -sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* +sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" spotless = "8.4.0" springboot2 = "2.7.18" @@ -107,8 +107,8 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } -androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } -androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } From f082155e971b6ff724767cdc68f269f188f58d0a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 13:46:19 +0200 Subject: [PATCH 122/276] build: Remove redundant Java compatibility block from sentry-apollo-4 (#5633) The root build script already sets sourceCompatibility/targetCompatibility to VERSION_1_8 for every java-library subproject, so the module-level declaration was a no-op. --- sentry-apollo-4/build.gradle.kts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index d9f41891dc1..abb7ccb760e 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -11,11 +11,6 @@ plugins { alias(libs.plugins.animalsniffer) } -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - 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 From e0a2a6e63c9cf289a0d15f16b91b2ce19adf2fc6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 14:18:18 +0200 Subject: [PATCH 123/276] build: Remove redundant mavenCentral repository declarations (#5638) settings.gradle.kts already declares google(), mavenCentral() and mavenLocal() via dependencyResolutionManagement for every project, so the module-level repositories { mavenCentral() } blocks were redundant. With the default PREFER_PROJECT mode they only narrowed each project to mavenCentral; removing them falls back to the central superset and resolution is unaffected. --- sentry-reactor/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- sentry-samples/sentry-samples-console-otlp/build.gradle.kts | 2 -- sentry-samples/sentry-samples-console/build.gradle.kts | 2 -- sentry-samples/sentry-samples-jul/build.gradle.kts | 2 -- sentry-samples/sentry-samples-log4j2/build.gradle.kts | 2 -- sentry-samples/sentry-samples-logback/build.gradle.kts | 2 -- sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts | 2 -- sentry-samples/sentry-samples-servlet/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-7/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-otlp/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-jakarta/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring/build.gradle.kts | 2 -- 25 files changed, 50 deletions(-) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 07024b3a23b..615ce38ecc5 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -62,8 +62,6 @@ tasks.withType().configureEach { } } -repositories { mavenCentral() } - tasks.jar { manifest { attributes( diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 23df981060f..5b67053449e 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 9bb0678bf65..232a4ff2248 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 8fdef6ef70e..f490939ed61 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 5381f3ff2f0..25e682a19ba 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 07df6703c85..52c5c8bb035 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index bb37638d8c5..d608f0aa549 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts index 202b8d8f058..90bc1ffc86f 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts +++ b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts @@ -19,8 +19,6 @@ java.sourceCompatibility = JavaVersion.VERSION_1_8 java.targetCompatibility = JavaVersion.VERSION_1_8 -repositories { mavenCentral() } - dependencies { implementation(platform(libs.springboot2.bom)) implementation(libs.springboot.starter.web) diff --git a/sentry-samples/sentry-samples-servlet/build.gradle.kts b/sentry-samples/sentry-samples-servlet/build.gradle.kts index 9dc9278bcb9..01ecef54154 100644 --- a/sentry-samples/sentry-samples-servlet/build.gradle.kts +++ b/sentry-samples/sentry-samples-servlet/build.gradle.kts @@ -8,8 +8,6 @@ java.sourceCompatibility = JavaVersion.VERSION_1_8 java.targetCompatibility = JavaVersion.VERSION_1_8 -repositories { mavenCentral() } - dependencies { implementation(projects.sentryServlet) implementation("javax.servlet:javax.servlet-api:4.0.1") diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 6de7ed62e9f..daeab91f28d 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -26,8 +26,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom(SpringBootPlugin.BOM_COORDINATES) } } dependencies { diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index afdb92e5c5b..090afbd4542 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index f0e2d468fec..fa73c191a92 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index d7c2c009bc9..22245cae979 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index 20ccf2d662c..b75f70b3574 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 2cc1f34b9eb..17ec5b2a45f 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 553affc3620..39d6dbf39b6 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index e4fefab7de7..6f1af65dc88 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -19,8 +19,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 65850a6f2bd..320a9cc2512 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index e32eec82ac8..27d0cd1a772 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 085d6e362af..37aae899e4f 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 3e462517ded..213eb60296d 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 8dc51e07a53..836608500dc 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsGraphql(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 54fe99d56d4..0c8d2dc28e7 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 5fe0334a629..2b360019cb5 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -24,8 +24,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 3ab6610d96d..236e577a17a 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -25,8 +25,6 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -repositories { mavenCentral() } - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" From 2ebf90a0da3127c7b3adee4a86fe3c142bc6fa26 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 25 Jun 2026 15:45:49 +0200 Subject: [PATCH 124/276] perf(core): SDK Overhead Reduction (#5499) * collection: SDK Overhead Reduction * perf(core): Skip java.specification.version lookup on Android Android is never Java 9+, so the System.getProperty + Double.valueOf parse in the Platform static initializer is unnecessary overhead on the Android cold-start path. Short-circuit to isJavaNinePlus=false when isAndroid is true. * perf(android): Replace reflective OptionsContainer with direct subclass Replace OptionsContainer.create(SentryAndroidOptions.class) which uses getDeclaredConstructor().newInstance() with a direct SentryAndroidOptionsContainer subclass that returns new SentryAndroidOptions() without reflection. Make OptionsContainer non-final (@Open) with a protected no-arg constructor so Android can subclass it. * collection: SDK Overhead reduction for JVM * perf(core): Short-circuit combined scope breadcrumbs Avoid allocating and sorting a merged breadcrumb queue when only one component scope has breadcrumbs. This keeps the full merge path for multi-scope breadcrumbs and returns the default write scope queue when all scopes are empty. Co-Authored-By: Claude * perf(core): Reduce envelope writer buffer size Use an explicit 512-character BufferedWriter buffer for envelope item and envelope serialization. This avoids allocating the oversized default char buffer for each short-lived serialization writer while preserving the existing OutputStreamWriter-based encoding path. Co-Authored-By: Claude * changelog * perf(core): Remove redundant event map copies Avoid creating temporary maps when applying scope and options tags or scope extras. The event setters already copy these maps, so this preserves snapshot semantics while reducing allocation overhead. Co-Authored-By: Claude * changelog * changelog * perf(core): Short-circuit combined scope collections Avoid allocating merged collection copies when only one combined scope contains values. This extends the breadcrumbs optimization to tags, attributes, extras, and attachments while preserving merge behavior when multiple scopes contribute data. Co-Authored-By: Claude * changelog * perf(android): Use TimeZone.getDefault for device timezone Avoid constructing a Calendar only to read the default device timezone. The locale passed to Calendar does not affect the timezone value, so TimeZone.getDefault returns the same value with less work during device context collection. Co-Authored-By: Claude * perf(core): Replace Calendar with Date in DateUtils Avoid constructing Calendar instances when DateUtils only needs the current epoch millis or a Date for an existing millis value. Date stores epoch millis without timezone state, so the returned values are unchanged while avoiding unnecessary Calendar allocation and field computation. Co-Authored-By: Claude * perf(core): Reduce JsonWriter stack allocation Shrink the vendored JsonWriter nesting stack from 32 entries to 8 entries. The stack still grows on demand for deeply nested payloads, while common SDK serialization avoids the larger initial array allocation. Co-Authored-By: Claude * perf(core): Lazily allocate Breadcrumb data Avoid allocating a ConcurrentHashMap for breadcrumbs that never set data. Initialize the data map on first write while preserving concurrent writes with double-checked locking. Co-Authored-By: Claude * perf(core): Reduce context serialization allocations Use sorted key arrays when serializing contexts to avoid allocating an ArrayList for each serialization. This preserves deterministic key ordering while keeping the snapshot representation smaller. Co-Authored-By: Claude * perf(core): Lazily allocate reflection serializer state Defer creation of the reflection serializer visiting set until reflection serialization is actually needed. Normal SDK payload serialization uses explicit serializers, so this avoids an unused HashSet allocation for each writer. * perf(core): Lazily create reflection JSON serializer Defer creation of JsonReflectionObjectSerializer until unknown-object reflection serialization is needed. Normal SDK payloads use explicit serializers, so this avoids allocating unused reflection serializer state for each writer. * fix(android): Preserve locale timezone extension Keep the Calendar-based timezone path for Android 13+ locales that carry a Unicode tz extension. This preserves the existing device timezone behavior while keeping the direct default timezone fast path for normal locales. Co-Authored-By: Claude * perf(core): Replace ISO8601 timestamp handling Replace the Calendar-backed vendored ISO8601 formatting and parsing path with a small Sentry-specific utility that works directly from epoch milliseconds. This avoids formatter and parser allocations on timestamp-heavy serialization paths while keeping the existing DateUtils API as the facade. Co-Authored-By: Claude * ref(core): Move ISO8601 utility to vendor package Move the Sentry ISO8601 helper under the vendor package and mark it as internal API so the adapted public-domain date conversion code is isolated from core SDK classes. Update attribution metadata to reflect the public-domain dedication source. Co-Authored-By: Claude * perf(core): Avoid cloning Date getters * fix(core): Preserve ISO8601 utility compatibility Match edge-case behavior from the previous vendored ISO8601 utility for date-only timestamps, trailing characters after Z, and Gregorian cutover dates. * fix(core): Preserve mutable breadcrumb data access Initialize the lazy breadcrumb data map when callers request the full map. This keeps getData() mutable for existing callers while preserving lazy allocation for breadcrumbs that only serialize or read individual values. Co-Authored-By: Claude * docs(android): Explain timezone Calendar fallback Document why Android 13+ locales with Unicode timezone extensions keep using Calendar while normal locales use the default timezone directly for performance. Co-Authored-By: Claude * fix(core): Avoid KeySetView in context serialization Use ConcurrentHashMap.keys() when creating sorted context key snapshots so the serialization path stays compatible with Android API 21. Keep the array snapshot optimization without relying on KeySetView, which AnimalSniffer rejects for the SDK's minSdk. Co-Authored-By: Claude * test(core): Add breadcrumb timestamp serialization coverage Cover that breadcrumbs backed by timestamp milliseconds serialize the same timestamp as breadcrumbs backed by Date for the same instant. * fix(core): Parse date-only timestamps with timezones Preserve ISO8601 parser compatibility for date-only values that include a timezone suffix. Keep modern date-only timezone parsing on the fast path and add parity coverage against the previous parser. * docs(core): Add timezone changelog entry * docs(core): Add DateUtils changelog entry * docs(core): Add JsonWriter changelog entry * docs(core): Add breadcrumb changelog entry * docs(core): Add contexts changelog entry * docs(core): Add reflection state changelog entry * docs(core): Add reflection serializer changelog entry * docs(core): Add ISO8601 handling changelog entry * docs(core): Add Date getter changelog entries * changelog --------- Co-authored-by: Claude --- CHANGELOG.md | 26 ++ THIRD_PARTY_NOTICES.md | 16 + .../sentry/android/core/DeviceInfoUtil.java | 11 +- .../io/sentry/android/core/SentryAndroid.java | 3 +- .../core/SentryAndroidOptionsContainer.java | 16 + .../sentry/android/core/DeviceInfoUtilTest.kt | 32 ++ sentry/api/sentry.api | 10 +- .../src/main/java/io/sentry/Breadcrumb.java | 65 ++- .../java/io/sentry/CombinedScopeView.java | 157 ++++++- sentry/src/main/java/io/sentry/DateUtils.java | 35 +- .../java/io/sentry/JsonObjectSerializer.java | 14 +- .../JsonReflectionObjectSerializer.java | 10 +- .../main/java/io/sentry/JsonSerializer.java | 5 +- .../java/io/sentry/MainEventProcessor.java | 3 +- .../main/java/io/sentry/MonitorContexts.java | 6 +- .../main/java/io/sentry/OptionsContainer.java | 18 +- .../src/main/java/io/sentry/SentryClient.java | 9 +- .../java/io/sentry/SentryEnvelopeItem.java | 41 +- .../src/main/java/io/sentry/SentryEvent.java | 2 +- sentry/src/main/java/io/sentry/Session.java | 8 +- .../src/main/java/io/sentry/protocol/App.java | 3 +- .../java/io/sentry/protocol/Contexts.java | 6 +- .../main/java/io/sentry/protocol/Device.java | 3 +- .../java/io/sentry/util/CollectionUtils.java | 21 + .../main/java/io/sentry/util/Platform.java | 21 +- .../io/sentry/vendor/SentryIso8601Utils.java | 397 ++++++++++++++++++ .../sentry/vendor/gson/stream/JsonWriter.java | 4 +- .../src/test/java/io/sentry/BreadcrumbTest.kt | 36 ++ .../java/io/sentry/CombinedScopeViewTest.kt | 69 +++ .../src/test/java/io/sentry/DateUtilsTest.kt | 204 +++++++++ .../io/sentry/JsonObjectSerializerTest.kt | 24 ++ .../java/io/sentry/MainEventProcessorTest.kt | 13 + .../java/io/sentry/MonitorContextsTest.kt | 19 + .../test/java/io/sentry/SentryClientTest.kt | 18 + .../test/java/io/sentry/protocol/AppTest.kt | 5 +- .../protocol/BreadcrumbSerializationTest.kt | 8 + .../java/io/sentry/protocol/DeviceTest.kt | 5 +- .../SentryBaseEventSerializationTest.kt | 23 + 38 files changed, 1252 insertions(+), 114 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java create mode 100644 sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java create mode 100644 sentry/src/test/java/io/sentry/MonitorContextsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2049c2b2543..851fc3985e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## Unreleased + +### Behavioral Changes + +- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. + - This has now changed in order to reduce SDK overhead. +- `Date` objects returned by SDK data model getters are shared state and should not be mutated. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + - Previously, these getters returned defensive copies for some date fields. + - This has now changed in order to reduce SDK overhead. + +### Performance + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) +- Optimize combined scope by adding an early return if only one scope has data ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) +- Reduce model access overhead by avoiding defensive `Date` copies in SDK data model getters. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) +- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602)) +- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601)) +- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600)) +- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599)) +- Reduce breadcrumb allocation overhead by creating the `Breadcrumb` data map only when data is added. ([#5598](https://github.com/getsentry/sentry-java/pull/5598)) +- Reduce JSON serialization overhead by lowering the initial `JsonWriter` nesting stack size while preserving on-demand growth. ([#5591](https://github.com/getsentry/sentry-java/pull/5591)) +- Reduce timestamp helper overhead by replacing unnecessary `Calendar` usage in `DateUtils` with direct `Date` creation. ([#5589](https://github.com/getsentry/sentry-java/pull/5589)) +- Reduce Android startup overhead by using the default timezone directly on older devices or when no timezone info is available in the locale. ([#5587](https://github.com/getsentry/sentry-java/pull/5587)) + ## 8.45.0 ### Features diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 925add4a71a..7b87b92dcb3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -62,6 +62,22 @@ limitations under the License. --- +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** None; public domain dedication by Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. + +``` +Consider these donated to the public domain. +``` + +--- + ## Android Open Source Project — Base64 (Apache 2.0) **Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index f3b17c5854a..63b88c0e440 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -257,14 +257,19 @@ private void setDeviceIO( @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a + // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that + // extension. For all other cases, use the process default timezone directly for performance. + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { LocaleList locales = context.getResources().getConfiguration().getLocales(); if (!locales.isEmpty()) { Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } } } - return Calendar.getInstance().getTimeZone(); + return TimeZone.getDefault(); } @SuppressWarnings("JdkObsolete") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 0d249f73790..f27259fd635 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -9,7 +9,6 @@ import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; -import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -98,7 +97,7 @@ public static void init( @NotNull Sentry.OptionsConfiguration configuration) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( - OptionsContainer.create(SentryAndroidOptions.class), + new SentryAndroidOptionsContainer(), options -> { final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java new file mode 100644 index 00000000000..678f7ab29b2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java @@ -0,0 +1,16 @@ +package io.sentry.android.core; + +import io.sentry.OptionsContainer; +import org.jetbrains.annotations.NotNull; + +/** + * Direct OptionsContainer for SentryAndroidOptions that avoids reflective + * getDeclaredConstructor().newInstance() on the Android startup path. + */ +final class SentryAndroidOptionsContainer extends OptionsContainer { + + @Override + public @NotNull SentryAndroidOptions createInstance() { + return new SentryAndroidOptions(); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 6d90d6be538..faf993e1610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -2,16 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.BatteryManager +import android.os.Build +import android.os.LocaleList import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.Locale +import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class DeviceInfoUtilTest { @@ -47,6 +53,32 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets default timezone`() { + val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals(TimeZone.getDefault(), deviceInfo.timezone) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `preserves timezone from locale unicode extension`() { + val defaultTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag("en-US-u-tz-usnyc"))) + val localizedContext = context.createConfigurationContext(configuration) + val deviceInfoUtil = DeviceInfoUtil(localizedContext, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals("America/New_York", deviceInfo.timezone?.id) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e9083350349..04c876fdbdb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1312,7 +1312,6 @@ public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public final class io/sentry/JsonObjectSerializer { public static final field OBJECT_PLACEHOLDER Ljava/lang/String; - public final field jsonReflectionObjectSerializer Lio/sentry/JsonReflectionObjectSerializer; public fun (I)V public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;Ljava/lang/Object;)V } @@ -2067,7 +2066,8 @@ public abstract interface class io/sentry/ObjectWriter { public abstract fun value (Z)Lio/sentry/ObjectWriter; } -public final class io/sentry/OptionsContainer { +public class io/sentry/OptionsContainer { + protected fun ()V public static fun create (Ljava/lang/Class;)Lio/sentry/OptionsContainer; public fun createInstance ()Ljava/lang/Object; } @@ -7618,6 +7618,7 @@ public final class io/sentry/util/CollectionUtils { public static fun newHashMap (Ljava/util/Map;)Ljava/util/Map; public static fun reverseListIterator (Ljava/util/concurrent/CopyOnWriteArrayList;)Ljava/util/ListIterator; public static fun size (Ljava/lang/Iterable;)I + public static fun toSortedStringArray (Ljava/util/Enumeration;I)[Ljava/lang/String; } public abstract interface class io/sentry/util/CollectionUtils$Mapper { @@ -8075,6 +8076,11 @@ public class io/sentry/vendor/Base64 { public static fun encodeToString ([BIII)Ljava/lang/String; } +public final class io/sentry/vendor/SentryIso8601Utils { + public static fun formatTimestamp (J)Ljava/lang/String; + public static fun parseTimestamp (Ljava/lang/String;)J +} + public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils { public static final field TIMEZONE_UTC Ljava/util/TimeZone; public fun ()V diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..fff6954ee56 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -34,8 +34,10 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** The type of breadcrumb. */ private @Nullable String type; + private static final @NotNull Map EMPTY_DATA = Collections.emptyMap(); + /** Data associated with this breadcrumb. */ - private @NotNull Map data = new ConcurrentHashMap<>(); + private volatile @NotNull Map data = EMPTY_DATA; /** Dotted strings that indicate what the crumb is or where it comes from. */ private @Nullable String category; @@ -78,9 +80,11 @@ public Breadcrumb(final long timestamp) { this.type = breadcrumb.type; this.category = breadcrumb.category; this.origin = breadcrumb.origin; - final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); - if (dataClone != null) { - this.data = dataClone; + if (!breadcrumb.data.isEmpty()) { + final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); + if (dataClone != null) { + this.data = dataClone; + } } this.unknown = CollectionUtils.newConcurrentHashMap(breadcrumb.unknown); this.level = breadcrumb.level; @@ -100,7 +104,7 @@ public static Breadcrumb fromMap( @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -129,6 +133,9 @@ public static Breadcrumb fromMap( if (untypedData != null) { for (Map.Entry dataEntry : untypedData.entrySet()) { if (dataEntry.getKey() instanceof String && dataEntry.getValue() != null) { + if (data == null) { + data = new ConcurrentHashMap<>(); + } data.put((String) dataEntry.getKey(), dataEntry.getValue()); } else { options @@ -166,7 +173,9 @@ public static Breadcrumb fromMap( final Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; @@ -494,7 +503,7 @@ public static Breadcrumb fromMap( breadcrumb.setData("view.tag", viewTag); } for (final Map.Entry entry : additionalData.entrySet()) { - breadcrumb.getData().put(entry.getKey(), entry.getValue()); + breadcrumb.setData(entry.getKey(), entry.getValue()); } breadcrumb.setLevel(SentryLevel.INFO); return breadcrumb; @@ -553,9 +562,9 @@ public Breadcrumb(@Nullable String message) { @SuppressWarnings("JavaUtilDate") public @NotNull Date getTimestamp() { if (timestamp != null) { - return (Date) timestamp.clone(); + return timestamp; } else if (timestampMs != null) { - // we memoize it here into timestamp to avoid instantiating Calendar again and again + // we memoize it here into timestamp to avoid creating a Date again and again timestamp = DateUtils.getDateTime(timestampMs); return timestamp; } @@ -598,6 +607,20 @@ public void setType(@Nullable String type) { this.type = type; } + private @NotNull Map getOrCreateData() { + Map currentData = data; + if (currentData == EMPTY_DATA) { + synchronized (this) { + currentData = data; + if (currentData == EMPTY_DATA) { + currentData = new ConcurrentHashMap<>(); + data = currentData; + } + } + } + return currentData; + } + /** * Returns the data map * @@ -606,7 +629,7 @@ public void setType(@Nullable String type) { @ApiStatus.Internal @NotNull public Map getData() { - return data; + return getOrCreateData(); } /** @@ -636,7 +659,7 @@ public void setData(@Nullable String key, @Nullable Object value) { if (value == null) { removeData(key); } else { - data.put(key, value); + getOrCreateData().put(key, value); } } @@ -649,7 +672,10 @@ public void removeData(@Nullable String key) { if (key == null) { return; } - data.remove(key); + final Map currentData = data; + if (currentData != EMPTY_DATA) { + currentData.remove(key); + } } /** @@ -823,7 +849,12 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); + writer + .name(JsonKeys.TIMESTAMP) + .value( + timestampMs != null + ? DateUtils.getTimestampFromMillis(timestampMs) + : DateUtils.getTimestamp(getTimestamp())); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } @@ -859,7 +890,7 @@ public static final class Deserializer implements JsonDeserializer { @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -884,7 +915,7 @@ public static final class Deserializer implements JsonDeserializer { Map deserializedData = CollectionUtils.newConcurrentHashMap( (Map) reader.nextObjectOrNull()); - if (deserializedData != null) { + if (deserializedData != null && !deserializedData.isEmpty()) { data = deserializedData; } break; @@ -913,7 +944,9 @@ public static final class Deserializer implements JsonDeserializer { Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index f21f8697fa4..ea2d752d44b 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -171,10 +171,31 @@ public void setFingerprint(@NotNull List fingerprint) { @Override public @NotNull Queue getBreadcrumbs() { + final @NotNull Queue globalBreadcrumbs = globalScope.getBreadcrumbs(); + final @NotNull Queue isolationBreadcrumbs = isolationScope.getBreadcrumbs(); + final @NotNull Queue currentBreadcrumbs = scope.getBreadcrumbs(); + + final boolean hasGlobalBreadcrumbs = !globalBreadcrumbs.isEmpty(); + final boolean hasIsolationBreadcrumbs = !isolationBreadcrumbs.isEmpty(); + final boolean hasCurrentBreadcrumbs = !currentBreadcrumbs.isEmpty(); + + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return getDefaultScopeValue(globalBreadcrumbs, isolationBreadcrumbs, currentBreadcrumbs); + } + if (!hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return globalBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasCurrentBreadcrumbs) { + return isolationBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs) { + return currentBreadcrumbs; + } + final @NotNull List allBreadcrumbs = new ArrayList<>(); - allBreadcrumbs.addAll(globalScope.getBreadcrumbs()); - allBreadcrumbs.addAll(isolationScope.getBreadcrumbs()); - allBreadcrumbs.addAll(scope.getBreadcrumbs()); + allBreadcrumbs.addAll(globalBreadcrumbs); + allBreadcrumbs.addAll(isolationBreadcrumbs); + allBreadcrumbs.addAll(currentBreadcrumbs); Collections.sort(allBreadcrumbs); final @NotNull Queue breadcrumbs = @@ -224,10 +245,31 @@ public void clear() { @Override public @NotNull Map getTags() { + final @NotNull Map globalTags = globalScope.getTags(); + final @NotNull Map isolationTags = isolationScope.getTags(); + final @NotNull Map currentTags = scope.getTags(); + + final boolean hasGlobalTags = !globalTags.isEmpty(); + final boolean hasIsolationTags = !isolationTags.isEmpty(); + final boolean hasCurrentTags = !currentTags.isEmpty(); + + if (!hasGlobalTags && !hasIsolationTags && !hasCurrentTags) { + return getDefaultScopeValue(globalTags, isolationTags, currentTags); + } + if (!hasIsolationTags && !hasCurrentTags) { + return globalTags; + } + if (!hasGlobalTags && !hasCurrentTags) { + return isolationTags; + } + if (!hasGlobalTags && !hasIsolationTags) { + return currentTags; + } + final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getTags()); - allTags.putAll(isolationScope.getTags()); - allTags.putAll(scope.getTags()); + allTags.putAll(globalTags); + allTags.putAll(isolationTags); + allTags.putAll(currentTags); return allTags; } @@ -243,10 +285,32 @@ public void removeTag(@Nullable String key) { @Override public @NotNull Map getAttributes() { + final @NotNull Map globalAttributes = globalScope.getAttributes(); + final @NotNull Map isolationAttributes = + isolationScope.getAttributes(); + final @NotNull Map currentAttributes = scope.getAttributes(); + + final boolean hasGlobalAttributes = !globalAttributes.isEmpty(); + final boolean hasIsolationAttributes = !isolationAttributes.isEmpty(); + final boolean hasCurrentAttributes = !currentAttributes.isEmpty(); + + if (!hasGlobalAttributes && !hasIsolationAttributes && !hasCurrentAttributes) { + return getDefaultScopeValue(globalAttributes, isolationAttributes, currentAttributes); + } + if (!hasIsolationAttributes && !hasCurrentAttributes) { + return globalAttributes; + } + if (!hasGlobalAttributes && !hasCurrentAttributes) { + return isolationAttributes; + } + if (!hasGlobalAttributes && !hasIsolationAttributes) { + return currentAttributes; + } + final @NotNull Map allAttributes = new ConcurrentHashMap<>(); - allAttributes.putAll(globalScope.getAttributes()); - allAttributes.putAll(isolationScope.getAttributes()); - allAttributes.putAll(scope.getAttributes()); + allAttributes.putAll(globalAttributes); + allAttributes.putAll(isolationAttributes); + allAttributes.putAll(currentAttributes); return allAttributes; } @@ -272,11 +336,32 @@ public void removeAttribute(@Nullable String key) { @Override public @NotNull Map getExtras() { - final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getExtras()); - allTags.putAll(isolationScope.getExtras()); - allTags.putAll(scope.getExtras()); - return allTags; + final @NotNull Map globalExtras = globalScope.getExtras(); + final @NotNull Map isolationExtras = isolationScope.getExtras(); + final @NotNull Map currentExtras = scope.getExtras(); + + final boolean hasGlobalExtras = !globalExtras.isEmpty(); + final boolean hasIsolationExtras = !isolationExtras.isEmpty(); + final boolean hasCurrentExtras = !currentExtras.isEmpty(); + + if (!hasGlobalExtras && !hasIsolationExtras && !hasCurrentExtras) { + return getDefaultScopeValue(globalExtras, isolationExtras, currentExtras); + } + if (!hasIsolationExtras && !hasCurrentExtras) { + return globalExtras; + } + if (!hasGlobalExtras && !hasCurrentExtras) { + return isolationExtras; + } + if (!hasGlobalExtras && !hasIsolationExtras) { + return currentExtras; + } + + final @NotNull Map allExtras = new ConcurrentHashMap<>(); + allExtras.putAll(globalExtras); + allExtras.putAll(isolationExtras); + allExtras.putAll(currentExtras); + return allExtras; } @Override @@ -342,6 +427,23 @@ public void removeContexts(@Nullable String key) { return getSpecificScope(null); } + private @NotNull T getDefaultScopeValue( + final @NotNull T globalValue, + final @NotNull T isolationValue, + final @NotNull T currentValue) { + switch (getOptions().getDefaultScopeType()) { + case CURRENT: + return currentValue; + case ISOLATION: + return isolationValue; + case GLOBAL: + return globalValue; + default: + // calm the compiler + return currentValue; + } + } + IScope getSpecificScope(final @Nullable ScopeType scopeType) { if (scopeType != null) { switch (scopeType) { @@ -373,10 +475,31 @@ IScope getSpecificScope(final @Nullable ScopeType scopeType) { @Override public @NotNull List getAttachments() { + final @NotNull List globalAttachments = globalScope.getAttachments(); + final @NotNull List isolationAttachments = isolationScope.getAttachments(); + final @NotNull List currentAttachments = scope.getAttachments(); + + final boolean hasGlobalAttachments = !globalAttachments.isEmpty(); + final boolean hasIsolationAttachments = !isolationAttachments.isEmpty(); + final boolean hasCurrentAttachments = !currentAttachments.isEmpty(); + + if (!hasGlobalAttachments && !hasIsolationAttachments && !hasCurrentAttachments) { + return getDefaultScopeValue(globalAttachments, isolationAttachments, currentAttachments); + } + if (!hasIsolationAttachments && !hasCurrentAttachments) { + return globalAttachments; + } + if (!hasGlobalAttachments && !hasCurrentAttachments) { + return isolationAttachments; + } + if (!hasGlobalAttachments && !hasIsolationAttachments) { + return currentAttachments; + } + final @NotNull List allAttachments = new CopyOnWriteArrayList<>(); - allAttachments.addAll(globalScope.getAttachments()); - allAttachments.addAll(isolationScope.getAttachments()); - allAttachments.addAll(scope.getAttachments()); + allAttachments.addAll(globalAttachments); + allAttachments.addAll(isolationAttachments); + allAttachments.addAll(currentAttachments); return allAttachments; } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index e407391c394..fcba83fbe05 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -1,13 +1,8 @@ package io.sentry; -import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; - -import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils; +import io.sentry.vendor.SentryIso8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; import java.util.Date; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -15,6 +10,7 @@ /** Utilities to deal with dates */ @ApiStatus.Internal +@SuppressWarnings("JavaUtilDate") public final class DateUtils { private DateUtils() {} @@ -24,10 +20,9 @@ private DateUtils() {} * * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getCurrentDateTime() { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - return calendar.getTime(); + return new Date(); } /** @@ -39,8 +34,8 @@ private DateUtils() {} public static @NotNull Date getDateTime(final @NotNull String timestamp) throws IllegalArgumentException { try { - return ISO8601Utils.parse(timestamp, new ParsePosition(0)); - } catch (ParseException e) { + return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp)); + } catch (IllegalArgumentException e) { throw new IllegalArgumentException("timestamp is not ISO format " + timestamp); } } @@ -51,7 +46,6 @@ private DateUtils() {} * @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis) * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp) throws IllegalArgumentException { try { @@ -69,7 +63,17 @@ private DateUtils() {} * @return the UTC/ISO 8601 timestamp */ public static @NotNull String getTimestamp(final @NotNull Date date) { - return ISO8601Utils.format(date, true); + return getTimestampFromMillis(date.getTime()); + } + + /** + * Get the UTC/ISO 8601 timestamp from millis. + * + * @param millis the UTC millis from the epoch + * @return the UTC/ISO 8601 timestamp + */ + static @NotNull String getTimestampFromMillis(final long millis) { + return SentryIso8601Utils.formatTimestamp(millis); } /** @@ -78,10 +82,9 @@ private DateUtils() {} * @param millis the UTC millis from the epoch * @return the UTC Date */ + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getDateTime(final long millis) { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - calendar.setTimeInMillis(millis); - return calendar.getTime(); + return new Date(millis); } /** diff --git a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java index 5f986746be9..38abc960521 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java @@ -28,10 +28,11 @@ public final class JsonObjectSerializer { public static final String OBJECT_PLACEHOLDER = "[OBJECT]"; - public final JsonReflectionObjectSerializer jsonReflectionObjectSerializer; + private final int maxDepth; + private @Nullable JsonReflectionObjectSerializer jsonReflectionObjectSerializer; public JsonObjectSerializer(int maxDepth) { - jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + this.maxDepth = maxDepth; } public void serialize( @@ -127,7 +128,7 @@ public void serialize( writer.value(object.toString()); } else { try { - Object serializableObject = jsonReflectionObjectSerializer.serialize(object, logger); + Object serializableObject = getJsonReflectionObjectSerializer().serialize(object, logger); serialize(writer, logger, serializableObject); } catch (Exception exception) { logger.log(SentryLevel.ERROR, "Failed serializing unknown object.", exception); @@ -138,6 +139,13 @@ public void serialize( // Helper + private @NotNull JsonReflectionObjectSerializer getJsonReflectionObjectSerializer() { + if (jsonReflectionObjectSerializer == null) { + jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + } + return jsonReflectionObjectSerializer; + } + private void serializeDate( @NotNull ObjectWriter writer, @NotNull ILogger logger, @NotNull Date date) throws IOException { diff --git a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java index 97c23031044..bb9ee1fcd3f 100644 --- a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java @@ -30,7 +30,7 @@ @ApiStatus.Internal public final class JsonReflectionObjectSerializer { - private final Set visiting = new HashSet<>(); + private @Nullable Set visiting; private final int maxDepth; JsonReflectionObjectSerializer(int maxDepth) { @@ -69,6 +69,7 @@ public final class JsonReflectionObjectSerializer { } else if (object.getClass().isEnum()) { return object.toString(); } else { + final Set visiting = getVisiting(); if (visiting.contains(object)) { logger.log(SentryLevel.INFO, "Cyclic reference detected. Calling toString() on object."); return object.toString(); @@ -135,6 +136,13 @@ public final class JsonReflectionObjectSerializer { // Helper + private @NotNull Set getVisiting() { + if (visiting == null) { + visiting = new HashSet<>(); + } + return visiting; + } + private @NotNull List list(@NotNull Object[] objectArray, @NotNull ILogger logger) throws Exception { List list = new ArrayList<>(); diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 2b24090d0cc..79a1c72bef3 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -64,6 +64,8 @@ public final class JsonSerializer implements ISerializer { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + /** the SentryOptions */ private final @NotNull SentryOptions options; @@ -233,7 +235,8 @@ public void serialize(@NotNull SentryEnvelope envelope, @NotNull OutputStream ou // we do not want to close these as we would also close the stream that was passed in final BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream); - final Writer writer = new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8)); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8), WRITER_BUFFER_SIZE); try { envelope diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 8c684bfb65a..d84c9e47be8 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -11,7 +11,6 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -191,7 +190,7 @@ private void setSdk(final @NotNull SentryBaseEvent event) { private void setTags(final @NotNull SentryBaseEvent event) { if (event.getTags() == null) { - event.setTags(new HashMap<>(options.getTags())); + event.setTags(options.getTags()); } else { for (Map.Entry item : options.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 193d9ee5a6f..a52ecc6b97f 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -1,10 +1,9 @@ package io.sentry; +import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; -import java.util.List; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -49,8 +48,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/OptionsContainer.java b/sentry/src/main/java/io/sentry/OptionsContainer.java index 52032880aaf..b29aef2e000 100644 --- a/sentry/src/main/java/io/sentry/OptionsContainer.java +++ b/sentry/src/main/java/io/sentry/OptionsContainer.java @@ -1,28 +1,40 @@ package io.sentry; +import com.jakewharton.nopen.annotation.Open; +import io.sentry.util.Objects; import java.lang.reflect.InvocationTargetException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class OptionsContainer { +@Open +public class OptionsContainer { public @NotNull static OptionsContainer create(final @NotNull Class clazz) { return new OptionsContainer<>(clazz); } - private final @NotNull Class clazz; + private final @Nullable Class clazz; private OptionsContainer(final @NotNull Class clazz) { super(); this.clazz = clazz; } + /** Constructor for subclasses that create the instance directly without reflection. */ + protected OptionsContainer() { + super(); + this.clazz = null; + } + public @NotNull T createInstance() throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { - return clazz.getDeclaredConstructor().newInstance(); + return Objects.requireNonNull(clazz, "OptionsContainer clazz is required") + .getDeclaredConstructor() + .newInstance(); } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 78225f05d19..a739eddd9d9 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -27,7 +27,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -1425,7 +1424,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri event.setUser(scope.getUser()); } if (event.getTags() == null) { - event.setTags(new HashMap<>(scope.getTags())); + event.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { @@ -1483,7 +1482,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri replayEvent.setUser(scope.getUser()); } if (replayEvent.getTags() == null) { - replayEvent.setTags(new HashMap<>(scope.getTags())); + replayEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!replayEvent.getTags().containsKey(item.getKey())) { @@ -1523,7 +1522,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sentryBaseEvent.setUser(scope.getUser()); } if (sentryBaseEvent.getTags() == null) { - sentryBaseEvent.setTags(new HashMap<>(scope.getTags())); + sentryBaseEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!sentryBaseEvent.getTags().containsKey(item.getKey())) { @@ -1537,7 +1536,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sortBreadcrumbsByDate(sentryBaseEvent, scope.getBreadcrumbs()); } if (sentryBaseEvent.getExtras() == null) { - sentryBaseEvent.setExtras(new HashMap<>(scope.getExtras())); + sentryBaseEvent.setExtras(scope.getExtras()); } else { for (Map.Entry item : scope.getExtras().entrySet()) { if (!sentryBaseEvent.getExtras().containsKey(item.getKey())) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index dbbc36524db..728478f5906 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -41,6 +41,8 @@ public final class SentryEnvelopeItem { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + private final SentryEnvelopeItemHeader header; // Either dataFactory is set or data needs to be set. private final @Nullable Callable dataFactory; @@ -85,7 +87,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(session, writer); return stream.toByteArray(); } @@ -119,7 +123,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(event, writer); return stream.toByteArray(); } @@ -179,7 +185,9 @@ public static SentryEnvelopeItem fromUserFeedback( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(userFeedback, writer); return stream.toByteArray(); } @@ -206,7 +214,9 @@ public static SentryEnvelopeItem fromCheckIn( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(checkIn, writer); return stream.toByteArray(); } @@ -344,7 +354,9 @@ private static void ensureAttachmentSizeLimit( } try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profileChunk, writer); return stream.toByteArray(); } catch (IOException e) { @@ -403,7 +415,9 @@ private static void ensureAttachmentSizeLimit( profilingTraceData.readDeviceCpuFrequencies(); try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profilingTraceData, writer); return stream.toByteArray(); } catch (IOException e) { @@ -437,7 +451,9 @@ private static void ensureAttachmentSizeLimit( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(clientReport, writer); return stream.toByteArray(); } @@ -481,7 +497,8 @@ public static SentryEnvelopeItem fromReplay( try { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = - new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { // relay expects the payload to be in this exact order: [event,rrweb,video] final Map replayPayload = new LinkedHashMap<>(); // first serialize replay event json bytes @@ -541,7 +558,9 @@ public static SentryEnvelopeItem fromLogs( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(logEvents, writer); return stream.toByteArray(); } @@ -571,7 +590,9 @@ public static SentryEnvelopeItem fromMetrics( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(metricsEvents, writer); return stream.toByteArray(); } diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 007d50681fb..8b8575fe7ec 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -114,7 +114,7 @@ public SentryEvent(final @NotNull Date timestamp) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public Date getTimestamp() { - return (Date) timestamp.clone(); + return timestamp; } public void setTimestamp(final @NotNull Date timestamp) { diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 3ce2d70e89e..2fdfffb35d9 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -131,10 +131,7 @@ public boolean isTerminated() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getStarted() { - if (started == null) { - return null; - } - return (Date) started.clone(); + return started; } public @Nullable String getDistinctId() { @@ -193,8 +190,7 @@ public int errorCount() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { - final Date timestampRef = timestamp; - return timestampRef != null ? (Date) timestampRef.clone() : null; + return timestamp; } /** Ends a session and update its values */ diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index 989c3464be8..878ad0ec960 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -98,8 +98,7 @@ public void setAppIdentifier(final @Nullable String appIdentifier) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getAppStartTime() { - final Date appStartTimeRef = appStartTime; - return appStartTimeRef != null ? (Date) appStartTimeRef.clone() : null; + return appStartTime; } public void setAppStartTime(final @Nullable Date appStartTime) { diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 35168e5bcc2..83a770eb0fc 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -10,14 +10,13 @@ import io.sentry.ProfileContext; import io.sentry.SpanContext; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CollectionUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -302,8 +301,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), internalStorage.size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index e6113efbcb5..5b765640a39 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -366,8 +366,7 @@ public void setScreenDpi(final @Nullable Integer screenDpi) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getBootTime() { - final Date bootTimeRef = bootTime; - return bootTimeRef != null ? (Date) bootTimeRef.clone() : null; + return bootTime; } public void setBootTime(final @Nullable Date bootTime) { diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index 266055fa1ce..5b00eb6531c 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -1,7 +1,9 @@ package io.sentry.util; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.ListIterator; @@ -15,9 +17,28 @@ /** Util class for Collections */ @ApiStatus.Internal public final class CollectionUtils { + private static final String[] EMPTY_STRINGS = new String[0]; private CollectionUtils() {} + public static @NotNull String[] toSortedStringArray( + final @NotNull Enumeration source, final int size) { + String[] sorted = size == 0 ? EMPTY_STRINGS : new String[size]; + int index = 0; + while (source.hasMoreElements()) { + if (index == sorted.length) { + sorted = Arrays.copyOf(sorted, sorted.length + 1); + } + sorted[index] = source.nextElement(); + index++; + } + if (index != sorted.length) { + sorted = Arrays.copyOf(sorted, index); + } + Arrays.sort(sorted); + return sorted; + } + /** * Returns an Iterator size * diff --git a/sentry/src/main/java/io/sentry/util/Platform.java b/sentry/src/main/java/io/sentry/util/Platform.java index cc924fb2815..ad2a4e7f3c3 100644 --- a/sentry/src/main/java/io/sentry/util/Platform.java +++ b/sentry/src/main/java/io/sentry/util/Platform.java @@ -20,16 +20,21 @@ public final class Platform { isAndroid = false; } - try { - final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); - if (javaStringVersion != null) { - final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); - isJavaNinePlus = javaVersion >= 9.0; - } else { + if (isAndroid) { + // Android is never Java 9+, skip the system property lookup + parse on the startup path. + isJavaNinePlus = false; + } else { + try { + final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); + if (javaStringVersion != null) { + final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); + isJavaNinePlus = javaVersion >= 9.0; + } else { + isJavaNinePlus = false; + } + } catch (Throwable e) { isJavaNinePlus = false; } - } catch (Throwable e) { - isJavaNinePlus = false; } } diff --git a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java new file mode 100644 index 00000000000..b5cb1811aa9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -0,0 +1,397 @@ +// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. +// Placed in the public domain by Howard Hinnant. +// https://howardhinnant.github.io/date_algorithms.html + +package io.sentry.vendor; + +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.SimpleTimeZone; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class SentryIso8601Utils { + + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; + private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE; + private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR; + private static final long GREGORIAN_CUTOVER_MILLIS = -12219292800000L; + private static final int DAYS_0000_TO_1970 = 719468; + + private SentryIso8601Utils() {} + + public static long parseTimestamp(final @NotNull String timestamp) { + final int length = timestamp.length(); + int offset = 0; + + final int year = parseInt(timestamp, offset, offset += 4); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int month = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int day = parseInt(timestamp, offset, offset += 2); + + if (!checkOffset(timestamp, offset, 'T')) { + if (offset == length) { + return dateOnlyEpochMillis(year, month, day); + } + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z' || timezoneIndicator == '+' || timezoneIndicator == '-') { + return dateOnlyEpochMillisWithTimezone(timestamp, length, offset, year, month, day); + } + throw new IllegalArgumentException("Invalid date separator"); + } + validateDate(year, month, day); + offset++; + + final int hour = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + final int minute = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + int second = 0; + int millisecond = 0; + if (length > offset) { + final char c = timestamp.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + second = parseInt(timestamp, offset, offset += 2); + if (second > 59 && second < 63) { + second = 59; + } + if (checkOffset(timestamp, offset, '.')) { + offset++; + final int endOffset = indexOfNonDigit(timestamp, offset); + if (endOffset == offset) { + throw new IllegalArgumentException("Missing millisecond digits"); + } + final int parseEndOffset = Math.min(endOffset, offset + 3); + final int fraction = parseInt(timestamp, offset, parseEndOffset); + switch (parseEndOffset - offset) { + case 1: + millisecond = fraction * 100; + break; + case 2: + millisecond = fraction * 10; + break; + default: + millisecond = fraction; + break; + } + offset = endOffset; + } + } + } + validateTime(hour, minute, second, millisecond); + + if (length <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + allowTrailingCharacters = true; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (!allowTrailingCharacters && offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar( + year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + public static @NotNull String formatTimestamp(final long millis) { + if (millis < GREGORIAN_CUTOVER_MILLIS) { + return formatTimestampWithCalendar(millis); + } + + final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); + int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); + + final int[] yearMonthDay = epochDayToYearMonthDay(epochDay); + final int hour = millisOfDay / (int) MILLIS_PER_HOUR; + millisOfDay -= hour * (int) MILLIS_PER_HOUR; + final int minute = millisOfDay / (int) MILLIS_PER_MINUTE; + millisOfDay -= minute * (int) MILLIS_PER_MINUTE; + final int second = millisOfDay / (int) MILLIS_PER_SECOND; + final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND; + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, yearMonthDay[0], "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[1], "MM".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[2], "dd".length()); + timestamp.append('T'); + padInt(timestamp, hour, "hh".length()); + timestamp.append(':'); + padInt(timestamp, minute, "mm".length()); + timestamp.append(':'); + padInt(timestamp, second, "ss".length()); + timestamp.append('.'); + padInt(timestamp, millisecond, "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long dateOnlyEpochMillis(final int year, final int month, final int day) { + return new GregorianCalendar(year, month - 1, day).getTimeInMillis(); + } + + private static long dateOnlyEpochMillisWithTimezone( + final @NotNull String timestamp, + final int length, + int offset, + final int year, + final int month, + final int day) { + final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + allowTrailingCharacters = true; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (!allowTrailingCharacters && offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + validateDate(year, month, day); + return epochMillis(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + + private static long epochMillisWithCalendar( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(timezoneOffsetMillis, "GMT")); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, millisecond); + return calendar.getTimeInMillis(); + } + + private static @NotNull String formatTimestampWithCalendar(final long millis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(0, "UTC")); + calendar.setTimeInMillis(millis); + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, calendar.get(Calendar.YEAR), "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.MONTH) + 1, "MM".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + timestamp.append('T'); + padInt(timestamp, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.MINUTE), "mm".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.SECOND), "ss".length()); + timestamp.append('.'); + padInt(timestamp, calendar.get(Calendar.MILLISECOND), "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long epochMillis( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY + + hour * MILLIS_PER_HOUR + + minute * MILLIS_PER_MINUTE + + second * MILLIS_PER_SECOND + + millisecond + - timezoneOffsetMillis; + } + + private static long daysFromYearMonthDay(int year, final int month, final int day) { + year -= month <= 2 ? 1 : 0; + final long era = Math.floorDiv(year, 400); + final int yearOfEra = (int) (year - era * 400); + final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146097 + dayOfEra - DAYS_0000_TO_1970; + } + + private static int[] epochDayToYearMonthDay(long epochDay) { + epochDay += DAYS_0000_TO_1970; + final long era = Math.floorDiv(epochDay, 146097); + final int dayOfEra = (int) (epochDay - era * 146097); + final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365; + final int year = (int) (yearOfEra + era * 400); + final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + final int monthPrime = (5 * dayOfYear + 2) / 153; + final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1; + final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9; + return new int[] {year + (month <= 2 ? 1 : 0), month, day}; + } + + private static boolean isBeforeGregorianCutover(final int year, final int month, final int day) { + return year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day < 15))); + } + + private static void validateDate(final int year, final int month, final int day) { + if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + throw new IllegalArgumentException("Invalid date"); + } + } + + private static void validateTime( + final int hour, final int minute, final int second, final int millisecond) { + if (hour < 0 + || hour > 23 + || minute < 0 + || minute > 59 + || second < 0 + || second > 59 + || millisecond < 0 + || millisecond > 999) { + throw new IllegalArgumentException("Invalid time"); + } + } + + private static void validateTimezone(final int hour, final int minute) { + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new IllegalArgumentException("Invalid time zone"); + } + } + + private static int daysInMonth(final int year, final int month) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + private static boolean isLeapYear(final int year) { + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); + } + + private static boolean checkOffset( + final @NotNull String value, final int offset, final char expected) { + return offset < value.length() && value.charAt(offset) == expected; + } + + private static int parseInt( + final @NotNull String value, final int beginIndex, final int endIndex) { + if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) { + throw new NumberFormatException(value); + } + + int result = 0; + for (int i = beginIndex; i < endIndex; i++) { + final char c = value.charAt(i); + if (c < '0' || c > '9') { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = result * 10 + c - '0'; + } + return result; + } + + private static void padInt( + final @NotNull StringBuilder buffer, final int value, final int length) { + if (value < 0) { + buffer.append('-'); + padInt(buffer, -value, length); + return; + } + final String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + private static int indexOfNonDigit(final @NotNull String string, final int offset) { + for (int i = offset; i < string.length(); i++) { + final char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java index b030bc174b7..3119c833fd2 100644 --- a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java +++ b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java @@ -17,7 +17,7 @@ // Source: https://github.com/google/gson // Tag: gson-parent-2.8.7 // Commit Hash: 4520489c29e770c64b11ca35e0a0fdf17a1874ab -// Changes: @ApiStatus.Internal, SuppressWarnings +// Changes: @ApiStatus.Internal, SuppressWarnings, reduced stack size package io.sentry.vendor.gson.stream; @@ -175,7 +175,7 @@ public class JsonWriter implements Closeable, Flushable { /** The output data, containing at most one top-level array or object. */ private final Writer out; - private int[] stack = new int[32]; + private int[] stack = new int[8]; private int stackSize = 0; { push(EMPTY_DOCUMENT); diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 30c322641b8..f51acca81cb 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,6 +1,9 @@ package io.sentry import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -329,6 +332,39 @@ class BreadcrumbTest { breadcrumb.removeData(null) } + @Test + fun `getData returns mutable map for new breadcrumb`() { + val breadcrumb = Breadcrumb() + + breadcrumb.data["k"] = "v" + + assertEquals("v", breadcrumb.getData("k")) + } + + @Test + fun `concurrent first writes keep all data entries`() { + val breadcrumb = Breadcrumb() + val count = 32 + val executor = Executors.newFixedThreadPool(count) + val start = CountDownLatch(1) + val futures = + (0 until count).map { index -> + executor.submit { + start.await() + breadcrumb.setData("key-$index", index) + } + } + + start.countDown() + futures.forEach { it.get(5, TimeUnit.SECONDS) } + executor.shutdown() + + assertEquals(count, breadcrumb.data.size) + for (index in 0 until count) { + assertEquals(index, breadcrumb.data["key-$index"]) + } + } + class TestKey(val id: Long) { override fun toString(): String = id.toString() } diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index d768d6d32d6..fd187235a92 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -11,6 +11,7 @@ import junit.framework.TestCase.assertTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame import org.junit.Assert.assertNotEquals @@ -72,6 +73,74 @@ class CombinedScopeViewTest { assertEquals("current 2", breadcrumbs.poll().message) } + @Test + fun `returns single non-empty breadcrumb queue directly`() { + var combined = fixture.getSut() + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + assertSame(fixture.globalScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + assertSame(fixture.isolationScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.scope.addBreadcrumb(Breadcrumb.info("current")) + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns default write scope breadcrumbs when all scopes are empty`() { + val combined = fixture.getSut(SentryOptions().also { it.defaultScopeType = ScopeType.CURRENT }) + + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns merged breadcrumb copy when multiple scopes have breadcrumbs`() { + val combined = fixture.getSut() + + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + + val breadcrumbs = combined.breadcrumbs + + assertNotSame(fixture.globalScope.breadcrumbs, breadcrumbs) + assertNotSame(fixture.isolationScope.breadcrumbs, breadcrumbs) + assertEquals(2, breadcrumbs.size) + } + + @Test + fun `returns single non-empty combined collections directly`() { + val globalScope = mock() + val isolationScope = mock() + val scope = mock() + val combined = CombinedScopeView(globalScope, isolationScope, scope) + + val tags = mapOf("tag" to "value") + whenever(globalScope.tags).thenReturn(emptyMap()) + whenever(isolationScope.tags).thenReturn(emptyMap()) + whenever(scope.tags).thenReturn(tags) + assertSame(tags, combined.tags) + + val attributes = mapOf("attribute" to SentryAttribute.named("attribute", "value")) + whenever(globalScope.attributes).thenReturn(emptyMap()) + whenever(isolationScope.attributes).thenReturn(emptyMap()) + whenever(scope.attributes).thenReturn(attributes) + assertSame(attributes, combined.attributes) + + val extras = mapOf("extra" to "value") + whenever(globalScope.extras).thenReturn(emptyMap()) + whenever(isolationScope.extras).thenReturn(emptyMap()) + whenever(scope.extras).thenReturn(extras) + assertSame(extras, combined.extras) + + val attachments = listOf(createAttachment("attachment.png")) + whenever(globalScope.attachments).thenReturn(emptyList()) + whenever(isolationScope.attachments).thenReturn(emptyList()) + whenever(scope.attachments).thenReturn(attachments) + assertSame(attachments, combined.attachments) + } + @Test fun `oldest breadcrumbs are dropped first`() { val options = SentryOptions().also { it.maxBreadcrumbs = 5 } diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 9e234b50c1b..97882198e62 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -1,12 +1,16 @@ package io.sentry +import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils +import java.text.ParsePosition import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date +import java.util.TimeZone import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,6 +38,54 @@ class DateUtilsTest { assertEquals("2020-03-27T08:52:58.000Z", timestamp) } + @Test + fun `When ISO date has offset`() { + val input = + mapOf( + "2020-03-27T10:52:58.015+02:00" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+0200" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+02" to "2020-03-27T08:52:58.015Z", + "2020-03-27T05:52:58.015-03:00" to "2020-03-27T08:52:58.015Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date uses compact separators`() { + val date = DateUtils.getDateTime("20200327T085258.015Z") + + val utcDate = convertDate(date) + val timestamp = utcDate.format(isoFormat) + + assertEquals("2020-03-27T08:52:58.015Z", timestamp) + } + + @Test + fun `When ISO date has short fraction`() { + val input = + mapOf( + "2020-03-27T08:52:58.1Z" to "2020-03-27T08:52:58.100Z", + "2020-03-27T08:52:58.12Z" to "2020-03-27T08:52:58.120Z", + "2020-03-27T08:52:58.123456Z" to "2020-03-27T08:52:58.123Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date is invalid`() { + assertFailsWith { DateUtils.getDateTime("2020-02-30T08:52:58Z") } + } + @Test fun `Converts from Date to ISO 8601 and back to Date`() { val currentDate = DateUtils.getCurrentDateTime() @@ -78,6 +130,147 @@ class DateUtilsTest { assertTrue { utcCurrentDate.minusSeconds(1).isBefore(utcDate) } } + @Test + fun `Formats millis to ISO 8601 timestamp`() { + val input = + mapOf( + Instant.parse("1970-01-01T00:00:00.000Z").toEpochMilli() to "1970-01-01T00:00:00.000Z", + Instant.parse("1969-12-31T23:59:59.999Z").toEpochMilli() to "1969-12-31T23:59:59.999Z", + Instant.parse("2000-02-29T12:34:56.789Z").toEpochMilli() to "2000-02-29T12:34:56.789Z", + Instant.parse("1900-03-01T00:00:00.000Z").toEpochMilli() to "1900-03-01T00:00:00.000Z", + Instant.parse("2100-03-01T00:00:00.000Z").toEpochMilli() to "2100-03-01T00:00:00.000Z", + Instant.parse("2400-02-29T23:59:59.999Z").toEpochMilli() to "2400-02-29T23:59:59.999Z", + ) + + input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) } + } + + @Test + fun `Fast timestamp formatter matches previous ISO8601 formatter`() { + val input = + listOf( + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "1969-12-31T23:59:59.999Z", + "1970-01-01T00:00:00.000Z", + "1999-12-31T23:59:59.999Z", + "2000-02-29T12:34:56.789Z", + "2020-03-27T08:52:58.015Z", + "2024-02-29T23:59:59.001Z", + "2100-03-01T00:00:00.000Z", + "2400-02-29T23:59:59.999Z", + ) + + input + .map { ISO8601Utils.parse(it, ParsePosition(0)).time } + .forEach { + assertEquals( + ISO8601Utils.format(Date(it), true), + DateUtils.getTimestampFromMillis(it), + "millis=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser`() { + val input = + listOf( + "2020-03-27T08:52Z", + "2020-03-27T08:52:58Z", + "2020-03-27T08:52:58.015Z", + "20200327T085258.015Z", + "2020-03-27T10:52:58.015+02:00", + "2020-03-27T10:52:58.015+0200", + "2020-03-27T10:52:58.015+02", + "2020-03-27T05:52:58.015-03:00", + "2020-03-27T05:22:58.015-0330", + "2020-03-27T08:52:58.1Z", + "2020-03-27T08:52:58.12Z", + "2020-03-27T08:52:58.123456Z", + "2020-03-27T08:52:58Ztrailing", + "2016-12-31T23:59:60Z", + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "2000-02-29T12:34:56.789Z", + "2100-03-01T00:00:00.000Z", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values`() { + withDefaultTimeZone("America/Los_Angeles") { + val input = listOf("2020-03-27", "20200327", "2020-02-30") + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values with timezone`() { + val input = + listOf( + "2020-03-27Z", + "2020-03-27+02:00", + "2020-03-27+0200", + "2020-03-27+02", + "2020-03-27-03:30", + "20200327Z", + "20200327+02:00", + "20200327-0330", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser rejects invalid date-only values with timezone like previous ISO8601 parser`() { + val timestamp = "2020-02-30Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + + @Test + fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() { + val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015") + + input.forEach { + assertFailsWith("timestamp=$it") { ISO8601Utils.parse(it, ParsePosition(0)) } + assertFailsWith("timestamp=$it") { DateUtils.getDateTime(it) } + } + } + + @Test + fun `Fast timestamp parser rejects Gregorian cutover gap like previous ISO8601 parser`() { + val timestamp = "1582-10-10T00:00:00.000Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + @Test fun `Millis formats to Date`() { val millis = 1591533492L * 1000L + 631 @@ -86,6 +279,7 @@ class DateUtilsTest { val utcActual = convertDate(actual) val timestamp = utcActual.format(isoFormat) + assertEquals(millis, actual.time) assertEquals("2020-06-07T12:38:12.631Z", timestamp) } @@ -120,6 +314,16 @@ class DateUtilsTest { private fun convertDate(date: Date): LocalDateTime = Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime() + private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) { + val previousTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)) + block() + } finally { + TimeZone.setDefault(previousTimeZone) + } + } + private fun assertClose(expected: Double, actual: Double?) { assertNotNull(actual) val diff = Math.abs(expected - actual) diff --git a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt index 3323be84cda..572c27abced 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt @@ -7,6 +7,8 @@ import java.util.Locale import java.util.TimeZone import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicIntegerArray +import kotlin.test.assertNotNull +import kotlin.test.assertNull import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -192,6 +194,21 @@ internal class JsonObjectSerializerTest { verify(jsonSerializable).serialize(fixture.writer, fixture.logger) } + @Test + fun `serialize json serializable does not create reflection serializer`() { + val serializer = fixture.getSUT() + val jsonSerializable: JsonSerializable = mock() + serializer.serialize(fixture.writer, fixture.logger, jsonSerializable) + assertNull(serializer.reflectionObjectSerializer) + } + + @Test + fun `serialize unknown object creates reflection serializer`() { + val serializer = fixture.getSUT() + serializer.serialize(fixture.writer, fixture.logger, object {}) + assertNotNull(serializer.reflectionObjectSerializer) + } + @Test fun `serialize unknown object without data`() { val value = object {} @@ -355,3 +372,10 @@ internal class JsonObjectSerializerTest { data class ClassWithEnumProperty(val enumProperty: DataCategory) data class ClassWithLocaleProperty(val localeProperty: Locale) + +private val JsonObjectSerializer.reflectionObjectSerializer: JsonReflectionObjectSerializer? + get() { + val field = JsonObjectSerializer::class.java.getDeclaredField("jsonReflectionObjectSerializer") + field.isAccessible = true + return field.get(this) as JsonReflectionObjectSerializer? + } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 229fd571871..fe5c835c90f 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -358,6 +358,19 @@ class MainEventProcessorTest { } } + @Test + fun `options tags are copied when applied to event`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + val event = SentryEvent() + + sut.process(event, Hint()) + val eventTags = event.tags!! + + fixture.sentryOptions.setTag("tag2", "value2") + + assertFalse(eventTags.containsKey("tag2")) + } + @Test fun `when event has a tag set with the same name as SentryOptions tags, the tag value from the event is retained`() { val sut = fixture.getSut(tags = mapOf("tag1" to "value1", "tag2" to "value2")) diff --git a/sentry/src/test/java/io/sentry/MonitorContextsTest.kt b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt new file mode 100644 index 00000000000..2b0d57e605e --- /dev/null +++ b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt @@ -0,0 +1,19 @@ +package io.sentry + +import io.sentry.protocol.SerializationUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.mock + +class MonitorContextsTest { + @Test + fun `serializes entries in alphabetical order`() { + val contexts = + MonitorContexts().apply { + put("b", 2) + put("a", 1) + } + + assertEquals("{\"a\":1,\"b\":2}", SerializationUtils.serializeToString(contexts, mock())) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index ab6fd2075a3..f51345957e4 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -534,6 +534,24 @@ class SentryClientTest { assertNotNull(event.request) { assertEquals("post", it.method) } } + @Test + fun `when captureEvent applies scope tags and extras, event map containers are copied`() { + val event = SentryEvent() + val scope = createScope() + + val sut = fixture.getSut() + + sut.captureEvent(event, scope) + val eventTags = event.tags!! + val eventExtras = event.extras!! + + scope.setTag("newTag", "newValue") + scope.setExtra("newExtra", "newValue") + + assertFalse(eventTags.containsKey("newTag")) + assertFalse(eventExtras.containsKey("newExtra")) + } + @Test fun `when breadcrumbs are not empty, sort them out by date`() { val b1 = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.001Z")) diff --git a/sentry/src/test/java/io/sentry/protocol/AppTest.kt b/sentry/src/test/java/io/sentry/protocol/AppTest.kt index 84b4c7088e3..cc0f504b7c8 100644 --- a/sentry/src/test/java/io/sentry/protocol/AppTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/AppTest.kt @@ -5,10 +5,11 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class AppTest { @Test - fun `copying app wont have the same references`() { + fun `copying app keeps date reference and copies collections`() { val app = App() app.appBuild = "app build" app.appIdentifier = "app identifier" @@ -28,7 +29,7 @@ class AppTest { assertNotNull(clone) assertNotSame(app, clone) - assertNotSame(app.appStartTime, clone.appStartTime) + assertSame(app.appStartTime, clone.appStartTime) assertNotSame(app.permissions, clone.permissions) assertNotSame(app.viewNames, clone.viewNames) diff --git a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt index 72856c3c27d..a33ddb91a2b 100644 --- a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt @@ -11,6 +11,7 @@ import io.sentry.SentryLevel import io.sentry.SentryOptions import java.io.StringReader import java.io.StringWriter +import java.util.Date import kotlin.test.assertEquals import kotlin.test.assertTrue import org.junit.Test @@ -49,6 +50,13 @@ class BreadcrumbSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `timestampMs fast path serializes same timestamp as Date fallback`() { + val timestampMs = DateUtils.getDateTime("2009-11-16T01:08:47.123Z").time + + assertEquals(serialize(Breadcrumb(Date(timestampMs))), serialize(Breadcrumb(timestampMs))) + } + @Test fun deserializeFromMap() { val map: Map = diff --git a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt index 121cbe6537f..a67305c37ea 100644 --- a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt @@ -6,11 +6,12 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class DeviceTest { @Test - fun `copying device wont have the same references`() { + fun `copying device keeps date reference and copies other mutable references`() { val device = Device() device.archs = arrayOf("archs1", "archs2") device.bootTime = Date() @@ -23,7 +24,7 @@ class DeviceTest { assertNotNull(clone) assertNotSame(device, clone) assertNotSame(device.archs, clone.archs) - assertNotSame(device.bootTime, clone.bootTime) + assertSame(device.bootTime, clone.bootTime) assertNotSame(device.timezone, clone.timezone) assertNotSame(device.unknown, clone.unknown) } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4cafb1ed8a8..35322d2659e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -9,6 +9,7 @@ import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.vendor.gson.stream.JsonToken import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.After import org.junit.Before import org.junit.Test @@ -102,4 +103,26 @@ class SentryBaseEventSerializationTest { assertEquals(expectedJson, actualJson) } + + @Test + fun `setTags copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.tags = source + source["b"] = "2" + + assertFalse(sut.tags!!.containsKey("b")) + } + + @Test + fun `setExtras copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.setExtras(source) + source["b"] = "2" + + assertFalse(sut.extras!!.containsKey("b")) + } } From 8c43a107a007ae5e2aea365bdf434318784049d7 Mon Sep 17 00:00:00 2001 From: adinauer <2542832+adinauer@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:21:16 +0000 Subject: [PATCH 125/276] release: 8.46.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 851fc3985e5..085cf8e35df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.46.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index f83b851f8d9..804e4b58573 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.45.0 +versionName=8.46.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From d500866b45ecf8012bdd05876ab70b538a7d6371 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Jun 2026 20:21:03 +0200 Subject: [PATCH 126/276] fix(replay): Fix network detail response body size being unknown for gzip-compressed responses (#5592) * fix(replay): Derive response body size from peeked bytes when contentLength is unknown For gzip-compressed responses, OkHttp strips the Content-Length header during transparent decompression, so response.body.contentLength() returns -1. This caused NetworkRequestData.responseBodySize to be unknown for replay network details. Add originalByteCount to NetworkBody, set it from the actual byte array in NetworkBodyParser.fromBytes, and use it as a fallback in NetworkDetailCaptureUtils when the passed bodySize is null or -1. This piggybacks on the existing peekBody call with no additional I/O. Co-Authored-By: Claude Opus 4.6 (1M context) * changelog * Changelog * fix(replay): make NetworkBody 3-arg constructor package-private Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++ .../io/sentry/util/network/NetworkBody.java | 15 ++++- .../util/network/NetworkBodyParser.java | 17 ++++-- .../network/NetworkDetailCaptureUtils.java | 8 ++- .../util/network/NetworkBodyParserTest.kt | 21 +++++++ .../network/NetworkDetailCaptureUtilsTest.kt | 58 +++++++++++++++++++ 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085cf8e35df..4e8df66b782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 8.46.0 +### Fixes + +- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592)) + ### Behavioral Changes - Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java index 5b4f6365ad4..bcea8cff6e7 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java @@ -16,15 +16,24 @@ public final class NetworkBody { private final @Nullable Object body; private final @Nullable List warnings; + private final long originalByteCount; public NetworkBody(final @Nullable Object body) { - this(body, null); + this(body, null, -1); } public NetworkBody( final @Nullable Object body, final @Nullable List warnings) { + this(body, warnings, -1); + } + + NetworkBody( + final @Nullable Object body, + final @Nullable List warnings, + final long originalByteCount) { this.body = body; this.warnings = warnings; + this.originalByteCount = originalByteCount; } public @Nullable Object getBody() { @@ -35,6 +44,10 @@ public NetworkBody( return warnings; } + long getOriginalByteCount() { + return originalByteCount; + } + // Based on // https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12 public enum NetworkBodyWarning { diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java index 49325a99003..42df5ca35b9 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -45,24 +45,33 @@ private NetworkBodyParser() {} return null; } + final boolean isTruncated = bytes.length > maxSizeBytes; + final long originalByteCount = bytes.length; + if (contentType != null && isBinaryContentType(contentType)) { // For binary content, return a description instead of the actual content return new NetworkBody( - "[Binary data, " + bytes.length + " bytes, type: " + contentType + "]"); + "[Binary data, " + bytes.length + " bytes, type: " + contentType + "]", + null, + originalByteCount); } // Convert to string and parse try { final String effectiveCharset = charset != null ? charset : "UTF-8"; final int size = Math.min(bytes.length, maxSizeBytes); - final boolean isPartial = bytes.length > maxSizeBytes; final String content = new String(bytes, 0, size, effectiveCharset); - return parse(content, contentType, isPartial, logger); + final NetworkBody parsed = parse(content, contentType, isTruncated, logger); + if (parsed == null) { + return null; + } + return new NetworkBody(parsed.getBody(), parsed.getWarnings(), originalByteCount); } catch (UnsupportedEncodingException e) { logger.log(SentryLevel.WARNING, "Failed to decode bytes: " + e.getMessage()); return new NetworkBody( "[Failed to decode bytes, " + bytes.length + " bytes]", - Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR)); + Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR), + originalByteCount); } } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index e0438c375b1..f5134693e00 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -160,9 +160,15 @@ private static boolean shouldCaptureUrl( body = bodyExtractor.extract(httpObject); } + // When contentLength is unknown (-1), use the actual byte count from body extraction + Long effectiveBodySize = bodySize; + if ((bodySize == null || bodySize == -1L) && body != null && body.getOriginalByteCount() >= 0) { + effectiveBodySize = body.getOriginalByteCount(); + } + Map headers = getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); - return new ReplayNetworkRequestOrResponse(bodySize, body, headers); + return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers); } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt index 3b1da25a0c2..04a19d47712 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt @@ -341,6 +341,27 @@ class NetworkBodyParserTest { val body = NetworkBodyParser.fromBytes(bytes, "image/png", null, bytes.size, logger) assertNotNull(body) assertEquals("[Binary data, 100 bytes, type: image/png]", body.body) + assertEquals(100, body.originalByteCount) + } + + @Test + fun `originalByteCount is set when body fits within limit`() { + val logger = mock() + val bytes = """{"key":"value"}""".toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertEquals(bytes.size.toLong(), body.originalByteCount) + } + + @Test + fun `originalByteCount is set to capped size when body is truncated`() { + val logger = mock() + val bytes = """{"key":"value"}""".toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size - 1, logger) + assertNotNull(body) + assertEquals(bytes.size.toLong(), body.originalByteCount) } @Test diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt index cf4ec4828ff..25b142af7e9 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -1,12 +1,70 @@ package io.sentry.util.network +import io.sentry.ILogger import java.util.LinkedHashMap import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.Test +import org.mockito.kotlin.mock class NetworkDetailCaptureUtilsTest { + @Test + fun `createResponse uses originalByteCount when bodySize is unknown`() { + val logger = mock() + val jsonBytes = """{"key":"value"}""".toByteArray() + + val result = + NetworkDetailCaptureUtils.createResponse( + jsonBytes, + -1L, + true, + { bytes -> + NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + }, + emptyList(), + { emptyMap() }, + ) + + assertEquals(jsonBytes.size.toLong(), result.size) + } + + @Test + fun `createResponse keeps explicit bodySize when available`() { + val logger = mock() + val jsonBytes = """{"key":"value"}""".toByteArray() + + val result = + NetworkDetailCaptureUtils.createResponse( + jsonBytes, + 42L, + true, + { bytes -> + NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + }, + emptyList(), + { emptyMap() }, + ) + + assertEquals(42L, result.size) + } + + @Test + fun `createResponse keeps null bodySize when body capture is off`() { + val result = + NetworkDetailCaptureUtils.createResponse( + "unused", + null, + false, + { null }, + emptyList(), + { emptyMap() }, + ) + + assertNull(result.size) + } + @Test fun `getCaptureHeaders should match headers case-insensitively`() { // Setup: allHeaders with mixed case keys From b3299ecb0a4145c3409adfb2ad70c8c444741626 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:47:34 +0000 Subject: [PATCH 127/276] chore(deps): bump the github-actions group across 1 directory with 6 updates (#5647) Bumps the github-actions group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/setup-java](https://github.com/actions/setup-java) | `5.3.0` | `5.4.0` | | [gradle/actions/setup-gradle](https://github.com/gradle/actions) | `6.1.0` | `6.2.0` | | [actions/cache](https://github.com/actions/cache) | `5.0.5` | `6.0.0` | | [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) | `2.26.10` | `2.26.12` | | [getsentry/craft](https://github.com/getsentry/craft) | `2.26.10` | `2.26.12` | | [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` | Updates `actions/setup-java` from 5.3.0 to 5.4.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/ad2b38190b15e4d6bdf0c97fb4fca8412226d287...1bcf9fb12cf4aa7d266a90ae39939e61372fe520) Updates `gradle/actions/setup-gradle` from 6.1.0 to 6.2.0 - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/50e97c2cd7a37755bbfafc9c5b7cafaece252f6e...3f131e8634966bd73d06cc69884922b02e6faf92) Updates `actions/cache` from 5.0.5 to 6.0.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8) Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.10 to 2.26.12 - [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/acdb88019720182caf57293360d7cdc8db9e75ac...9312e4dfc82e545ef0cad911c23f430fe5f52673) Updates `getsentry/craft` from 2.26.10 to 2.26.12 - [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/acdb88019720182caf57293360d7cdc8db9e75ac...9312e4dfc82e545ef0cad911c23f430fe5f52673) Updates `actions/setup-python` from 6.2.0 to 6.3.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: gradle/actions/setup-gradle dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/cache dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 6.3.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/agp-matrix.yml | 6 +++--- .github/workflows/build.yml | 6 +++--- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/enforce-license-compliance.yml | 4 ++-- .github/workflows/format-code.yml | 4 ++-- .github/workflows/generate-javadocs.yml | 4 ++-- .github/workflows/integration-tests-benchmarks.yml | 10 +++++----- .github/workflows/integration-tests-size.yml | 6 +++--- .github/workflows/integration-tests-ui-critical.yml | 6 +++--- .github/workflows/integration-tests-ui.yml | 4 ++-- .github/workflows/release-build.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 8 ++++---- .github/workflows/spring-boot-3-matrix.yml | 8 ++++---- .github/workflows/spring-boot-4-matrix.yml | 8 ++++---- .github/workflows/system-tests-backend.yml | 6 +++--- 17 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 8ddb961ec96..d196d595d73 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -33,13 +33,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -50,7 +50,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: avd-cache with: path: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6cba7e07e0a..57106c8e05a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,20 +25,20 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index d814ca72002..3e510787ce2 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@acdb88019720182caf57293360d7cdc8db9e75ac # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@9312e4dfc82e545ef0cad911c23f430fe5f52673 # v2 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ccc9cc04a85..c3cad17b1a9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,13 +25,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 38680fe0a23..33a0cc237fc 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 2892df16701..3fc47aa0f6b 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -13,13 +13,13 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index fabd36736aa..be15b66d370 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -14,13 +14,13 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 45b063705dc..fa025030e8a 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -32,13 +32,13 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -82,17 +82,17 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: app-plain-cache with: path: sentry-android-integration-tests/test-app-plain/build/outputs/apk/release/test-app-plain-release.apk diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 5c212d5895a..d67237d7089 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,20 +23,20 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: "temurin" java-version: "17" # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 7d0b74b4329..bd4a9058ddc 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -30,13 +30,13 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Java 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -86,7 +86,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: avd-cache with: path: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index e271227b97e..9404975a1fc 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -27,13 +27,13 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 050782006f0..eac4a94966c 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,13 +20,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Build artifacts run: make publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd266d948c2..807236d4ed2 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@acdb88019720182caf57293360d7cdc8db9e75ac # v2 + uses: getsentry/craft@9312e4dfc82e545ef0cad911c23f430fe5f52673 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 6e0b1366c9f..cf69a869def 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 00e93f5442b..2a94987549c 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 450dbd8c98d..b5516e17453 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 67f81f2fb64..ed6b5eab5f7 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -112,13 +112,13 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From c4538565b7fe7d1d50dddb46c44b2b93129d74ee Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 26 Jun 2026 10:21:49 +0200 Subject: [PATCH 128/276] test(replay): ignore flaky ComposeMaskingOptionsTest unmask test (#5648) * test(replay): ignore flaky ComposeMaskingOptionsTest unmask test Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> * Format code --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Sentry Github Bot --- .../android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 fe3fbc1ba67..baf0a32a415 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 @@ -44,6 +44,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -219,6 +220,9 @@ class ComposeMaskingOptionsTest { } @Test + @Ignore( + "Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic" + ) fun `when sentry-unmask modifier is set unmasks the node`() { ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() From d8b6ce11cabd05be9a3f03a1d20fe247956d091d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 26 Jun 2026 10:52:36 +0200 Subject: [PATCH 129/276] perf(android): Hit-test gestures without getLocationOnScreen (#5595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(android): Hit-test gestures without getLocationOnScreen (JAVA-534) ViewUtils.findTarget called View.getLocationOnScreen for every visited view, and that walks from the view up to the root each time, making the traversal O(N*depth) per tap and scroll start. Instead, map the touch point down into each child's local coordinate space as we descend the tree — the same way ViewGroup dispatches touch events — so each view costs O(1) and the whole traversal is O(N). The locators still receive the original decor-view-relative coordinates, since the Compose locator hit-tests against window coordinates. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * Update changelog * test(android): Cover scroll and child matrix in findTarget hit-testing (JAVA-534) The existing test only exercised the left/top offset path of mapToChild. Add cases for a scrolled parent and a non-identity child matrix so the other two coordinate-mapping branches are covered, and switch the class to Robolectric so the real Matrix math runs. Co-Authored-By: Claude Opus 4.8 (1M context) * Move changelog entry to Unreleased as a performance improvement Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ .../core/internal/gestures/ViewUtils.java | 88 +++++++++++---- .../core/internal/gestures/ViewHelpers.kt | 29 ++--- .../core/internal/gestures/ViewUtilsTest.kt | 100 +++++++++++++++++- .../gestures/ComposeGestureTargetLocator.kt | 9 +- 5 files changed, 184 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8df66b782..eaec96a2e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Performance + +- Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) + ## 8.46.0 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 501a05a5007..6f52612e50d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -1,13 +1,14 @@ package io.sentry.android.core.internal.gestures; import android.content.res.Resources; +import android.graphics.Matrix; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import java.util.LinkedList; +import java.util.ArrayDeque; import java.util.List; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,30 +18,53 @@ @ApiStatus.Internal public final class ViewUtils { - private static final int[] coordinates = new int[2]; - /** - * Verifies if the given touch coordinates are within the bounds of the given view. + * Verifies if the given touch coordinates, expressed in the view's own local coordinate space, + * are within the bounds of the given view. * * @param view the view to check if the touch coordinates are within its bounds - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param localX - the x coordinate of the touch, relative to the view's top-left corner + * @param localY - the y coordinate of the touch, relative to the view's top-left corner * @return true if the touch coordinates are within the bounds of the view, false otherwise */ private static boolean touchWithinBounds( - final @Nullable View view, final float x, final float y) { + final @Nullable View view, final float localX, final float localY) { if (view == null) { return false; } - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; + final int w = view.getWidth(); + final int h = view.getHeight(); - int w = view.getWidth(); - int h = view.getHeight(); + return !(localX < 0 || localX > w || localY < 0 || localY > h); + } - return !(x < vx || x > vx + w || y < vy || y > vy + h); + /** + * Maps a touch point expressed in the parent's local coordinate space into the child's local + * coordinate space. This mirrors how {@link ViewGroup} dispatches touch events to its children + * and lets us hit-test the whole tree with a single downward traversal, instead of calling {@link + * View#getLocationOnScreen(int[])} (which walks up to the root) for every view. + */ + private static @NotNull ViewWithLocation mapToChild( + final @NotNull View child, + final float parentX, + final float parentY, + final int parentScrollX, + final int parentScrollY) { + float childX = parentX + parentScrollX - child.getLeft(); + float childY = parentY + parentScrollY - child.getTop(); + + final @Nullable Matrix matrix = child.getMatrix(); + if (matrix != null && !matrix.isIdentity()) { + final Matrix inverse = new Matrix(); + if (matrix.invert(inverse)) { + final float[] point = {childX, childY}; + inverse.mapPoints(point); + childX = point[0]; + childY = point[1]; + } + } + return new ViewWithLocation(child, childX, childY); } /** @@ -48,8 +72,8 @@ private static boolean touchWithinBounds( * given {@code viewTargetSelector}. * * @param decorView - the root view of this window - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param x - the x coordinate of a {@link MotionEvent}, relative to the decor view + * @param y - the y coordinate of {@link MotionEvent}, relative to the decor view * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} @@ -62,25 +86,35 @@ private static boolean touchWithinBounds( final UiElement.Type targetType) { final List locators = options.getGestureTargetLocators(); - final Queue queue = new LinkedList<>(); - queue.add(decorView); + final Queue queue = new ArrayDeque<>(); + // The touch coordinates from the MotionEvent are already relative to the decor view, i.e. in + // its local coordinate space. + queue.add(new ViewWithLocation(decorView, x, y)); @Nullable UiElement target = null; - while (queue.size() > 0) { - final View view = queue.poll(); + while (!queue.isEmpty()) { + final ViewWithLocation current = queue.poll(); + final View view = current.view; - if (!touchWithinBounds(view, x, y)) { + if (!touchWithinBounds(view, current.x, current.y)) { // if the touch is not hitting the view, skip traversal of its children continue; } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; + final int scrollX = viewGroup.getScrollX(); + final int scrollY = viewGroup.getScrollY(); for (int i = 0; i < viewGroup.getChildCount(); i++) { - queue.add(viewGroup.getChildAt(i)); + final @Nullable View child = viewGroup.getChildAt(i); + if (child != null) { + queue.add(mapToChild(child, current.x, current.y, scrollX, scrollY)); + } } } + // Locators receive the original decor-view-relative coordinates, as the Compose locator + // hit-tests against window coordinates. for (int i = 0; i < locators.size(); i++) { final GestureTargetLocator locator = locators.get(i); final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); @@ -96,6 +130,18 @@ private static boolean touchWithinBounds( return target; } + private static final class ViewWithLocation { + final @NotNull View view; + final float x; + final float y; + + ViewWithLocation(final @NotNull View view, final float x, final float y) { + this.view = view; + this.x = x; + this.y = y; + } + } + /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 1a4f28bbe35..15123ce0a31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -5,9 +5,6 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.Window -import kotlin.math.abs -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -35,31 +32,17 @@ internal inline fun mockView( context: Context? = null, finalize: (T) -> Unit = {}, ): T { - val coordinates = IntArray(2) - if (!touchWithinBounds) { - coordinates[0] = (event.x).toInt() + 10 - coordinates[1] = (event.y).toInt() + 10 - } else { - coordinates[0] = (event.x).toInt() - 10 - coordinates[1] = (event.y).toInt() - 10 - } + // The decor-view-relative touch point used in these tests is (0, 0), and child views are mocked + // at offset (0, 0), so the point reaches every view unchanged. A view therefore contains the + // touch iff its width/height are non-negative; a negative size marks the touch as outside. + val size = if (touchWithinBounds) 10 else -1 val mockView: T = mock { whenever(it.id).thenReturn(id) whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) - - whenever(it.getLocationOnScreen(any())).doAnswer { - val array = it.arguments[0] as IntArray - array[0] = coordinates[0] - array[1] = coordinates[1] - null - } - - val diffPosX = abs(event.x - coordinates[0]).toInt() - val diffPosY = abs(event.y - coordinates[1]).toInt() - whenever(it.width).thenReturn(diffPosX + 10) - whenever(it.height).thenReturn(diffPosY + 10) + whenever(it.width).thenReturn(size) + whenever(it.height).thenReturn(size) finalize(this.mock) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 77a38e6ccc1..10064b1cd74 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -2,10 +2,19 @@ package io.sentry.android.core.internal.gestures import android.content.Context import android.content.res.Resources +import android.graphics.Matrix import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.internal.gestures.UiElement +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -14,12 +23,13 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ViewUtilsTest { @Test fun `getResourceId returns resourceId when available`() { val view = mock { - whenever(it.id).doReturn(View.generateViewId()) + whenever(it.id).doReturn(0x7f010001) val context = mock() val resources = mock() @@ -80,6 +90,94 @@ class ViewUtilsTest { verify(context, never()).resources } + @Test + fun `findTarget hit-tests children in their own local coordinate space`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (120, 220) maps to (20, 20) in the child's space -> inside its 50x50 bounds. + assertNotNull(ViewUtils.findTarget(options, decorView, 120f, 220f, UiElement.Type.CLICKABLE)) + + // (90, 220) maps to (-10, 20) in the child's space -> outside, despite being inside the decor. + assertNull(ViewUtils.findTarget(options, decorView, 90f, 220f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget accounts for parent scroll when mapping into a child`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.scrollX).thenReturn(30) + whenever(it.scrollY).thenReturn(40) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // With scroll (30, 40), (90, 180) maps to (90 + 30 - 100, 180 + 40 - 200) = (20, 20) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 90f, 180f, UiElement.Type.CLICKABLE)) + + // The same point without accounting for scroll would map to (-10, -20) -> outside the child. + assertNull(ViewUtils.findTarget(options, decorView, 50f, 140f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget applies the inverse of a non-identity child matrix`() { + // The child is visually translated by (40, 40) within its parent, so a parent-space point is + // mapped back by (-40, -40) to reach the child's own coordinate space. + val matrix = Matrix().apply { setTranslate(40f, 40f) } + val child = clickableChild { whenever(it.matrix).thenReturn(matrix) } + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (180, 280) lands at (80, 80) before the matrix (outside 50x50), but the inverse pulls it to + // (40, 40) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 180f, 280f, UiElement.Type.CLICKABLE)) + + // (130, 230) lands at (30, 30) before the matrix (inside), but the inverse pushes it to + // (-10, -10) -> outside. + assertNull(ViewUtils.findTarget(options, decorView, 130f, 230f, UiElement.Type.CLICKABLE)) + } + + // A clickable child positioned at (100, 200) within its parent, 50x50 in size. + private fun clickableChild(finalize: (View) -> Unit = {}): View { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + whenever(resources.getResourceEntryName(any())).thenReturn("child") + return mock { + whenever(it.id).thenReturn(0x7f010001) + whenever(it.context).thenReturn(context) + whenever(it.isClickable).thenReturn(true) + whenever(it.visibility).thenReturn(View.VISIBLE) + whenever(it.left).thenReturn(100) + whenever(it.top).thenReturn(200) + whenever(it.width).thenReturn(50) + whenever(it.height).thenReturn(50) + finalize(this.mock) + } + } + + private fun optionsWithViewLocator(): SentryAndroidOptions = + SentryAndroidOptions().apply { + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view = diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index 54deb774c53..47dda6eda9c 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -15,7 +15,7 @@ import io.sentry.compose.boundsInWindow import io.sentry.internal.gestures.GestureTargetLocator import io.sentry.internal.gestures.UiElement import io.sentry.util.AutoClosableReentrantLock -import java.util.LinkedList +import java.util.ArrayDeque import java.util.Queue @OptIn(InternalComposeUiApi::class) @@ -45,7 +45,7 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT val rootLayoutNode = root.root // Pair - val queue: Queue> = LinkedList() + val queue: Queue> = ArrayDeque() queue.add(Pair(rootLayoutNode, null)) // the final tag to return, only relevant for clicks @@ -92,7 +92,10 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT } } } - queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) }) + val children = node.zSortedChildren.asMutableList() + for (index in children.indices) { + queue.add(Pair(children[index], tag)) + } } } From d28345f99bf478304e7bfab7beda00c46b01ece0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 29 Jun 2026 10:18:37 +0200 Subject: [PATCH 130/276] fix(core): Guard clearSession with session lock to prevent NPE (#5657) * fix(core): Guard clearSession with session lock to prevent NPE clearSession() reset the session field without acquiring sessionLock, unlike the other session mutators (startSession, endSession, withSession). This allowed it to null out the session between a null-check and a dereference (e.g. session.clone()) in those locked methods, leading to a NullPointerException. Acquire sessionLock so all session mutations are mutually exclusive. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * changelog * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ sentry/src/main/java/io/sentry/Scope.java | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaec96a2e0a..dbb532e1f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) + ### Performance - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 9e8d3ee554e..282fc4df67f 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1147,7 +1147,9 @@ public SentryOptions getOptions() { @ApiStatus.Internal @Override public void clearSession() { - session = null; + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + session = null; + } } @ApiStatus.Internal From 151b497f664e05aed370dcab83278ba3c68ee826 Mon Sep 17 00:00:00 2001 From: XYZboom <58654313+XYZboom@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:52:49 +0800 Subject: [PATCH 131/276] Add @Throws on SentryOkHttpInterceptor::intercept. (#5654) Fixes #5653 --- CHANGELOG.md | 4 ++++ .../src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 1 + 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb532e1f82..4a37500b9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Behavioral Changes + +- `SentryOkHttpInterceptor::intercept` now throws `IOException`. This is a source-only and Java-only breaking change ([#5654](https://github.com/getsentry/sentry-java/pull/5654)) + ### Fixes - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index ea8fdb44159..7031be3b0b3 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -77,6 +77,7 @@ public open class SentryOkHttpInterceptor( } @Suppress("LongMethod") + @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() From 012eaebafc1507c0a4767236b7acc5c26fca1988 Mon Sep 17 00:00:00 2001 From: Chris Aigner <25478494+christophaigner@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:55:43 +0200 Subject: [PATCH 132/276] docs: Add AI Use section to CONTRIBUTING.md (#5659) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eb38413d64..f4354c72a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,3 +68,8 @@ issue without a closing keyword is not enough. Build and tests are automatically run against branches and pull requests via GH Actions. + + +# AI Use + +You are welcome to use whatever tools you prefer for making a contribution. However, any changes you propose have to be reviewed and tested by you, a human, first, before you submit a pull request with them for the Sentry team to review. If we feel like that did not happen, we will close the PR outright. For example, we will not review visibly AI-generated PRs from an agent instructed to look for and "fix" open issues in the repo. This aligns with our SDK principle: [every line has an owner](https://develop.sentry.dev/sdk/getting-started/principles/#every-line-has-an-owner). From 8fe8bad58f1cfd746f853286f0f241a3f2c5b3fb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:20:16 +0200 Subject: [PATCH 133/276] perf: Reduce reflection cost during SDK init (Init Reflection stack) (#5634) * collection: Reduce reflection cost during SDK init * perf(core): [Init Reflection 1] Probe class availability without initializing (#5635) * perf(core): Probe class availability without initializing the class LoadClass.loadClass used Class.forName(name) which initializes the class. Used purely for availability probing during init, this eagerly runs unrelated static initializers (e.g. Compose's Owner, the fragment integration). Use Class.forName(name, false, classLoader) so the class is only initialized lazily on first real use. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * changelog: move init reflection entries to Performance * perf(core): Limit no-init class probing to isClassAvailable The previous change made loadClass itself skip class initialization, which affected callers that load a class to actually use it (NDK integration, OTEL span factory and scopes storage). Restore loadClass to its initializing behavior and confine the non-initializing probe to isClassAvailable, which is only ever used for classpath availability checks. This keeps SDK init cheap while leaving real-use callers unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../main/java/io/sentry/util/LoadClass.java | 34 ++++++++- .../test/java/io/sentry/util/LoadClassTest.kt | 70 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/util/LoadClassTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a37500b9a1..dce1fd22d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Performance - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) +- Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) ## 8.46.0 diff --git a/sentry/src/main/java/io/sentry/util/LoadClass.java b/sentry/src/main/java/io/sentry/util/LoadClass.java index 1946ce8381f..2c39cace39b 100644 --- a/sentry/src/main/java/io/sentry/util/LoadClass.java +++ b/sentry/src/main/java/io/sentry/util/LoadClass.java @@ -12,15 +12,23 @@ public class LoadClass { /** - * Try to load a class via reflection + * Loads and initializes a class via reflection. Use this when you intend to actually use the + * class (e.g. instantiate it or invoke its methods). The returned class is fully initialized, so + * its static initializers run. To merely check whether a class is on the classpath, use {@link + * #isClassAvailable} instead, which avoids running those initializers. * * @param clazz the full class name * @param logger an instance of ILogger * @return a Class<?> if it's available, or null */ public @Nullable Class loadClass(final @NotNull String clazz, final @Nullable ILogger logger) { + return loadClass(clazz, logger, true); + } + + private @Nullable Class loadClass( + final @NotNull String clazz, final @Nullable ILogger logger, final boolean initialize) { try { - return Class.forName(clazz); + return Class.forName(clazz, initialize, LoadClass.class.getClassLoader()); } catch (ClassNotFoundException e) { if (logger != null) { logger.log(SentryLevel.INFO, "Class not available: " + clazz); @@ -37,8 +45,19 @@ public class LoadClass { return null; } + /** + * Probes whether a class is on the classpath without initializing it. Use this for availability + * checks (e.g. deciding whether to register an integration); the class is not initialized, so its + * static initializers do not run until something actually uses it. This keeps SDK init cheap by + * not triggering unrelated initializers. If you need to use the class, use {@link #loadClass} + * instead. + * + * @param clazz the full class name + * @param logger an instance of ILogger + * @return true if the class is on the classpath + */ public boolean isClassAvailable(final @NotNull String clazz, final @Nullable ILogger logger) { - return loadClass(clazz, logger) != null; + return loadClass(clazz, logger, false) != null; } public boolean isClassAvailable( @@ -46,6 +65,15 @@ public boolean isClassAvailable( return isClassAvailable(clazz, options != null ? options.getLogger() : null); } + /** + * Like {@link #isClassAvailable}, but defers the (non-initializing) availability check until the + * result is first read. Use this when the check itself should not run during SDK init but only + * later, on first access. + * + * @param clazz the full class name + * @param logger an instance of ILogger + * @return a lazily-evaluated availability check + */ public LazyEvaluator isClassAvailableLazy( final @NotNull String clazz, final @Nullable ILogger logger) { return new LazyEvaluator<>(() -> isClassAvailable(clazz, logger)); diff --git a/sentry/src/test/java/io/sentry/util/LoadClassTest.kt b/sentry/src/test/java/io/sentry/util/LoadClassTest.kt new file mode 100644 index 00000000000..7a8bc802049 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/LoadClassTest.kt @@ -0,0 +1,70 @@ +package io.sentry.util + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LoadClassTest { + @Test + fun `loadClass returns the class when it is available`() { + assertNotNull(LoadClass().loadClass("io.sentry.SentryEvent", null)) + } + + @Test + fun `loadClass returns null when the class is not available`() { + assertNull(LoadClass().loadClass("io.sentry.ThisClassDoesNotExist", null)) + } + + @Test + fun `isClassAvailable reflects whether the class is on the classpath`() { + val loadClass = LoadClass() + assertNotNull(loadClass.loadClass("io.sentry.SentryEvent", null)) + assertFalse( + loadClass.isClassAvailable("io.sentry.ThisClassDoesNotExist", null as io.sentry.ILogger?) + ) + } + + @Test + fun `isClassAvailable does not run the static initializer of the probed class`() { + // Reading the flag initializes the flag holder, not the probe. + assertFalse(IsClassAvailableNoInitFlag.initialized) + + // Obtaining the name via ::class.java does not initialize the probe either. + LoadClass() + .isClassAvailable(IsClassAvailableNoInitProbe::class.java.name, null as io.sentry.ILogger?) + + // Availability probing must not trigger the probe's static initializer. + assertFalse(IsClassAvailableNoInitFlag.initialized) + } + + @Test + fun `loadClass runs the static initializer of the loaded class`() { + assertFalse(LoadClassInitFlag.initialized) + + LoadClass().loadClass(LoadClassInitProbe::class.java.name, null) + + assertTrue(LoadClassInitFlag.initialized) + } +} + +private object IsClassAvailableNoInitFlag { + @JvmField var initialized = false +} + +private object IsClassAvailableNoInitProbe { + init { + IsClassAvailableNoInitFlag.initialized = true + } +} + +private object LoadClassInitFlag { + @JvmField var initialized = false +} + +private object LoadClassInitProbe { + init { + LoadClassInitFlag.initialized = true + } +} From e279b061f72b2bbcf3b1b3c178024933e0031f60 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:45:14 +0200 Subject: [PATCH 134/276] perf(android): Avoid exception-driven control flow in getResourceId (#5631) * perf(android): Avoid exception-driven control flow in getResourceId ViewUtils.getResourceId threw Resources.NotFoundException for views with no id or a generated id, and callers caught and discarded it. During a view-hierarchy snapshot and on every gesture this ran per view, so in Compose-heavy apps where most views have generated ids the SDK constructed an exception (and a native stack trace fill) per view on the main thread. Add a non-throwing resolveResourceId that returns null for unresolved ids and route the hot callers through it. The public getResourceId remains as a throwing wrapper for backward compatibility. Behavior (emitted identifiers and fallbacks) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../core/ViewHierarchyEventProcessor.java | 6 +- .../AndroidViewGestureTargetLocator.java | 10 +- .../core/internal/gestures/ViewUtils.java | 29 ++-- .../core/internal/gestures/ViewUtilsTest.kt | 126 ++++++++---------- 5 files changed, 85 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dce1fd22d22..6f9baf77a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) +- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) ## 8.46.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index c32b05892f9..7090985a38b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { node.setType(className); try { - final String identifier = ViewUtils.getResourceId(view); - node.setIdentifier(identifier); + final @Nullable String identifier = ViewUtils.getResourceIdOrNull(view); + if (identifier != null) { + node.setIdentifier(identifier); + } } catch (Throwable e) { // ignored } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index c85fb80dc35..5f6187cd39a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,6 +1,5 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -42,13 +41,12 @@ && isViewScrollable(view, isAndroidXAvailable.getValue())) { } private UiElement createUiElement(final @NotNull View targetView) { - try { - final String resourceName = ViewUtils.getResourceId(targetView); - @Nullable String className = ClassUtil.getClassName(targetView); - return new UiElement(targetView, className, resourceName, null, ORIGIN); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView); + if (resourceName == null) { return null; } + @Nullable String className = ClassUtil.getClassName(targetView); + return new UiElement(targetView, className, resourceName, null, ORIGIN); } private static boolean isViewTappable(final @NotNull View view) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 6f52612e50d..78c73713bd4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -150,32 +150,37 @@ private static final class ViewWithLocation { * @return human-readable view id */ static String getResourceIdWithFallback(final @NotNull View view) { - final int viewId = view.getId(); - try { - return getResourceId(view); - } catch (Resources.NotFoundException e) { + final @Nullable String resourceId = getResourceIdOrNull(view); + if (resourceId == null) { // fall back to hex representation of the id - return "0x" + Integer.toString(viewId, 16); + return "0x" + Integer.toString(view.getId(), 16); } + return resourceId; } /** - * Retrieves the human-readable view id based on {@code view.getContext().getResources()}. + * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or + * {@code null} when the view has no resource-backed id. Returning {@code null} rather than + * throwing avoids exception-driven control flow on hot, main-thread paths such as view-hierarchy + * snapshots and gesture target resolution. * * @param view - the view whose id is being retrieved - * @return human-readable view id - * @throws Resources.NotFoundException in case the view id was not found + * @return human-readable view id, or {@code null} if it cannot be resolved */ - public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + public static @Nullable String getResourceIdOrNull(final @NotNull View view) { final int viewId = view.getId(); if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { - throw new Resources.NotFoundException(); + return null; } final Resources resources = view.getContext().getResources(); - if (resources != null) { + if (resources == null) { + return ""; + } + try { return resources.getResourceEntryName(viewId); + } catch (Resources.NotFoundException e) { + return null; } - return ""; } private static boolean isViewIdGenerated(int id) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 10064b1cd74..ed3e6d8ca89 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -11,13 +11,11 @@ import io.sentry.internal.gestures.UiElement import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -25,71 +23,6 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class ViewUtilsTest { - @Test - fun `getResourceId returns resourceId when available`() { - val view = - mock { - whenever(it.id).doReturn(0x7f010001) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") - whenever(context.resources).thenReturn(resources) - whenever(it.context).thenReturn(context) - } - - assertEquals(ViewUtils.getResourceId(view), "test_view") - } - - @Test - fun `getResourceId throws when resource id is not available`() { - val view = - mock { - whenever(it.id).doReturn(View.generateViewId()) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(any())).doThrow(Resources.NotFoundException()) - whenever(context.resources).thenReturn(resources) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - } - - @Test - fun `when view has no id set, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - - val view = - mock { - whenever(it.id).doReturn(View.NO_ID) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources - } - - @Test - fun `when view id is generated, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - - val view = - mock { - // View.generateViewId() starts with 1 - whenever(it.id).doReturn(1) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources - } - @Test fun `findTarget hit-tests children in their own local coordinate space`() { val child = clickableChild() @@ -178,6 +111,65 @@ class ViewUtilsTest { gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) } + @Test + fun `getResourceIdOrNull returns resource name when available`() { + val view = + mock { + whenever(it.id).doReturn(0x7f010001) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertEquals("test_view", ViewUtils.getResourceIdOrNull(view)) + } + + @Test + fun `getResourceIdOrNull returns null without throwing for generated id`() { + val context = mock() + val view = + mock { + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources + } + + @Test + fun `getResourceIdOrNull returns null without throwing when view has no id`() { + val context = mock() + val view = + mock { + whenever(it.id).doReturn(View.NO_ID) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources + } + + @Test + fun `getResourceIdOrNull returns null without throwing when resource not found`() { + val view = + mock { + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view = From 3859a2cc37716e2c8d9f149a0d94a1552f22c248 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:58:42 +0200 Subject: [PATCH 135/276] perf(android): Defer SentryFrameMetricsCollector thread startup (#5641) * perf(android): Start frame metrics thread lazily on first collection SentryFrameMetricsCollector created and started its HandlerThread in the constructor, blocking the calling thread (the main thread during SDK init) on HandlerThread.getLooper(). The handler is only needed once startCollection() registers a listener, so start the thread lazily there instead. Apps that never collect frame metrics no longer start the thread at all. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../util/SentryFrameMetricsCollector.java | 35 +++++++++++++++---- .../util/SentryFrameMetricsCollectorTest.kt | 10 ++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9baf77a16..fb4fee3db81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) +- Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) ## 8.46.0 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 241ab1e4cca..4f76a51e86f 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 @@ -14,12 +14,14 @@ import android.view.Window; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -45,7 +47,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); private final @NotNull ILogger logger; - private @Nullable Handler handler; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -113,12 +116,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + // The frame metrics HandlerThread is started lazily on the first startCollection() call. + // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -281,12 +280,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } + ensureHandlerThreadStarted(); final String uid = SentryUUID.generateSentryId(); listenerMap.put(uid, listener); trackCurrentWindow(); return uid; } + /** + * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the + * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during + * SDK init) until the thread is ready, and the handler is only needed once collection starts. + */ + private void ensureHandlerThreadStarted() { + if (handler != null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { + if (handler == null) { + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index 02f65665a9e..f90c07b70e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -141,6 +141,16 @@ class SentryFrameMetricsCollectorTest { assertNotNull(id) } + @Test + fun `handler thread is started lazily on first startCollection`() { + val collector = fixture.getSut(context) + // not started during construction (would block the main thread on getLooper at SDK init) + assertNull(collector.getProperty("handler")) + + collector.startCollection(mock()) + assertNotNull(collector.getProperty("handler")) + } + @Test fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { val collector = fixture.getSut(context) From 307edcd968452d07d801c46362bf98f815fea808 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 30 Jun 2026 03:50:02 -0700 Subject: [PATCH 136/276] refactor: do not start redundant UI event transaction when one is already on Scope (#5658) SentryGestureListener.startTracing always started a UI transaction and only later, in applyScope, declined to bind it when the Scope already held a manually-bound transaction. The unbound UI transaction then gathered no children and was dropped as an idle transaction. Now we read the Scope's bound transaction first and return early without starting a new one when it is present. Fixes #5491 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- CHANGELOG.md | 2 ++ .../internal/gestures/SentryGestureListener.java | 15 +++++++++++++++ .../gestures/SentryGestureListenerTracingTest.kt | 12 ++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4fee3db81..73bb2ef396e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixes +- Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) + - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) ### Performance diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 8caffedad94..61a32b675db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -244,6 +244,21 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur } } + // if there's already a transaction bound to the Scope (e.g. started manually by the user), we + // skip starting a new UI transaction: it would never be bound to the Scope in applyScope, would + // gather no children, and would be dropped as an idle transaction without children + final @Nullable ITransaction[] boundTransaction = {null}; + scopes.configureScope(scope -> boundTransaction[0] = scope.getTransaction()); + if (boundTransaction[0] != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction won't be created for view with id: %s since there's already a transaction bound to the Scope.", + viewIdentifier); + return; + } + // we can only bind to the scope if there's no running transaction final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + getGestureType(eventType); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index fe994f4a828..9d7606bfe44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -160,6 +160,18 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) } + @Test + fun `when a transaction is already bound to the Scope, does not start a new UI transaction`() { + val sut = fixture.getSut() + val boundTransaction = SentryTracer(TransactionContext("bound", "op"), fixture.scopes) + whenever(fixture.scope.transaction).thenReturn(boundTransaction) + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).startTransaction(any(), any()) + assertEquals(false, boundTransaction.isFinished) + } + @Test fun `stopTracing remove transaction from scope`() { val sut = fixture.getSut() From 58b65f0fd57114f98e9f2bd4517e8ffae1d51e05 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 1 Jul 2026 11:48:59 +0200 Subject: [PATCH 137/276] chore: Add PR template checkbox for cross sdk review on public API changes (#5665) Add PR template checkbox for cross sdk review on public API changes --- .github/pull_request_template.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b337ac9ea4e..e4a12165077 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,6 +25,7 @@ - [ ] Review from the native team if needed. - [ ] No breaking change or entry added to the changelog. - [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs. +- [ ] Public API changes reviewed by another Mobile SDK team member or implemented according to the [develop docs](https://develop.sentry.dev/) spec. ## :crystal_ball: Next steps From d06126055527212a23f245ea8640d20b61bb5cd2 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:32:56 -0700 Subject: [PATCH 138/276] fix: guard executor shutdown in BaseCaptureStrategy.stop() (#5627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: guard executor shutdown in BaseCaptureStrategy.stop() Each start/stop cycle leaked one SentryReplayPersister-* thread because stop() reset delegated properties (segmentTimestamp, currentReplayId) whose setters dispatch to persistingExecutor, initialising the lazy — but stop() never shut it down. Replace the lazy delegate with an explicit nullable holder so the executor is only created when actually needed and can be detected at stop() time. Call shutdownNow() (non-blocking) rather than the blocking shutdown() to avoid ANRs when stop() runs on the main thread. Fixes #5564 * style: apply spotless formatting * refactor(replay): move persistingExecutor ownership to ReplayIntegration Move persistingExecutor out of BaseCaptureStrategy and into ReplayIntegration, passing it as a constructor argument to CaptureStrategy subclasses. Shut it down in ReplayIntegration.close() alongside replayExecutor so executor lifecycle is managed in one place. * Fix leak in ReplayIntegration due to persisting executor not being shut down Add the persistingExecutor argument to SessionCaptureStrategy and BufferCaptureStrategy constructor calls in tests, and add changelog entry. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove stray merge conflict marker from CHANGELOG.md Co-Authored-By: Claude Opus 4.6 (1M context) * Remove no-op leak test from SessionCaptureStrategyTest The test used a mocked executor that never spawned threads, so the thread-count assertion was always true regardless of the fix. The executor lifecycle is now owned by ReplayIntegration, not SessionCaptureStrategy, so the test belonged at the wrong layer. Co-Authored-By: Claude Opus 4.6 (1M context) * Add executor leak regression test to ReplayIntegrationTest Uses real ScheduledThreadPoolExecutor threads so the test actually fails if the shutdown in close() is removed. Co-Authored-By: Claude Opus 4.6 (1M context) * Use shutdownNow() for replay executors in close() to avoid ANR shutdown() calls awaitTermination() which blocks up to shutdownTimeoutMillis. Since close() can run on the main thread (via Sentry.close() from hybrid SDKs), this risks an ANR. shutdownNow() is non-blocking and sufficient at teardown. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Roman Zavarnitsyn Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../android/replay/ReplayIntegration.kt | 36 +++++++++++++++++-- .../replay/capture/BaseCaptureStrategy.kt | 19 +--------- .../replay/capture/BufferCaptureStrategy.kt | 8 +++-- .../replay/capture/SessionCaptureStrategy.kt | 11 +++++- .../replay/util/ReplayExecutorService.kt | 8 +++++ .../android/replay/ReplayIntegrationTest.kt | 27 ++++++++++++++ .../capture/BufferCaptureStrategyTest.kt | 6 ++++ .../capture/SessionCaptureStrategyTest.kt | 8 +++++ 9 files changed, 101 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bb2ef396e..b2f260761b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) +- Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) ### Performance 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 116ab45af06..612517438f6 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 @@ -107,10 +107,17 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } - private val replayExecutor by lazy { + internal val lazyReplayExecutor = lazy { val delegate = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) ReplayExecutorService(delegate, options) } + internal val replayExecutor by lazyReplayExecutor + internal val lazyPersistingExecutor = lazy { + val delegate = + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + ReplayExecutorService(delegate, options) + } + internal val persistingExecutor by lazyPersistingExecutor internal val isEnabled = AtomicBoolean(false) internal val isManualPause = AtomicBoolean(false) @@ -192,6 +199,7 @@ public class ReplayIntegration( scopes, dateProvider, replayExecutor, + persistingExecutor, replayCacheProvider, ) } else { @@ -201,6 +209,7 @@ public class ReplayIntegration( dateProvider, random, replayExecutor, + persistingExecutor, replayCacheProvider, ) } @@ -373,7 +382,20 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - replayExecutor.shutdown() + if (lazyReplayExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + replayExecutor.gracefulShutdown() + } else { + replayExecutor.shutdown() + } + } + if (lazyPersistingExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + persistingExecutor.gracefulShutdown() + } else { + persistingExecutor.shutdown() + } + } lifecycle.currentState = CLOSED } } @@ -554,4 +576,14 @@ public class ReplayIntegration( return ret } } + + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } } 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 dab98ec4e24..6bb58c5e2a2 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 @@ -25,7 +25,6 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.ReplayExecutorService import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent @@ -34,9 +33,7 @@ import java.io.File import java.util.Date import java.util.Deque import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference @@ -50,6 +47,7 @@ internal abstract class BaseCaptureStrategy( private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, protected val replayExecutor: ScheduledExecutorService, + protected val persistingExecutor: ScheduledExecutorService, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : CaptureStrategy { internal companion object { @@ -58,11 +56,6 @@ internal abstract class BaseCaptureStrategy( private const val MAX_TRACE_IDS = 100 } - private val persistingExecutor: ScheduledExecutorService by lazy { - val delegate = - Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) - ReplayExecutorService(delegate, options) - } private val gestureConverter = ReplayGestureConverter(dateProvider) protected val isTerminating = AtomicBoolean(false) @@ -192,16 +185,6 @@ internal abstract class BaseCaptureStrategy( } } - private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayPersister-" + cnt++) - ret.setDaemon(true) - return ret - } - } - private inline fun persistableAtomicNullable( initialValue: T? = null, propertyName: String, 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 0eea2043bd8..0df8a642f63 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 @@ -33,6 +33,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, private val random: Random, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : BaseCaptureStrategy( @@ -40,6 +41,7 @@ internal class BufferCaptureStrategy( scopes, dateProvider, executor, + persistingExecutor, replayCacheProvider = replayCacheProvider, ) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is @@ -150,8 +152,10 @@ internal class BufferCaptureStrategy( ) return this } - // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of + // execution + val captureStrategy = + SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor) captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, 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 4d3ee588f01..d62efb534cc 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 @@ -21,8 +21,17 @@ internal class SessionCaptureStrategy( private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, -) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { +) : + BaseCaptureStrategy( + options, + scopes, + dateProvider, + executor, + persistingExecutor, + replayCacheProvider, + ) { internal companion object { private const val TAG = "SessionCaptureStrategy" } 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 31a3279d074..9e9491f516f 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 @@ -57,6 +57,14 @@ internal class ReplayExecutorService( } } } + + fun gracefulShutdown() { + synchronized(this) { + if (!isShutdown) { + delegate.shutdown() + } + } + } } internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate 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 3df0c9f005f..61b5213e76f 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 @@ -754,6 +754,12 @@ class ReplayIntegrationTest { null } }, + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) { _ -> fixture.replayCache } @@ -1104,6 +1110,20 @@ class ReplayIntegrationTest { assertEquals(traceId, traceIdRegistered) } + @Test + fun `close shuts down replay executors`() { + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.stop() + replay.close() + + assertTrue(replay.replayExecutor.isShutdown) + assertTrue(replay.persistingExecutor.isShutdown) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, @@ -1116,5 +1136,12 @@ class ReplayIntegrationTest { null } }, + persistingExecutor = + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) } 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 380e9b3ce75..b5048e856ff 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 @@ -111,6 +111,12 @@ class BufferCaptureStrategyTest { null } }, + mock { + whenever(it.submit(any())).doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + }, ) { _ -> replayCache } 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 b5a00bc624b..dd9e6c6ce1d 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 @@ -122,6 +122,14 @@ class SessionCaptureStrategyTest { .whenever(it) .submit(any()) }, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + .whenever(it) + .submit(any()) + }, ) { _ -> replayCache } From 0980ed763492be856f205dabfea93f14e8942878 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 10:18:42 +0200 Subject: [PATCH 139/276] perf(core): Drop per-instance lock from SentryId and SpanId (#5645) * perf(core): Drop per-instance lock from SentryId and SpanId (JAVA-589) SentryId and SpanId stored their string value behind a LazyEvaluator, which allocates an AutoClosableReentrantLock (a ReentrantLock with its internal Sync) plus a capturing lambda on every instance. Since one SentryId is created per event/transaction and one SpanId per span, this per-instance lock machinery is far heavier than the single String it guards, and the eager string-arg constructors gained no laziness at all. Replace the LazyEvaluator with a plain volatile String guarded by a double-checked synchronized(this) block. Eager constructors now assign the value directly; the no-arg and UUID constructors still defer UUID-string generation. Synchronization is retained because UUID generation is non-idempotent and two racing threads must not produce different ids. Follow-up to the SDK Overhead Reduction work (#5499). Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/SpanId.java | 31 ++++++++++----- .../java/io/sentry/protocol/SentryId.java | 39 ++++++++++++------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f260761b3..2a9e71a0cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) - Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) +- Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) ## 8.46.0 diff --git a/sentry/src/main/java/io/sentry/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index fcc7f3a4f38..2048647f9f9 100644 --- a/sentry/src/main/java/io/sentry/SpanId.java +++ b/sentry/src/main/java/io/sentry/SpanId.java @@ -2,24 +2,35 @@ import static io.sentry.util.StringUtils.PROPER_NIL_UUID; -import io.sentry.util.LazyEvaluator; import java.io.IOException; import java.util.Objects; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class SpanId implements JsonSerializable { public static final SpanId EMPTY_ID = new SpanId(PROPER_NIL_UUID.replace("-", "").substring(0, 16)); - private final @NotNull LazyEvaluator lazyValue; + private volatile @Nullable String value; public SpanId(final @NotNull String value) { - Objects.requireNonNull(value, "value is required"); - this.lazyValue = new LazyEvaluator<>(() -> value); + this.value = Objects.requireNonNull(value, "value is required"); } - public SpanId() { - this.lazyValue = new LazyEvaluator<>(SentryUUID::generateSpanId); + public SpanId() {} + + private @NotNull String getValue() { + String result = value; + if (result == null) { + synchronized (this) { + result = value; + if (result == null) { + result = SentryUUID.generateSpanId(); + value = result; + } + } + } + return result; } @Override @@ -27,17 +38,17 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SpanId spanId = (SpanId) o; - return lazyValue.getValue().equals(spanId.lazyValue.getValue()); + return getValue().equals(spanId.getValue()); } @Override public int hashCode() { - return lazyValue.getValue().hashCode(); + return getValue().hashCode(); } @Override public String toString() { - return lazyValue.getValue(); + return getValue(); } // JsonElementSerializer @@ -45,7 +56,7 @@ public String toString() { @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { - writer.value(lazyValue.getValue()); + writer.value(getValue()); } // JsonElementDeserializer diff --git a/sentry/src/main/java/io/sentry/protocol/SentryId.java b/sentry/src/main/java/io/sentry/protocol/SentryId.java index a5bd7980c3f..8d85afe4639 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryId.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryId.java @@ -6,7 +6,6 @@ import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryUUID; -import io.sentry.util.LazyEvaluator; import io.sentry.util.StringUtils; import io.sentry.util.UUIDStringUtils; import java.io.IOException; @@ -19,19 +18,15 @@ public final class SentryId implements JsonSerializable { public static final SentryId EMPTY_ID = new SentryId(StringUtils.PROPER_NIL_UUID.replace("-", "")); - private final @NotNull LazyEvaluator lazyStringValue; + private volatile @Nullable String value; + private final @Nullable UUID uuid; public SentryId() { this((UUID) null); } public SentryId(@Nullable UUID uuid) { - if (uuid != null) { - this.lazyStringValue = - new LazyEvaluator<>(() -> normalize(UUIDStringUtils.toSentryIdString(uuid))); - } else { - this.lazyStringValue = new LazyEvaluator<>(SentryUUID::generateSentryId); - } + this.uuid = uuid; } public SentryId(final @NotNull String sentryIdString) { @@ -42,16 +37,30 @@ public SentryId(final @NotNull String sentryIdString) { + "or 36 characters long (completed UUID). Received: " + sentryIdString); } - if (normalized.length() == 36) { - this.lazyStringValue = new LazyEvaluator<>(() -> normalize(normalized)); - } else { - this.lazyStringValue = new LazyEvaluator<>(() -> normalized); + this.uuid = null; + this.value = normalized.length() == 36 ? normalized.replace("-", "") : normalized; + } + + private @NotNull String getValue() { + String result = value; + if (result == null) { + synchronized (this) { + result = value; + if (result == null) { + result = + uuid != null + ? normalize(UUIDStringUtils.toSentryIdString(uuid)) + : SentryUUID.generateSentryId(); + value = result; + } + } } + return result; } @Override public String toString() { - return lazyStringValue.getValue(); + return getValue(); } @Override @@ -59,12 +68,12 @@ public boolean equals(final @Nullable Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SentryId sentryId = (SentryId) o; - return lazyStringValue.getValue().equals(sentryId.lazyStringValue.getValue()); + return getValue().equals(sentryId.getValue()); } @Override public int hashCode() { - return lazyStringValue.getValue().hashCode(); + return getValue().hashCode(); } private @NotNull String normalize(@NotNull String uuidString) { From 30862fca3f9c52541d12e1d966e19f22ed22d402 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:23:22 -0700 Subject: [PATCH 140/276] fix(compose): add isImportantForBounds() to SentryTagModifierNode for compose-ui 1.11+ (#5672) * fix(compose): add isImportantForBounds() to SentryTagModifierNode for compose-ui 1.11+ compose-ui 1.11 added SemanticsModifierNode.isImportantForBounds() as an abstract method. SentryTagModifierNode was compiled against compose-ui 1.6.x, where the method does not exist, so its bytecode lacks an implementation. When an accessibility client (TalkBack, UiAutomator, adb uiautomator dump) traverses the Compose semantics tree at runtime on 1.11+, the JVM cannot find the method and throws AbstractMethodError. Adding fun isImportantForBounds(): Boolean = false without the override keyword (since the method is absent from the 1.6.x compile-time dependency) places the method in the class bytecode. The JVM satisfies the abstract method requirement via signature matching at runtime. SentryTagModifierNode stores only a semantic tag with no layout/visual effect, so false is the correct return value. * fix formatting * chore(changelog): Add Changelog entry --------- Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../kotlin/io/sentry/compose/SentryModifier.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9e71a0cf4..31c600cbcf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) - Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) +- Fix AbstractMethodError when compose-ui 1.11+ is used in combination with `Modifier.sentryTag()` or the Sentry Kotlin compiler plugin ([#5672](https://github.com/getsentry/sentry-java/pull/5672)) ### Performance 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 3fec407987b..787c66b3b0b 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -53,5 +53,15 @@ public object SentryModifier { override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } + + // SemanticsModifierNode.isImportantForBounds() was added as an abstract 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 } } From 4414d9f4cd5601bee4c95f72a868b2c1ddff1f80 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 10:27:25 +0200 Subject: [PATCH 141/276] build: Remove global per-test JVM heap cap (#5671) * build: Remove global per-test JVM heap cap The minHeapSize/maxHeapSize cap in the root build.gradle.kts was applied to every module's test task. Most modules do not need it, so remove it and let tests use the JVM defaults. If a specific module turns out to require a larger heap, the cap can be re-added to that module only. Co-Authored-By: Claude Opus 4.8 * build: Restore per-test heap cap for sentry-android-core CI showed :sentry-android-core:testReleaseUnitTest fails with OutOfMemoryError (Robolectric loading the android-all jar) once the global cap is removed. Restore the 256m/2g cap for this module only, where it is actually needed. Co-Authored-By: Claude Opus 4.8 * build: Drop stale comment about root build.gradle.kts Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- build.gradle.kts | 4 ---- sentry-android-core/build.gradle.kts | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 93c82cd8c9a..2e334f43a65 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -103,10 +103,6 @@ allprojects { TestLogEvent.PASSED, TestLogEvent.FAILED ) - - // Cap JVM args per test - minHeapSize = "256m" - maxHeapSize = "2g" } withType().configureEach { options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f7440b19494..0388b7de486 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -40,6 +40,12 @@ android { unitTests.apply { isReturnDefaultValues = true isIncludeAndroidResources = true + // Robolectric loads the android-all jar into each test JVM, which needs more heap + // than the default. + all { + it.minHeapSize = "256m" + it.maxHeapSize = "2g" + } } } From ea2a517b565d0dc5c37a0e2f22f68324f0fc724f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 11:23:30 +0200 Subject: [PATCH 142/276] perf(core): Lazily allocate AutoClosableReentrantLock (JAVA-588) (#5643) * perf(core): Lazily allocate AutoClosableReentrantLock (JAVA-588) AutoClosableReentrantLock extended ReentrantLock, so every SDK object holding one allocated a ReentrantLock (and its AbstractQueuedSynchronizer) eagerly in its field initializer. A customer Perfetto trace showed ~81 such allocations on the main thread during SentryAndroid.init, many for locks that are never acquired during init. Hold the ReentrantLock internally and create it lazily on first acquire(), using an AtomicReferenceFieldUpdater CAS so creation stays atomic and Loom-friendly (no synchronized, preserving #3715). Every call site uses acquire() only, so dropping the ReentrantLock superclass touches no caller. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * perf(core): Harden lazy lock init and mark AutoClosableReentrantLock internal (JAVA-588) Replace the unreachable candidate fallback after a failed CAS with an explicit non-null check, so a broken invariant fails loudly instead of handing two threads different locks. Mark the class @ApiStatus.Internal and make the lazy-allocation test assert the lock field directly. Co-Authored-By: Claude Fable 5 * perf(core): Return the lock itself as the lifecycle token (JAVA-588) Every acquire() allocated a fresh lifecycle token, which is per-use garbage on every lock acquisition forever, not just at init. The token was stateless apart from its lock reference, so AutoClosableReentrantLock now implements ISentryLifecycleToken itself and acquire() returns this, making the steady-state acquire/close path allocation-free. Semantics are unchanged: try-with-resources closes once per acquire, so reentrant acquires stay balanced, and unlocking without holding the lock still throws IllegalMonitorStateException. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + sentry/api/sentry.api | 3 +- .../util/AutoClosableReentrantLock.java | 69 +++++++++++++++---- .../util/AutoClosableReentrantLockTest.kt | 58 ++++++++++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c600cbcf4..95a6a5b5f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) - Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) - Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) +- Lazily allocate the `ReentrantLock` backing `AutoClosableReentrantLock` to avoid eager lock allocations for SDK objects that never contend during `SentryAndroid.init` ([#5643](https://github.com/getsentry/sentry-java/pull/5643)) ## 8.46.0 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 04c876fdbdb..383ea92b116 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7589,9 +7589,10 @@ public abstract class io/sentry/transport/TransportResult { public static fun success ()Lio/sentry/transport/TransportResult; } -public final class io/sentry/util/AutoClosableReentrantLock : java/util/concurrent/locks/ReentrantLock { +public final class io/sentry/util/AutoClosableReentrantLock : io/sentry/ISentryLifecycleToken { public fun ()V public fun acquire ()Lio/sentry/ISentryLifecycleToken; + public fun close ()V } public final class io/sentry/util/CheckInUtils { diff --git a/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java b/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java index 2a95a58b5fe..cf53d860e08 100644 --- a/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java +++ b/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java @@ -1,29 +1,70 @@ package io.sentry.util; import io.sentry.ISentryLifecycleToken; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.concurrent.locks.ReentrantLock; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; -public final class AutoClosableReentrantLock extends ReentrantLock { +/** + * Hands out an {@link ISentryLifecycleToken} from {@link #acquire()} for use with + * try-with-resources (replacing {@code synchronized} blocks). + * + *

The underlying {@link ReentrantLock} is created lazily on the first {@link #acquire()}. Many + * SDK objects hold a lock but never contend on it (especially during {@code SentryAndroid.init}), + * so the eager allocation of a {@link ReentrantLock} (and its {@code AbstractQueuedSynchronizer}) + * was pure GC and main-thread overhead. We keep a {@link ReentrantLock} rather than reverting to + * {@code synchronized} to stay friendly to virtual threads (Loom), see #3715. + * + *

{@link #acquire()} returns this instance as the token, so the steady-state acquire/close path + * allocates nothing. Reentrant acquires stay balanced because try-with-resources calls {@link + * #close()} exactly once per acquire. + */ +@ApiStatus.Internal +public final class AutoClosableReentrantLock implements ISentryLifecycleToken { - private static final long serialVersionUID = -3283069816958445549L; + private static final @NotNull AtomicReferenceFieldUpdater< + AutoClosableReentrantLock, ReentrantLock> + LOCK_UPDATER = + AtomicReferenceFieldUpdater.newUpdater( + AutoClosableReentrantLock.class, ReentrantLock.class, "lock"); - public ISentryLifecycleToken acquire() { - lock(); - return new AutoClosableReentrantLockLifecycleToken(this); - } + private volatile @Nullable ReentrantLock lock; - static final class AutoClosableReentrantLockLifecycleToken implements ISentryLifecycleToken { + public @NotNull ISentryLifecycleToken acquire() { + getOrCreateLock().lock(); + return this; + } - private final @NotNull ReentrantLock lock; + @Override + public void close() { + Objects.requireNonNull(lock, "close() called before acquire()").unlock(); + } - AutoClosableReentrantLockLifecycleToken(final @NotNull ReentrantLock lock) { - this.lock = lock; + private @NotNull ReentrantLock getOrCreateLock() { + final @Nullable ReentrantLock existing = lock; + if (existing != null) { + return existing; } - - @Override - public void close() { - lock.unlock(); + final @NotNull ReentrantLock candidate = new ReentrantLock(); + if (LOCK_UPDATER.compareAndSet(this, null, candidate)) { + return candidate; } + // The CAS can only fail because another thread installed its lock first, and the field is + // never reset, so all callers end up contending on that same instance. + return Objects.requireNonNull(lock, "lock must have been set by the winning thread"); + } + + @TestOnly + boolean isLocked() { + final @Nullable ReentrantLock current = lock; + return current != null && current.isLocked(); + } + + @TestOnly + boolean isLockAllocated() { + return lock != null; } } diff --git a/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt b/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt index 4a69b9638e7..943a2c2bf70 100644 --- a/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt +++ b/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt @@ -1,7 +1,12 @@ package io.sentry.util +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue class AutoClosableReentrantLockTest { @@ -11,4 +16,57 @@ class AutoClosableReentrantLockTest { lock.acquire().use { assertTrue(lock.isLocked) } assertFalse(lock.isLocked) } + + @Test + fun `acquire returns the lock itself as the token, allocating nothing`() { + val lock = AutoClosableReentrantLock() + lock.acquire().use { token -> assertSame(lock, token) } + } + + @Test + fun `does not allocate the underlying lock until first acquire`() { + val lock = AutoClosableReentrantLock() + assertFalse(lock.isLockAllocated) + lock.acquire().use {} + assertTrue(lock.isLockAllocated) + } + + @Test + fun `supports reentrant acquire from the same thread`() { + val lock = AutoClosableReentrantLock() + lock.acquire().use { + lock.acquire().use { assertTrue(lock.isLocked) } + assertTrue(lock.isLocked) + } + assertFalse(lock.isLocked) + } + + @Test + fun `mutually excludes concurrent threads`() { + val lock = AutoClosableReentrantLock() + val inCriticalSection = AtomicInteger(0) + val maxObserved = AtomicInteger(0) + val start = CountDownLatch(1) + val threadCount = 8 + val iterations = 1000 + val threads = + (0 until threadCount).map { + Thread { + start.await() + repeat(iterations) { + lock.acquire().use { + val current = inCriticalSection.incrementAndGet() + maxObserved.accumulateAndGet(current, ::maxOf) + inCriticalSection.decrementAndGet() + } + } + } + } + threads.forEach(Thread::start) + start.countDown() + threads.forEach { it.join(TimeUnit.SECONDS.toMillis(10)) } + + assertEquals(1, maxObserved.get()) + assertFalse(lock.isLocked) + } } From 844c3e85ea53e298315edb075db1fd39bfdd47f0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 11:24:49 +0200 Subject: [PATCH 143/276] build: Suppress obsolete Java 8 option warning under JDK 21+ (#5664) The root build compiles Java with -Xlint:all -Werror. On JDK 21+, javac flags -source/-target 8 as obsolete, and -Werror promotes that warning to an error, failing :sentry:compileJava. CI pins JDK 17, where the warning does not exist, so this only breaks local builds and tooling on newer JDKs (e.g. the Kotlin LSP's bundled JDK 25, whose Gradle project import aborts and loses cross-module resolution). Add -Xlint:-options, the suppression javac itself recommends when intentionally targeting an older release. Co-authored-by: Claude Opus 4.8 (1M context) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2e334f43a65..55b5a71a1e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -105,7 +105,7 @@ allprojects { ) } withType().configureEach { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) } } } From ac08f86d4a15996d553eceef3d1cdfbbfe6bcc32 Mon Sep 17 00:00:00 2001 From: markushi <1411808+markushi@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:52:17 +0000 Subject: [PATCH 144/276] release: 8.47.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a6a5b5f67..5c43f0976ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.47.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index 804e4b58573..4c0a1cffd1e 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.46.0 +versionName=8.47.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 7d8a3947cce374aa65ec6b9e702733ff89a0f29d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jul 2026 13:44:30 +0200 Subject: [PATCH 145/276] fix(android-fragment): support detach/attach navigation in fragment tracing (#5660) * fix(android-fragment): support detach/attach navigation in fragment tracing For detach/attach tab navigation (manual tab switching, ViewPager v1 with FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is skipped for off-screen fragments that are re-attached. Previously this left ui.load spans open until the 30s activity transaction deadline, producing inflated performance data. Fix by calling startTracing in onFragmentViewCreated as well as onFragmentCreated. startTracing is idempotent (no-op if a span is already running), so the normal onFragmentCreated -> onFragmentViewCreated path is unaffected. Add matching stopTracing calls in onFragmentResumed (covers the detach/attach path where onFragmentStarted may be skipped) and onFragmentViewDestroyed (failsafe for fragments destroyed before reaching STARTED or RESUMED). stopTracing is also idempotent, so the normal path is unaffected. Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> * Format code * Add changelog entry and detach/attach sample Co-Authored-By: Claude Opus 4.6 (1M context) * Remove duplicate test methods in fragment lifecycle test Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Update screen name on scope for detach/attach fragment re-attachment onFragmentCreated is skipped during detach/attach navigation, so the screen name was never updated for re-attached fragments. Mirror the screen tracking into onFragmentViewCreated to cover that path. Co-Authored-By: Claude Opus 4.6 (1M context) * Format code * Address PR feedback: internalize guards into startTracing and fix sample layout Move isAdded check, screen tracking, and tracing logic into startTracing() to deduplicate guards from onFragmentCreated and onFragmentViewCreated. Fix DetachAttachTabsActivity sample rendering on API 35+ by using NoActionBar theme and fitsSystemWindows. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Decouple screen tracking from performance tracing in fragments Screen name updates on scope should work independently of whether performance tracing is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Sentry Github Bot Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../SentryFragmentLifecycleCallbacks.kt | 39 ++++-- .../SentryFragmentLifecycleCallbacksTest.kt | 117 +++++++++++++++++- .../src/main/AndroidManifest.xml | 5 + .../android/DetachAttachTabsActivity.kt | 50 ++++++++ .../io/sentry/samples/android/MainActivity.kt | 12 ++ .../layout/activity_detach_attach_tabs.xml | 32 +++++ .../src/main/res/layout/fragment_tab.xml | 12 ++ 8 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/fragment_tab.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c43f0976ed..3a290e2b3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660)) - Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 230510fb4de..374713ba969 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -76,14 +76,7 @@ public class SentryFragmentLifecycleCallbacks( ) { addBreadcrumb(fragment, FragmentLifecycleState.CREATED) - // we only start the tracing for the fragment if the fragment has been added to its activity - // and not only to the backstack - if (fragment.isAdded) { - if (scopes.options.isEnableScreenTracking) { - scopes.configureScope { it.screen = getFragmentName(fragment) } - } - startTracing(fragment) - } + startTracing(fragment) } override fun onFragmentViewCreated( @@ -93,17 +86,30 @@ public class SentryFragmentLifecycleCallbacks( savedInstanceState: Bundle?, ) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_CREATED) + + // For detach/attach navigation (e.g. manual tab switching, ViewPager v1 with + // FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is never called for + // off-screen fragments that are re-attached. Starting here enables a narrower + // "view created -> resumed" span for those paths. startTracing is idempotent, so for the + // normal onFragmentCreated -> onFragmentViewCreated path this is a no-op. + startTracing(fragment) } override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.STARTED) - // ViewPager2 locks background fragments to STARTED state + // ViewPager2 locks background fragments to STARTED state, so we stop here to avoid + // spans hanging for off-screen fragments that never reach RESUMED. stopTracing(fragment) } override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.RESUMED) + + // For detach/attach navigation, onFragmentStarted may not fire before onFragmentResumed. + // If a span is still running here, stop it now. stopTracing is idempotent, so this is a + // no-op for the normal path where onFragmentStarted already stopped the span. + stopTracing(fragment) } override fun onFragmentPaused(fragmentManager: FragmentManager, fragment: Fragment) { @@ -116,6 +122,10 @@ public class SentryFragmentLifecycleCallbacks( override fun onFragmentViewDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_DESTROYED) + + // Failsafe: cancel any span that didn't finish via the normal started/resumed path + // (e.g. fragment view destroyed before reaching STARTED or RESUMED). + stopTracing(fragment) } override fun onFragmentDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { @@ -153,6 +163,16 @@ public class SentryFragmentLifecycleCallbacks( fragmentsWithOngoingTransactions.containsKey(fragment) private fun startTracing(fragment: Fragment) { + if (!fragment.isAdded) { + return + } + + val fragmentName = getFragmentName(fragment) + + if (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = fragmentName } + } + if (!isPerformanceEnabled || isRunningSpan(fragment)) { return } @@ -160,7 +180,6 @@ public class SentryFragmentLifecycleCallbacks( var transaction: ISpan? = null scopes.configureScope { transaction = it.transaction } - val fragmentName = getFragmentName(fragment) val span = transaction?.startChild(FRAGMENT_LOAD_OP, fragmentName) span?.let { diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 9446e1caef5..997c1206398 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -43,9 +43,15 @@ class SentryFragmentLifecycleCallbacksTest { enableAutoFragmentLifecycleTracing: Boolean = false, tracesSampleRate: Double? = 1.0, isAdded: Boolean = true, + enableScreenTracking: Boolean = false, ): SentryFragmentLifecycleCallbacks { whenever(scopes.options) - .thenReturn(SentryOptions().apply { setTracesSampleRate(tracesSampleRate) }) + .thenReturn( + SentryOptions().apply { + setTracesSampleRate(tracesSampleRate) + isEnableScreenTracking = enableScreenTracking + } + ) whenever(span.spanContext) .thenReturn(SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null)) whenever(transaction.startChild(any(), any())).thenReturn(span) @@ -251,6 +257,115 @@ class SentryFragmentLifecycleCallbacksTest { verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) } + @Test + fun `When fragment view is created via detach-attach, it should start tracing if enabled`() { + // Simulates detach/attach navigation: onFragmentCreated is NOT called, only + // onFragmentViewCreated + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction) + .startChild( + check { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @Test + fun `When fragment view is created via detach-attach, it should update screen name`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + } + + @Test + fun `When performance is disabled, it should still update screen name`() { + val sut = + fixture.getSut(enableAutoFragmentLifecycleTracing = false, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + verify(fixture.transaction, never()).startChild(any(), any()) + } + + @Test + fun `When fragment view is created after onFragmentCreated, it should not start a second span`() { + // Normal path: onFragmentCreated already started the span; onFragmentViewCreated is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction).startChild(any(), any()) + } + + @Test + fun `When fragment is resumed, it should stop tracing if span is still running`() { + // Simulates detach/attach path where onFragmentStarted may be skipped + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + + @Test + fun `When fragment is resumed after started, it should not double-finish the span`() { + // Normal path: onFragmentStarted already stopped the span; onFragmentResumed is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentStarted(fixture.fragmentManager, fixture.fragment) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(any()) + } + + @Test + fun `When fragment view is destroyed before started, it should stop tracing as failsafe`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + private fun verifyBreadcrumbAdded(expectedState: String) { verify(fixture.scopes) .addBreadcrumb( diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 1150dd5ef2e..d72087fbfa5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -65,6 +65,11 @@ android:name=".ThirdActivityFragment" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt new file mode 100644 index 00000000000..3a38814c5d8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt @@ -0,0 +1,50 @@ +package io.sentry.samples.android + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit + +class DetachAttachTabsActivity : AppCompatActivity(R.layout.activity_detach_attach_tabs) { + + private val tags = arrayOf("tab_a", "tab_b") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + findViewById(R.id.btn_tab_a).setOnClickListener { showTab(0) } + findViewById(R.id.btn_tab_b).setOnClickListener { showTab(1) } + + if (savedInstanceState == null) { + val tabB = TabFragmentB() + supportFragmentManager.commit { + add(R.id.tab_container, TabFragmentA(), tags[0]) + add(R.id.tab_container, tabB, tags[1]) + detach(tabB) + } + } + } + + private fun showTab(index: Int) { + supportFragmentManager.commit { + for (i in tags.indices) { + val frag = supportFragmentManager.findFragmentByTag(tags[i]) ?: continue + if (i == index) attach(frag) else detach(frag) + } + } + } +} + +class TabFragmentA : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab A" + } +} + +class TabFragmentB : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab B" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index b87e7a3190c..d53f7e4687f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -794,6 +794,18 @@ fun IntegrationsScreen() { } } } + item { + SentryTraced("open_detach_attach_tabs") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, DetachAttachTabsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Detach/Attach Tabs", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("open_permissions_activity") { OutlinedButton( diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml new file mode 100644 index 00000000000..b2dc323d185 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml @@ -0,0 +1,32 @@ + + + + + +