From 258098e31bec7a45df56c02e329158e3ba8a0991 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:02:07 +0200 Subject: [PATCH 01/48] chore: update scripts/update-sentry-native-ndk.sh to 0.17.4 (#6278) 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 13f6624bfb..496c5333ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ - Measure the hostname cache TTL on the monotonic ticker the options provide, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100)) - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) +### Dependencies + +- Bump Native SDK from v0.17.3 to v0.17.4 ([#6278](https://github.com/getsentry/sentry-java/pull/6278)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0174) + - [diff](https://github.com/getsentry/sentry-native/compare/0.17.3...0.17.4) + ## 8.61.0 ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b31df47f6..43c3a7660c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -179,7 +179,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.17.3" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.4" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From ee24060bf022a10163193a2f414ac903cd6768f1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 20 Oct 2026 14:37:33 +0200 Subject: [PATCH 02/48] feat(android): Add masked screenshot capture for Hybrid SDKs (#6281) * feat(android): Add masked screenshot capture for Hybrid SDKs Hybrid SDKs capture screenshots for their own errors through ScreenshotUtils.takeScreenshot, which ignores the screenshot masking options. Add ScreenshotUtils.takeMaskedScreenshot, backed by a shared captureMaskedScreenshot that ScreenshotEventProcessor now uses as well, so native and hybrid screenshots follow the same masking rules. Refs getsentry/sentry-react-native#6900 Co-Authored-By: Claude Opus 5.5 * changelog * ref(android): Narrow catches in ScreenshotMasking Catch only InterruptedException around the main-thread wait and RuntimeException around bitmap masking. Keep the broad catch for view hierarchy traversal, which runs custom View code, but let fatal errors through with ExceptionUtils.rethrowIfFatal. Co-Authored-By: Claude Opus 5.5 * changelog: Move entry to Internal * ref(android): Move replay availability check to ScreenshotMasking Add ScreenshotMasking.isAvailable() so the replay integration class name no longer needs to be public on SentryAndroid. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../core/ScreenshotEventProcessor.java | 133 +------------- .../android/core/SentryScreenshotOptions.java | 3 +- .../core/internal/util/ScreenshotMasking.java | 170 ++++++++++++++++++ .../core/internal/util/ScreenshotUtils.java | 57 ++++++ .../ScreenshotUtilsMaskedScreenshotTest.kt | 154 ++++++++++++++++ 6 files changed, 388 insertions(+), 130 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 496c5333ec..15817dd70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Measure the hostname cache TTL on the monotonic ticker the options provide, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100)) - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) +- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281)) ### Dependencies 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 6283b47f95..8b51c512c8 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 @@ -1,12 +1,10 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; import android.graphics.Bitmap; -import android.view.View; import io.sentry.Attachment; import io.sentry.Hint; import io.sentry.SentryEvent; @@ -14,18 +12,12 @@ import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.ScreenshotUtils; -import io.sentry.android.replay.util.MaskRenderer; -import io.sentry.android.replay.util.ViewsKt; -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.util.ExceptionUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -43,7 +35,6 @@ public final class ScreenshotEventProcessor implements SentryEventProcessor { private final @NotNull Debouncer debouncer; private static final long DEBOUNCE_WAIT_TIME_MS = 2000; private static final int DEBOUNCE_MAX_EXECUTIONS = 3; - private static final long MASKING_TIMEOUT_MS = 2000; private final boolean isReplayAvailable; private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false); @@ -70,10 +61,6 @@ public ScreenshotEventProcessor( } } - private boolean isMaskingEnabled() { - return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable; - } - @Override public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { @@ -131,32 +118,16 @@ private boolean isMaskingEnabled() { return event; } - Bitmap screenshot = - captureScreenshot( - activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); + final @Nullable Bitmap screenshot = + ScreenshotUtils.captureMaskedScreenshot( + activity, options, buildInfoProvider, isReplayAvailable); if (screenshot == null) { return event; } - // Apply masking if enabled and replay module is available - if (isMaskingEnabled()) { - final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity); - if (rootNode == null) { - screenshot.recycle(); - return event; - } - final @Nullable Bitmap masked = applyMasking(screenshot, rootNode); - if (masked == null) { - // applyMasking already recycles its bitmaps on failure - return event; - } - screenshot = masked; - } - - final Bitmap finalScreenshot = screenshot; hint.setScreenshot( Attachment.fromByteProvider( - () -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()), + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), "screenshot.png", "image/png", false)); @@ -164,102 +135,6 @@ private boolean isMaskingEnabled() { return event; } - /** - * Captures the view hierarchy on the main thread, since view traversal requires it. If already on - * the main thread, captures directly; otherwise posts to the main thread and waits. - */ - private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) { - if (options.getThreadChecker().isMainThread()) { - return buildViewHierarchy(activity); - } - - final AtomicReference result = new AtomicReference<>(null); - final CountDownLatch latch = new CountDownLatch(1); - - try { - activity.runOnUiThread( - () -> { - try { - result.set(buildViewHierarchy(activity)); - } finally { - latch.countDown(); - } - }); - - if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - options - .getLogger() - .log( - SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread"); - return null; - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e); - return null; - } - - return result.get(); - } - - private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) { - try { - final @Nullable View rootView = - activity.getWindow() != null - && activity.getWindow().peekDecorView() != null - && activity.getWindow().peekDecorView().getRootView() != null - ? activity.getWindow().peekDecorView().getRootView() - : null; - if (rootView == null) { - return null; - } - - final ViewHierarchyNode rootNode = - ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot()); - 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); - return null; - } - } - - private @Nullable Bitmap applyMasking( - final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) { - Bitmap mutableBitmap = screenshot; - boolean createdCopy = false; - try (final MaskRenderer maskRenderer = new MaskRenderer()) { - // Make bitmap mutable if needed - if (!screenshot.isMutable()) { - mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true); - if (mutableBitmap == null) { - screenshot.recycle(); - return null; - } - createdCopy = true; - } - - maskRenderer.renderMasks(mutableBitmap, rootNode, null); - - // Recycle original if we created a copy - if (createdCopy && !screenshot.isRecycled()) { - screenshot.recycle(); - } - - return mutableBitmap; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e); - if (createdCopy) { - if (!mutableBitmap.isRecycled()) { - mutableBitmap.recycle(); - } - } - if (!screenshot.isRecycled()) { - screenshot.recycle(); - } - return null; - } - } - @Override public @Nullable Long getOrder() { return 10000L; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java index 8680ed907d..6e1cceb760 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java @@ -10,7 +10,8 @@ * #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}. * *

Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at - * runtime. If the replay module is not available, screenshots will be captured without masking. + * runtime. If masking is enabled but the replay module is not available, no screenshot is captured + * to avoid leaking sensitive data. */ public final class SentryScreenshotOptions extends SentryMaskingOptions { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java new file mode 100644 index 0000000000..4d3e2d1595 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java @@ -0,0 +1,170 @@ +package io.sentry.android.core.internal.util; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.view.View; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.SentryScreenshotOptions; +import io.sentry.android.replay.util.MaskRenderer; +import io.sentry.android.replay.util.ViewsKt; +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; +import io.sentry.util.ExceptionUtils; +import io.sentry.util.LoadClass; +import io.sentry.util.thread.IThreadChecker; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Applies screenshot masking using the Session Replay view hierarchy approach. Requires the + * `sentry-android-replay` module at runtime, callers must check {@link #isAvailable} first. + */ +@ApiStatus.Internal +public final class ScreenshotMasking { + + private static final long MASKING_TIMEOUT_MS = 2000; + + // Checked instead of the masking classes, since it's the only replay class whose name is kept + // by the consumer proguard rules + private static final String REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + + private ScreenshotMasking() {} + + /** Whether the `sentry-android-replay` module, required for masking, is available at runtime. */ + public static boolean isAvailable(final @NotNull ILogger logger) { + return new LoadClass().isClassAvailable(REPLAY_INTEGRATION_CLASS_NAME, logger); + } + + /** + * Masks the given screenshot according to the screenshot options. + * + * @return the masked bitmap, or null if masking failed. The passed screenshot is recycled on + * failure, so a raw screenshot is never returned in place of a masked one. + */ + public static @Nullable Bitmap mask( + final @NotNull Activity activity, + final @NotNull Bitmap screenshot, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger) { + final @Nullable ViewHierarchyNode rootNode = + captureViewHierarchy(activity, screenshotOptions, threadChecker, logger); + if (rootNode == null) { + screenshot.recycle(); + return null; + } + // applyMasking already recycles its bitmaps on failure + return applyMasking(screenshot, rootNode, logger); + } + + /** + * Captures the view hierarchy on the main thread, since view traversal requires it. If already on + * the main thread, captures directly; otherwise posts to the main thread and waits. + */ + private static @Nullable ViewHierarchyNode captureViewHierarchy( + final @NotNull Activity activity, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger) { + if (threadChecker.isMainThread()) { + return buildViewHierarchy(activity, screenshotOptions, logger); + } + + final AtomicReference result = new AtomicReference<>(null); + final CountDownLatch latch = new CountDownLatch(1); + + try { + activity.runOnUiThread( + () -> { + try { + result.set(buildViewHierarchy(activity, screenshotOptions, logger)); + } finally { + latch.countDown(); + } + }); + + if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + logger.log( + SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread"); + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.log(SentryLevel.ERROR, "Interrupted while capturing view hierarchy", e); + return null; + } + + return result.get(); + } + + private static @Nullable ViewHierarchyNode buildViewHierarchy( + final @NotNull Activity activity, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull ILogger logger) { + try { + final @Nullable View rootView = + activity.getWindow() != null + && activity.getWindow().peekDecorView() != null + && activity.getWindow().peekDecorView().getRootView() != null + ? activity.getWindow().peekDecorView().getRootView() + : null; + if (rootView == null) { + return null; + } + + final ViewHierarchyNode rootNode = + ViewHierarchyNode.Companion.fromView(rootView, null, 0, screenshotOptions); + ViewsKt.traverse(rootView, rootNode, screenshotOptions, logger, null); + return rootNode; + } catch (Throwable e) { + // Traversal calls into arbitrary (custom) View code, which can throw anything + ExceptionUtils.rethrowIfFatal(e); + logger.log(SentryLevel.ERROR, "Failed to build view hierarchy", e); + return null; + } + } + + private static @Nullable Bitmap applyMasking( + final @NotNull Bitmap screenshot, + final @NotNull ViewHierarchyNode rootNode, + final @NotNull ILogger logger) { + Bitmap mutableBitmap = screenshot; + boolean createdCopy = false; + try (final MaskRenderer maskRenderer = new MaskRenderer()) { + // Make bitmap mutable if needed + if (!screenshot.isMutable()) { + mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true); + if (mutableBitmap == null) { + screenshot.recycle(); + return null; + } + createdCopy = true; + } + + maskRenderer.renderMasks(mutableBitmap, rootNode, null); + + // Recycle original if we created a copy + if (createdCopy && !screenshot.isRecycled()) { + screenshot.recycle(); + } + + return mutableBitmap; + } catch (RuntimeException e) { + logger.log(SentryLevel.ERROR, "Failed to mask screenshot", e); + if (createdCopy) { + if (!mutableBitmap.isRecycled()) { + mutableBitmap.recycle(); + } + } + if (!screenshot.isRecycled()) { + screenshot.recycle(); + } + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 092caa040d..657ba54cbd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -14,6 +14,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.SentryAndroidOptions; import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; @@ -51,6 +52,62 @@ public class ScreenshotUtils { return compressBitmapToPng(screenshot, logger); } + // Used by Hybrid SDKs + /** + * Takes a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}, the + * same way {@link io.sentry.android.core.ScreenshotEventProcessor} does for native errors. + * + * @see #captureMaskedScreenshot(Activity, SentryAndroidOptions, BuildInfoProvider, boolean) + * @noinspection unused + */ + public static @Nullable byte[] takeMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + final boolean isReplayAvailable = ScreenshotMasking.isAvailable(options.getLogger()); + return compressBitmapToPng( + captureMaskedScreenshot(activity, options, buildInfoProvider, isReplayAvailable), + options.getLogger()); + } + + /** + * Captures a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}. + * Shared by {@link io.sentry.android.core.ScreenshotEventProcessor} and Hybrid SDKs, so native + * and hybrid screenshots follow the same masking rules. + * + *

If no masking is configured, the screenshot is returned unmasked. If masking is configured + * but the `sentry-android-replay` module is not available, or masking fails, null is returned to + * avoid leaking sensitive data. + */ + public static @Nullable Bitmap captureMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final boolean isReplayAvailable) { + final boolean isMaskingConfigured = !options.getScreenshot().getMaskViewClasses().isEmpty(); + if (isMaskingConfigured && !isReplayAvailable) { + options + .getLogger() + .log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module"); + return null; + } + + final @Nullable Bitmap screenshot = + captureScreenshot( + activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); + if (screenshot == null || !isMaskingConfigured) { + return screenshot; + } + + // mask() recycles the screenshot on failure, so a raw screenshot is never returned + return ScreenshotMasking.mask( + activity, + screenshot, + options.getScreenshot(), + options.getThreadChecker(), + options.getLogger()); + } + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt new file mode 100644 index 0000000000..9bc6f9f85b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt @@ -0,0 +1,154 @@ +package io.sentry.android.core.internal.util + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.thread.IThreadChecker +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowPixelCopy + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ScreenshotUtilsMaskedScreenshotTest { + + private class Fixture { + lateinit var activity: Activity + val threadChecker = mock() + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + threadChecker = this@Fixture.threadChecker + } + val buildInfoProvider = BuildInfoProvider(options.logger) + + init { + whenever(threadChecker.isMainThread).thenReturn(true) + } + } + + private lateinit var fixture: Fixture + + private fun takeMaskedScreenshot(isReplayAvailable: Boolean): ByteArray? = + ScreenshotUtils.compressBitmapToPng( + ScreenshotUtils.captureMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + isReplayAvailable, + ), + fixture.options.logger, + ) + + @BeforeTest + fun `set up`() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + + fixture = Fixture() + fixture.activity = buildActivity(TextActivity::class.java, null).setup().get() + } + + @Test + fun `when masking is not configured, returns an unmasked screenshot`() { + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNotNull(bytes) + } + + @Test + fun `when masking is configured, returns a masked screenshot`() { + val unmasked = takeMaskedScreenshot(isReplayAvailable = true) + fixture.options.screenshot.setMaskAllText(true) + val masked = takeMaskedScreenshot(isReplayAvailable = true) + + assertNotNull(unmasked) + assertNotNull(masked) + assertFalse(unmasked.contentEquals(masked)) + } + + @Test + fun `when masking is configured but replay is not available, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNull(bytes) + } + + @Test + fun `when masking is configured but fails, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + fixture.activity.findViewById(android.R.id.text1).throwOnTraversal = true + + val bytes = takeMaskedScreenshot(isReplayAvailable = true) + + assertNull(bytes) + } + + @Test + fun `public overload detects replay availability`() { + fixture.options.screenshot.setMaskAllText(true) + + // sentry-android-replay is on the test classpath + val bytes = + ScreenshotUtils.takeMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + ) + + assertNotNull(bytes) + } +} + +private class TextActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(Color.WHITE) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + linearLayout.addView( + ThrowingTextView(this).apply { + id = android.R.id.text1 + text = "Sensitive text" + setTextColor(Color.BLACK) + textSize = 32f + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + ) + setContentView(linearLayout) + } +} + +/** Fails view hierarchy traversal on demand, to exercise the masking failure path. */ +private class ThrowingTextView(context: Context) : TextView(context) { + var throwOnTraversal = false + + override fun getTotalPaddingLeft(): Int { + if (throwOnTraversal) { + throw IllegalStateException("Traversal failure") + } + return super.getTotalPaddingLeft() + } +} From e5c61b8485688d75251a6eec45c1f4b4d8bb62ef Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:42:42 +0200 Subject: [PATCH 03/48] ci: Move jobs from Bitrise back to GitHub-hosted runners (#6289) Co-Authored-By: Nelson Osacky Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .github/actionlint.yaml | 4 ---- .github/workflows/build.yml | 4 +--- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-macrobenchmark.yml | 2 +- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 4 +--- .github/workflows/integration-tests-ui.yml | 2 +- 9 files changed, 8 insertions(+), 16 deletions(-) delete mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 83df0b81ec..0000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Custom runner labels for actionlint (same approach as sentry-cocoa). -self-hosted-runner: - labels: - - bitrise_pool_name:tahoe diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6add05cd0..f4ae724df4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,7 @@ jobs: build: # Job name is a required status check in the main branch ruleset; keep it stable. name: Build Job ubuntu-latest - Java 17 - # Runs on Bitrise-hosted runners (same pattern as sentry-cocoa) instead of - # GitHub-hosted ubuntu-latest, which ran out of memory during `make preMerge`. - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest env: GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d25122d59a..e31db87a4e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,7 +13,7 @@ concurrency: jobs: analyze: name: Analyze - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: macos-15 env: GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index ed0fa2539a..d236a64b0c 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -5,7 +5,7 @@ on: jobs: format-code: name: Format Code - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 4bc252f7a3..7cb5c903ca 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -6,7 +6,7 @@ on: jobs: build-and-deploy-javadocs: name: Build and deploy Javadocs - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest steps: - name: Checkout šŸ›Žļø uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index 7ba03815fc..377db931fc 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -12,7 +12,7 @@ concurrency: jobs: macrobenchmark: name: Macrobenchmark - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest # we copy the secret to the env variable in order to access it in the workflow env: diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 530f5ea471..ebdad2a4d9 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -13,7 +13,7 @@ concurrency: jobs: build: name: Build and Analyze SDK Size - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest env: GRADLE_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 c793151d3d..27ecbff7d1 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -20,9 +20,7 @@ env: jobs: build: name: Build - # Only the APK build runs on Bitrise; the Maestro job below needs KVM for the - # x86_64 emulator, which the macOS Bitrise VMs don't provide. - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest env: GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 2483394aa1..2ece76b6b5 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: name: Ui tests - runs-on: ["bitrise_pool_name:tahoe"] + runs-on: ubuntu-latest # we copy the secret to the env variable in order to access it in the workflow env: From 3f3b8ef146bb106eaedd9eb238a43e4861c3d94a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:24:52 +0200 Subject: [PATCH 04/48] chore(deps): bump the github-actions group across 1 directory with 4 updates (#6280) Bumps the github-actions group with 4 updates in the / directory: [actions/setup-java](https://github.com/actions/setup-java), [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [getsentry/craft](https://github.com/getsentry/craft). Updates `actions/setup-java` from 6.0.1 to 6.1.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad) Updates `github/codeql-action/init` from 4.38.3 to 4.39.0 - [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/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0 - [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/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) Updates `getsentry/craft` from 2.34.1 to 2.35.0 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/3f2abdc5703191d12ef2cc013f792f1bd220f44d...7a7857552e2cdc08b502de96e6076849c01fba32) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.35.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.39.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 | 6 +++--- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-macrobenchmark.yml | 2 +- .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/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 +- 16 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index f1c03f0884..973cfcf628 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4ae724df4..f11c6475b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e31db87a4e..662c20a088 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # pin@v2 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # pin@v2 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 8d37523888..6b94bc1b62 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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Set up Java - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index d236a64b0c..7b356dbada 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 7cb5c903ca..7a74d941f6 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index 377db931fc..ad7153f750 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: 'Set up Java: 17' - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index ebdad2a4d9..e9d3d6e80c 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 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 27ecbff7d1..ab379c32f3 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 2ece76b6b5..5871d37beb 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 5ab90f51c8..6151098703 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9996b6dfa2..ce434f67c4 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@3f2abdc5703191d12ef2cc013f792f1bd220f44d # v2.34.1 + uses: getsentry/craft@7a7857552e2cdc08b502de96e6076849c01fba32 # v2.35.0 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 c13f5e695a..d4e0839209 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 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 fde7ec44a3..1e6623d772 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 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 2d28cc01b9..3554926c3e 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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 7449e5a182..1d32bba87b 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -118,7 +118,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' From d5802a7e014d3986a701d2f1e82437d88d610717 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:23:56 +0000 Subject: [PATCH 05/48] docs: Require issue discussion before PRs (#6288) * docs: Require issue discussion before PRs Co-Authored-By: Chris Aigner * docs: Add Sentry logo header to CONTRIBUTING.md Co-Authored-By: Chris Aigner * Apply suggestion from @christophaigner * Apply suggestion from @christophaigner --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Chris Aigner <25478494+christophaigner@users.noreply.github.com> --- CONTRIBUTING.md | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4354c72a8..684ef3c735 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,38 @@ -# Contributing to sentry-java +

+ + + +
+

-We love pull requests from everyone. -We suggest opening an issue to discuss bigger changes before investing on a big PR. +# Contributing + +Thanks for your interest in contributing to `sentry-java`! This guide explains how we work with external contributions and how to get your environment set up. If you have questions, reach out on [Discord](https://discord.com/invite/sentry). + +# Submitting Changes + +**TL;DR: Discuss your change in a GitHub issue before opening a pull request. We may close PRs that weren't discussed with a maintainer first.** + +Every line we merge is code we maintain long-term for hundreds of thousands of apps, so we want to agree on the problem and the approach before anyone writes code. This saves time for you and for us. + +## Before You Start + +1. **Find or create an issue.** Search the [issue tracker](https://github.com/getsentry/sentry-java/issues) first. If nothing fits, open a new issue describing the problem or feature. +2. **Discuss your approach with a maintainer.** Comment on the issue with what you plan to change and wait for a maintainer to respond before you start working. +3. **Check the assignee.** If the issue is already assigned to someone else, coordinate with them in the issue before starting work. + +## Making Your Contribution + +- Fork the repo and make your changes on a branch. +- Add tests. Non-trivial PRs won't be accepted without them. +- Don't bump version numbers. We do that when we release. +- Open a PR that links the issue (e.g. `Fixes #123`) +- Name your PR according to our [commit message format](https://develop.sentry.dev/commit-messages/#commit-message-format). +- Keep PRs focused: one change per PR, no unrelated refactors. + +## AI Use + +You are welcome to use whatever tools you prefer for making a contribution. However, any changes you propose have to be understood, 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 didn't happen, we will close the PR outright. For example, we won't 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). # Requirements @@ -68,8 +99,3 @@ 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 ebf465f515f471725d89aea8c70961fe6431a574 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Oct 2026 09:21:19 +0200 Subject: [PATCH 06/48] fix(client-reports): Count spans in batched span envelope items (#6265) Prior to this commit, ClientReportRecorder didn't have any explicit logic for dealing with span envelopes because sentry-java didn't implement them. That meant that any span envelopes created by hybrid SDKs and dropped by sentry-java would be shunted to the recorder's fallback branch and wrongly register a single dropped span per dropped envelope, rather than the actual number. This commit adds the missing span envelope logic, so that we now report the actual span count. Refs [sentry-docs#19854](https://github.com/getsentry/sentry-docs/pull/19854) --- CHANGELOG.md | 1 + .../clientreport/ClientReportRecorder.java | 12 +++- .../sentry/clientreport/ClientReportTest.kt | 59 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15817dd70d..acca00fc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java index ccd7c5dc40..ba6d60e256 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java @@ -113,6 +113,12 @@ public void recordLostEnvelopeItem( recordLostEventInternal( reason.getReason(), DataCategory.TraceMetricByte.getCategory(), metricBytes); executeOnDiscard(reason, itemCategory, count); + } else if (itemCategory.equals(DataCategory.Span)) { + // Batched span items (span streaming) aren't created by this SDK yet, but hybrid SDKs + // like sentry-dart send them through it via InternalSentrySdk.captureEnvelope. + final long count = itemCountFromHeader(envelopeItem); + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), count); + executeOnDiscard(reason, itemCategory, count); } else { recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); executeOnDiscard(reason, itemCategory, 1L); @@ -156,9 +162,9 @@ private void recordLostEventInternal( storage.addCount(key, countToAdd); } - // The number of items batched into a log or metric envelope item is stored in its header, so we - // read it from there instead of deserializing the payload. Deserializing on the discard path is - // expensive and, under sustained rate limiting, caused a CPU busy-loop (JAVA-662). + // The number of items batched into a log, metric or span envelope item is stored in its header, + // so we read it from there instead of deserializing the payload. Deserializing on the discard + // path is expensive and, under sustained rate limiting, caused a CPU busy-loop (JAVA-662). private long itemCountFromHeader(final @NotNull SentryEnvelopeItem envelopeItem) { final @Nullable Integer itemCount = envelopeItem.getHeader().getItemCount(); return itemCount != null ? itemCount : 1L; diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 165f3c6c03..e077b25fb3 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -59,6 +59,7 @@ import org.mockito.kotlin.whenever private const val LOG_CONTENT_TYPE = "application/vnd.sentry.items.log+json" private const val METRIC_CONTENT_TYPE = "application/vnd.sentry.items.trace-metric+json" +private const val SPAN_CONTENT_TYPE = "application/vnd.sentry.items.span.v2+json" class ClientReportTest { lateinit var opts: SentryOptions @@ -503,6 +504,64 @@ class ClientReportTest { assertEquals(1, clientReport.quantityOf(DataCategory.TraceMetric)) } + @Test + fun `recording lost span item reads count from the header`() { + val onDiscardMock = mock() + givenClientReportRecorder { options -> options.onDiscard = onDiscardMock } + + // Batched span items reach this SDK from hybrid SDKs (e.g. Flutter span streaming). + val item = + mockEnvelopeItem( + SentryItemType.Span, + SPAN_CONTENT_TYPE, + itemCount = 500, + data = "irrelevant payload".toByteArray(), + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, item) + + verify(onDiscardMock, times(1)).execute(DiscardReason.NETWORK_ERROR, DataCategory.Span, 500) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + assertEquals(500, clientReport.quantityOf(DataCategory.Span)) + } + + @Test + fun `recording rate limited span item reads count from the header`() { + givenClientReportRecorder() + + val item = + mockEnvelopeItem(SentryItemType.Span, SPAN_CONTENT_TYPE, itemCount = 7, data = ByteArray(0)) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.RATELIMIT_BACKOFF, item) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + val spanItem = + clientReport!!.discardedEvents!!.single { + it.category == DataCategory.Span.category && + it.reason == DiscardReason.RATELIMIT_BACKOFF.reason + } + assertEquals(7, spanItem.quantity) + } + + @Test + fun `recording lost span item without item count in header falls back to one`() { + givenClientReportRecorder() + + val item = + mockEnvelopeItem( + SentryItemType.Span, + SPAN_CONTENT_TYPE, + itemCount = null, + data = ByteArray(0), + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, item) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + assertEquals(1, clientReport.quantityOf(DataCategory.Span)) + } + private fun mockEnvelopeItem( type: SentryItemType, contentType: String, From 37d61d8e6076e98fcc6a74f6d846aed5d6463a4e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:21:38 +0200 Subject: [PATCH 07/48] build(deps): Bump certifi to 2026.10.19 (#6333) Removes trust in the compromised eMudhra/emSign CA roots (VULN-3216). Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Geoff Goldsmith --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ffdb120a7a..62c248385c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -certifi==2025.7.14 +certifi==2026.10.19 charset-normalizer==3.4.2 idna==3.15 requests==2.33.0 From ece9581269ee35dac7c76397c2b9188014bbb75f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 10:07:21 +0200 Subject: [PATCH 08/48] test: Add an in-process MockSentryServer to sentry-test-support (#6266) First step of moving the sample system tests into the modules they cover. The server stands in for the Python mock server: it binds a free port, accepts envelopes through the SDK's real HTTP transport, and lets tests wait for a matching one. The release-minified UI test APK pulls in sentry-test-support, so R8 needs to be told the JDK-only com.sun.net.httpserver classes are absent. Co-authored-by: Claude Opus 5.5 (1M context) --- .../sentry-uitest-android/proguard-rules.pro | 1 + sentry-test-support/build.gradle.kts | 2 + .../kotlin/io/sentry/test/MockSentryServer.kt | 87 +++++++++++++++++++ .../io/sentry/test/MockSentryServerTest.kt | 34 ++++++++ 4 files changed, 124 insertions(+) create mode 100644 sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt create mode 100644 sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 5de2dac4bd..174e6e3877 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -24,6 +24,7 @@ -dontwarn com.google.errorprone.** -dontwarn com.sun.jna.** +-dontwarn com.sun.net.httpserver.** -dontwarn edu.umd.cs.findbugs.** -dontwarn java.lang.instrument.** -dontwarn java.lang.management.** diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index a0b508c971..ca60a47c6b 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -30,4 +30,6 @@ dependencies { implementation(kotlin(Config.kotlinStdLib)) implementation(libs.kotlin.test.junit) implementation(libs.mockito.kotlin) + + testImplementation(libs.google.truth) } diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt new file mode 100644 index 0000000000..439b61f2c2 --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -0,0 +1,87 @@ +package io.sentry.test + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.sentry.JsonSerializer +import io.sentry.SentryEnvelope +import io.sentry.SentryOptions +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.Closeable +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream + +/** + * A stand-in for Sentry's ingestion endpoint that integration tests point the SDK at through [dsn]. + * + * Envelopes travel through the real HTTP transport, so serialization, compression and the DSN are + * covered too. It binds a free port so that test tasks running in parallel don't clash, and it + * works for an app in a child process as well as in the test's own JVM. + */ +class MockSentryServer : Closeable { + private val serializer = JsonSerializer(SentryOptions.empty()) + private val received = CopyOnWriteArrayList() + private val server = + HttpServer.create(InetSocketAddress(InetAddress.getByName(HOST), 0), 0).apply { + createContext("/api/") { exchange -> receive(exchange) } + start() + } + + val dsn: String = "http://key@$HOST:${server.address.port}/0" + + val envelopes: List + get() = received.toList() + + /** Returns the first received envelope matching [predicate], waiting up to [timeoutMillis]. */ + fun awaitEnvelope( + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + predicate: (SentryEnvelope) -> Boolean, + ): SentryEnvelope { + val deadline = + Deadline.after(JavaMonotonicTicker.getInstance(), timeoutMillis, TimeUnit.MILLISECONDS) + while (true) { + received.firstOrNull(predicate)?.let { + return it + } + if (deadline.hasPassed()) { + throw AssertionError( + "No matching envelope within ${timeoutMillis}ms, received ${received.size} envelopes" + ) + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + + fun reset() { + received.clear() + } + + override fun close() { + server.stop(0) + } + + private fun receive(exchange: HttpExchange) { + try { + val body = + if (exchange.requestHeaders.getFirst("Content-Encoding") == "gzip") { + GZIPInputStream(exchange.requestBody) + } else { + exchange.requestBody + } + serializer.deserializeEnvelope(body)?.let { received.add(it) } + exchange.sendResponseHeaders(200, -1) + } finally { + exchange.close() + } + } + + private companion object { + // An IP rather than "localhost", which can resolve to IPv4 for the server and IPv6 for the SDK. + const val HOST = "127.0.0.1" + const val DEFAULT_TIMEOUT_MILLIS = 10_000L + const val POLL_INTERVAL_MILLIS = 50L + } +} diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt new file mode 100644 index 0000000000..6a1e3f0322 --- /dev/null +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -0,0 +1,34 @@ +package io.sentry.test + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryItemType +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class MockSentryServerTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `receives an event sent through the HTTP transport`() { + initForTest { it.dsn = server.dsn } + + val eventId = Sentry.captureMessage("hello") + Sentry.flush(5000) + + val envelope = server.awaitEnvelope { it.header.eventId == eventId } + assertThat(envelope.items.single().header.type).isEqualTo(SentryItemType.Event) + } + + @Test + fun `awaitEnvelope fails once the timeout passes without a match`() { + assertFailsWith { server.awaitEnvelope(timeoutMillis = 100) { true } } + } +} From 8d7b785cbc7863425cee09e6c5b0f28927c3319d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 10:45:11 +0200 Subject: [PATCH 09/48] ci: Use the latest Python 3.10 patch in CI (#6339) ubuntu-latest now resolves to Ubuntu 26.04, and actions/python-versions ships no 3.10.5 build for it, so "Set up Python" fails before any test runs in the Spring Boot matrix and system-test jobs. Pinning the minor version lets setup-python pick the newest 3.10.x available for the runner image while keeping requirements.txt and the system-test runner on the same Python minor. Co-authored-by: Claude Opus 5.5 --- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index d4e0839209..d798f0e437 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.10.5' + python-version: '3.10' - name: Install Python dependencies run: | diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 1e6623d772..fdc9169ccc 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.10.5' + python-version: '3.10' - name: Install Python dependencies run: | diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 3554926c3e..08dfd5e389 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.10.5' + python-version: '3.10' - name: Install Python dependencies run: | diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 1d32bba87b..aa25e9854c 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -110,7 +110,7 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.10.5' + python-version: '3.10' - name: Install Python dependencies run: | From 5e493bdef0dff1829b6914db10b348884e9b0097 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 21 Oct 2026 11:56:40 +0200 Subject: [PATCH 10/48] chore(build): Compile with a JDK 21 toolchain and upgrade Mockito and Error Prone (#6242) * build: Switch to JDK 21 and upgrade Mockito, Robolectric and Error Prone * build: Compile tests for JDK 21 and fix buildSrc JVM target * build: Compile with a JDK 21 toolchain from a build-logic convention plugin * docs(kafka): Explain the explicit double cast at the latency call site * build: Compile the samples and their tests with the toolchain JDK * Format code * build: Drop the now-unused JvmTarget imports from the samples * build: Clarify the jvm-targets plugin comments and rename its helper * build: Require JDK 21 to run Gradle again * build: Keep the Spring Boot 2 sample apps on their published bytecode level * ci: Drop the stale Java version from the build job name * build: Keep Robolectric at 4.15 and leave its test environment untouched * build: Move the Java 8 release pin into the jvm-targets convention plugin * build: Drop the Error Prone and NullAway code fixes, they move to their own PR * ci: Drop the stale Java version from the build job name * fix(core): Suppress the new Error Prone and javac 21 findings in the sentry module * fix(core): Resolve the remaining Error Prone and javac 21 findings in the sentry module * build: Move the JDK and bytecode level decisions into the io.sentry.compilation plugin * Address PR comments * test(android): Make the flush timeout verifies match the real log call * chore(core): Reword the JDK 21 suppression comments * build: Keep only the javac unit test targets inside afterEvaluate * build: Drop afterEvaluate, pin the Kotlin API level and rename the convention plugin * Fix JDK 21 findings in sentry-android-core (#6261) Fix JDK 21 findings in sentry-android-core * Fix JDK 21 findings in the Spring modules (#6260) * Fix JDK 21 findings in the JVM integrations (#6259) * fix multiplatform target compilations --------- Co-authored-by: Sentry Github Bot --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 4 +- .github/workflows/codeql-analysis.yml | 2 +- .../workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .../integration-tests-macrobenchmark.yml | 2 +- .github/workflows/integration-tests-size.yml | 2 +- .../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 +- CONTRIBUTING.md | 4 +- build-logic/build.gradle.kts | 6 + build-logic/settings.gradle.kts | 2 + .../io/sentry/gradle/JdkTargetConvention.kt | 116 ++++++++++++++++++ build.gradle.kts | 27 +--- buildSrc/build.gradle.kts | 6 - gradle/libs.versions.toml | 16 ++- sentry-android-core/build.gradle.kts | 2 +- .../core/ApplicationNotResponding.java | 2 + .../core/SentryUserFeedbackButton.java | 3 + .../core/performance/AppStartMetrics.java | 3 + .../android/core/AnrV2IntegrationTest.kt | 2 +- .../ApplicationExitIntegrationTestBase.kt | 11 +- .../android/core/TombstoneIntegrationTest.kt | 2 +- sentry-android-fragment/build.gradle.kts | 2 +- .../build.gradle.kts | 2 - .../build.gradle.kts | 9 -- .../sentry-uitest-android/build.gradle.kts | 3 - sentry-android-navigation/build.gradle.kts | 2 +- sentry-android-navigation3/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 +- .../android/timber/SentryTimberTreeTest.kt | 3 +- sentry-apache-http-client-5/build.gradle.kts | 4 +- sentry-apollo-3/build.gradle.kts | 4 +- sentry-apollo-4/build.gradle.kts | 4 +- sentry-apollo-5/build.gradle.kts | 4 +- sentry-apollo/build.gradle.kts | 6 +- sentry-async-profiler/build.gradle.kts | 4 +- ...AsyncProfilerToSentryProfileConverter.java | 2 +- sentry-compose/build.gradle.kts | 2 +- sentry-graphql-22/build.gradle.kts | 4 +- sentry-graphql-core/build.gradle.kts | 4 +- sentry-graphql/build.gradle.kts | 4 +- sentry-jcache/build.gradle.kts | 4 +- sentry-jdbc/build.gradle.kts | 4 +- sentry-jul/build.gradle.kts | 2 +- .../java/io/sentry/jul/SentryHandler.java | 3 + sentry-kafka/build.gradle.kts | 4 +- .../kafka/SentryKafkaConsumerTracing.java | 2 +- .../io/sentry/kafka/SentryKafkaProducer.java | 6 +- sentry-kotlin-extensions/build.gradle.kts | 2 +- sentry-ktor-client/build.gradle.kts | 4 +- sentry-launchdarkly-android/build.gradle.kts | 2 +- sentry-launchdarkly-server/build.gradle.kts | 4 +- sentry-log4j2/build.gradle.kts | 2 +- sentry-logback/build.gradle.kts | 2 +- .../io/sentry/logback/SentryAppender.java | 3 +- sentry-okhttp/build.gradle.kts | 4 +- sentry-openfeature/build.gradle.kts | 4 +- sentry-openfeign/build.gradle.kts | 2 +- .../build.gradle.kts | 2 +- .../build.gradle.kts | 2 +- .../OtelContextScopesStorage.java | 2 +- .../SentryOtelThreadLocalStorage.java | 2 + .../build.gradle.kts | 2 +- .../build.gradle.kts | 2 +- sentry-quartz/build.gradle.kts | 4 +- sentry-reactor/build.gradle.kts | 8 +- .../io/sentry/reactor/SentryReactorUtils.java | 4 +- .../sentry-samples-android/build.gradle.kts | 8 -- .../build.gradle.kts | 14 --- .../build.gradle.kts | 14 --- .../sentry-samples-console/build.gradle.kts | 14 --- .../sentry-samples-jul/build.gradle.kts | 10 -- .../sentry-samples-log4j2/build.gradle.kts | 10 -- .../sentry-samples-logback/build.gradle.kts | 10 -- .../sentry-samples-spring-7/build.gradle.kts | 5 - .../build.gradle.kts | 10 -- .../build.gradle.kts | 10 -- .../build.gradle.kts | 10 -- .../build.gradle.kts | 10 -- .../build.gradle.kts | 5 - .../build.gradle.kts | 10 -- .../build.gradle.kts | 13 -- .../build.gradle.kts | 12 -- .../build.gradle.kts | 13 -- .../build.gradle.kts | 13 -- .../build.gradle.kts | 23 ++-- .../build.gradle.kts | 22 ++-- .../build.gradle.kts | 6 - .../build.gradle.kts | 16 ++- .../build.gradle.kts | 21 ++-- .../build.gradle.kts | 6 - .../sentry-samples-spring/build.gradle.kts | 17 +-- sentry-servlet-jakarta/build.gradle.kts | 2 +- sentry-servlet/build.gradle.kts | 2 +- sentry-spotlight/build.gradle.kts | 4 +- sentry-spring-7/build.gradle.kts | 12 +- .../HttpServletRequestSentryUserProvider.java | 5 +- .../spring7/SentryInitBeanPostProcessor.java | 3 +- .../cache/SentryCacheBeanPostProcessor.java | 4 +- .../spring7/checkin/SentryCheckInAdvice.java | 6 +- ...SentryCaptureExceptionParameterAdvice.java | 11 +- .../graphql/SentryBatchLoaderRegistry.java | 4 +- .../SentryGraphqlBeanPostProcessor.java | 4 +- .../SentryKafkaProducerBeanPostProcessor.java | 4 +- .../kafka/SentryKafkaRecordInterceptor.java | 4 +- .../spring7/tracing/SentrySpanAdvice.java | 6 +- ...entrySpanClientHttpRequestInterceptor.java | 2 +- .../tracing/SentryTransactionAdvice.java | 8 +- .../webflux/SentryWebExceptionHandler.java | 2 +- sentry-spring-boot-4-starter/build.gradle.kts | 8 +- sentry-spring-boot-4/build.gradle.kts | 8 +- .../spring/boot4/SentryLog4j2Initializer.java | 3 +- .../SentryOkHttpClientBeanPostProcessor.java | 3 +- sentry-spring-boot-jakarta/build.gradle.kts | 8 +- .../boot/jakarta/SentryLog4j2Initializer.java | 3 +- .../SentryOkHttpClientBeanPostProcessor.java | 3 +- .../build.gradle.kts | 8 +- sentry-spring-boot-starter/build.gradle.kts | 2 +- sentry-spring-boot/build.gradle.kts | 2 +- sentry-spring-jakarta/build.gradle.kts | 21 ++-- .../HttpServletRequestSentryUserProvider.java | 5 +- .../jakarta/SentryInitBeanPostProcessor.java | 3 +- .../cache/SentryCacheBeanPostProcessor.java | 4 +- ...SentryCaptureExceptionParameterAdvice.java | 4 +- .../graphql/SentryBatchLoaderRegistry.java | 4 +- .../SentryGraphqlBeanPostProcessor.java | 4 +- .../SentryKafkaProducerBeanPostProcessor.java | 4 +- .../kafka/SentryKafkaRecordInterceptor.java | 2 +- ...entrySpanClientHttpRequestInterceptor.java | 2 +- .../tracing/SentryTransactionAdvice.java | 2 +- sentry-spring/build.gradle.kts | 4 +- .../kafka/SentryKafkaRecordInterceptor.java | 2 +- .../tracing/SentryTransactionAdvice.java | 2 +- sentry-system-test-support/build.gradle.kts | 6 - sentry-test-support/build.gradle.kts | 6 - sentry/build.gradle.kts | 7 +- sentry/src/main/java/io/sentry/Baggage.java | 8 ++ .../src/main/java/io/sentry/Breadcrumb.java | 4 + .../java/io/sentry/CombinedContextsView.java | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 4 +- .../java/io/sentry/DefaultSpanFactory.java | 2 +- .../main/java/io/sentry/DiagnosticLogger.java | 2 + .../java/io/sentry/DirectoryProcessor.java | 2 +- .../main/java/io/sentry/ExternalOptions.java | 3 +- sentry/src/main/java/io/sentry/Hint.java | 2 +- .../main/java/io/sentry/MonitorContexts.java | 4 + .../main/java/io/sentry/ReplayRecording.java | 3 +- .../main/java/io/sentry/SamplingContext.java | 2 +- sentry/src/main/java/io/sentry/Scope.java | 5 +- .../java/io/sentry/SentryEnvelopeItem.java | 5 +- .../io/sentry/SentryExceptionFactory.java | 3 +- .../main/java/io/sentry/SentryOptions.java | 3 + .../java/io/sentry/SentryThreadFactory.java | 2 + .../src/main/java/io/sentry/SentryTracer.java | 2 +- .../src/main/java/io/sentry/SpanContext.java | 3 + .../io/sentry/SynchronizedCollection.java | 5 + .../src/main/java/io/sentry/TraceContext.java | 2 +- .../UncaughtExceptionHandlerIntegration.java | 2 + .../sentry/cache/PersistingScopeObserver.java | 3 + .../ExceptionMechanismException.java | 7 ++ .../featureflags/FeatureFlagBuffer.java | 5 +- .../modules/ManifestModulesLoader.java | 2 +- .../main/java/io/sentry/logger/LoggerApi.java | 7 +- .../java/io/sentry/metrics/MetricsApi.java | 7 +- .../java/io/sentry/protocol/Contexts.java | 7 ++ .../java/io/sentry/rrweb/RRWebEventType.java | 4 + .../rrweb/RRWebIncrementalSnapshotEvent.java | 4 + .../sentry/rrweb/RRWebInteractionEvent.java | 4 + .../main/java/io/sentry/time/Deadline.java | 2 + .../main/java/io/sentry/util/HttpUtils.java | 2 + .../main/java/io/sentry/util/InitUtil.java | 2 + .../sentry/util/JsonSerializationUtils.java | 2 +- .../src/main/java/io/sentry/util/Objects.java | 2 + .../main/java/io/sentry/util/Platform.java | 3 +- .../java/io/sentry/util/UUIDGenerator.java | 2 +- .../util/network/NetworkBodyParser.java | 7 +- .../sentry/vendor/gson/stream/JsonReader.java | 2 + .../test/java/io/sentry/OutboxSenderTest.kt | 5 +- .../io/sentry/PreviousSessionFinalizerTest.kt | 3 +- 188 files changed, 514 insertions(+), 540 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 973cfcf628..6b0ecf7d1f 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -36,7 +36,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f11c6475b7..3b185217b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ concurrency: jobs: build: # Job name is a required status check in the main branch ruleset; keep it stable. - name: Build Job ubuntu-latest - Java 17 + name: Build Job ubuntu-latest runs-on: ubuntu-latest env: @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 662c20a088..00fe6dc8a7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 6b94bc1b62..0ed7cbb4ef 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -17,7 +17,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 7b356dbada..0fe0c84ccf 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -16,7 +16,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 7a74d941f6..262b0fb3f4 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -17,7 +17,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index ad7153f750..b85ed38713 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -26,7 +26,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index e9d3d6e80c..fb61149ac5 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -26,7 +26,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: "temurin" - java-version: "17" + java-version: "21" # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index ab379c32f3..72d660c505 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -33,7 +33,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 5871d37beb..2bd88afd5a 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 6151098703..9b04020e6c 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -23,7 +23,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index d798f0e437..8a723dd994 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -48,7 +48,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index fdc9169ccc..33e06c49b5 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -48,7 +48,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 08dfd5e389..a960e00082 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -48,7 +48,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index aa25e9854c..2165a6c6a6 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -121,7 +121,7 @@ jobs: uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 684ef3c735..8c49840c0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,9 @@ You are welcome to use whatever tools you prefer for making a contribution. Howe # Requirements -The project requires you to run JDK 17. +The project requires you to run JDK 21 or newer. Compilation and tests always happen on JDK 21 +through a [toolchain](https://docs.gradle.org/current/userguide/toolchains.html), so a newer JDK +runs the build but never changes its output. ## Android diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index fc90a79278..d02ab58488 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -5,10 +5,16 @@ plugins { dependencies { implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.android.gradle.plugin) } gradlePlugin { plugins { + register("sentryJdkTarget") { + id = "io.sentry.jdk-target" + implementationClass = "io.sentry.gradle.JdkTargetConvention" + } register("sentryAnimalSniffer") { id = "io.sentry.animalsniffer" implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 58fadd9f17..76cf7346e5 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -10,6 +10,8 @@ dependencyResolutionManagement { repositories { mavenCentral() gradlePluginPortal() + // AGP is only published to Google's repository. + google() } versionCatalogs { create("libs") { diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt new file mode 100644 index 0000000000..c92c83bbac --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt @@ -0,0 +1,116 @@ +package io.sentry.gradle + +import com.android.build.gradle.BaseExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.JavaBasePlugin +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +/** The JDK every module compiles and tests with, whatever JDK runs Gradle. */ +private val BUILD_JDK = JavaLanguageVersion.of(21) + +/** The level the SDK publishes, so that Java 8 consumers can still use it. */ +private val PUBLISHED_JAVA_VERSION = JavaVersion.VERSION_1_8 + +/** + * Holds every toolchain, bytecode level decision and compilation option of a module. It does the + * following + * - sample apps target the build JDK (21) + * - unit test code will target the build JDK (21) (required for Mockito 5) + * - published JVM modules main code pins the Java 1.8 release, for Java and for Kotlin + * - published Android modules main code targets Java 1.8 + * - minSdk is set for Android libraries + */ +class JdkTargetConvention : Plugin { + override fun apply(project: Project) { + project.plugins.withType(JavaBasePlugin::class.java) { + project.extensions.getByType(JavaPluginExtension::class.java).toolchain { + languageVersion.set(BUILD_JDK) + } + } + + project.plugins.withId("com.vanniktech.maven.publish") { + // Only works for non-android modules + // See https://issuetracker.google.com/issues/278800528 + project.plugins.withId("java-library") { + project.tasks.named("compileJava", JavaCompile::class.java) { + options.release.set(PUBLISHED_JAVA_VERSION.majorVersion.toInt()) + } + } + // -Xjdk-release is the Kotlin counterpart of release: without it Kotlin resolves against the + // build JDK and happily compiles e.g. Files.readString() into Java 8 bytecode. The version + // follows jvmTarget, which the modules that publish a higher level set themselves. + project.plugins.withId("org.jetbrains.kotlin.jvm") { + project.tasks.named("compileKotlin", KotlinJvmCompile::class.java) { + compilerOptions.freeCompilerArgs.add( + compilerOptions.jvmTarget.map { "-Xjdk-release=${it.target}" } + ) + } + } + } + + project.plugins.withId("com.android.library") { + val android = project.extensions.getByType(BaseExtension::class.java) + android.compileOptions { + sourceCompatibility = PUBLISHED_JAVA_VERSION + targetCompatibility = PUBLISHED_JAVA_VERSION + } + android.defaultConfig { aarMetadata { minCompileSdk = project.minSdk() } } + } + + listOf("com.android.library", "com.android.application").forEach { pluginId -> + project.plugins.withId(pluginId) { project.targetBuildJdkForUnitTests() } + } + } + + private fun Project.minSdk(): Int = + extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + .findVersion("minSdk") + .orElseThrow { IllegalStateException("libs.versions.minSdk is missing") } + .requiredVersion + .toInt() + + /** + * Mockito 5 and mockito-kotlin 6 are built for Java 11, so test code - which is never published - + * cannot share the Java 8 target of the main code. AGP has no per variant compileOptions, so this + * goes through the Kotlin compilations: their compile task providers are configured after AGP + * wrote its own compileOptions, which an apply time configureEach would lose against. + */ + private fun Project.targetBuildJdkForUnitTests() { + plugins.withId("org.jetbrains.kotlin.android") { + extensions.getByType(KotlinAndroidProjectExtension::class.java).target.targetBuildJdk() + } + plugins.withId("org.jetbrains.kotlin.multiplatform") { + extensions + .getByType(KotlinMultiplatformExtension::class.java) + .targets + .withType(KotlinAndroidTarget::class.java) + .configureEach { targetBuildJdk() } + } + } + + private fun KotlinAndroidTarget.targetBuildJdk() { + compilations.configureEach { + if (name.endsWith("UnitTest")) { + compileTaskProvider.configure { + compilerOptions.jvmTarget.set(JvmTarget.fromTarget(BUILD_JDK.toString())) + } + compileJavaTaskProvider?.configure { + sourceCompatibility = BUILD_JDK.toString() + targetCompatibility = BUILD_JDK.toString() + } + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 52e17b1d30..898b429cb5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,10 @@ plugins { alias(libs.plugins.sentry) apply false } +check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { + "This build requires JDK 21 or newer, but runs on JDK ${JavaVersion.current()}." +} + buildscript { repositories { google() @@ -111,6 +115,7 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } + apply { plugin("io.sentry.jdk-target") } plugins.withId(Config.QualityPlugins.detektPlugin) { configure { @@ -162,28 +167,6 @@ subprojects { configure { withJavadocJar() - - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - } - - // AGP 9 defaults Android modules to Java 11. Pin the published library modules back - // to Java 8 so their bytecode stays consumable by Java 8 projects, mirroring the - // java-library pin above. - plugins.withId("com.android.library") { - configure { - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - // AGP 9 defaults the AAR metadata minCompileSdk to the library's compileSdk, - // which would force every consumer onto that compile SDK. Pin it to our minSdk - // so consumers remain free to compile against any SDK we support, as before. - defaultConfig { - aarMetadata { minCompileSdk = libs.versions.minSdk.get().toInt() } - } } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 4459ee342f..bc0172f0f0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,9 +1,3 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { `kotlin-dsl` } - -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43c3a7660c..e5c2f9e217 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,9 @@ retrofit = "2.9.0" room2 = "2.8.4" room3 = "3.0.0-rc01" sagp = "6.13.0" +# Compile-time only, for the AGP types the build-logic convention plugins use. The AGP that +# actually runs the build comes from the root buildscript (Config.AGP, overridable via VERSION_AGP). +agp = "9.4.0" sqlite = "2.6.2" sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" @@ -64,7 +67,7 @@ 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" } binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.13.0" } -errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } +errorprone = { id = "net.ltgt.errorprone", version = "5.1.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" } @@ -129,7 +132,7 @@ caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = " coil-compose = { module = "io.coil-kt:coil-compose", version = "2.6.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.28.0"} context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" } -errorprone-core = { module = "com.google.errorprone:error_prone_core", version = "2.11.0" } +errorprone-core = { module = "com.google.errorprone:error_prone_core", version = "2.50.0" } feign-core = { module = "io.github.openfeign:feign-core", version.ref = "feign" } feign-gson = { module = "io.github.openfeign:feign-gson", version.ref = "feign" } graphql-java17 = { module = "com.graphql-java:graphql-java", version = "17.3" } @@ -152,9 +155,11 @@ log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "lo 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" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } -nullaway = { module = "com.uber.nullaway:nullaway", version = "0.9.5" } +nullaway = { module = "com.uber.nullaway:nullaway", version = "0.14.2" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } openfeature = { module = "dev.openfeature:sdk", version.ref = "openfeature" } @@ -273,9 +278,8 @@ 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" } leakcanary-instrumentation = { module = "com.squareup.leakcanary:leakcanary-android-instrumentation", version = "2.14" } -mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.1.0" } -mockito-kotlin-spring7 = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.0.0" } -mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.0" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.4.0" } +mockito-core = { module = "org.mockito:mockito-core", version = "5.24.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } # apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0d7f16af19..4facf6f4c2 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -118,7 +118,7 @@ dependencies { testImplementation(libs.awaitility.kotlin) testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(projects.sentryTestSupport) testImplementation(projects.sentrySpotlight) testImplementation(projects.sentryAndroidFragment) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java index 7b21c2e392..120f2e0bef 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java @@ -17,6 +17,8 @@ final class ApplicationNotResponding extends RuntimeException { private static final long serialVersionUID = 252541144579117016L; + // temp fix for JDK 21: Never serialized. + @SuppressWarnings("serial") private final @Nullable Thread thread; ApplicationNotResponding(final @Nullable String message) { 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 f842f18674..589a9fa2ce 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 @@ -13,6 +13,9 @@ /** * @deprecated `SentryUserFeedbackButton` will be removed in the next major version */ +// temp fix for JDK 21: Constructors call overridable methods; making them final would break the +// public API. +@SuppressWarnings("this-escape") @Deprecated public class SentryUserFeedbackButton extends Button { 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 82376a75d8..dacab11e4f 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 @@ -50,6 +50,9 @@ *

This class is also responsible for - determining the app start type (cold, warm) - determining * if the app was launched in foreground */ +// temp fix for JDK 21: Constructors call overridable methods; making them final would break the +// public API. +@SuppressWarnings("this-escape") @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { public interface HeadlessAppStartListener { 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 cd21f92fd2..e41d5778a7 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 @@ -121,7 +121,7 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { } shadowActivityManager.addApplicationExitInfo(exitInfo) }, - flushLogPrefix = "Timed out waiting to flush ANR event to disk.", + flushLogPrefix = "Timed out waiting to flush %s event to disk.", ) override fun assertEnrichedEvent(event: SentryEvent) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 82cf1c25c9..7db354ea64 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -27,6 +27,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.anyVararg import org.mockito.kotlin.argThat import org.mockito.kotlin.atMost import org.mockito.kotlin.check @@ -171,7 +172,7 @@ abstract class ApplicationExitIntegrationTestBase { verify(fixture.scopes).captureEvent(any(), anyOrNull()) verify(fixture.logger, never()) - .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, anyVararg()) } @Test @@ -184,7 +185,7 @@ abstract class ApplicationExitIntegrationTestBase { verify(fixture.scopes).captureEvent(any(), anyOrNull()) verify(fixture.logger, never()) - .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, anyVararg()) } @Test @@ -316,7 +317,7 @@ abstract class ApplicationExitIntegrationTestBase { .log( any(), argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), + anyVararg(), ) } @@ -337,7 +338,7 @@ abstract class ApplicationExitIntegrationTestBase { .log( any(), argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), + anyVararg(), ) verify(fixture.scopes).captureEvent(any(), any()) } @@ -354,7 +355,7 @@ abstract class ApplicationExitIntegrationTestBase { .log( any(), argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), + anyVararg(), ) assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) } 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 bc05f3b23f..39a63148a1 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 @@ -65,7 +65,7 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase().configureEach { diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index 9ca0c1f6d9..e606ab5255 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -33,7 +32,6 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts index 18a5b6cb7f..cab7c28f93 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 - plugins { id("com.android.test") alias(libs.plugins.kotlin.android) @@ -28,13 +26,6 @@ android { } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlin { compilerOptions.jvmTarget = JVM_11 } - targetProjectPath = ":sentry-samples:sentry-samples-android" // Run the test in its own process so it measures the target app cold, not itself. experimentalProperties["android.experimental.self-instrumenting"] = true 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 85ff2b1a3c..8f479470ca 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,6 +1,5 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone -import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -65,8 +64,6 @@ android { } } - kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } - lint { warningsAsErrors = true checkDependencies = true diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 19b9b3c753..ddc6d6f416 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -71,7 +71,7 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.roboelectric) testImplementation(libs.androidx.core) diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index da7ad9f36f..b9d02bb49d 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -67,7 +67,7 @@ dependencies { testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.google.truth) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) testImplementation(libs.roboelectric) } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 1318f7767c..1a92f9a0b3 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -85,7 +85,7 @@ dependencies { testImplementation(libs.awaitility.kotlin) testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.androidx.compose.ui) testImplementation(libs.androidx.compose.foundation) testImplementation(libs.androidx.compose.foundation.layout) diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index fd15526fcb..38ea832082 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -79,7 +79,7 @@ dependencies { testImplementation(libs.androidx.sqlite) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index f66c1a8a96..92f14b37c8 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -80,7 +80,7 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt index 8ef43a9b69..3fd80e495f 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt @@ -14,6 +14,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.mockito.kotlin.any +import org.mockito.kotlin.anyVararg import org.mockito.kotlin.check import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -326,7 +327,7 @@ class SentryTimberTreeTest { fun `Tree adds a log if min level is higher`() { val sut = fixture.getSut() sut.e(Throwable("test")) - verify(fixture.logs).log(any(), any(), any(), any()) + verify(fixture.logs).log(any(), any(), any(), anyVararg()) } @Test diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 7502fb6c4b..06c5f839f9 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -1,6 +1,7 @@ import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` @@ -12,11 +13,12 @@ plugins { } kotlin { - compilerOptions.jvmTarget = JvmTarget.JVM_1_8 compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } +tasks.named("compileKotlin") { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } + dependencies { api(projects.sentry) api(libs.apache.httpclient) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 6711b165dd..ae8721b22b 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -37,7 +37,7 @@ dependencies { testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp.mockwebserver) } diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 9fb024a01d..63c8b5431c 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -38,7 +38,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp.mockwebserver) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") } diff --git a/sentry-apollo-5/build.gradle.kts b/sentry-apollo-5/build.gradle.kts index e4cd7dd034..2d78dff8fa 100644 --- a/sentry-apollo-5/build.gradle.kts +++ b/sentry-apollo-5/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -37,7 +37,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp.mockwebserver5) } diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index b3cb3cff95..72a183bb7b 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -38,7 +38,7 @@ dependencies { testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp.mockwebserver) } @@ -46,6 +46,8 @@ tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) option("NullAway:AnnotatedPackages", "io.sentry") + // Generated by the Apollo plugin and marked "DO NOT MODIFY". + excludedPaths.set(".*/LaunchDetailsQuery.java") } } diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 17454baa66..3bcdefd1d8 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } @@ -34,7 +34,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { 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 718fae422f..a0ce94c08e 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 @@ -83,7 +83,7 @@ private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull JfrReader jfr; private final @NotNull Arguments args; - public ProfileEventVisitor( + ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @NotNull SentryStackTraceFactory stackTraceFactory, @NotNull JfrReader jfr, diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 394885df1b..19fce9f95b 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -69,7 +69,7 @@ kotlin { implementation(libs.androidx.test.runner) implementation(libs.google.truth) implementation(libs.kotlin.test.junit) - implementation(libs.mockito.inline) + implementation(libs.mockito.core) implementation(libs.mockito.kotlin) implementation(libs.roboelectric) } diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index def6708f67..9da6bcc1e3 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -36,7 +36,7 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.springboot.starter.graphql) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index 34f71ab9cf..df0496f070 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -34,7 +34,7 @@ dependencies { testImplementation(libs.graphql.java17) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.springboot.starter.graphql) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index f996e0310a..25613e1fdd 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -36,7 +36,7 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.springboot.starter.graphql) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index b388f35881..b773c42c34 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -34,7 +34,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index e2a7f57313..9d0aa1596f 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } @@ -32,7 +32,7 @@ dependencies { testImplementation(libs.hsqldb) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 2eec61eb17..130b46ad5e 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java index 4442052dd4..7d929ec772 100644 --- a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java +++ b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java @@ -40,6 +40,9 @@ import org.slf4j.MDC; /** Logging handler in charge of sending the java.util.logging records to a Sentry server. */ +// temp fix for JDK 21: Constructors call overridable methods; making them final would break the +// public API. +@SuppressWarnings("this-escape") @Open public class SentryHandler extends Handler { public static final String MECHANISM_TYPE = "JulSentryHandler"; diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 0d543bad27..37240c8305 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } @@ -30,7 +30,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.kafka.clients) } diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java index dbce760de9..a45505f101 100644 --- a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java @@ -247,7 +247,7 @@ private void finishTransaction( try { final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); - final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final double nowSeconds = DateUtils.millisToSeconds((double) System.currentTimeMillis()); final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); return latencyMs >= 0 ? latencyMs : null; } catch (NumberFormatException ignored) { diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java index bcc538e339..312faabbb4 100644 --- a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java @@ -114,7 +114,8 @@ static final class SentryProducerHandler implements InvocationHandler { } @Override - @SuppressWarnings("unchecked") + // temp fix for JDK 21: Proxy equals() is identity-based. + @SuppressWarnings({"unchecked", "ReferenceEquality"}) public @Nullable Object invoke( final @NotNull Object proxy, final @NotNull Method method, final @Nullable Object[] args) throws Throwable { @@ -236,7 +237,8 @@ private void maybeInjectHeaders(final @NotNull Headers headers, final @Nullable headers.remove(SENTRY_ENQUEUED_TIME_HEADER); headers.add( SENTRY_ENQUEUED_TIME_HEADER, - DateUtils.doubleToBigDecimal(DateUtils.millisToSeconds(System.currentTimeMillis())) + DateUtils.doubleToBigDecimal( + DateUtils.millisToSeconds((double) System.currentTimeMillis())) .toString() .getBytes(StandardCharsets.UTF_8)); } catch (Throwable t) { diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 101761b2a8..ded17d445e 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index cbee84179f..28edd1689e 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -37,7 +37,7 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.java) testImplementation(libs.okhttp.mockwebserver) diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index ddc982e9d6..df081b2121 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -70,6 +70,6 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.launchdarkly.android) } diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 95aba9faaf..f2ed3d1574 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -34,7 +34,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.launchdarkly.server) } diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 6e5250ece5..2d2444a239 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index de2c9dbb80..48f84725a7 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java index 722845e2d7..66f61a182e 100644 --- a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java +++ b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java @@ -197,7 +197,8 @@ protected void captureLog(@NotNull ILoggingEvent loggingEvent) { final @NotNull SentryLogParameters params = SentryLogParameters.create(attributes); params.setOrigin("auto.log.logback"); - Sentry.logger().log(sentryLevel, params, formattedMessage, arguments); + Sentry.logger() + .log(sentryLevel, params, formattedMessage, arguments != null ? arguments : new Object[0]); } @SuppressWarnings("deprecation") diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index 5abd1cf26b..0b3e238841 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -36,7 +36,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.google.truth) diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index b079ead1fc..6750e22e61 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -34,7 +34,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.openfeature) } diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 8b7adc9754..209e5c34e5 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 087568d03e..3390f2695c 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -10,7 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 3585aa40d4..28a1b030e1 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -10,7 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java index 143ebb6c16..ad70d2346a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java @@ -17,7 +17,7 @@ public final class OtelContextScopesStorage implements IScopesStorage { @Override public void init() { - /** + /* * We're currently overriding the storage mechanism to allow for cleanup of non closed OTel * scopes. These happen when using e.g. Sentry static API due to getCurrentScopes() invoking * Context.makeCurrent and then ignoring the returned lifecycle token (OTel Scope). After fixing diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java index e44afc5a2d..7ac6bee4a8 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java @@ -29,6 +29,8 @@ public final class SentryOtelThreadLocalStorage implements ContextStorage { private static final ThreadLocal THREAD_LOCAL_STORAGE = new ThreadLocal<>(); + // temp fix for JDK 21: Same identity check as OpenTelemetry's ThreadLocalContextStorage. + @SuppressWarnings("ReferenceEquality") @Override public Scope attach(Context toAttach) { if (toAttach == null) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index a252628c1a..1c1e7f984b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -10,7 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index ec240c681a..afab9ef180 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -10,7 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index f4f0d9d07d..b5387daf20 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 @@ -33,7 +33,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) } tasks.withType().configureEach { diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index fe9023586e..b98eafd028 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -10,12 +10,10 @@ plugins { alias(libs.plugins.buildconfig) } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorUtils.java b/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorUtils.java index 16fed92bd0..04d87a8b45 100644 --- a/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorUtils.java +++ b/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorUtils.java @@ -48,7 +48,7 @@ public static Mono withSentryForkedRoots(final @NotNull Mono mono) { */ public static Mono withSentryScopes( final @NotNull Mono mono, final @NotNull IScopes scopes) { - /** + /* * WARNING: Cannot set the scopes as current. It would be used by others to clone again causing * shared scopes and thus leading to issues like unrelated breadcrumbs showing up in events. */ @@ -96,7 +96,7 @@ public static Flux withSentryForkedRoots(final @NotNull Flux flux) { */ public static Flux withSentryScopes( final @NotNull Flux flux, final @NotNull IScopes scopes) { - /** + /* * WARNING: Cannot set the scopes as current. It would be used by others to fork again causing * shared scopes and thus leading to issues like unrelated breadcrumbs showing up in events. */ diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 4fee4a1b92..cf4b054e35 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -129,14 +129,6 @@ android { } } - // 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.onVariants { variant -> variant.buildConfigFields?.put( "USE_SAGP", 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 a71f0bb904..57d1dea0e8 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -11,23 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.console.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 491583175a..58a8578c7d 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -11,23 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.console.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index d714a0ed54..f0b0d86289 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -11,23 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.console.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 3edf8e427a..6fa4bbb8c8 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -11,19 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.jul.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 809167d040..901604d2cf 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -11,19 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.log4j2.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 8bbc70a8fb..6d6e705103 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -11,19 +11,9 @@ plugins { application { mainClass.set("io.sentry.samples.logback.Main") } -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 8770dd8d38..82a18d86c7 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -22,10 +22,6 @@ group = "io.sentry.sample.spring-7" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom(SpringBootPlugin.BOM_COORDINATES) @@ -65,7 +61,6 @@ tasks.withType().configureEach { // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts index 2ca03bfc28..06cbdbe940 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts @@ -13,22 +13,12 @@ group = "io.sentry.sample.spring-boot-4-log4j2" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { explicitApi() // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } 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 a2fefae6ba..bfd59790ce 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 @@ -13,22 +13,12 @@ group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { explicitApi() // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } 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 112465bb5f..15bb3fca8a 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 @@ -14,22 +14,12 @@ group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { explicitApi() // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } 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 d995d39de2..c5bc2b96fb 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 @@ -13,22 +13,12 @@ group = "io.sentry.sample.spring-boot-4-otlp" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { explicitApi() // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } 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 e5b44c1793..c0c021e832 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 @@ -13,10 +13,6 @@ group = "io.sentry.sample.spring-boot-4-webflux" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) @@ -53,7 +49,6 @@ tasks.withType().configureEach { // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } 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 3ec1817ce6..00b85d45bd 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -13,22 +13,12 @@ group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { kotlin { explicitApi() // skip metadata version check, as Spring 7 / Spring Boot 4 is // compiled against a newer version of Kotlin compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts index 9ac03b5ebf..3535e1232e 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -14,10 +13,6 @@ group = "io.sentry.sample.spring-boot-jakarta-log4j2" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") @@ -27,17 +22,9 @@ dependencyManagement { // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_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 6d1549842c..da761af1f6 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,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -14,10 +13,6 @@ group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") @@ -28,16 +23,9 @@ dependencyManagement { // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - tasks.withType().configureEach { - compilerOptions.jvmTarget = JvmTarget.JVM_17 kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_17 } } 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 bfe39c0dfa..9b59911bd3 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,5 +1,4 @@ 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 @@ -15,10 +14,6 @@ group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") @@ -28,17 +23,9 @@ dependencyManagement { // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_17 } } 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 722ccc69d3..cdc5753a9c 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -14,10 +13,6 @@ group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") @@ -27,17 +22,9 @@ dependencyManagement { // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_17 } } 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 466c4fa7b5..5ffc83b7f6 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,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,16 +10,22 @@ plugins { id("io.sentry.systemtest") } +// Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version +// the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the +// toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test +// code keeps following the toolchain. +tasks.named("compileJava") { options.release.set(11) } + +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 +} + application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_11 - -java.targetCompatibility = JavaVersion.VERSION_11 - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } @@ -33,17 +38,9 @@ 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 = JvmTarget.JVM_11 } } 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 f27ee6b234..3b7f56523d 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,16 +10,22 @@ plugins { id("io.sentry.systemtest") } +// Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version +// the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the +// toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test +// code keeps following the toolchain. +tasks.named("compileJava") { options.release.set(11) } + +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 +} + application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_11 - -java.targetCompatibility = JavaVersion.VERSION_11 - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } @@ -33,16 +38,9 @@ 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 kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_11 } } 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 08f74e90ba..342aaa0d70 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,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -14,10 +13,6 @@ group = "io.sentry.sample.spring-boot-webflux-jakarta" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") @@ -59,7 +54,6 @@ dependencies { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - 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 1ef3a196e9..fe1859c0df 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,16 +10,22 @@ plugins { id("io.sentry.systemtest") } +// Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version +// the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the +// toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test +// code keeps following the toolchain. +tasks.named("compileJava") { options.release.set(11) } + +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 +} + application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_11 - -java.targetCompatibility = JavaVersion.VERSION_11 - fun springBoot2SupportsGraphql(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } @@ -94,7 +99,6 @@ configure { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_11 } } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 86ed855e2d..316e98ebdb 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,16 +10,22 @@ plugins { id("io.sentry.systemtest") } +// Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version +// the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the +// toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test +// code keeps following the toolchain. +tasks.named("compileJava") { options.release.set(11) } + +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 +} + application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_11 - -java.targetCompatibility = JavaVersion.VERSION_11 - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } @@ -33,15 +38,9 @@ val includeGraphql = !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() -configure { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_11 } } diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 2d28ed2531..022b9fbb0c 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -20,10 +19,6 @@ group = "io.sentry.sample.spring-jakarta" version = "0.0.1-SNAPSHOT" -java.sourceCompatibility = JavaVersion.VERSION_17 - -java.targetCompatibility = JavaVersion.VERSION_17 - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -64,7 +59,6 @@ dependencies { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 7c6b6a915f..8ae94a2e36 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -11,6 +10,16 @@ plugins { id("io.sentry.systemtest") } +// Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version +// the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the +// toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test +// code keeps following the toolchain. +tasks.named("compileJava") { options.release.set(8) } + +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + application { mainClass.set("io.sentry.samples.spring.Main") } // Ensure WAR is up to date before run task @@ -20,11 +29,6 @@ group = "io.sentry.sample.spring" version = "0.0.1-SNAPSHOT" -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" @@ -65,7 +69,6 @@ dependencies { tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } } diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 5762a4ae52..b9d6ffc2ec 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index c2e3fe8e52..08ac7d4528 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index a8d538c2e2..072cb360e6 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -11,7 +11,7 @@ plugins { alias(libs.plugins.buildconfig) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } @@ -28,7 +28,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(projects.sentryTestSupport) } diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index 4e5ea54d29..c848c9416a 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -12,12 +12,10 @@ plugins { alias(libs.plugins.springboot4) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) @@ -67,8 +65,8 @@ dependencies { testImplementation(libs.context.propagation) testImplementation(libs.graphql.java24) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.mockito.kotlin.spring7) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.core) testImplementation(libs.springboot4.starter.aspectj) testImplementation(libs.springboot4.starter.graphql) testImplementation(libs.spring.kafka4) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java index 44ae584ec1..27720570a2 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java @@ -25,9 +25,8 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options public @Nullable User provideUser() { if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - if (requestAttributes instanceof ServletRequestAttributes) { - final ServletRequestAttributes servletRequestAttributes = - (ServletRequestAttributes) requestAttributes; + if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) { + final HttpServletRequest request = servletRequestAttributes.getRequest(); final User user = new User(); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java index 4b778d6864..b36df8be77 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java @@ -42,8 +42,7 @@ public SentryInitBeanPostProcessor() { @SuppressWarnings({"unchecked", "deprecation"}) public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, @NotNull final String beanName) throws BeansException { - if (bean instanceof SentryOptions) { - final SentryOptions options = (SentryOptions) bean; + if (bean instanceof SentryOptions options) { if (applicationContext != null) { applicationContext diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java index b6569a9953..8c755be2de 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java @@ -16,8 +16,8 @@ public final class SentryCacheBeanPostProcessor implements BeanPostProcessor, Pr @Override public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, final @NotNull String beanName) throws BeansException { - if (bean instanceof CacheManager && !(bean instanceof SentryCacheManagerWrapper)) { - return new SentryCacheManagerWrapper((CacheManager) bean, ScopesAdapter.getInstance()); + if (bean instanceof CacheManager cacheManager && !(bean instanceof SentryCacheManagerWrapper)) { + return new SentryCacheManagerWrapper(cacheManager, ScopesAdapter.getInstance()); } return bean; } 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 e58e0e9dd9..a14f0d4c96 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 @@ -44,9 +44,11 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { } @Override - public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + public @Nullable Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final @Nullable Object target = invocation.getThis(); final Method mostSpecificMethod = - AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + AopUtils.getMostSpecificMethod( + invocation.getMethod(), target != null ? target.getClass() : null); @Nullable SentryCheckIn checkInAnnotation = diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java index cb5cba62a4..f4c630033a 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java @@ -11,6 +11,7 @@ import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.core.annotation.AnnotationUtils; @@ -33,17 +34,19 @@ public SentryCaptureExceptionParameterAdvice(final @NotNull IScopes scopes) { } @Override - public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + public @Nullable Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final @Nullable Object target = invocation.getThis(); final Method mostSpecificMethod = - AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + AopUtils.getMostSpecificMethod( + invocation.getMethod(), target != null ? target.getClass() : null); SentryCaptureExceptionParameter sentryCaptureExceptionParameter = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCaptureExceptionParameter.class); if (sentryCaptureExceptionParameter != null) { Object[] args = invocation.getArguments(); for (Object arg : args) { - if (arg instanceof Exception) { - captureException((Exception) arg); + if (arg instanceof Exception exception) { + captureException(exception); break; } } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java index 591863d906..647da38381 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java @@ -108,8 +108,8 @@ public void registerMappedBatchLoader( private @NotNull IScopes scopesFromContext(final @NotNull BatchLoaderEnvironment environment) { Object context = environment.getContext(); - if (context instanceof GraphQLContext) { - GraphQLContext graphqlContext = (GraphQLContext) context; + if (context instanceof GraphQLContext graphqlContext) { + return graphqlContext.getOrDefault(SENTRY_SCOPES_CONTEXT_KEY, NoOpScopes.getInstance()); } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java index 2ea390fd2d..1a973c6203 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java @@ -12,8 +12,8 @@ public final class SentryGraphqlBeanPostProcessor implements BeanPostProcessor, @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof BatchLoaderRegistry) { - return new SentryBatchLoaderRegistry((BatchLoaderRegistry) bean); + if (bean instanceof BatchLoaderRegistry batchLoaderRegistry) { + return new SentryBatchLoaderRegistry(batchLoaderRegistry); } return bean; } 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 index eff0b4154b..c8b41d4d0f 100644 --- 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 @@ -36,8 +36,8 @@ public final class SentryKafkaProducerBeanPostProcessor @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; + if (bean instanceof ProducerFactory factory) { + final @NotNull SentryProducerPostProcessor pp = new SentryProducerPostProcessor<>(); factory.addPostProcessor(pp); if (!factory.getPostProcessors().contains(pp)) { 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 index b2b4d20b94..dca7c7d61d 100644 --- 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 @@ -196,7 +196,9 @@ private boolean isIgnored() { if (enqueuedTimeStr != null) { try { final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); - final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + // millisToSeconds takes a double; the cast is explicit for Error Prone's + // LongDoubleConversion check. + final double nowSeconds = DateUtils.millisToSeconds((double) System.currentTimeMillis()); final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); if (latencyMs >= 0) { transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java index 6c9a3287ed..36eb08d524 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java @@ -35,15 +35,17 @@ public SentrySpanAdvice(final @NotNull IScopes scopes) { @SuppressWarnings("deprecation") @Override - public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + public @Nullable Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { final ISpan activeSpan = scopes.getSpan(); if (activeSpan == null || activeSpan.isNoOp()) { // there is no active transaction, we do not start new span return invocation.proceed(); } else { + final @Nullable Object target = invocation.getThis(); final Method mostSpecificMethod = - AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + AopUtils.getMostSpecificMethod( + invocation.getMethod(), target != null ? target.getClass() : null); final Class targetClass = invocation.getMethod().getDeclaringClass(); SentrySpan sentrySpan = AnnotationUtils.findAnnotation(mostSpecificMethod, SentrySpan.class); if (sentrySpan == null) { diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java index 46a31245ba..d0d0923a42 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -39,7 +39,7 @@ public SentrySpanClientHttpRequestInterceptor(final @NotNull IScopes scopes) { } public SentrySpanClientHttpRequestInterceptor( - final @NotNull IScopes scopes, final @NotNull boolean isRestTemplate) { + final @NotNull IScopes scopes, final boolean isRestTemplate) { this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.traceOrigin = isRestTemplate ? TRACE_ORIGIN_REST_TEMPLATE : TRACE_ORIGIN_REST_CLIENT; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java index b16a6ab00e..913ee09b2d 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java @@ -41,9 +41,11 @@ public SentryTransactionAdvice(final @NotNull IScopes scopes) { @SuppressWarnings("deprecation") @Override - public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + public @Nullable Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final @Nullable Object target = invocation.getThis(); final Method mostSpecificMethod = - AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + AopUtils.getMostSpecificMethod( + invocation.getMethod(), target != null ? target.getClass() : null); @Nullable SentryTransaction sentryTransaction = @@ -116,7 +118,7 @@ private static class TransactionNameAndSource { private final @NotNull String name; private final @NotNull TransactionNameSource source; - public TransactionNameAndSource( + TransactionNameAndSource( final @NotNull String name, final @NotNull TransactionNameSource source) { this.name = name; this.source = source; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java index 19e6fa0fc4..421cb7a2a9 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java @@ -38,7 +38,7 @@ public SentryWebExceptionHandler(final @NotNull IScopes scopes) { public @NotNull Mono handle( final @NotNull ServerWebExchange serverWebExchange, final @NotNull Throwable ex) { final @Nullable IScopes requestScopes = - serverWebExchange.getAttributeOrDefault(SentryWebFilter.SENTRY_SCOPES_KEY, null); + serverWebExchange.getAttribute(SentryWebFilter.SENTRY_SCOPES_KEY); final @NotNull IScopes scopesToUse = requestScopes != null ? requestScopes : scopes; return SentryReactorUtils.withSentryScopes( diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index bffe53aab0..7ad300d479 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -11,12 +11,10 @@ plugins { alias(libs.plugins.springboot4) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index d1128ce8a0..ce3a42989e 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -13,12 +13,10 @@ plugins { alias(libs.plugins.springboot4) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java index efdf9dc255..51d86eb6bb 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java @@ -45,13 +45,12 @@ public boolean supportsEventType(final @NotNull ResolvableType eventType) { @Override public void onApplicationEvent(final @NotNull ApplicationEvent event) { final Object context = LogManager.getContext(false); - if (!(context instanceof LoggerContext)) { + if (!(context instanceof LoggerContext loggerContext)) { logger.info( "Sentry Log4j2 appender was not configured because Log4j2 Core is not the active logging backend. Log4j2 API calls may be routed through SLF4J."); return; } - final LoggerContext loggerContext = (LoggerContext) context; final Configuration configuration = loggerContext.getConfiguration(); boolean changed = false; diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java index 5dd84ab413..88099b6eea 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java @@ -19,11 +19,10 @@ final class SentryOkHttpClientBeanPostProcessor implements BeanPostProcessor, Pr @Override public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, final @NotNull String beanName) throws BeansException { - if (!(bean instanceof OkHttpClient)) { + if (!(bean instanceof OkHttpClient client)) { return bean; } - final @NotNull OkHttpClient client = (OkHttpClient) bean; if (client.getClass() != OkHttpClient.class) { ScopesAdapter.getInstance() .getOptions() diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 5f2bbe87b3..8c1a07aa9c 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -12,12 +12,10 @@ plugins { alias(libs.plugins.springboot3) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java index 141f6a8ba0..c9d8684304 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java @@ -45,13 +45,12 @@ public boolean supportsEventType(final @NotNull ResolvableType eventType) { @Override public void onApplicationEvent(final @NotNull ApplicationEvent event) { final Object context = LogManager.getContext(false); - if (!(context instanceof LoggerContext)) { + if (!(context instanceof LoggerContext loggerContext)) { logger.info( "Sentry Log4j2 appender was not configured because Log4j2 Core is not the active logging backend. Log4j2 API calls may be routed through SLF4J."); return; } - final LoggerContext loggerContext = (LoggerContext) context; final Configuration configuration = loggerContext.getConfiguration(); boolean changed = false; diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryOkHttpClientBeanPostProcessor.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryOkHttpClientBeanPostProcessor.java index 8409240d7f..4c0bbf6c92 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryOkHttpClientBeanPostProcessor.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryOkHttpClientBeanPostProcessor.java @@ -19,11 +19,10 @@ final class SentryOkHttpClientBeanPostProcessor implements BeanPostProcessor, Pr @Override public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, final @NotNull String beanName) throws BeansException { - if (!(bean instanceof OkHttpClient)) { + if (!(bean instanceof OkHttpClient client)) { return bean; } - final @NotNull OkHttpClient client = (OkHttpClient) bean; if (client.getClass() != OkHttpClient.class) { ScopesAdapter.getInstance() .getOptions() diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index c6fe511073..63594df7a2 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -11,12 +11,10 @@ plugins { alias(libs.plugins.springboot3) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index cd05baecc4..641b335bec 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -10,7 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 27cb2d7805..c5537accef 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { 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 diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index f103bfcbe0..138ad1e5f1 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -12,18 +12,17 @@ plugins { alias(libs.plugins.springboot3) apply false } -configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 +// Published code targets Java 17. Test code is not published and follows the toolchain. +tasks.named("compileJava") { options.release.set(17) } + +kotlin { + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") } -tasks.withType().configureEach { - kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } dependencies { @@ -66,7 +65,7 @@ dependencies { testImplementation(libs.graphql.java24) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.springboot3.starter.aop) testImplementation(libs.springboot3.starter.graphql) testImplementation(libs.springboot3.starter.security) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java index b7f4646b4a..a64eb826f0 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java @@ -25,9 +25,8 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options public @Nullable User provideUser() { if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - if (requestAttributes instanceof ServletRequestAttributes) { - final ServletRequestAttributes servletRequestAttributes = - (ServletRequestAttributes) requestAttributes; + if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) { + final HttpServletRequest request = servletRequestAttributes.getRequest(); final User user = new User(); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryInitBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryInitBeanPostProcessor.java index 4d9e63a151..28157e0336 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryInitBeanPostProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryInitBeanPostProcessor.java @@ -42,8 +42,7 @@ public SentryInitBeanPostProcessor() { @SuppressWarnings({"unchecked", "deprecation"}) public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, @NotNull final String beanName) throws BeansException { - if (bean instanceof SentryOptions) { - final SentryOptions options = (SentryOptions) bean; + if (bean instanceof SentryOptions options) { if (applicationContext != null) { applicationContext diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java index ec9964f7ab..ad4425131f 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java @@ -16,8 +16,8 @@ public final class SentryCacheBeanPostProcessor implements BeanPostProcessor, Pr @Override public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, final @NotNull String beanName) throws BeansException { - if (bean instanceof CacheManager && !(bean instanceof SentryCacheManagerWrapper)) { - return new SentryCacheManagerWrapper((CacheManager) bean, ScopesAdapter.getInstance()); + if (bean instanceof CacheManager cacheManager && !(bean instanceof SentryCacheManagerWrapper)) { + return new SentryCacheManagerWrapper(cacheManager, ScopesAdapter.getInstance()); } return bean; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java index c6537f853c..5fe03f385d 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java @@ -42,8 +42,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl if (sentryCaptureExceptionParameter != null) { Object[] args = invocation.getArguments(); for (Object arg : args) { - if (arg instanceof Exception) { - captureException((Exception) arg); + if (arg instanceof Exception exception) { + captureException(exception); break; } } 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 4e7c3665aa..6a9f33e65f 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 @@ -108,8 +108,8 @@ public void registerMappedBatchLoader( private @NotNull IScopes scopesFromContext(final @NotNull BatchLoaderEnvironment environment) { Object context = environment.getContext(); - if (context instanceof GraphQLContext) { - GraphQLContext graphqlContext = (GraphQLContext) context; + if (context instanceof GraphQLContext graphqlContext) { + return graphqlContext.getOrDefault(SENTRY_SCOPES_CONTEXT_KEY, NoOpScopes.getInstance()); } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java index 5eb4374bb1..79bc5e11cc 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java @@ -12,8 +12,8 @@ public final class SentryGraphqlBeanPostProcessor implements BeanPostProcessor, @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof BatchLoaderRegistry) { - return new SentryBatchLoaderRegistry((BatchLoaderRegistry) bean); + if (bean instanceof BatchLoaderRegistry batchLoaderRegistry) { + return new SentryBatchLoaderRegistry(batchLoaderRegistry); } return bean; } 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 index 8a06e4e338..dc3636a2b2 100644 --- 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 @@ -36,8 +36,8 @@ public final class SentryKafkaProducerBeanPostProcessor @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; + if (bean instanceof ProducerFactory factory) { + final @NotNull SentryProducerPostProcessor pp = new SentryProducerPostProcessor<>(); factory.addPostProcessor(pp); if (!factory.getPostProcessors().contains(pp)) { 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 index 7253571269..4d5abda711 100644 --- 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 @@ -196,7 +196,7 @@ private boolean isIgnored() { if (enqueuedTimeStr != null) { try { final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); - final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final double nowSeconds = DateUtils.millisToSeconds((double) System.currentTimeMillis()); final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); if (latencyMs >= 0) { transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java index 0628bc1d30..8a5d1db3e0 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -39,7 +39,7 @@ public SentrySpanClientHttpRequestInterceptor(final @NotNull IScopes scopes) { } public SentrySpanClientHttpRequestInterceptor( - final @NotNull IScopes scopes, final @NotNull boolean isRestTemplate) { + final @NotNull IScopes scopes, final boolean isRestTemplate) { this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.traceOrigin = isRestTemplate ? TRACE_ORIGIN_REST_TEMPLATE : TRACE_ORIGIN_REST_CLIENT; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTransactionAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTransactionAdvice.java index 95618f76fd..833e59670e 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTransactionAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTransactionAdvice.java @@ -116,7 +116,7 @@ private static class TransactionNameAndSource { private final @NotNull String name; private final @NotNull TransactionNameSource source; - public TransactionNameAndSource( + TransactionNameAndSource( final @NotNull String name, final @NotNull TransactionNameSource source) { this.name = name; this.source = source; diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 03176d7b86..71eb6d1f15 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -12,7 +12,7 @@ plugins { id("io.sentry.animalsniffer") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { 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 @@ -54,7 +54,7 @@ dependencies { testImplementation(libs.graphql.java17) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.springboot.starter.aop) testImplementation(libs.springboot.starter.graphql) testImplementation(libs.springboot.starter.security) 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 index d1ad308609..ed26e2ee79 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/kafka/SentryKafkaRecordInterceptor.java @@ -202,7 +202,7 @@ private boolean isIgnored() { if (enqueuedTimeStr != null) { try { final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); - final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final double nowSeconds = DateUtils.millisToSeconds((double) System.currentTimeMillis()); final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); if (latencyMs >= 0) { transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, latencyMs); diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTransactionAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTransactionAdvice.java index 180d5df00a..eab13d1a7d 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTransactionAdvice.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTransactionAdvice.java @@ -115,7 +115,7 @@ private static class TransactionNameAndSource { private final @NotNull String name; private final @NotNull TransactionNameSource source; - public TransactionNameAndSource( + TransactionNameAndSource( final @NotNull String name, final @NotNull TransactionNameSource source) { this.name = name; this.source = source; diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index 7f08bf6d01..ecfdc6011f 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -7,13 +7,7 @@ plugins { id("com.apollographql.apollo") version "4.1.1" } -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 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index ca60a47c6b..2539beccb5 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -6,13 +6,7 @@ plugins { alias(libs.plugins.gradle.versions) } -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 compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 6b98ff790c..9cbcf5bf2c 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("io.sentry.animalsniffer.android") } -tasks.withType().configureEach { +tasks.named("compileKotlin") { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } @@ -28,7 +28,7 @@ dependencies { testImplementation(libs.javafaker) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.core) testImplementation(libs.msgpack) testImplementation(libs.okio) testImplementation(projects.sentryTestSupport) @@ -51,6 +51,9 @@ tasks { "--add-opens", "java.base/java.lang=ALL-UNNAMED", ) + // SentryEnvelopeItemTest."fromAttachment with file SecurityManager denies read access" installs + // a SecurityManager to simulate unreadable attachments; JDK 18+ refuses that without opt-in. + systemProperty("java.security.manager", "allow") environment["SENTRY_TEST_PROPERTY"] = "\"some-value\"" environment["SENTRY_TEST_MAP_KEY1"] = "\"value1\"" environment["SENTRY_TEST_MAP_KEY2"] = "value2" diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 9f0753f867..a4ef2c9e85 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -257,6 +257,10 @@ public String getThirdPartyHeader() { return thirdPartyHeader; } + // temp fix for JDK 21: ConcurrentHashMap.keySet() compiled against JDK 8+ returns KeySetView, + // which crashes on Android + // < 24. + @SuppressWarnings("JdkObsolete") public @NotNull String toHeaderString(@Nullable String thirdPartyBaggageHeaderString) { final StringBuilder sb = new StringBuilder(); String separator = ""; @@ -325,10 +329,14 @@ public String getThirdPartyHeader() { return sb.toString(); } + // temp fix for JDK 21: The Charset overload requires Java 10. + @SuppressWarnings("JdkObsolete") private String encode(final @NotNull String value) throws UnsupportedEncodingException { return URLEncoder.encode(value, CHARSET).replaceAll("\\+", "%20"); } + // temp fix for JDK 21: The Charset overload requires Java 10. + @SuppressWarnings("JdkObsolete") private static String decode(final @NotNull String value) throws UnsupportedEncodingException { return URLDecoder.decode(value, CHARSET); } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 4db0dcf46c..24ac5561d1 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -630,6 +630,8 @@ public void setType(@Nullable String type) { this.type = type; } + // temp fix for JDK 21: EMPTY_DATA is a sentinel, so identity comparison is intended. + @SuppressWarnings("ReferenceEquality") private @NotNull Map getOrCreateData() { Map currentData = data; if (currentData == EMPTY_DATA) { @@ -691,6 +693,8 @@ public void setData(@Nullable String key, @Nullable Object value) { * * @param key the key */ + // temp fix for JDK 21: EMPTY_DATA is a sentinel, so identity comparison is intended. + @SuppressWarnings("ReferenceEquality") public void removeData(@Nullable String key) { if (key == null) { return; diff --git a/sentry/src/main/java/io/sentry/CombinedContextsView.java b/sentry/src/main/java/io/sentry/CombinedContextsView.java index d9a8b14c62..0fff6b681b 100644 --- a/sentry/src/main/java/io/sentry/CombinedContextsView.java +++ b/sentry/src/main/java/io/sentry/CombinedContextsView.java @@ -243,7 +243,7 @@ public void setSpring(@NotNull Spring spring) { @ApiStatus.Internal @Override - /** Not intended to be set on a scopes Context directly */ + /* Not intended to be set on a scopes Context directly */ public void setFeatureFlags(@NotNull FeatureFlags spring) { getDefaultContexts().setFeatureFlags(spring); } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index fcba83fbe0..4be99b6322 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -151,10 +151,10 @@ public static double nanosToSeconds(final long nanos) { */ @SuppressWarnings("JavaUtilDate") public static double dateToSeconds(final @NotNull Date date) { - return millisToSeconds(date.getTime()); + return millisToSeconds((double) date.getTime()); } - public static long secondsToNanos(final @NotNull long seconds) { + public static long secondsToNanos(final long seconds) { return seconds * (1000L * 1000L * 1000L); } diff --git a/sentry/src/main/java/io/sentry/DefaultSpanFactory.java b/sentry/src/main/java/io/sentry/DefaultSpanFactory.java index f664360f49..71dd40843a 100644 --- a/sentry/src/main/java/io/sentry/DefaultSpanFactory.java +++ b/sentry/src/main/java/io/sentry/DefaultSpanFactory.java @@ -21,7 +21,7 @@ public final class DefaultSpanFactory implements ISpanFactory { final @NotNull SpanOptions spanOptions, final @NotNull SpanContext spanContext, @Nullable ISpan parentSpan) { - /** + /* * Be careful here when executing something like parentSpan.startChild() as that might cause a * loop and a stack overflow. This can happen, e.g. when OpenTelemetry is creating spans that * use OtelSpanWrapper which calls this createSpan method that then in turn calls startChild diff --git a/sentry/src/main/java/io/sentry/DiagnosticLogger.java b/sentry/src/main/java/io/sentry/DiagnosticLogger.java index c96e061f2c..1f067cd6f1 100644 --- a/sentry/src/main/java/io/sentry/DiagnosticLogger.java +++ b/sentry/src/main/java/io/sentry/DiagnosticLogger.java @@ -29,6 +29,8 @@ public DiagnosticLogger(final @NotNull SentryOptions options, final @Nullable IL * @param level The SentryLevel to test against. * @return True if a log message would be recorded for the level. Otherwise false. */ + // temp fix for JDK 21: SentryLevel is declared in ascending severity order. + @SuppressWarnings("EnumOrdinal") @Override public boolean isEnabled(final @Nullable SentryLevel level) { final SentryLevel diagLevel = options.getDiagnosticLevel(); diff --git a/sentry/src/main/java/io/sentry/DirectoryProcessor.java b/sentry/src/main/java/io/sentry/DirectoryProcessor.java index 17f65bb8c7..d9f0f32c40 100644 --- a/sentry/src/main/java/io/sentry/DirectoryProcessor.java +++ b/sentry/src/main/java/io/sentry/DirectoryProcessor.java @@ -120,7 +120,7 @@ private static final class SendCachedEnvelopeHint private final @NotNull String filePath; private final @NotNull Queue processedEnvelopes; - public SendCachedEnvelopeHint( + SendCachedEnvelopeHint( final long flushTimeoutMillis, final @NotNull ILogger logger, final @NotNull String filePath, diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 272cf1c13a..810130416a 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -242,7 +242,8 @@ public final class ExternalOptions { String profileLifecycleString = propertiesProvider.getProperty("profile-lifecycle"); if (profileLifecycleString != null && !profileLifecycleString.isEmpty()) { - options.setProfileLifecycle(ProfileLifecycle.valueOf(profileLifecycleString.toUpperCase())); + options.setProfileLifecycle( + ProfileLifecycle.valueOf(profileLifecycleString.toUpperCase(Locale.ROOT))); } return options; diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index 1e09dca554..9281d41fa9 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -60,7 +60,7 @@ public void set(@NotNull String name, @Nullable Object hint) { } @SuppressWarnings("unchecked") - public @Nullable T getAs(@NotNull String name, @NotNull Class clazz) { + public @Nullable T getAs(@NotNull String name, @NotNull Class clazz) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { Object hintValue = internalStorage.get(name); diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index a52ecc6b97..73db50ea4c 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -43,6 +43,10 @@ public void setTrace(final @NotNull SpanContext traceContext) { // region json + // temp fix for JDK 21: ConcurrentHashMap.keySet() compiled against JDK 8+ returns KeySetView, + // which crashes on Android + // < 24. + @SuppressWarnings("JdkObsolete") @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index a83eddd380..0688ce38f0 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -102,7 +102,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { - @SuppressWarnings("unchecked") + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings({"unchecked", "EnumOrdinal"}) @Override public @NotNull ReplayRecording deserialize( @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { diff --git a/sentry/src/main/java/io/sentry/SamplingContext.java b/sentry/src/main/java/io/sentry/SamplingContext.java index 17ce111d0c..7a9c69b073 100644 --- a/sentry/src/main/java/io/sentry/SamplingContext.java +++ b/sentry/src/main/java/io/sentry/SamplingContext.java @@ -20,7 +20,7 @@ public final class SamplingContext { @Deprecated @SuppressWarnings("InlineMeSuggester") - /** + /* * @deprecated creating a SamplingContext is something only the SDK should do */ public SamplingContext( diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 42aef2821a..ec98cc5fca 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -463,10 +463,10 @@ public Queue getBreadcrumbs() { */ private @Nullable Breadcrumb executeBeforeBreadcrumb( final @NotNull SentryOptions.BeforeBreadcrumbCallback callback, - @NotNull Breadcrumb breadcrumb, + final @NotNull Breadcrumb breadcrumb, final @NotNull Hint hint) { try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { - breadcrumb = callback.execute(breadcrumb, hint); + return callback.execute(breadcrumb, hint); } catch (Throwable e) { options .getLogger() @@ -476,7 +476,6 @@ public Queue getBreadcrumbs() { e); return null; } - return breadcrumb; } /** diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 2a7f06968d..d1858c0276 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -691,11 +691,12 @@ private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; - public CachedItem(final @Nullable Callable dataFactory) { + CachedItem(final @Nullable Callable dataFactory) { this.dataFactory = dataFactory; } - public @NotNull byte[] getBytes() throws Exception { + @NotNull + byte[] getBytes() throws Exception { if (bytes == null && dataFactory != null) { bytes = dataFactory.call(); } diff --git a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java index cd4ab1cc0e..3c07ec38b9 100644 --- a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java +++ b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java @@ -11,6 +11,7 @@ import java.util.Deque; import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -141,7 +142,7 @@ Deque extractExceptionQueue(final @NotNull Throwable throwable) Deque extractExceptionQueueInternal( final @NotNull Throwable throwable, final @NotNull AtomicInteger exceptionId, - final @NotNull HashSet circularityDetector, + final @NotNull Set circularityDetector, final @NotNull Deque exceptions, @Nullable String mechanismTypeOverride) { Mechanism exceptionMechanism; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3706a9f0a1..f4a17e3ebe 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -58,6 +58,9 @@ import org.jetbrains.annotations.TestOnly; /** Sentry SDK options */ +// temp fix for JDK 21: Constructors call overridable methods; making them final would break the +// public API. +@SuppressWarnings("this-escape") @Open public class SentryOptions implements RateLimiterConfig { diff --git a/sentry/src/main/java/io/sentry/SentryThreadFactory.java b/sentry/src/main/java/io/sentry/SentryThreadFactory.java index 8bd1448326..552ab1ed5c 100644 --- a/sentry/src/main/java/io/sentry/SentryThreadFactory.java +++ b/sentry/src/main/java/io/sentry/SentryThreadFactory.java @@ -78,6 +78,8 @@ List getCurrentThreads( * crashed. This is the case for e.g. watchdog threads which are not the one erroring. * @return a list of SentryThread or null if none */ + // temp fix for JDK 21: Identifies the current thread by identity. + @SuppressWarnings("ReferenceEquality") @TestOnly @Nullable List getCurrentThreads( diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index d60187cb4d..781cf51417 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -154,7 +154,7 @@ private void onDeadlineTimeoutReached() { } @Override - public @NotNull void forceFinish( + public void forceFinish( final @NotNull SpanStatus status, final boolean dropIfNoChildren, final @Nullable Hint hint) { if (isFinished()) { return; diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index 19ab2c3ad8..ebbeb56cd6 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -16,6 +16,9 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; +// temp fix for JDK 21: Constructors call overridable methods; making them final would break the +// public API. +@SuppressWarnings("this-escape") @Open public class SpanContext implements JsonUnknown, JsonSerializable { public static final String TYPE = "trace"; diff --git a/sentry/src/main/java/io/sentry/SynchronizedCollection.java b/sentry/src/main/java/io/sentry/SynchronizedCollection.java index 8a58401020..6e8e6b76c6 100644 --- a/sentry/src/main/java/io/sentry/SynchronizedCollection.java +++ b/sentry/src/main/java/io/sentry/SynchronizedCollection.java @@ -50,9 +50,14 @@ class SynchronizedCollection implements Collection, Serializable { private static final long serialVersionUID = 2412805092710877986L; /** The collection to decorate */ + // temp fix for JDK 21: Serializable only if the decorated collection is, as in Apache Commons + // Collections. + @SuppressWarnings("serial") private final Collection collection; /** The object to lock on, needed for List/SortedSet views */ + // temp fix for JDK 21: Not Serializable either; the SDK never serializes these collections. + @SuppressWarnings("serial") final AutoClosableReentrantLock lock; /** diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index 1bb5508f85..957bda79f4 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -30,7 +30,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { } @SuppressWarnings("InlineMeSuggester") - /** + /* * @deprecated please use the constructor than also takes sampleRand */ @Deprecated diff --git a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java index 6ea6189579..07e9b6357d 100644 --- a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java +++ b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java @@ -47,6 +47,8 @@ public UncaughtExceptionHandlerIntegration() { this.threadAdapter = threadAdapter; } + // temp fix for JDK 21: Both integrations must share the same global scope instance. + @SuppressWarnings("ReferenceEquality") @Override public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { if (registered) { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 8a097486db..8ebf494dc9 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -265,6 +265,9 @@ private void flush() { } } + // temp fix for JDK 21: DELETE_MARKER and CLEAR_MARKER are sentinels, so identity comparison is + // intended. + @SuppressWarnings("ReferenceEquality") private void writePending() { // ConcurrentHashMap's iterator is weakly consistent, so removing while iterating is safe. Keys // added after iteration starts may be missed, but flush() re-checks and queues another write. diff --git a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java index 5df6d473f7..f3e8cbd474 100644 --- a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java +++ b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java @@ -14,9 +14,16 @@ public final class ExceptionMechanismException extends RuntimeException { private static final long serialVersionUID = 142345454265713915L; + // temp fix for JDK 21: Never serialized. + @SuppressWarnings("serial") private final @NotNull Mechanism exceptionMechanism; + private final @NotNull Throwable throwable; + + // temp fix for JDK 21: Never serialized. + @SuppressWarnings("serial") private final @Nullable Thread thread; + private final boolean snapshot; /** diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 5a02a911ac..8444887819 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -233,13 +233,14 @@ private static class FeatureFlagEntry { private final boolean result; private final long nanos; - public FeatureFlagEntry(final @NotNull String flag, final boolean result, final long nanos) { + FeatureFlagEntry(final @NotNull String flag, final boolean result, final long nanos) { this.flag = flag; this.result = result; this.nanos = nanos; } - public @NotNull FeatureFlag toFeatureFlag() { + @NotNull + FeatureFlag toFeatureFlag() { return new FeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java index 1f7ac93950..fc1c313ec4 100644 --- a/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java +++ b/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java @@ -93,7 +93,7 @@ private static final class Module { private final @NotNull String name; private final @NotNull String version; - public Module(final @NotNull String name, final @NotNull String version) { + Module(final @NotNull String name, final @NotNull String version) { this.name = name; this.version = version; } diff --git a/sentry/src/main/java/io/sentry/logger/LoggerApi.java b/sentry/src/main/java/io/sentry/logger/LoggerApi.java index 139ac61539..77de690261 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerApi.java @@ -151,7 +151,7 @@ private void captureLog( } } - private @NotNull HashMap createAttributes( + private @NotNull Map createAttributes( final @NotNull SentryLogParameters params, final @NotNull String message, final @Nullable Object... args) { @@ -246,8 +246,7 @@ private void captureLog( return attributes; } - private void setServerName( - final @NotNull HashMap attributes) { + private void setServerName(final @NotNull Map attributes) { final @NotNull SentryOptions options = scopes.getOptions(); final @Nullable String optionsServerName = options.getServerName(); if (optionsServerName != null) { @@ -264,7 +263,7 @@ private void setServerName( } } - private void setUser(final @NotNull HashMap attributes) { + private void setUser(final @NotNull Map attributes) { final @Nullable User user = scopes.getCombinedScopeView().getUser(); if (user == null) { // In case no user is set, we should fallback to the distinct id, known as installation id, diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java index 93564e4dda..fa78511d22 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java @@ -155,7 +155,7 @@ private void captureMetrics( } } - private @NotNull HashMap createAttributes( + private @NotNull Map createAttributes( final @NotNull SentryMetricsParameters params) { final @NotNull HashMap attributes = new HashMap<>(); @@ -231,8 +231,7 @@ private void captureMetrics( return attributes; } - private void setServerName( - final @NotNull HashMap attributes) { + private void setServerName(final @NotNull Map attributes) { final @NotNull SentryOptions options = scopes.getOptions(); final @Nullable String optionsServerName = options.getServerName(); if (optionsServerName != null) { @@ -249,7 +248,7 @@ private void setServerName( } } - private void setUser(final @NotNull HashMap attributes) { + private void setUser(final @NotNull Map attributes) { final @Nullable User user = scopes.getCombinedScopeView().getUser(); if (user == null) { // In case no user is set, we should fallback to the distinct id, known as installation id, diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index f6ba635383..0ca6d77ec8 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -36,6 +36,9 @@ public class Contexts implements JsonSerializable { public Contexts() {} + // temp fix for JDK 21: Constructors call overridable methods; making them final would break the + // public API. + @SuppressWarnings("this-escape") public Contexts(final @NotNull Contexts contexts) { for (final Map.Entry entry : contexts.entrySet()) { if (entry != null) { @@ -252,6 +255,10 @@ public void clear() { internalStorage.clear(); } + // temp fix for JDK 21: ConcurrentHashMap.keySet() compiled against JDK 8+ returns KeySetView, + // which crashes on Android + // < 24. + @SuppressWarnings("JdkObsolete") public @NotNull Enumeration keys() { return internalStorage.keys(); } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java index fc9c8c7e69..9532998df2 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -17,6 +17,8 @@ public enum RRWebEventType implements JsonSerializable { Custom, Plugin; + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { @@ -24,6 +26,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } public static final class Deserializer implements JsonDeserializer { + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public @NotNull RRWebEventType deserialize( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java index aff3c55ac3..9c224bbbdd 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -30,6 +30,8 @@ public enum IncrementalSource implements JsonSerializable { AdoptedStyleSheet, CustomElement; + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { @@ -37,6 +39,8 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) } public static final class Deserializer implements JsonDeserializer { + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public @NotNull IncrementalSource deserialize( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java index c7bd613c1b..21d5be90ab 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -30,6 +30,8 @@ public enum InteractionType implements JsonSerializable { TouchEnd, TouchCancel; + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { @@ -37,6 +39,8 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) } public static final class Deserializer implements JsonDeserializer { + // temp fix for JDK 21: The rrweb protocol encodes these enums by ordinal. + @SuppressWarnings("EnumOrdinal") @Override public @NotNull InteractionType deserialize( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java index b6cc63e876..68f65ba5f6 100644 --- a/sentry/src/main/java/io/sentry/time/Deadline.java +++ b/sentry/src/main/java/io/sentry/time/Deadline.java @@ -79,6 +79,8 @@ public long remaining(final @NotNull TimeUnit unit) { * @throws IllegalArgumentException if the two were created from different tickers, whose origins * are unrelated and whose ticks are therefore not comparable. */ + // temp fix for JDK 21: Deadlines are only comparable when they share the same ticker instance. + @SuppressWarnings("ReferenceEquality") public boolean isAfter(final @NotNull Deadline other) { if (ticker != other.ticker) { throw new IllegalArgumentException( diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 59ed67eb9f..73da780805 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -125,6 +125,8 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return filteredHeaders; } + // temp fix for JDK 21: The Charset overload requires Java 10. + @SuppressWarnings("JdkObsolete") private static @NotNull String decodeQueryParamName(final @NotNull String name) { try { return URLDecoder.decode(name, "UTF-8"); diff --git a/sentry/src/main/java/io/sentry/util/InitUtil.java b/sentry/src/main/java/io/sentry/util/InitUtil.java index 471723ac18..6c1787474f 100644 --- a/sentry/src/main/java/io/sentry/util/InitUtil.java +++ b/sentry/src/main/java/io/sentry/util/InitUtil.java @@ -16,6 +16,8 @@ @ApiStatus.Internal public final class InitUtil { + // temp fix for JDK 21: InitPriority is declared in ascending priority order. + @SuppressWarnings("EnumOrdinal") public static boolean shouldInit( final @Nullable SentryOptions previousOptions, final @NotNull SentryOptions newOptions, diff --git a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java index fc9bc75aa3..4e5f9be90e 100644 --- a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java +++ b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java @@ -129,7 +129,7 @@ public void close() { // Nothing to close } - public long getByteCount() { + long getByteCount() { return byteCount; } diff --git a/sentry/src/main/java/io/sentry/util/Objects.java b/sentry/src/main/java/io/sentry/util/Objects.java index df7aeab38b..340d31c385 100644 --- a/sentry/src/main/java/io/sentry/util/Objects.java +++ b/sentry/src/main/java/io/sentry/util/Objects.java @@ -14,6 +14,8 @@ public static T requireNonNull(final @Nullable T obj, final @NotNull String return obj; } + // temp fix for JDK 21: Identity short-circuit, same as java.util.Objects.equals (Java 7+). + @SuppressWarnings("ReferenceEquality") public static boolean equals(@Nullable Object a, @Nullable Object b) { return (a == b) || (a != null && a.equals(b)); } diff --git a/sentry/src/main/java/io/sentry/util/Platform.java b/sentry/src/main/java/io/sentry/util/Platform.java index ad2a4e7f3c..bad0f214db 100644 --- a/sentry/src/main/java/io/sentry/util/Platform.java +++ b/sentry/src/main/java/io/sentry/util/Platform.java @@ -1,7 +1,6 @@ package io.sentry.util; import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal @@ -27,7 +26,7 @@ public final class Platform { try { final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); if (javaStringVersion != null) { - final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); + final double javaVersion = Double.parseDouble(javaStringVersion); isJavaNinePlus = javaVersion >= 9.0; } else { isJavaNinePlus = false; diff --git a/sentry/src/main/java/io/sentry/util/UUIDGenerator.java b/sentry/src/main/java/io/sentry/util/UUIDGenerator.java index 1da7b4feee..0402190c85 100644 --- a/sentry/src/main/java/io/sentry/util/UUIDGenerator.java +++ b/sentry/src/main/java/io/sentry/util/UUIDGenerator.java @@ -56,7 +56,7 @@ public static UUID randomUUID() { // clear variant randomBytes[8] &= 0x3f; // set to IETF variant - randomBytes[8] |= 0x80; + randomBytes[8] = (byte) (randomBytes[8] | 0x80); long msb = 0; long lsb = 0; for (int i = 0; i < 8; i++) { 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 42df5ca35b..a7195c0462 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -34,6 +34,9 @@ private NetworkBodyParser() {} * @param logger Optional logger * @return A NetworkBody or null if bytes are null/empty */ + // temp fix for JDK 21: The charset is a name from the Content-Type header; unsupported names are + // handled below. + @SuppressWarnings("JdkObsolete") public static @Nullable NetworkBody fromBytes( @Nullable final byte[] bytes, @Nullable final String contentType, @@ -131,6 +134,8 @@ private static NetworkBody parseJson( } /** Parses URL-encoded form data into a JsonObject NetworkBody. */ + // temp fix for JDK 21: The Charset overload requires Java 10. + @SuppressWarnings("JdkObsolete") @NotNull private static NetworkBody parseFormUrlEncoded( @NotNull final String content, final boolean isPartial, @Nullable final ILogger logger) { @@ -206,7 +211,7 @@ private static class Result { private SaferJsonParser() {} @NotNull - public static SaferJsonParser.Result parse(final @NotNull JsonReader reader) { + static SaferJsonParser.Result parse(final @NotNull JsonReader reader) { final SaferJsonParser parser = new SaferJsonParser(); parser.result.data = parser.parse(reader, 0); return parser.result; diff --git a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonReader.java b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonReader.java index 4991a554c1..e09b768bf2 100644 --- a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonReader.java +++ b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonReader.java @@ -1502,6 +1502,8 @@ public String getPath() { * @throws NumberFormatException if any unicode escape sequences are * malformed. */ + // temp fix for JDK 21: Vendored from Gson, kept identical to upstream. + @SuppressWarnings("lossy-conversions") private char readEscapeCharacter() throws IOException { if (pos == limit && !fillBuffer(1)) { throw syntaxError("Unterminated escape sequence"); diff --git a/sentry/src/test/java/io/sentry/OutboxSenderTest.kt b/sentry/src/test/java/io/sentry/OutboxSenderTest.kt index eecacc95bd..703f0823c0 100644 --- a/sentry/src/test/java/io/sentry/OutboxSenderTest.kt +++ b/sentry/src/test/java/io/sentry/OutboxSenderTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.anyVararg import org.mockito.kotlin.argWhere import org.mockito.kotlin.check import org.mockito.kotlin.eq @@ -309,7 +310,7 @@ class OutboxSenderTest { sut.processEnvelopeFile(path, hints) // Additionally make sure we have no errors logged - verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), any()) + verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), anyVararg()) verify(fixture.scopes, never()).captureEvent(any()) assertFalse(File(path).exists()) } @@ -327,7 +328,7 @@ class OutboxSenderTest { sut.processEnvelopeFile(path, hints) // Additionally make sure we have no errors logged - verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), any()) + verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), anyVararg()) verify(fixture.scopes, never()).captureEvent(any()) assertFalse(File(path).exists()) } diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 3a730e971b..4fb74690c3 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -13,6 +13,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any +import org.mockito.kotlin.anyVararg import org.mockito.kotlin.argThat import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -280,7 +281,7 @@ class PreviousSessionFinalizerTest { "Timed out waiting to flush previous session to its own file in session finalizer." ) }, - any(), + anyVararg(), ) verify(fixture.scopes, never()).captureEnvelope(any()) } From b731c0b566e00dcb927ebc19ad8934a425495f28 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 13:53:04 +0200 Subject: [PATCH 11/48] test: Move the system-test envelope matchers into sentry-test-support (#6267) In-module integration tests need the same envelope matchers the sample system tests use. TestHelper now delegates to the shared copies, so the sample tests keep working unchanged while they are moved. Mismatch diagnostics now flush System.out instead of closing it, which silenced all output after the first mismatch. Co-authored-by: Claude Opus 5.5 (1M context) --- .../io/sentry/systemtest/util/TestHelper.kt | 361 +++--------------- .../kotlin/io/sentry/test/EnvelopeMatchers.kt | 170 +++++++++ .../io/sentry/test/EnvelopeMatchersTest.kt | 92 +++++ 3 files changed, 317 insertions(+), 306 deletions(-) create mode 100644 sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt create mode 100644 sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 7f0dfc8c95..8ef74cae3c 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -4,9 +4,9 @@ import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.Operation import io.sentry.JsonSerializer import io.sentry.ProfileChunk +import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEvent -import io.sentry.SentryItemType import io.sentry.SentryLogEvents import io.sentry.SentryMetricsEvents import io.sentry.SentryOptions @@ -14,10 +14,22 @@ import io.sentry.protocol.FeatureFlag import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.BufferedReader -import java.io.ByteArrayInputStream +import io.sentry.test.event +import io.sentry.test.hasExceptionMessage +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.hasTraceId +import io.sentry.test.logs +import io.sentry.test.metrics +import io.sentry.test.profileChunk +import io.sentry.test.transaction import java.io.File -import java.io.InputStreamReader import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -107,24 +119,9 @@ class TestHelper(backendUrl: String) { envelopeString: String, callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val logsItem = deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Log } - if (logsItem == null) { - return false - } - - val logs = logsItem.getLogs(jsonSerializer) - if (logs == null) { - return false - } - - return callback(logs, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val logs = envelope.logs() ?: return false + return callback(logs, envelope.header) } fun doesContainMetric( @@ -133,22 +130,7 @@ class TestHelper(backendUrl: String) { type: String, value: Double, unit: String? = null, - ): Boolean { - val metricItem = - metrics.items.firstOrNull { metricItem -> - metricItem.name == name && - metricItem.type == type && - metricItem.value == value && - (unit == null || metricItem.unit == unit) - } - if (metricItem == null) { - println("Unable to find metric item with name $name, type $type and value $value in metrics:") - logObject(metrics) - return false - } - - return true - } + ): Boolean = metrics.hasMetric(name, type, value, unit) fun ensureMetricsReceived(callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean)) { ensureEnvelopeReceived { envelopeString -> checkIfMetricsMatch(envelopeString, callback) } @@ -158,172 +140,49 @@ class TestHelper(backendUrl: String) { envelopeString: String, callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val metricsItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.TraceMetric } - if (metricsItem == null) { - return false - } - - val metrics = metricsItem.getMetrics(jsonSerializer) - if (metrics == null) { - return false - } - - return callback(metrics, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val metrics = envelope.metrics() ?: return false + return callback(metrics, envelope.header) } - fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean { - val logItem = logs.items.firstOrNull { logItem -> logItem.body == body } - if (logItem == null) { - println("Unable to find log item with body $body in logs:") - logObject(logs) - return false - } - - return true - } + fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean = + logs.hasLogWithBody(body) fun doesLogWithBodyHaveAttribute( logs: SentryLogEvents, body: String, attributeKey: String, attributeValue: Any?, - ): Boolean { - val logItem = logs.items.firstOrNull { logItem -> logItem.body == body } - if (logItem == null) { - println("Unable to find log item with body $body in logs:") - logObject(logs) - return false - } - - val attr = logItem.attributes?.get(attributeKey) - if (attr == null) { - println("Unable to find attribute $attributeKey on log with body $body:") - logObject(logItem) - return false - } - - if (attr.value != attributeValue) { - println( - "Attribute $attributeKey has value ${attr.value} but expected $attributeValue on log with body $body:" - ) - logObject(logItem) - return false - } - - return true - } + ): Boolean = logs.hasLogWithAttribute(body, attributeKey, attributeValue) fun doesMetricHaveAttribute( metrics: SentryMetricsEvents, metricName: String, attributeKey: String, attributeValue: Any?, - ): Boolean { - val metricItem = metrics.items.firstOrNull { it.name == metricName } - if (metricItem == null) { - println("Unable to find metric with name $metricName in metrics:") - logObject(metrics) - return false - } - - val attr = metricItem.attributes?.get(attributeKey) - if (attr == null) { - println("Unable to find attribute $attributeKey on metric $metricName:") - logObject(metricItem) - return false - } - - if (attr.value != attributeValue) { - println( - "Attribute $attributeKey has value ${attr.value} but expected $attributeValue on metric $metricName:" - ) - logObject(metricItem) - return false - } - - return true - } + ): Boolean = metrics.hasMetricWithAttribute(metricName, attributeKey, attributeValue) private fun checkIfTransactionMatches( envelopeString: String, callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return false - } - - return callback(transaction, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val transaction = envelope.transaction() ?: return false + return callback(transaction, envelope.header) } private fun checkIfProfileMatches( envelopeString: String, callback: ((ProfileChunk, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val profileChunkItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.ProfileChunk } - - if (profileChunkItem == null) { - return false - } - - val chunk = - BufferedReader(InputStreamReader(ByteArrayInputStream(profileChunkItem.data), Charsets.UTF_8)) - .use { eventReader -> jsonSerializer.deserialize(eventReader, ProfileChunk::class.java) } - - if (chunk == null) { - return false - } - - return callback(chunk, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val chunk = envelope.profileChunk() ?: return false + return callback(chunk, envelope.header) } fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { ensureEnvelopeReceived(retryCount = 3) { envelopeString -> - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - + val error = deserialize(envelopeString)?.event() ?: return@ensureEnvelopeReceived false val callbackResult = callback(error) if (!callbackResult) { println("found an error event but it did not match:") @@ -351,10 +210,17 @@ class TestHelper(backendUrl: String) { fun logObject(obj: Any?) { obj ?: return - PrintWriter(System.out).use { jsonSerializer.serialize(obj, it) } + // Flush rather than close: closing the writer would close System.out. + PrintWriter(System.out).apply { + jsonSerializer.serialize(obj, this) + flush() + } println() } + private fun deserialize(envelopeString: String): SentryEnvelope? = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -365,156 +231,39 @@ class TestHelper(backendUrl: String) { assertEquals(errorCount, response.errors?.size) } - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean = + transaction.hasSpanWithOp(op) fun doesTransactionContainSpanWithOpAndDescription( transaction: SentryTransaction, op: String, description: String, - ): Boolean { - val span = - transaction.spans.firstOrNull { span -> span.op == op && span.description == description } - if (span == null) { - println("Unable to find span with op $op and description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription( - transaction: SentryTransaction, - description: String, - ): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean { - val spanContext = transaction.contexts.trace - if (spanContext?.traceId?.toString() != traceId) { - println("Unable to find trace ID $traceId in transaction:") - logObject(transaction) - return false - } + ): Boolean = transaction.hasSpanWithOpAndDescription(op, description) - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } + fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean = + transaction.hasTraceId(traceId) - return true - } + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean = + transaction.hasOp(op) fun doesTransactionHave( transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null, - ): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - val foundFlag = transaction.contexts.trace?.data?.get(featureFlag?.flag) - if (featureFlag != null && foundFlag == null) { - println("Unable to find span with feature flag ${featureFlag?.flag}:") - logObject(transaction) - return false - } - if (featureFlag != null && foundFlag != featureFlag.result) { - println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") - logObject(transaction) - return false - } - - return true - } + ): Boolean = transaction.hasOp(op, featureFlag) fun doesTransactionHaveSpanWith( transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null, noFeatureFlags: Boolean = false, - ): Boolean { - val foundSpan = transaction.spans.firstOrNull { span -> span.op == op } - if (foundSpan == null) { - println("Unable to find span with op $op:") - logObject(transaction) - return false - } + ): Boolean = transaction.hasSpanWithOp(op, featureFlag, noFeatureFlags) - val featureFlagNames = - foundSpan.data?.keys?.filter { it.startsWith("flag.evaluation.") } ?: emptyList() - if (noFeatureFlags && featureFlagNames.isNotEmpty()) { - println("Expected 0 feature flags but found ${featureFlagNames}:") - logObject(transaction) - return false - } - - val foundFlag = foundSpan.data?.get(featureFlag?.flag) - if (featureFlag != null && foundFlag == null) { - println("Unable to find span with feature flag ${featureFlag?.flag}:") - logObject(transaction) - return false - } - if (featureFlag != null && foundFlag != featureFlag.result) { - println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") - logObject(transaction) - return false - } + fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean = + event.hasExceptionMessage(expectedMessage) - return true - } - - fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean { - val exceptions = event.exceptions - if (exceptions == null) { - println("Unable to find exceptions in event") - return false - } - - val foundException = exceptions.firstOrNull { expectedMessage == it.value } - return foundException != null - } - - fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean { - val featureFlags = event.contexts.featureFlags - if (featureFlags == null) { - println("Unable to find feature flags in event:") - return false - } - val foundFlag = - featureFlags.values.firstOrNull { featureFlag -> - println("checking flag ${featureFlag.flag}:${featureFlag.result}") - featureFlag.flag == flag && featureFlag.result == result - } - return foundFlag != null - } + fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean = + event.hasFlag(flag, result) fun findJar(prefix: String, inDir: String = "build/libs"): File { val buildDir = File(inDir) diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt new file mode 100644 index 0000000000..16f7642f35 --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt @@ -0,0 +1,170 @@ +package io.sentry.test + +import io.sentry.JsonSerializer +import io.sentry.ProfileChunk +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeItem +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryLogEvents +import io.sentry.SentryMetricsEvents +import io.sentry.SentryOptions +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryTransaction +import java.io.PrintWriter + +// Matchers return false instead of throwing, so tests can pick one envelope out of many. On a +// mismatch they print what they looked at, which is otherwise hard to see from a failed wait. + +private val serializer = JsonSerializer(SentryOptions.empty()) + +fun SentryEnvelope.event(): SentryEvent? = + firstItem(SentryItemType.Event) { it.getEvent(serializer) } + +fun SentryEnvelope.transaction(): SentryTransaction? = + firstItem(SentryItemType.Transaction) { it.getTransaction(serializer) } + +fun SentryEnvelope.logs(): SentryLogEvents? = + firstItem(SentryItemType.Log) { it.getLogs(serializer) } + +fun SentryEnvelope.metrics(): SentryMetricsEvents? = + firstItem(SentryItemType.TraceMetric) { it.getMetrics(serializer) } + +fun SentryEnvelope.profileChunk(): ProfileChunk? = + firstItem(SentryItemType.ProfileChunk) { item -> + item.data.inputStream().reader().use { serializer.deserialize(it, ProfileChunk::class.java) } + } + +/** Matches the transaction's own op and, if given, a feature flag in its trace data. */ +fun SentryTransaction.hasOp(op: String, featureFlag: FeatureFlag? = null): Boolean { + val trace = contexts.trace + if (trace?.operation != op) { + return mismatch("Unable to find transaction with op $op:", this) + } + return featureFlag == null || matchesFlag(trace.data, featureFlag) +} + +fun SentryTransaction.hasTraceId(traceId: String): Boolean { + if (contexts.trace?.traceId?.toString() != traceId) { + return mismatch("Unable to find trace ID $traceId in transaction:", this) + } + return true +} + +/** + * Matches a span with [op] and, if given, a feature flag in its data. With [noFeatureFlags] the + * span must carry no flag evaluations at all. + */ +fun SentryTransaction.hasSpanWithOp( + op: String, + featureFlag: FeatureFlag? = null, + noFeatureFlags: Boolean = false, +): Boolean { + val span = + spans.firstOrNull { it.op == op } ?: return mismatch("Unable to find span with op $op:", this) + val data = span.data ?: emptyMap() + if (noFeatureFlags && data.keys.any { it.startsWith("flag.evaluation.") }) { + return mismatch("Expected no feature flags on span with op $op:", this) + } + return featureFlag == null || matchesFlag(data, featureFlag) +} + +fun SentryTransaction.hasSpanWithOpAndDescription(op: String, description: String): Boolean { + if (spans.none { it.op == op && it.description == description }) { + return mismatch( + "Unable to find span with op $op and description $description in transaction:", + this, + ) + } + return true +} + +fun SentryLogEvents.hasLogWithBody(body: String): Boolean { + if (items.none { it.body == body }) { + return mismatch("Unable to find log item with body $body in logs:", this) + } + return true +} + +fun SentryLogEvents.hasLogWithAttribute(body: String, key: String, value: Any?): Boolean { + val log = + items.firstOrNull { it.body == body } + ?: return mismatch("Unable to find log item with body $body in logs:", this) + val attribute = + log.attributes?.get(key) + ?: return mismatch("Unable to find attribute $key on log with body $body:", log) + if (attribute.value != value) { + return mismatch("Attribute $key is ${attribute.value}, expected $value on log $body:", log) + } + return true +} + +fun SentryMetricsEvents.hasMetric( + name: String, + type: String, + value: Double, + unit: String? = null, +): Boolean { + val found = items.any { + it.name == name && it.type == type && it.value == value && (unit == null || it.unit == unit) + } + if (!found) { + return mismatch("Unable to find metric $name of type $type with value $value in metrics:", this) + } + return true +} + +fun SentryMetricsEvents.hasMetricWithAttribute(name: String, key: String, value: Any?): Boolean { + val metric = + items.firstOrNull { it.name == name } + ?: return mismatch("Unable to find metric with name $name in metrics:", this) + val attribute = + metric.attributes?.get(key) + ?: return mismatch("Unable to find attribute $key on metric $name:", metric) + if (attribute.value != value) { + return mismatch( + "Attribute $key is ${attribute.value}, expected $value on metric $name:", + metric, + ) + } + return true +} + +fun SentryEvent.hasExceptionMessage(message: String): Boolean { + if (exceptions?.none { it.value == message } != false) { + return mismatch("Unable to find exception with message $message in event:", this) + } + return true +} + +fun SentryEvent.hasFlag(flag: String, result: Boolean): Boolean { + if (contexts.featureFlags?.values?.none { it.flag == flag && it.result == result } != false) { + return mismatch("Unable to find feature flag $flag=$result in event:", this) + } + return true +} + +private inline fun SentryEnvelope.firstItem( + type: SentryItemType, + read: (SentryEnvelopeItem) -> T?, +): T? = items.firstOrNull { it.header.type == type }?.let(read) + +private fun matchesFlag(data: Map?, featureFlag: FeatureFlag): Boolean { + val result = data?.get(featureFlag.flag) + if (result != featureFlag.result) { + println("Feature flag ${featureFlag.flag} is $result, expected ${featureFlag.result}") + return false + } + return true +} + +private fun mismatch(message: String, subject: Any): Boolean { + println(message) + // Flush rather than close: closing the writer would close System.out. + PrintWriter(System.out).apply { + serializer.serialize(subject, this) + println() + flush() + } + return false +} diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt new file mode 100644 index 0000000000..68a71809e2 --- /dev/null +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt @@ -0,0 +1,92 @@ +package io.sentry.test + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class EnvelopeMatchersTest { + private val server = MockSentryServer() + + @BeforeTest + fun setup() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.logs.isEnabled = true + } + } + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `matches a transaction by op, trace ID and span`() { + val transaction = Sentry.startTransaction("name", "http.server") + transaction.startChild("db.query", "select 1").apply { + setData("flag.evaluation.my-flag", true) + finish() + } + transaction.finish() + Sentry.flush(5000) + + val received = server.awaitEnvelope { it.transaction() != null }.transaction()!! + + assertThat(received.hasOp("http.server")).isTrue() + assertThat(received.hasOp("other")).isFalse() + assertThat(received.hasTraceId(transaction.spanContext.traceId.toString())).isTrue() + assertThat(received.hasSpanWithOpAndDescription("db.query", "select 1")).isTrue() + assertThat(received.hasSpanWithOp("db.query", FeatureFlag("flag.evaluation.my-flag", true))) + .isTrue() + assertThat(received.hasSpanWithOp("db.query", FeatureFlag("flag.evaluation.my-flag", false))) + .isFalse() + assertThat(received.hasSpanWithOp("db.query", noFeatureFlags = true)).isFalse() + } + + @Test + fun `matches an event by exception message and feature flag`() { + Sentry.addFeatureFlag("my-flag", true) + Sentry.captureException(RuntimeException("boom")) + Sentry.flush(5000) + + val event = server.awaitEnvelope { it.event() != null }.event()!! + + assertThat(event.hasExceptionMessage("boom")).isTrue() + assertThat(event.hasExceptionMessage("other")).isFalse() + assertThat(event.hasFlag("my-flag", true)).isTrue() + assertThat(event.hasFlag("my-flag", false)).isFalse() + } + + @Test + fun `matches logs by body and attribute`() { + Sentry.setAttribute("user.type", "admin") + Sentry.logger().info("hello") + Sentry.flush(5000) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + + assertThat(logs.hasLogWithBody("hello")).isTrue() + assertThat(logs.hasLogWithBody("other")).isFalse() + assertThat(logs.hasLogWithAttribute("hello", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("hello", "user.type", "guest")).isFalse() + } + + @Test + fun `matches metrics by value and attribute`() { + Sentry.setAttribute("user.type", "admin") + Sentry.metrics().gauge("my.gauge", 5.0) + Sentry.flush(5000) + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + + assertThat(metrics.hasMetric("my.gauge", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("my.gauge", "gauge", 6.0)).isFalse() + assertThat(metrics.hasMetricWithAttribute("my.gauge", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("my.gauge", "user.type", "guest")).isFalse() + } +} From a0ac761bd71c70969aab36074461f80b4d5953dc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:30:23 +0200 Subject: [PATCH 12/48] fix(replay): Use software canvas for the video encoder on the Android Emulator (#6282) * fix(replay): Use software canvas for the video encoder on the Android Emulator Co-Authored-By: Roman Zavarnitsyn * changelog Co-Authored-By: Roman Zavarnitsyn Co-Authored-By: Roman Zavarnitsyn --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 1 + .../replay/video/SimpleVideoEncoder.kt | 47 ++++++++++------- .../video/SimpleVideoEncoderCanvasTest.kt | 51 +++++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c..8fbe9dd507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry SaaS (DSNs on `sentry.io`), not to self-hosted Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. - Apps need to update to an SDK version with this fix to keep sending events from these devices. +- Session Replay: Use a software canvas for video encoding on the Android Emulator to avoid a native crash in `eglDestroySurface` ([#6282](https://github.com/getsentry/sentry-java/pull/6282)) ### Internal 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 dd9af1c24d..b1e3cd2051 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 @@ -177,25 +177,8 @@ internal class SimpleVideoEncoder( } fun encode(image: Bitmap) { - /** - * it seems that Xiaomi devices have problems with hardware canvas, so we have to use lockCanvas - * instead https://stackoverflow.com/a/73520742 - * --- - * Same for Motorola devices. - * --- - * As for the Spreadtrum/Unisoc chipset, it can be spread across various devices, so we have to - * check the SOC_MANUFACTURER property, as the manufacturer name might have changed. - * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 - */ val canvas = - if ( - Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || - Build.MANUFACTURER.contains("motorola", ignoreCase = true) || - SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) - .equals("spreadtrum", ignoreCase = true) || - SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) - .equals("unisoc", ignoreCase = true) - ) { + if (shouldUseSoftwareCanvas()) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() @@ -338,6 +321,34 @@ internal class SimpleVideoEncoder( } } +/** + * Whether frames should be drawn onto the encoder's input surface with a software canvas + * ([Surface.lockCanvas]) instead of a hardware one ([Surface.lockHardwareCanvas]). + * + * It seems that Xiaomi devices have problems with hardware canvas, so we have to use lockCanvas + * instead https://stackoverflow.com/a/73520742 + * --- + * Same for Motorola devices. + * --- + * As for the Spreadtrum/Unisoc chipset, it can be spread across various devices, so we have to + * check the SOC_MANUFACTURER property, as the manufacturer name might have changed. + * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 + * --- + * On the Android Emulator (goldfish/ranchu), a hardware canvas makes hwui own an EGL window surface + * on the input surface. The emulator's EGL driver keeps a dequeued buffer on it and cancels that + * buffer when the surface is destroyed, which can crash natively in eglDestroySurface once the + * encoder has been torn down. https://github.com/getsentry/sentry-java/issues/6279 + */ +internal fun shouldUseSoftwareCanvas(): Boolean = + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) + .equals("spreadtrum", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) + .equals("unisoc", ignoreCase = true) || + Build.HARDWARE.contains("goldfish", ignoreCase = true) || + Build.HARDWARE.contains("ranchu", ignoreCase = true) + @SuppressLint("UseRequiresApi") @TargetApi(24) internal data class MuxerConfig( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt new file mode 100644 index 0000000000..01598df8ef --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt @@ -0,0 +1,51 @@ +package io.sentry.android.replay.video + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SimpleVideoEncoderCanvasTest { + + @Test + fun `uses hardware canvas on regular devices`() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setHardware("tensor") + + assertThat(shouldUseSoftwareCanvas()).isFalse() + } + + @Test + fun `uses software canvas on Xiaomi devices`() { + ShadowBuild.setManufacturer("Xiaomi") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on Motorola devices`() { + ShadowBuild.setManufacturer("motorola") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on ranchu emulators`() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setHardware("ranchu") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on goldfish emulators`() { + ShadowBuild.setManufacturer("unknown") + ShadowBuild.setHardware("goldfish") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } +} From 814ae44a66219a2f26a5613d53177c7b5d3e8f39 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 19:34:50 +0200 Subject: [PATCH 13/48] fix(replay): Keep flushed video and segment id when stop follows flush (#6340) * fix(replay): Keep flushed video and segment id when stop follows flush createVideoOf deleted the existing segment video before checking for captured frames, so an immediate stop() after flush() in buffer mode removed the flushed video before the transport read it, and segment 0 was sent without video. The segment-sent callback that advances the converted session strategy hopped to the main thread and was dropped when stop() ran first, so the session reused the flushed segment id for its stop segment and overwrote the flushed video. Advance it on the replay executor the strategies share instead, which orders it before any later segment of the session. Fixes #6247 Co-Authored-By: Claude Opus 5.5 * changelog * chore(replay): Add ReplayCacheTest to the detekt LargeClass baseline The regression test for #6247 pushed ReplayCacheTest over the LargeClass threshold, like ReplayIntegrationTest already is. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + sentry-android-replay/detekt-baseline.xml | 1 + .../io/sentry/android/replay/ReplayCache.kt | 7 +-- .../android/replay/ReplayIntegration.kt | 24 ++++------- .../sentry/android/replay/ReplayCacheTest.kt | 12 ++++++ .../android/replay/ReplayIntegrationTest.kt | 43 +++++++++++++++++++ 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbe9dd507..04e0d50f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) +- Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 2adcb41234..03a6d77fef 100644 --- a/sentry-android-replay/detekt-baseline.xml +++ b/sentry-android-replay/detekt-baseline.xml @@ -43,6 +43,7 @@ FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_dest: Bitmap FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas + LargeClass:ReplayCacheTest.kt$ReplayCacheTest LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest LongMethod:CaptureStrategy.kt$CaptureStrategy.Companion$@Suppress("NewApi") private fun buildReplay( options: SentryOptions, video: File, currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, height: Int, width: Int, frameCount: Int, frameRate: Int, videoDuration: Long, replayType: ReplayType, screenAtStart: String?, breadcrumbs: List<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode? 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 21d8f88c7f..64aec8a077 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 @@ -139,16 +139,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4"), ): GeneratedVideo? { - if (videoFile.exists() && videoFile.length() > 0) { - videoFile.delete() - } // Work on a snapshot of frames to avoid races with writers val framesSnapshot = framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() } if (framesSnapshot.isEmpty()) { + // keep an existing video intact, e.g. one from a flush whose envelope is still queued options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null } + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } encoder = SimpleVideoEncoder( 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 015e162afc..314c66a90f 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 @@ -340,23 +340,17 @@ public class ReplayIntegration( return } - var activeStrategy: CaptureStrategy = strategy + // Convert before capturing, so the callback knows which strategy continues the replay. + val activeStrategy = strategy.convert() capture(strategy) { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } - } + // Runs on the replay executor, which the converted strategy shares. Advancing the segment + // here lands before any segment the converted strategy creates later (e.g. on stop), so the + // flushed segment id is not reused. A stale strategy (stopped or restarted replay) is not + // in the state anymore, so updating it has no effect. + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true } - activeStrategy = strategy.convert() val replayId: SentryId? = activeStrategy.currentReplayId state.set( current.copy( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 5607ebd87c..aeca0c868d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -107,6 +107,18 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `when no frames are provided, does not delete existing video`() { + val replayCache = fixture.getSut(tmpDir) + val existingVideo = + File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(byteArrayOf(1, 2, 3)) } + + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + + assertNull(video) + assertEquals(3, existingVideo.length()) + } + @Test fun `releases the muxer when the encoder fails to start`() { ReplayShadowMediaCodec.throwOnStart = true 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 4f4f7ff3c9..d8bff1f0c3 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 @@ -752,6 +752,49 @@ class ReplayIntegrationTest { assertThat(replay.replayId).isEqualTo(newReplayId) } + @Test + fun `flush followed by immediate stop advances the converted strategy segment`() { + val replayId = SentryId() + var onSegmentSent: ((Date) -> Unit)? = null + val bufferStrategy = mock() + whenever(bufferStrategy.currentReplayId).thenReturn(replayId) + doAnswer { + @Suppress("UNCHECKED_CAST") + onSegmentSent = it.arguments[0] as (Date) -> Unit + } + .whenever(bufferStrategy) + .flush(any()) + val sessionStrategy = mock() + whenever(sessionStrategy.currentReplayId).thenReturn(replayId) + whenever(sessionStrategy.currentSegment).thenReturn(0) + whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { bufferStrategy }, + mainLooperHandler = MainLooperHandler(), + ) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + shadowOf(Looper.getMainLooper()).idle() + + replay.flush() + replay.stop() + shadowOf(Looper.getMainLooper()).idle() + verify(sessionStrategy).stop() + + // the flushed segment completes on the replay executor after stop ran on the main thread, + // but before the stop segment of the converted strategy is created + val timestamp = Date() + onSegmentSent!!.invoke(timestamp) + + verify(sessionStrategy).currentSegment = 1 + verify(sessionStrategy).segmentTimestamp = timestamp + verify(sessionStrategy).isFlushed = true + } + @Test fun `pause does nothing when not recording`() { val captureStrategy = mock() From 28c6417649ecdaf8513e5acfe1d9957f08fffb19 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 21:46:02 +0200 Subject: [PATCH 14/48] fix(replay): Do not recycle the canvas screenshot during a pixel copy (#6342) * fix(replay): Do not recycle the canvas screenshot during a pixel copy CanvasStrategy.close() recycled the screenshot bitmap and released the surface on the background handler even when a PixelCopy request was still writing into that bitmap on the RenderThread. libhwui then aborted the process with "cannot access an invalid/free'd bitmap". Track the in-flight copy on the background handler, which runs the render task, the copy callback and the cleanup. close() skips the cleanup while a copy is in flight, and the copy callback releases the resources once it sees the strategy is closed. Co-Authored-By: Claude Opus 5.5 * changelog --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + sentry-android-replay/detekt-baseline.xml | 1 + .../replay/screenshot/CanvasStrategy.kt | 23 +++- .../replay/screenshot/CanvasStrategyTest.kt | 125 ++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e0d50f0a..9c03fd882c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Session Replay: Fix a native crash (`SIGABRT` on the RenderThread) when stopping a replay that uses the canvas screenshot strategy ([#6342](https://github.com/getsentry/sentry-java/pull/6342)) - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) - Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 03a6d77fef..8432d83f8d 100644 --- a/sentry-android-replay/detekt-baseline.xml +++ b/sentry-android-replay/detekt-baseline.xml @@ -159,6 +159,7 @@ TooManyFunctions:WindowRecorder.kt$WindowRecorder : RecorderOnRootViewsChangedListenerExecutorProvider UnusedPrivateProperty:DebugOverlayDrawable.kt$DebugOverlayDrawable$private val padding = 6f UseCheckOrError:ReplayShadowMediaCodec.kt$ReplayShadowMediaCodec$throw IllegalStateException("Simulated codec start failure") + UtilityClassWithPublicConstructor:CanvasStrategyTest.kt$DeferredSurfacePixelCopyShadow UtilityClassWithPublicConstructor:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt index c3deefbf71..6956782cc2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -58,6 +58,9 @@ internal class CanvasStrategy( private val lastCaptureSuccessful = AtomicBoolean(false) private val textIgnoringCanvas = TextIgnoringDelegateCanvas() private val isClosed = AtomicBoolean(false) + // Only accessed on the background handler, which runs the render task, the PixelCopy callback and + // the cleanup, so it needs no synchronization + private var pixelCopyInFlight = false private val surfaceTexture = SurfaceTexture(false).apply { @@ -110,15 +113,19 @@ internal class CanvasStrategy( ) return@Runnable } + pixelCopyInFlight = true PixelCopy.request( surface, screenshot!!, { result -> + pixelCopyInFlight = false if (isClosed.get()) { options.logger.log( SentryLevel.DEBUG, "CanvasStrategy is closed, ignoring capture result", ) + // close() skipped the cleanup while this copy was still writing into the screenshot + releaseResources() return@request } if (result == PixelCopy.SUCCESS) { @@ -138,6 +145,8 @@ internal class CanvasStrategy( executor.getBackgroundHandler(), ) } catch (t: Throwable) { + // if the request itself threw, no callback will clear the flag + pixelCopyInFlight = false options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) lastCaptureSuccessful.set(false) } @@ -174,14 +183,22 @@ internal class CanvasStrategy( .getBackgroundHandler() .postSafely( ReplayRunnable("CanvasStrategy.close") { - screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } - surface.release() - surfaceTexture.release() + // PixelCopy writes into the screenshot on the RenderThread; recycling it mid-copy aborts + // the process, so an in-flight copy releases the resources from its callback instead + if (!pixelCopyInFlight) { + releaseResources() + } } ) unprocessedPictureRef.getAndSet(null) } + private fun releaseResources() { + screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } + surface.release() + surfaceTexture.release() + } + override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get() override fun emitLastScreenshot() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt new file mode 100644 index 0000000000..55137a5994 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt @@ -0,0 +1,125 @@ +package io.sentry.android.replay.screenshot + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.Surface +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryOptions +import io.sentry.android.replay.ExecutorProvider +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.MainLooperHandler +import java.util.concurrent.ScheduledExecutorService +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30], shadows = [DeferredSurfacePixelCopyShadow::class]) +class CanvasStrategyTest { + + private class Fixture { + val options = SentryOptions() + val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000) + + fun getSut(): CanvasStrategy = + CanvasStrategy( + object : ExecutorProvider { + override fun getExecutor(): ScheduledExecutorService = mock() + + override fun getMainLooperHandler(): MainLooperHandler = MainLooperHandler() + + // runs the render task, the PixelCopy callback and the cleanup, like production + override fun getBackgroundHandler(): Handler = Handler(Looper.getMainLooper()) + }, + mock(), + options, + config, + ) + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + DeferredSurfacePixelCopyShadow.reset() + } + + @Test + fun `close keeps the screenshot alive until the in-flight pixel copy finishes`() { + val strategy = fixture.getSut() + strategy.capture(View(ApplicationProvider.getApplicationContext())) + shadowOf(Looper.getMainLooper()).idle() + val screenshot = DeferredSurfacePixelCopyShadow.dest!! + + strategy.close() + shadowOf(Looper.getMainLooper()).idle() + + // the copy is still writing into the screenshot, recycling it now would abort the RenderThread + assertThat(screenshot.isRecycled).isFalse() + + DeferredSurfacePixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(screenshot.isRecycled).isTrue() + } + + @Test + fun `close recycles the screenshot right away when no pixel copy is in flight`() { + val strategy = fixture.getSut() + strategy.capture(View(ApplicationProvider.getApplicationContext())) + shadowOf(Looper.getMainLooper()).idle() + val screenshot = DeferredSurfacePixelCopyShadow.dest!! + DeferredSurfacePixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + strategy.close() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(screenshot.isRecycled).isTrue() + } +} + +/** Holds back the result of a [PixelCopy] from a [Surface] until [flush] is called. */ +@Implements(PixelCopy::class) +class DeferredSurfacePixelCopyShadow { + companion object { + private val pendingCallbacks = mutableListOf<() -> Unit>() + var dest: Bitmap? = null + + fun reset() { + pendingCallbacks.clear() + dest = null + } + + fun flush() { + val callbacks = pendingCallbacks.toList() + pendingCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + @JvmStatic + @Implementation + @Suppress("UNUSED_PARAMETER") + fun request( + source: Surface, + dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler, + ) { + this.dest = dest + pendingCallbacks.add { + listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + } + } +} From eb32cb55e2087af1d9666494573cc3cf1ae5ed30 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 22:52:45 +0200 Subject: [PATCH 15/48] fix(replay): Keep replays of this process in the startup cleanup (#6343) * fix(replay): Keep replays of this process in the startup cleanup finalizePreviousReplay runs once on the shared executor and can run late, e.g. behind ANR processing. cleanupReplays then deleted every replay folder except the current replay id, which is empty after stop(), so it removed the folder of a replay that had just stopped, including segment videos the transport had not read yet. It could also read this process's replay id from the persisted scope after a flush and treat it as a previous replay to recover, which deletes the folder of the running replay. Track the replay ids started in this process, keep their folders in the cleanup and skip finalizing them. Co-Authored-By: Claude Opus 5.5 * changelog * fix(replay): Use CopyOnWriteArraySet for the replay ids of this process ConcurrentHashMap.newKeySet() requires API 24 and the min SDK is 21. Co-Authored-By: Claude Opus 5.5 * fix(replay): Share the replay ids of this process across SDK inits A second Sentry.init registers a new ReplayIntegration, whose startup cleanup would delete the folders of replays the first instance recorded, including videos still waiting for the transport. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../android/replay/ReplayIntegration.kt | 26 ++++-- .../android/replay/ReplayIntegrationTest.kt | 89 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c03fd882c..6eb827360e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Session Replay: Fix a native crash (`SIGABRT` on the RenderThread) when stopping a replay that uses the canvas screenshot strategy ([#6342](https://github.com/getsentry/sentry-java/pull/6342)) +- Session Replay: Keep the segment videos of a replay that stopped before the startup cleanup of previous replays ran ([#6343](https://github.com/getsentry/sentry-java/pull/6343)) - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) - Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) 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 314c66a90f..4d6614a783 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 @@ -59,6 +59,7 @@ import java.io.Closeable import java.io.File import java.util.Date import java.util.LinkedList +import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -85,6 +86,11 @@ public class ReplayIntegration( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } + + // Replays recorded by this process. Their folders hold segment videos the transport may still + // read after the replay stopped, so the startup cleanup of previous replays must keep them. + // Shared across instances, because a second SDK init registers a new ReplayIntegration. + val replayIdsOfThisProcess = CopyOnWriteArraySet() } // needed for the Java's call site @@ -247,6 +253,9 @@ public class ReplayIntegration( recorder?.start() strategy.start() val replayId: SentryId? = strategy.currentReplayId + if (replayId != null && replayId != SentryId.EMPTY_ID) { + replayIdsOfThisProcess += replayId.toString() + } state.set( ReplayState( generation = current.generation + 1, @@ -638,15 +647,13 @@ public class ReplayIntegration( } private fun cleanupReplays(unfinishedReplayId: String = "") { - // clean up old replays + // clean up old replays, but keep the replays of this process and the previous process's + // unfinished replay (its folder is cleaned up after its envelope is assembled) + val keptReplayIds = replayIdsOfThisProcess + listOfNotNull(unfinishedReplayId.ifBlank { null }) options.cacheDirPath?.let { cacheDir -> File(cacheDir).listFiles()?.forEach { file -> val name = file.name - if ( - name.startsWith("replay_") && - !name.contains(replayId.toString()) && - !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) - ) { + if (name.startsWith("replay_") && keptReplayIds.none { name.contains(it) }) { FileUtils.deleteRecursively(file) } } @@ -663,7 +670,12 @@ public class ReplayIntegration( return@submitSafely } val previousReplayId = SentryId(previousReplayIdString) - if (previousReplayId == SentryId.EMPTY_ID) { + // when this task runs late, the persisted replay id can already be one this process recorded, + // which is not a previous replay to recover + if ( + previousReplayId == SentryId.EMPTY_ID || + previousReplayId.toString() in replayIdsOfThisProcess + ) { cleanupReplays() return@submitSafely } 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 d8bff1f0c3..70e8426d12 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 @@ -1263,6 +1263,95 @@ class ReplayIntegrationTest { assertFalse(evenOlderReplay.exists()) } + @Test + fun `delayed startup cleanup keeps replays recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val olderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + // hold back the startup work, like a busy executor does + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + // a flushed segment video that is still waiting for the transport + val stoppedReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + replay.stop() + + deferredTasks.forEach { it.run() } + + assertTrue(File(stoppedReplay, "0.mp4").exists()) + assertFalse(olderReplay.exists()) + } + + @Test + fun `second SDK init keeps replays recorded by the first one`() { + val firstReplayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val firstStrategy = mock { on { currentReplayId }.thenReturn(firstReplayId) } + val first = fixture.getSut(context, replayCaptureStrategyProvider = { firstStrategy }) + first.register(fixture.scopes, fixture.options) + first.startBuffering() + val firstReplay = + File(fixture.options.cacheDirPath, "replay_$firstReplayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + first.stop() + first.close() + + // a new Sentry.init registers a new integration, which runs the startup cleanup again + val secondStrategy = mock { on { currentReplayId }.thenReturn(SentryId()) } + val second = fixture.getSut(context, replayCaptureStrategyProvider = { secondStrategy }) + second.register(fixture.scopes, fixture.options) + + assertTrue(File(firstReplay, "0.mp4").exists()) + } + + @Test + fun `delayed startup task does not finalize a replay recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + // the scope observer persisted this replay's id after a flush, before the startup task ran + File(fixture.options.cacheDirPath, PersistingScopeObserver.SCOPE_CACHE).also { + it.mkdirs() + File(it, PersistingScopeObserver.REPLAY_FILENAME).writeText("\"$replayId\"") + } + + deferredTasks.forEach { it.run() } + + assertTrue(File(currentReplay, "0.mp4").exists()) + verify(fixture.scopes, never()).captureReplay(any(), any()) + } + @Test fun `onScreenshotRecorded supplies screen from scope to replay cache`() { val captureStrategy = From e220ec4c20de6eec960a343cdabc955b99152b7b Mon Sep 17 00:00:00 2001 From: romtsn <4999776+romtsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:02:50 +0000 Subject: [PATCH 16/48] release: 8.61.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb827360e..cfffb41a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.61.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 0bf31eb0f9..ed4c8eb9f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ android.newDsl=false android.experimental.lint.version=9.4.0 # Release information -versionName=8.61.0 +versionName=8.61.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 58299a4fcc2211edf3b5cbbe25866551cb5ebbf9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:58:23 +0200 Subject: [PATCH 17/48] build(deps): Bump uv to 0.13.4 (#6348) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Geoff Goldsmith --- devenv/config.ini | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/devenv/config.ini b/devenv/config.ini index b546b762c0..4127326e38 100644 --- a/devenv/config.ini +++ b/devenv/config.ini @@ -2,15 +2,15 @@ minimum_version = 1.22.1 [uv] -darwin_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-apple-darwin.tar.gz -darwin_arm64_sha256 = 954d24634d5f37fa26c7af75eb79893d11623fc81b4de4b82d60d1ade4bfca22 -darwin_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-apple-darwin.tar.gz -darwin_x86_64_sha256 = ae755df53c8c2c1f3dfbee6e3d2e00be0dfbc9c9b4bdffdb040b96f43678b7ce -linux_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-unknown-linux-gnu.tar.gz -linux_arm64_sha256 = 27da35ef54e9131c2e305de67dd59a07c19257882c6b1f3cf4d8d5fbb8eaf4ca -linux_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-unknown-linux-gnu.tar.gz -linux_x86_64_sha256 = 6dcb28a541868a455aefb2e8d4a1283dd6bf888605a2db710f0530cec888b0ad +darwin_arm64 = https://github.com/astral-sh/uv/releases/download/0.13.4/uv-aarch64-apple-darwin.tar.gz +darwin_arm64_sha256 = e0b1d4743ce64c55d9f0f4a444afa6b81125680070ee04e853fcd0f6c1ebf8f1 +darwin_x86_64 = https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-apple-darwin.tar.gz +darwin_x86_64_sha256 = 7be2dcd316122f4b3e5a0a7889f6248266c14425374e105ff7dc9779184603a6 +linux_arm64 = https://github.com/astral-sh/uv/releases/download/0.13.4/uv-aarch64-unknown-linux-gnu.tar.gz +linux_arm64_sha256 = 1ae72b4718729e2ccc4f1ae2c6d1e531ca8467800e0546e9130b3886023a60ab +linux_x86_64 = https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-unknown-linux-gnu.tar.gz +linux_x86_64_sha256 = 2b428396f12572d4429cf210ca234dd599a33b3af0958a4d5ed569d63e3f344f # used for autoupdate # NOTE: if using uv-build as a build backend, you'll have to make sure the versions match -version = 0.8.2 +version = 0.13.4 From ff8c06c945885fec5a9b397b4e44da580754026d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 14:03:02 +0200 Subject: [PATCH 18/48] test(logback): [Sample Tests 3] Move the logback system test into the module (#6268) * test: Move the system-test envelope matchers into sentry-test-support In-module integration tests need the same envelope matchers the sample system tests use. TestHelper now delegates to the shared copies, so the sample tests keep working unchanged while they are moved. Mismatch diagnostics now flush System.out instead of closing it, which silenced all output after the first mismatch. Co-Authored-By: Claude Opus 5.5 (1M context) * test(logback): Move the logback sample's system test into sentry-logback The test now configures the appender from XML in-process and sends to MockSentryServer, so it runs as part of :sentry-logback:test instead of needing the Python runner and a shadow JAR. It also checks that the XML contextTag reaches the event. The logback sample has no tests left, so its system-test wiring, CI matrix entry and runner entry are removed. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/system-tests-backend.yml | 3 - .../logback/SentryAppenderIntegrationTest.kt | 68 +++++++++++++++ .../sentry-appender-integration-test.xml | 18 ++++ .../sentry-samples-logback/build.gradle.kts | 36 -------- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 --- .../ConsoleApplicationSystemTest.kt | 85 ------------------- test/system-test-runner.py | 1 - 7 files changed, 86 insertions(+), 137 deletions(-) create mode 100644 sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt create mode 100644 sentry-logback/src/test/resources/sentry-appender-integration-test.xml delete mode 100644 sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 2165a6c6a6..92ef48a38c 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -64,9 +64,6 @@ jobs: - sample: "sentry-samples-console-otlp" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-logback" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-log4j2" agent: "false" agent-auto-init: "true" diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt new file mode 100644 index 0000000000..8f654b3d03 --- /dev/null +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt @@ -0,0 +1,68 @@ +package io.sentry.logback + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import kotlin.test.AfterTest +import kotlin.test.Test +import org.slf4j.MDC + +class SentryAppenderIntegrationTest { + private val server = MockSentryServer() + private val loggerContext = LoggerContext() + + @AfterTest + fun teardown() { + loggerContext.stop() + Sentry.close() + server.close() + MDC.clear() + } + + @Test + fun `appender configured from XML sends events, breadcrumbs and logs`() { + loggerContext.putProperty("dsn", server.dsn) + JoranConfigurator() + .apply { context = loggerContext } + .doConfigure(javaClass.getResource("/sentry-appender-integration-test.xml")) + val logger = loggerContext.getLogger(SentryAppenderIntegrationTest::class.java) + + logger.debug("Hello Sentry!") + MDC.put("userId", "user-1") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.warn("important warning") + logger.info("User has made a purchase of product: {}", 445) + logger.error("Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("important warning") + assertThat(warning.tags).containsEntry("userId", "user-1") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.formatted).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-logback/src/test/resources/sentry-appender-integration-test.xml b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml new file mode 100644 index 0000000000..049317a1bc --- /dev/null +++ b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml @@ -0,0 +1,18 @@ + + + + ${dsn} + userId + + true + + + WARN + DEBUG + INFO + + + + + + diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 6d6e705103..12644bf2de 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -1,32 +1,15 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - 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") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryLogback) implementation(libs.logback.classic) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -44,22 +27,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e45..0000000000 --- a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} diff --git a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index ad28ff7776..0000000000 --- a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.SentryLevel -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `logback application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-logback") - 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", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - Assert.assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> - ex.type == "RuntimeException" && ex.value == "Invalid productId=445" - } == true && - event.message?.formatted == "Something went wrong" && - event.level?.name == "ERROR" - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "Hello Sentry!" && it.level == SentryLevel.DEBUG - } != null - } - - testHelper.ensureErrorReceived { event -> - event.message?.message == "important warning" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "User has made a purchase of product: 445" && it.level == SentryLevel.INFO - } != null - } - - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "User has made a purchase of product: 445") && - testHelper.doesContainLogWithBody(logs, "Something went wrong") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "Something went wrong", "debug.enabled", true) - } - } -} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index ed3fda97dc..33cef830a3 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -874,7 +874,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-console", "false", "true", "false"), ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), - ModuleConfig("sentry-samples-logback", "false", "true", "false"), ModuleConfig("sentry-samples-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-jul", "false", "true", "false"), ] From 1295a3d80d9526ee43af514fa81de60ff3ae5daa Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 15:26:31 +0200 Subject: [PATCH 19/48] test(otel): Add a smoke test that runs an app with the built agent (#6269) The agent had no tests; only the sample system tests exercised it. The test launches a JDK-only app in a child JVM with the agent attached and asserts that an incoming request reaches MockSentryServer as an http.server transaction from the agent SDK. The app runs in a child JVM so the test's own JVM stays uninstrumented. Otherwise the agent would turn the test's HTTP calls and the mock server into spans. The agent jar is wired as a test input, so the test reruns whenever the jar changes. Adding Kotlin for the test enrolls the module in API validation, hence the new API dump. Co-authored-by: Claude Opus 5.5 (1M context) --- .../api/sentry-opentelemetry-agent.api | 4 + .../build.gradle.kts | 27 ++++++ .../agent/fixture/HelloServer.java | 34 +++++++ .../opentelemetry/agent/AgentSmokeTest.kt | 88 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api new file mode 100644 index 0000000000..eab018dee5 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api @@ -0,0 +1,4 @@ +public final class io/sentry/opentelemetry/agent/AgentMarker { + public fun ()V +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 054db790dc..141f848793 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -1,12 +1,18 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.animalsniffer") id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + fun relocatePackages(shadowJar: ShadowJar) { // rewrite dependencies calling Logger.getLogger shadowJar.relocate("java.util.logging.Logger", "io.opentelemetry.javaagent.bootstrap.PatchLogger") @@ -76,6 +82,11 @@ dependencies { bootstrapLibs(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) javaagentLibs(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) upstreamAgent(libs.otel.javaagent) + + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } fun isolateClasses(jars: Iterable): CopySpec = copySpec { @@ -197,6 +208,22 @@ tasks { } assemble { dependsOn(shadowJar) } + + test { + jvmArgumentProviders.add( + objects.newInstance().apply { + agentJar.set(shadowJar.flatMap { it.archiveFile }) + } + ) + } +} + +// Tests launch apps with the built agent attached, so the agent jar is a test input. +abstract class AgentJarArgument : CommandLineArgumentProvider { + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val agentJar: RegularFileProperty + + override fun asArguments() = + listOf("-Dsentry.test.agentJar=${agentJar.get().asFile.absolutePath}") } tasks.named("distZip").configure { this.dependsOn("jar") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java new file mode 100644 index 0000000000..78d322c3d3 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java @@ -0,0 +1,34 @@ +package io.sentry.opentelemetry.agent.fixture; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * An app for the agent to instrument. It only uses the JDK, so a test can run it with nothing but + * this class on the classpath, and it writes its port to the file named by the first argument. + */ +public final class HelloServer { + public static void main(final String[] args) throws IOException { + final HttpServer server = + HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); + server.createContext( + "/hello", + exchange -> { + final byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + Files.write( + Paths.get(args[0]), + String.valueOf(server.getAddress().getPort()).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt new file mode 100644 index 0000000000..824e31c34f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt @@ -0,0 +1,88 @@ +package io.sentry.opentelemetry.agent + +import com.google.common.truth.Truth.assertThat +import io.sentry.opentelemetry.agent.fixture.HelloServer +import io.sentry.test.MockSentryServer +import io.sentry.test.transaction +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.File +import java.net.URL +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs an app with the built agent attached, in a child JVM, so the test's own JVM stays + * uninstrumented. Instrumenting it would turn the test's HTTP calls and [MockSentryServer] itself + * into spans. + */ +class AgentSmokeTest { + private val server = MockSentryServer() + private val workDir = Files.createTempDirectory("agent-smoke-test").toFile() + private val appLog = File(workDir, "app.log") + private var app: Process? = null + + @AfterTest + fun teardown() { + app?.destroyForcibly()?.waitFor() + server.close() + if (appLog.exists()) { + println(appLog.readText()) + } + workDir.deleteRecursively() + } + + @Test + fun `agent sends a transaction for an incoming request`() { + val port = launchWithAgent(HelloServer::class.java) + + URL("http://127.0.0.1:$port/hello").readText() + + // The agent's BatchSpanProcessor exports every 5s. + val envelope = + server.awaitEnvelope(timeoutMillis = 30_000) { + it.transaction()?.contexts?.trace?.operation == "http.server" + } + assertThat(envelope.header.sdkVersion!!.name).isEqualTo("sentry.java.opentelemetry.agent") + } + + private fun launchWithAgent(mainClass: Class<*>): Int { + val portFile = File(workDir, "port") + val process = + ProcessBuilder( + File(System.getProperty("java.home"), "bin/java").path, + "-javaagent:${System.getProperty("sentry.test.agentJar")}", + "-cp", + File(mainClass.protectionDomain.codeSource.location.toURI()).path, + mainClass.name, + portFile.path, + ) + .redirectErrorStream(true) + .redirectOutput(appLog) + .apply { + environment()["SENTRY_DSN"] = server.dsn + environment()["SENTRY_TRACES_SAMPLE_RATE"] = "1.0" + environment()["OTEL_TRACES_EXPORTER"] = "none" + environment()["OTEL_METRICS_EXPORTER"] = "none" + environment()["OTEL_LOGS_EXPORTER"] = "none" + } + .start() + app = process + + val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), 60, TimeUnit.SECONDS) + while (true) { + portFile + .takeIf { it.exists() } + ?.readText() + ?.toIntOrNull() + ?.let { + return it + } + check(process.isAlive) { "App exited with ${process.exitValue()}" } + check(!deadline.hasPassed()) { "App did not start within 60s" } + Thread.sleep(100) + } + } +} From 31b2243721064bbbfa5db5ffe5cf12f86a2892a1 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 16:53:58 +0200 Subject: [PATCH 20/48] fix(android): Always remove FirstDrawDoneListener after the first draw (#6152) * fix(android): Always remove FirstDrawDoneListener after the first draw The listener was removed from an OnGlobalLayoutListener, which only runs if a layout pass follows the draw. On a static screen it stayed registered and ran on every frame, retaining its callback. Post the removal to the main thread instead so it always happens. Co-Authored-By: Claude Opus 5.5 (1M context) * changelog --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +++ .../internal/util/FirstDrawDoneListener.java | 20 +++++----- .../util/FirstDrawDoneListenerTest.kt | 39 +------------------ 3 files changed, 17 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffb41a11..465da8d957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) + ## 8.61.1 ### Fixes 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 0629b7a490..533739d51d 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 @@ -109,17 +109,15 @@ public void onDraw() { if (view == null) { return; } - // OnDrawListeners cannot be removed within onDraw, so we remove it with a - // GlobalLayoutListener - view.getViewTreeObserver() - .addOnGlobalLayoutListener( - new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - view.getViewTreeObserver().removeOnGlobalLayoutListener(this); - view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); - } - }); + // OnDrawListeners cannot be removed within onDraw. Posting the removal instead of waiting for + // the next global layout guarantees it happens, even if no layout pass follows the draw. + mainThreadHandler.post( + () -> { + final ViewTreeObserver observer = view.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnDrawListener(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 44d6d9fd03..57e72ac4d7 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 @@ -113,49 +113,14 @@ class FirstDrawDoneListenerTest { } @Test - fun `registerForNextDraw unregister itself after onDraw`() { + fun `registerForNextDraw unregisters itself after onDraw without a layout pass`() { val view = fixture.getSut() FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) assertFalse(fixture.onDrawListeners.isEmpty()) - // Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered - view.viewTreeObserver.dispatchOnGlobalLayout() - assertFalse(fixture.onDrawListeners.isEmpty()) - - // Removes OnDrawListener in the next OnGlobalLayout after onDraw - view.viewTreeObserver.dispatchOnDraw() - view.viewTreeObserver.dispatchOnGlobalLayout() - 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()) + Shadows.shadowOf(Looper.getMainLooper()).idle() - // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners - view.viewTreeObserver.dispatchOnGlobalLayout() - assertTrue(globalLayoutListeners().isEmpty()) assertTrue(fixture.onDrawListeners.isEmpty()) } From c2e5997b7c013349de7f05c5a0f4675132e80e69 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 17:45:20 +0200 Subject: [PATCH 21/48] test(otel): Test a Spring Boot app under the agent in both init modes (#6271) Runs a Spring Boot app with the built agent in a child JVM and checks that errors, the server transaction with OTel and Sentry API spans, and logs reach MockSentryServer. It runs once with the agent initializing Sentry and once with the Spring Boot starter doing it; the SDK name proves which path ran. The sample's auto-init=false CI variant shares task inputs with the true variant, so Gradle can skip it as up to date. The app has its own source set so it runs on an app-like classpath rather than the test's, which carries the agent's plain jar and would flip the auto-configuration conditions. The child-JVM launcher moves into sentry-test-support and the argument provider into build-logic, so both agent tests share them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/sentry/gradle/ChildJvmTestArguments.kt | 30 +++++ .../build.gradle.kts | 13 +- .../agent/fixture/HelloServer.java | 4 +- .../opentelemetry/agent/AgentSmokeTest.kt | 72 +++-------- sentry-spring-boot-jakarta/build.gradle.kts | 51 ++++++++ .../agentfixture/AgentFixtureApplication.kt | 56 +++++++++ .../boot/jakarta/OtelAgentPersonTest.kt | 119 ++++++++++++++++++ .../main/kotlin/io/sentry/test/ChildJvmApp.kt | 72 +++++++++++ 8 files changed, 352 insertions(+), 65 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt create mode 100644 sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt create mode 100644 sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt new file mode 100644 index 0000000000..84335b938f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt @@ -0,0 +1,30 @@ +package io.sentry.gradle + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.process.CommandLineArgumentProvider + +/** + * Tells a test task's JVM where to find the built OpenTelemetry agent and the app to run with it. + * + * The test launches the app in a child JVM, so neither file is on the test classpath. Declaring + * them here makes them task inputs: the test reruns when either changes and depends on the tasks + * that build them. + */ +abstract class ChildJvmTestArguments : CommandLineArgumentProvider { + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val agentJar: ConfigurableFileCollection + + @get:Classpath abstract val appClasspath: ConfigurableFileCollection + + override fun asArguments(): List = buildList { + add("-Dsentry.test.agentJar=${agentJar.singleFile.absolutePath}") + if (!appClasspath.isEmpty) { + add("-Dsentry.test.appClasspath=${appClasspath.asPath}") + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 141f848793..b4b1a8b840 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -1,4 +1,5 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import io.sentry.gradle.ChildJvmTestArguments import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -211,19 +212,9 @@ tasks { test { jvmArgumentProviders.add( - objects.newInstance().apply { - agentJar.set(shadowJar.flatMap { it.archiveFile }) - } + objects.newInstance().apply { agentJar.from(shadowJar) } ) } } -// Tests launch apps with the built agent attached, so the agent jar is a test input. -abstract class AgentJarArgument : CommandLineArgumentProvider { - @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val agentJar: RegularFileProperty - - override fun asArguments() = - listOf("-Dsentry.test.agentJar=${agentJar.get().asFile.absolutePath}") -} - tasks.named("distZip").configure { this.dependsOn("jar") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java index 78d322c3d3..b03287568d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java @@ -11,7 +11,7 @@ /** * An app for the agent to instrument. It only uses the JDK, so a test can run it with nothing but - * this class on the classpath, and it writes its port to the file named by the first argument. + * this class on the classpath, and it writes its port to the file named by {@code PORTFILE}. */ public final class HelloServer { public static void main(final String[] args) throws IOException { @@ -28,7 +28,7 @@ public static void main(final String[] args) throws IOException { }); server.start(); Files.write( - Paths.get(args[0]), + Paths.get(System.getenv("PORTFILE")), String.valueOf(server.getAddress().getPort()).getBytes(StandardCharsets.UTF_8)); } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt index 824e31c34f..2764a7d183 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt @@ -2,14 +2,11 @@ package io.sentry.opentelemetry.agent import com.google.common.truth.Truth.assertThat import io.sentry.opentelemetry.agent.fixture.HelloServer +import io.sentry.test.ChildJvmApp import io.sentry.test.MockSentryServer import io.sentry.test.transaction -import io.sentry.time.Deadline -import io.sentry.time.JavaMonotonicTicker import java.io.File import java.net.URL -import java.nio.file.Files -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test @@ -20,25 +17,34 @@ import kotlin.test.Test */ class AgentSmokeTest { private val server = MockSentryServer() - private val workDir = Files.createTempDirectory("agent-smoke-test").toFile() - private val appLog = File(workDir, "app.log") - private var app: Process? = null + private var app: ChildJvmApp? = null @AfterTest fun teardown() { - app?.destroyForcibly()?.waitFor() + app?.close() server.close() - if (appLog.exists()) { - println(appLog.readText()) - } - workDir.deleteRecursively() } @Test fun `agent sends a transaction for an incoming request`() { - val port = launchWithAgent(HelloServer::class.java) + val app = + ChildJvmApp.start( + mainClass = HelloServer::class.java.name, + classpath = + File(HelloServer::class.java.protectionDomain.codeSource.location.toURI()).path, + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + env = + mapOf( + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + ) + .also { app = it } - URL("http://127.0.0.1:$port/hello").readText() + URL("http://127.0.0.1:${app.awaitPort()}/hello").readText() // The agent's BatchSpanProcessor exports every 5s. val envelope = @@ -47,42 +53,4 @@ class AgentSmokeTest { } assertThat(envelope.header.sdkVersion!!.name).isEqualTo("sentry.java.opentelemetry.agent") } - - private fun launchWithAgent(mainClass: Class<*>): Int { - val portFile = File(workDir, "port") - val process = - ProcessBuilder( - File(System.getProperty("java.home"), "bin/java").path, - "-javaagent:${System.getProperty("sentry.test.agentJar")}", - "-cp", - File(mainClass.protectionDomain.codeSource.location.toURI()).path, - mainClass.name, - portFile.path, - ) - .redirectErrorStream(true) - .redirectOutput(appLog) - .apply { - environment()["SENTRY_DSN"] = server.dsn - environment()["SENTRY_TRACES_SAMPLE_RATE"] = "1.0" - environment()["OTEL_TRACES_EXPORTER"] = "none" - environment()["OTEL_METRICS_EXPORTER"] = "none" - environment()["OTEL_LOGS_EXPORTER"] = "none" - } - .start() - app = process - - val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), 60, TimeUnit.SECONDS) - while (true) { - portFile - .takeIf { it.exists() } - ?.readText() - ?.toIntOrNull() - ?.let { - return it - } - check(process.isAlive) { "App exited with ${process.exitValue()}" } - check(!deadline.hasPassed()) { "App did not start within 60s" } - Thread.sleep(100) - } - } } diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 8c1a07aa9c..28ebe0ba87 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -1,3 +1,4 @@ +import io.sentry.gradle.ChildJvmTestArguments import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.plugin.SpringBootPlugin @@ -139,3 +140,53 @@ tasks.jar { ) } } + +// The OpenTelemetry agent tests run this app in a child JVM, on its own classpath like a real app +// with the agent attached, rather than on the test's classpath. +val agentFixture = sourceSets.create("agentFixture") + +// The built agent jar, rather than the plain jar the unit tests compile against. +val otelAgentJar = + configurations.create("otelAgentJar") { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED)) + } + } + +dependencies { + "agentFixtureImplementation"(platform(SpringBootPlugin.BOM_COORDINATES)) + "agentFixtureImplementation"(projects.sentrySpringBootStarterJakarta) + "agentFixtureImplementation"(projects.sentryLogback) + "agentFixtureImplementation"(kotlin(Config.kotlinStdLib)) + "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot3.starter.web) + + otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) +} + +testing { + suites { + register("integrationTestOtelAgent") { + useJUnit() + dependencies { + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + } + targets.all { + testTask.configure { + jvmArgumentProviders.add( + objects.newInstance().apply { + agentJar.from(otelAgentJar) + appClasspath.from(agentFixture.runtimeClasspath) + } + ) + } + } + } + } +} diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt new file mode 100644 index 0000000000..d857274ded --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt @@ -0,0 +1,56 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.api.trace.Tracer +import io.sentry.Sentry +import org.slf4j.LoggerFactory +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.context.WebServerPortFileWriter +import org.springframework.context.annotation.Bean +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication +open class AgentFixtureApplication { + @Bean open fun tracer(): Tracer = GlobalOpenTelemetry.get().getTracer("agent-fixture") +} + +fun main(args: Array) { + SpringApplication(AgentFixtureApplication::class.java) + .apply { addListeners(WebServerPortFileWriter()) } + .run(*args) +} + +/** Reports to Sentry through every API an app with the agent uses, then fails the request. */ +@RestController +open class PersonController(private val tracer: Tracer) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + @GetMapping("/person/{id}") + open fun person(@PathVariable("id") id: Long): String { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() + try { + span.makeCurrent().use { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + } finally { + span.end() + } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt new file mode 100644 index 0000000000..9ade405a8f --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt @@ -0,0 +1,119 @@ +package io.sentry.spring.boot.jakarta + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.FeatureFlag +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.logs +import io.sentry.test.transaction +import java.net.HttpURLConnection +import java.net.URL +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs a Spring Boot app with the built OpenTelemetry agent in a child JVM. Sentry is initialized + * either by the agent or, with `SENTRY_AUTO_INIT=false`, by the Spring Boot starter. + */ +class OtelAgentPersonTest { + private val server = MockSentryServer() + private var app: ChildJvmApp? = null + + @AfterTest + fun teardown() { + app?.close() + server.close() + } + + @Test + fun `failed request is reported when the agent initializes Sentry`() { + assertFailedRequestIsReported(autoInit = true, sdkName = "sentry.java.opentelemetry.agent") + } + + @Test + fun `failed request is reported when the Spring Boot starter initializes Sentry`() { + assertFailedRequestIsReported(autoInit = false, sdkName = "sentry.java.spring-boot.jakarta") + } + + private fun assertFailedRequestIsReported(autoInit: Boolean, sdkName: String) { + val port = startApp(autoInit) + + val connection = URL("http://127.0.0.1:$port/person/1").openConnection() as HttpURLConnection + assertThat(connection.responseCode).isEqualTo(500) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + // The agent's BatchSpanProcessor exports every 5s. + val transactionEnvelope = + server.awaitEnvelope(timeoutMillis = 30_000) { + it.transaction()?.contexts?.trace?.operation == "http.server" + } + assertThat(transactionEnvelope.header.sdkVersion!!.name).isEqualTo(sdkName) + val transaction = transactionEnvelope.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughOtelApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).isTrue() + + val logs = + server + .awaitEnvelope(timeoutMillis = 30_000) { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + private fun startApp(autoInit: Boolean): Int { + val app = + ChildJvmApp.start( + mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", + classpath = System.getProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + env = + mapOf( + "SENTRY_AUTO_INIT" to autoInit.toString(), + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_LOGS_ENABLED" to "true", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + args = listOf("--server.port=0"), + ) + this.app = app + return app.awaitPort() + } +} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt new file mode 100644 index 0000000000..c9c529d45c --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt @@ -0,0 +1,72 @@ +package io.sentry.test + +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.Closeable +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * An app running in a child JVM, for apps that must not share the test's JVM, such as one with the + * OpenTelemetry agent attached. + * + * The app writes the port it listens on to the file named by the `PORTFILE` environment variable, + * which Spring Boot's `WebServerPortFileWriter` reads too. [close] kills the app and prints its + * output, which otherwise only lives in a temporary file. + */ +class ChildJvmApp private constructor(private val process: Process, private val workDir: File) : + Closeable { + private val portFile = File(workDir, PORT_FILE) + + /** Waits until the app has written its port and returns it. */ + fun awaitPort(timeoutSeconds: Long = 60): Int { + val deadline = + Deadline.after(JavaMonotonicTicker.getInstance(), timeoutSeconds, TimeUnit.SECONDS) + while (true) { + // The app may not have finished writing the file yet, in which case it doesn't parse. + val port = portFile.takeIf { it.exists() }?.readText()?.trim()?.toIntOrNull() + if (port != null) { + return port + } + check(process.isAlive) { "App exited with ${process.exitValue()}" } + check(!deadline.hasPassed()) { "App did not start within ${timeoutSeconds}s" } + Thread.sleep(100) + } + } + + override fun close() { + process.destroyForcibly().waitFor() + val log = File(workDir, LOG_FILE) + if (log.exists()) { + println(log.readText()) + } + workDir.deleteRecursively() + } + + companion object { + private const val PORT_FILE = "port" + private const val LOG_FILE = "app.log" + + fun start( + mainClass: String, + classpath: String, + jvmArgs: List = emptyList(), + env: Map = emptyMap(), + args: List = emptyList(), + ): ChildJvmApp { + val workDir = Files.createTempDirectory("child-jvm-app").toFile() + val java = File(System.getProperty("java.home"), "bin/java").path + val process = + ProcessBuilder(listOf(java) + jvmArgs + listOf("-cp", classpath, mainClass) + args) + .redirectErrorStream(true) + .redirectOutput(File(workDir, LOG_FILE)) + .apply { + environment().putAll(env) + environment()["PORTFILE"] = File(workDir, PORT_FILE).path + } + .start() + return ChildJvmApp(process, workDir) + } + } +} From 15691782e6a8a59cff174fa635c4ec0aa37d6192 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 18:42:05 +0200 Subject: [PATCH 22/48] test(spring-boot): Move the Boot 3 sample's Person test into the module (#6292) Adds an integrationTest suite to sentry-spring-boot-jakarta that runs a Spring Boot app in the test's JVM against MockSentryServer, and ports the jakarta sample's PersonSystemTest to it: errors with feature flags, the server transaction and its spans, logs, the JDBC query span and the profile linked to the transaction. It is a suite of its own because the unit tests' classpath carries the OpenTelemetry agent, which switches Sentry into agent mode. Test classes share one application context and one server, so the app starts once per suite. The Spring Boot 3 matrix now runs both of the module's integration suites against every Boot version it covers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 6 + .../io/sentry/systemtest/PersonSystemTest.kt | 109 ------------------ sentry-spring-boot-jakarta/build.gradle.kts | 21 ++++ .../integrationtest/IntegrationTest.kt | 52 +++++++++ .../IntegrationTestApplication.kt | 70 +++++++++++ .../integrationtest/PersonIntegrationTest.kt | 100 ++++++++++++++++ .../src/integrationTest/resources/schema.sql | 5 + 7 files changed, 254 insertions(+), 109 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 33e06c49b5..bf1eac06af 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -83,6 +83,12 @@ jobs: :sentry-samples:sentry-samples-spring-jakarta:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Test sentry-spring-boot-jakarta integration suites + run: | + ./gradlew \ + :sentry-spring-boot-jakarta:integrationTest \ + :sentry-spring-boot-jakarta:integrationTestOtelAgent + - name: Test sentry-samples-spring-boot-jakarta run: | python3 test/system-test-runner.py test \ diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 2389734a8b..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 28ebe0ba87..8b029ba2af 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -170,6 +170,27 @@ dependencies { testing { suites { + // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own + // because the unit tests' classpath carries the agent, which switches Sentry into agent mode. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBootStarterJakarta) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.springboot3.starter.aop) + implementation(libs.springboot3.starter.jdbc) + implementation(libs.springboot3.starter.test) + implementation(libs.springboot3.starter.web) + runtimeOnly(libs.hsqldb) + } + } + register("integrationTestOtelAgent") { useJUnit() dependencies { diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt new file mode 100644 index 0000000000..b2df6f0fbf --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -0,0 +1,52 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profile-lifecycle=TRACE", + "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", + "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + ], +) +abstract class IntegrationTest { + @Autowired lateinit var restTemplate: TestRestTemplate + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 0000000000..d0e19302ae --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,70 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.Sentry +import io.sentry.spring.jakarta.tracing.SentrySpan +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +@SpringBootApplication open class IntegrationTestApplication + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + open fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +open class PersonService(private val jdbcTemplate: JdbcTemplate) { + open fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 0000000000..a0838b6fee --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql b/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql new file mode 100644 index 0000000000..7ca8a5cbf4 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); From dbad316e67a58872c30ad9dbc54a3a2d8819d29e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:42:24 +0200 Subject: [PATCH 23/48] test(android): Wait for the previous session move in InternalSentrySdkTest (#6358) Sentry.init moves session.json to previous_session.json on the executor. When that task ran after a test persisted the live session, the test could no longer read session.json, and PreviousSessionFinalizer sent the moved session as an extra envelope. Wait for the move in the fixture and re-enable the test that was ignored for the same flake. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Nelson Osacky --- .../sentry/android/core/InternalSentrySdkTest.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index ba0322c7a1..031df4c523 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -38,13 +38,13 @@ import java.io.ByteArrayOutputStream import java.io.InputStreamReader import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.awaitility.kotlin.await import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -87,6 +87,12 @@ class InternalSentrySdkTest { } } + // Sentry.init moves any leftover session.json to previous_session.json on the executor. Wait + // for that to finish, otherwise it can move the session a test has just persisted. Test + // options set sessionFlushTimeoutMillis to 0, so waitPreviousSessionFlush() only polls. + val cache = options.envelopeDiskCache as EnvelopeCache + await.until { cache.waitPreviousSessionFlush() } + capturedEnvelopes.clear() } @@ -634,14 +640,6 @@ class InternalSentrySdkTest { assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) } - // Flaky: intermittently fails with FileNotFoundException reading session.json at the - // `sessionFile.reader()` call below, i.e. persistCurrentSession() did not leave a session file - // on disk by the time this test reads it back. Seen across unrelated PRs, e.g.: - // https://scans.gradle.com/s/55fnn6xhtfyfq/tests/task/:sentry-android-core:testReleaseUnitTest/details/io.sentry.android.core.InternalSentrySdkTest/updateSessionForDroppedEventNonTerminating%20flags%20an%20unhandled%20error%20without%20sending%20an%20envelope?top-execution=1 - // https://scans.gradle.com/s/ojzzz4yxag7rw/tests/task/:sentry-android-core:testReleaseUnitTest/details/io.sentry.android.core.InternalSentrySdkTest/updateSessionForDroppedEventNonTerminating%20flags%20an%20unhandled%20error%20without%20sending%20an%20envelope?top-execution=1 - // Disabling until root-caused; see https://github.com/getsentry/sentry-java/pull/5990 for the - // code under test. - @Ignore("Flaky: intermittently fails to find the persisted session.json, needs root-causing") @Test fun `updateSessionForDroppedEventNonTerminating flags an unhandled error without sending an envelope`() { val fixture = Fixture() From 44566d59d11beb0b3ce9bb4a4360dff54c7d6120 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:43:43 +0200 Subject: [PATCH 24/48] test(core): Allow repeated scopes storage set calls in SentryTest (#6359) The custom storage mock always returns null from get(), so the BackpressureMonitor health check, scheduled 500ms after init, forks the root scopes and calls set() again. On slow runners that landed before verify and failed the test with TooManyActualInvocations. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Nelson Osacky --- sentry/src/test/java/io/sentry/SentryTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 72400f3e33..8888d81c39 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1879,7 +1879,9 @@ class SentryTest { } verify(customStorage).init() - verify(customStorage).set(any()) + // get() always returns null here, so every later getCurrentScopes() call forks the root scopes + // and calls set() again, e.g. the BackpressureMonitor health check on the executor + verify(customStorage, atLeastOnce()).set(any()) } @Test From aa51872a6d2c3b4c76cc462f0167dc0cb7666252 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:44:50 +0200 Subject: [PATCH 25/48] test(core): Wait for the transaction capture in idle timeout tests (#6360) SentryTracer.finish() clears isFinishTimerRunning before it calls captureTransaction, so waiting on the flag could return while the timer thread was still finishing. Retry the verify until the capture happens instead. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Nelson Osacky --- .../test/java/io/sentry/SentryTracerTest.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 7c6324db0a..95f1934424 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1160,9 +1160,11 @@ class SentryTracerTest { val span = transaction.startChild("op") span.finish() - await.untilFalse(transaction.isFinishTimerRunning) - - verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + // finish() clears isFinishTimerRunning before it captures the transaction, so wait for the + // capture itself + await.untilAsserted { + verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } } @Test @@ -1221,18 +1223,20 @@ class SentryTracerTest { val span2 = transaction.startChild("op2") as Span span2.finish() - await.untilFalse(transaction.isFinishTimerRunning) - - verify(fixture.scopes) - .captureTransaction( - check { - assertEquals(2, it.spans.size) - assertEquals(transaction.root.finishDate, span2.finishDate) - }, - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) + // finish() clears isFinishTimerRunning before it captures the transaction, so wait for the + // capture itself + await.untilAsserted { + verify(fixture.scopes) + .captureTransaction( + check { + assertEquals(2, it.spans.size) + assertEquals(transaction.root.finishDate, span2.finishDate) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } } @Test From be877bee13cb70cce23260cd8e07f1c4abdb7052 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 10:49:38 +0200 Subject: [PATCH 26/48] test(spring-boot): [Sample Tests 7] Move the Boot 3 tracing and HTTP client tests (#6293) * test(spring-boot): Move the Boot 3 tracing and HTTP client tests into the module Ports the jakarta sample's DistributedTracingSystemTest and TodoSystemTest to the integrationTest suite. The app calls itself on its random port instead of a hardcoded localhost:8080, and serves the todo the sample fetched from jsonplaceholder.typicode.com, so the tests no longer need the internet. Adds MockSentryServer.assertNoEnvelope for the unsampled case. A flush can't prove an absence because the server may finish its transaction after the client already has the response, so it watches for a short window instead of the old 10s sleep. The suite declares context-propagation explicitly: without it Reactor can't carry the Sentry scope to the WebClient's thread. The sample only had it because Spring GraphQL pulls it in. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): Tighten the Boot 3 tracing checks and use a plain test client The unsampled-trace test now sends a request with an id of its own and rejects any transaction for it, whatever its trace, so a mishandled header that starts a new trace fails it. The sampled test checks that transactions carry their request, which that relies on. The sample_rand and deferred-sampling tests also check the envelope header's trace ID, as the sample's tests did. That check showed that the context's TestRestTemplate, built from the RestTemplateBuilder Sentry customizes, added trace headers of its own to the requests the tests sent. The suite now uses one built outside the context. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DistributedTracingSystemTest.kt | 197 ------------------ .../io/sentry/systemtest/TodoSystemTest.kt | 77 ------- sentry-spring-boot-jakarta/build.gradle.kts | 7 + .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 148 +++++++++++++ .../integrationtest/IntegrationTest.kt | 12 +- .../IntegrationTestApplication.kt | 27 ++- .../jakarta/integrationtest/TodoController.kt | 63 ++++++ .../integrationtest/TodoIntegrationTest.kt | 47 +++++ .../kotlin/io/sentry/test/MockSentryServer.kt | 19 ++ .../io/sentry/test/MockSentryServerTest.kt | 21 ++ 11 files changed, 363 insertions(+), 277 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd1600302..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 66600e9753..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo restclient works`() { - val restClient = testHelper.restClient - restClient.getTodoRestClient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo okhttp works`() { - val restClient = testHelper.restClient - restClient.getTodoOkHttp(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /todo-okhttp/{id}" && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 8b029ba2af..70c0ae267e 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -180,13 +180,20 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryJdbc) implementation(projects.sentryLogback) + implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + // The sample only had it because Spring GraphQL pulls it in. + implementation(libs.context.propagation) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + implementation(libs.okhttp) implementation(libs.springboot3.starter.aop) implementation(libs.springboot3.starter.jdbc) implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) + implementation(libs.springboot3.starter.webflux) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt new file mode 100644 index 0000000000..8f120bf250 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +open class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 0000000000..e48945453f --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,148 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.random.Random +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) + val status = + get( + "/tracing/$id", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { isForRequest(it, id) } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index b2df6f0fbf..aeffe53c78 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -3,9 +3,10 @@ package io.sentry.spring.boot.jakarta.integrationtest import io.sentry.test.MockSentryServer import org.junit.Before import org.junit.runner.RunWith -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.boot.web.client.RestTemplateBuilder import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -24,6 +25,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.clients.ok-http-enabled=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", @@ -33,7 +35,13 @@ import org.springframework.test.context.junit4.SpringRunner ], ) abstract class IntegrationTest { - @Autowired lateinit var restTemplate: TestRestTemplate + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so + // it would add trace headers of its own to the requests tests send. Tests use a plain one. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } @Before fun resetServer() { diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt index d0e19302ae..89d686dead 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -2,8 +2,11 @@ package io.sentry.spring.boot.jakarta.integrationtest import io.sentry.Sentry import io.sentry.spring.jakarta.tracing.SentrySpan +import okhttp3.OkHttpClient import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.context.annotation.Bean import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service import org.springframework.web.bind.annotation.GetMapping @@ -12,11 +15,33 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder // Classes and methods are open because Spring proxies them and this module doesn't apply the // kotlin-spring plugin. -@SpringBootApplication open class IntegrationTestApplication +@SpringBootApplication +open class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean open fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Traced through sentry.clients.ok-http-enabled. + @Bean open fun okHttpClient(): OkHttpClient = OkHttpClient() +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() open class Person(var firstName: String? = null, var lastName: String? = null) diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt new file mode 100644 index 0000000000..5edb7bc6aa --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt @@ -0,0 +1,63 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.fasterxml.jackson.databind.ObjectMapper +import io.sentry.reactor.SentryReactorUtils +import okhttp3.OkHttpClient +import okhttp3.Request +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +open class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +open class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val okHttpClient: OkHttpClient, + private val objectMapper: ObjectMapper, +) { + @GetMapping("/todo/{id}") + open fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + open fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + open fun todoRestClient(@PathVariable("id") id: Long): Todo? = + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + + @GetMapping("/todo-okhttp/{id}") + open fun todoOkHttp(@PathVariable("id") id: Long): Todo = + okHttpClient.newCall(Request.Builder().url(todoUrl(id)).build()).execute().use { response -> + objectMapper.readValue(response.body!!.byteStream(), Todo::class.java) + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +open class TodoStubController { + @GetMapping("/stub/todos/{id}") + open fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt new file mode 100644 index 0000000000..bb3e2d6a99 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.boot.test.web.server.LocalServerPort + +class TodoIntegrationTest : IntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-restclient/1", "GET /todo-restclient/{id}") + } + + @Test + fun `OkHttp request is traced`() { + assertOutgoingRequestIsTraced("/todo-okhttp/1", "GET /todo-okhttp/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt index 439b61f2c2..52cf59b52d 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -55,6 +55,25 @@ class MockSentryServer : Closeable { } } + /** + * Fails if an envelope matching [predicate] has arrived or arrives within [millis]. + * + * A flush alone can't prove an absence: the SDK may not have produced the envelope yet when the + * flush runs, for example when a server finishes its transaction after sending the response. + */ + fun assertNoEnvelope(millis: Long = 2_000, predicate: (SentryEnvelope) -> Boolean) { + val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), millis, TimeUnit.MILLISECONDS) + while (true) { + if (received.any(predicate)) { + throw AssertionError("Received an envelope that should not have been sent") + } + if (deadline.hasPassed()) { + return + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + fun reset() { received.clear() } diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt index 6a1e3f0322..e52c78790e 100644 --- a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -31,4 +31,25 @@ class MockSentryServerTest { fun `awaitEnvelope fails once the timeout passes without a match`() { assertFailsWith { server.awaitEnvelope(timeoutMillis = 100) { true } } } + + @Test + fun `assertNoEnvelope passes when nothing matches`() { + initForTest { it.dsn = server.dsn } + + Sentry.captureMessage("hello") + Sentry.flush(5000) + + server.assertNoEnvelope(millis = 100) { it.header.eventId == null } + } + + @Test + fun `assertNoEnvelope fails when a matching envelope arrives`() { + initForTest { it.dsn = server.dsn } + + val eventId = Sentry.captureMessage("hello") + + assertFailsWith { + server.assertNoEnvelope(millis = 5000) { it.header.eventId == eventId } + } + } } From f98c023579d64f66182a65e09ae602cba3728220 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:53:01 +0200 Subject: [PATCH 27/48] chore(deps): bump getsentry/craft (#6353) Bumps the github-actions group with 1 update in the / directory: [getsentry/craft](https://github.com/getsentry/craft). Updates `getsentry/craft` from 2.35.0 to 2.35.1 - [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/7a7857552e2cdc08b502de96e6076849c01fba32...0ff75896d43285ca376c7254e8143bac23acd429) --- updated-dependencies: - dependency-name: getsentry/craft dependency-version: 2.35.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce434f67c4..c58e24a134 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@7a7857552e2cdc08b502de96e6076849c01fba32 # v2.35.0 + uses: getsentry/craft@0ff75896d43285ca376c7254e8143bac23acd429 # v2.35.1 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 4d96804e079b8f9881fdf3b326193418eff0d39c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 11:25:14 +0200 Subject: [PATCH 28/48] test(spring-boot): Move the Boot 3 cache, metrics and feature flag tests into the module (#6294) Ports the jakarta sample's CacheSystemTest, MetricsSystemTest and FeatureFlagSystemTest to the integrationTest suite. Feature flags use the OpenFeature SDK's InMemoryProvider instead of the sample's hand-written provider. The suite's Kotlin is compiled with parameter names, as the Spring Boot Gradle plugin does for apps; without them Spring can't resolve `#id` in a cache key. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/sentry/systemtest/CacheSystemTest.kt | 51 --------- .../systemtest/FeatureFlagSystemTest.kt | 102 ------------------ .../io/sentry/systemtest/MetricsSystemTest.kt | 51 --------- sentry-spring-boot-jakarta/build.gradle.kts | 8 ++ .../integrationtest/CacheController.kt | 43 ++++++++ .../integrationtest/CacheIntegrationTest.kt | 35 ++++++ .../integrationtest/FeatureFlagController.kt | 53 +++++++++ .../FeatureFlagIntegrationTest.kt | 92 ++++++++++++++++ .../integrationtest/IntegrationTest.kt | 1 + .../IntegrationTestApplication.kt | 2 + .../integrationtest/MetricController.kt | 33 ++++++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++++++ 12 files changed, 317 insertions(+), 204 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c1085..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt deleted file mode 100644 index 10233e579e..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.systemtest.util.FeatureFlagResponse -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import org.junit.Before - -class FeatureFlagSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `check feature flag includes flag in transaction`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("new-checkout-flow") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("new-checkout-flow", response!!.flagKey) - assertEquals(true, response.value) - - // Verify feature flag is included in the transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.new-checkout-flow", true), - ) - } - } - - @Test - fun `error with feature flag includes flag in error event and transaction`() { - val restClient = testHelper.restClient - restClient.errorWithFeatureFlag("beta-features") - assertEquals(500, restClient.lastKnownStatusCode) - - // Verify feature flag is included in the error event - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage( - event, - "Error occurred with feature flag: beta-features = true", - ) && testHelper.doesEventHaveFlag(event, "beta-features", true) - } - - // Verify feature flag is also included in the transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/error/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.beta-features", true), - ) - } - } - - @Test - fun `check non-existent feature flag returns default value`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("non-existent-flag") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("non-existent-flag", response!!.flagKey) - assertEquals(false, response.value) // Default value - - // Verify transaction is still created - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" - } - } - - @Test - fun `check feature flag with false value`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("experimental-feature") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("experimental-feature", response!!.flagKey) - assertEquals(false, response.value) - - // Verify feature flag with false value is included in transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.experimental-feature", false), - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 70c0ae267e..95d93008dc 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -181,6 +181,7 @@ testing { implementation(projects.sentryJdbc) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) + implementation(projects.sentryOpenfeature) implementation(projects.sentryTestSupport) // Reactor's automatic context propagation needs it to carry the Sentry scope across // threads. @@ -188,8 +189,11 @@ testing { implementation(libs.context.propagation) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + implementation(libs.caffeine) implementation(libs.okhttp) + implementation(libs.openfeature) implementation(libs.springboot3.starter.aop) + implementation(libs.springboot3.starter.cache) implementation(libs.springboot3.starter.jdbc) implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) @@ -218,3 +222,7 @@ testing { } } } + +// The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example +// to resolve `#id` in a cache key. +tasks.named("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt new file mode 100644 index 0000000000..3eca5b5712 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/cache/") +open class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +open class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + open fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + open fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt new file mode 100644 index 0000000000..ee56c352ce --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheIntegrationTest : IntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt new file mode 100644 index 0000000000..808d89cb18 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt @@ -0,0 +1,53 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import dev.openfeature.sdk.OpenFeatureAPI +import dev.openfeature.sdk.providers.memory.Flag +import dev.openfeature.sdk.providers.memory.InMemoryProvider +import io.sentry.openfeature.SentryOpenFeatureHook +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +open class FeatureFlagResponse(var flagKey: String? = null, var value: Boolean = false) + +@RestController +@RequestMapping("/feature-flag/") +open class FeatureFlagController(private val openFeature: OpenFeatureAPI) { + @GetMapping("check/{flagKey}") + open fun check(@PathVariable("flagKey") flagKey: String): FeatureFlagResponse = + FeatureFlagResponse(flagKey, evaluate(flagKey)) + + @GetMapping("error/{flagKey}") + open fun error(@PathVariable("flagKey") flagKey: String): String { + val value = evaluate(flagKey) + throw RuntimeException("Error occurred with feature flag: $flagKey = $value") + } + + private fun evaluate(flagKey: String): Boolean = + openFeature.client.getBooleanValue(flagKey, false) +} + +@Configuration +open class OpenFeatureConfig { + /** Evaluations go through [SentryOpenFeatureHook], which records them on the current scope. */ + @Bean + open fun openFeatureAPI(): OpenFeatureAPI = + OpenFeatureAPI.getInstance().apply { + setProviderAndWait( + InMemoryProvider( + mapOf( + "new-checkout-flow" to booleanFlag(true), + "experimental-feature" to booleanFlag(false), + "beta-features" to booleanFlag(true), + ) + ) + ) + addHooks(SentryOpenFeatureHook()) + } + + private fun booleanFlag(value: Boolean): Flag = + Flag.builder().variant("value", value).defaultVariant("value").build() +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt new file mode 100644 index 0000000000..421bad16d3 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt @@ -0,0 +1,92 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasOp +import io.sentry.test.transaction +import kotlin.test.Test + +/** Feature flags evaluated through OpenFeature end up on the transaction and on errors. */ +class FeatureFlagIntegrationTest : IntegrationTest() { + + @Test + fun `evaluated flag is on the transaction`() { + assertThat(check("new-checkout-flow")).isTrue() + + assertThat( + awaitTransaction("new-checkout-flow") + .hasOp("http.server", FeatureFlag("flag.evaluation.new-checkout-flow", true)) + ) + .isTrue() + } + + @Test + fun `flag evaluated to false is on the transaction`() { + assertThat(check("experimental-feature")).isFalse() + + assertThat( + awaitTransaction("experimental-feature") + .hasOp("http.server", FeatureFlag("flag.evaluation.experimental-feature", false)) + ) + .isTrue() + } + + @Test + fun `unknown flag falls back to its default`() { + assertThat(check("non-existent-flag")).isFalse() + + awaitTransaction("non-existent-flag") + } + + @Test + fun `flag evaluated before an error is on the error and the transaction`() { + val response = + restTemplate.getForEntity("/feature-flag/error/beta-features", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + + val error = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> + e.value == "Error occurred with feature flag: beta-features = true" + } == true + } + .event()!! + assertThat(error.hasFlag("beta-features", true)).isTrue() + + val transaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /feature-flag/error/{flagKey}" && + t.contexts.trace?.data?.containsKey("flag.evaluation.beta-features") == true + } == true + } + .transaction()!! + assertThat(transaction.hasOp("http.server", FeatureFlag("flag.evaluation.beta-features", true))) + .isTrue() + } + + private fun check(flagKey: String): Boolean { + val response = + restTemplate.getForEntity("/feature-flag/check/$flagKey", FeatureFlagResponse::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.flagKey).isEqualTo(flagKey) + return response.body!!.value + } + + /** + * Every check request shares a transaction name, so this picks [flagKey]'s by the request URL. + */ + private fun awaitTransaction(flagKey: String) = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /feature-flag/check/{flagKey}" && + t.request?.url?.endsWith("/feature-flag/check/$flagKey") == true + } == true + } + .transaction()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index aeffe53c78..a430bd5b54 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner [ "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", + "sentry.enable-cache-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt index 89d686dead..50eb64ffc4 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -6,6 +6,7 @@ import okhttp3.OkHttpClient import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.Bean import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service @@ -24,6 +25,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder // kotlin-spring plugin. @SpringBootApplication +@EnableCaching open class IntegrationTestApplication { // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt new file mode 100644 index 0000000000..9c52045c74 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 0000000000..8feb647f4d --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} From 798c4c545bf74f5940c5383359ccb647527769a0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 11:59:02 +0200 Subject: [PATCH 29/48] test(spring-boot): [Sample Tests 9] Move the Boot 3 GraphQL tests into the module (#6295) * test(spring-boot): Move the Boot 3 GraphQL tests into the module Ports the jakarta sample's three GraphQL system tests to the integrationTest suite as plain JSON requests, so they no longer need the Apollo client: queries, a schema mapping, a mutation, resolver errors, and fields loaded through a batch mapping and a data loader. Errors are matched by their exception and tied to their transaction by trace ID, which both keeps two error tests apart on the shared server and checks that the error belongs to its request's transaction. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): Check the GraphQL task query's assignee and creator ids The sample's test asserted assigneeId and creatorId, which the query requests but the port didn't check. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../systemtest/GraphqlGreetingSystemTest.kt | 46 ------- .../systemtest/GraphqlProjectSystemTest.kt | 66 ---------- .../systemtest/GraphqlTaskSystemTest.kt | 50 -------- sentry-spring-boot-jakarta/build.gradle.kts | 2 + .../integrationtest/GraphqlControllers.kt | 83 +++++++++++++ .../integrationtest/GraphqlIntegrationTest.kt | 113 ++++++++++++++++++ .../resources/graphql/schema.graphqls | 38 ++++++ 7 files changed, 236 insertions(+), 162 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlControllers.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/resources/graphql/schema.graphqls diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024dec..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import org.junit.Before - -class GraphqlGreetingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `greeting works`() { - val response = testHelper.graphqlClient.greet("world") - - testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } - - @Test - fun `greeting error`() { - val response = testHelper.graphqlClient.greet("crash") - - testHelper.ensureErrorCount(response, 1) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Before - -class GraphqlProjectSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `project query works`() { - val response = testHelper.graphqlClient.project("proj-slug") - - testHelper.ensureNoErrors(response) - assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.project", - ) - } - } - - @Test - fun `project mutation works`() { - val response = testHelper.graphqlClient.addProject("proj-slug") - - testHelper.ensureNoErrors(response) - assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } - - @Test - fun `project mutation error`() { - val response = testHelper.graphqlClient.addProject("addprojectcrash") - - testHelper.ensureErrorCount(response, 1) - assertNull(response?.data?.addProject) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c077..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class GraphqlTaskSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `tasks and assignees query works`() { - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") - - testHelper.ensureNoErrors(response) - - assertEquals(5, response?.data?.tasks?.size) - - val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") - assertEquals("T1", firstTask.id) - assertEquals("A3", firstTask.assigneeId) - assertEquals("A3", firstTask.assignee?.id) - assertEquals("C3", firstTask.creatorId) - assertEquals("C3", firstTask.creator?.id) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.tasks", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.assignee", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.creator", - ) - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 95d93008dc..da954d28f2 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -178,6 +178,7 @@ testing { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation(projects.sentrySpringBootStarterJakarta) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) implementation(projects.sentryJdbc) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) @@ -194,6 +195,7 @@ testing { implementation(libs.openfeature) implementation(libs.springboot3.starter.aop) implementation(libs.springboot3.starter.cache) + implementation(libs.springboot3.starter.graphql) implementation(libs.springboot3.starter.jdbc) implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlControllers.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlControllers.kt new file mode 100644 index 0000000000..a852e31539 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import java.util.UUID +import java.util.concurrent.CompletableFuture +import org.dataloader.DataLoader +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.BatchMapping +import org.springframework.graphql.data.method.annotation.MutationMapping +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.data.method.annotation.SchemaMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.stereotype.Controller +import reactor.core.publisher.Mono + +class Project(val slug: String) + +enum class ProjectStatus { + ACTIVE, + COMMUNITY, +} + +class Task(val id: String, val name: String, val assigneeId: String?, val creatorId: String?) + +class Assignee(val id: String, val name: String) + +class Creator(val id: String, val name: String) + +/** Resolvers that throw when given a "crash" argument, so tests can check error reporting. */ +@Controller +open class ProjectController { + @QueryMapping + open fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping open fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + open fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + open fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + open fun tasks(@Argument projectSlug: String): List = + listOf( + Task("T1", "Create a new API", "A3", "C3"), + Task("T2", "Update dependencies", "A1", "C1"), + Task("T3", "Document API", "A1", "C1"), + Task("T4", "Merge community PRs", "A2", "C2"), + Task("T5", "Plan more work", null, null), + ) +} + +/** Loads a task's assignee with a batch mapping and its creator with a data loader. */ +@Controller +open class TaskRelationsController(batchLoaderRegistry: BatchLoaderRegistry) { + init { + batchLoaderRegistry + .forTypePair(String::class.java, Creator::class.java) + .registerMappedBatchLoader { ids, _ -> + Mono.just(ids.associateWith { Creator(it, "Name$it") }) + } + } + + @BatchMapping(typeName = "Task", field = "assignee") + open fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + open fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 0000000000..b3b68405c0 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,113 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.fasterxml.jackson.databind.JsonNode +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.event +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test + +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asText()).isEqualTo("Hello world!") + awaitTransactionWithSpan("Query.greeting") + } + + @Test + fun `resolver error is reported with its transaction`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + val transaction = awaitTransactionOfError("causing an error for crash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Query.greeting")).isTrue() + } + + @Test + fun `query with a schema mapping is traced`() { + val response = graphql("""query { project(slug: "proj-slug") { slug status } }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["project"]["slug"].asText()).isEqualTo("proj-slug") + awaitTransactionWithSpan("Query.project") + } + + @Test + fun `mutation is traced`() { + val response = graphql("""mutation { addProject(slug: "proj-slug") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["addProject"].asText()).isNotEmpty() + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `mutation error is reported with its transaction`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asText(null)).isNull() + val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue() + } + + @Test + fun `batch loaded fields are traced`() { + val response = + graphql( + """query { tasks(projectSlug: "project-slug") { id assigneeId assignee { id } creatorId creator { id } } }""" + ) + + assertThat(response.has("errors")).isFalse() + val tasks = response["data"]["tasks"] + assertThat(tasks.size()).isEqualTo(5) + assertThat(tasks[0]["id"].asText()).isEqualTo("T1") + assertThat(tasks[0]["assigneeId"].asText()).isEqualTo("A3") + assertThat(tasks[0]["creatorId"].asText()).isEqualTo("C3") + assertThat(tasks[0]["assignee"]["id"].asText()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asText()).isEqualTo("C3") + + val transaction = awaitTransactionWithSpan("Query.tasks") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.assignee")).isTrue() + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.creator")).isTrue() + } + + private fun graphql(query: String): JsonNode { + val response = + restTemplate.postForEntity("/graphql", mapOf("query" to query), JsonNode::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + return response.body!! + } + + private fun awaitTransactionWithSpan(description: String): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && + t.spans.any { span -> span.op == "graphql" && span.description == description } + } == true + } + .transaction()!! + + /** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */ + private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction { + val error = + server + .awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + .event()!! + val traceId = error.contexts.trace!!.traceId + return server + .awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId } + .transaction()!! + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/resources/graphql/schema.graphqls b/sentry-spring-boot-jakarta/src/integrationTest/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..fc2a8fd4c2 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/resources/graphql/schema.graphqls @@ -0,0 +1,38 @@ +type Query { + greeting(name: String!): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Project { + slug: ID! + status: ProjectStatus! +} + +enum ProjectStatus { + ACTIVE + COMMUNITY +} + +type Task { + id: String! + name: String! + assigneeId: String + assignee: Assignee + creatorId: String + creator: Creator +} + +type Assignee { + id: String! + name: String! +} + +type Creator { + id: String! + name: String! +} From 449c56d75bbcb30b5720ed703e4aca565d521027 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 12:52:26 +0200 Subject: [PATCH 30/48] test(spring-boot): [Sample Tests 10] Move the Boot 3 Kafka test into the module (#6296) * test(spring-boot): Move the Boot 3 Kafka test into the module Ports the jakarta sample's KafkaQueueSystemTest to the integrationTest suite with an embedded KRaft broker, so no Docker is needed. The five sample tests become two that keep every assertion: the publishing request's queue.publish span, and a queue.process transaction that continues the producer's trace. Both check the messaging attributes. The broker is declared with @EmbeddedKafka on the shared base class so it belongs to the one application context: it starts before the app's listener connects and stops after it. The jakarta sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 3 matrix, which keeps running the module's integration suites. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): Fix the wrapping of the embedded broker comment Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 9 -- .github/workflows/system-tests-backend.yml | 5 +- gradle/libs.versions.toml | 4 +- .../build.gradle.kts | 31 ----- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 ------------------ .../src/test/resources/logback.xml | 17 --- sentry-spring-boot-jakarta/build.gradle.kts | 3 + .../integrationtest/IntegrationTest.kt | 12 +- .../integrationtest/KafkaController.kt | 28 +++++ .../integrationtest/KafkaIntegrationTest.kt | 49 ++++++++ test/system-test-runner.py | 3 - 12 files changed, 93 insertions(+), 197 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index bf1eac06af..d62317b44f 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -71,8 +71,6 @@ jobs: - name: Build sample artifacts run: | ./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 \ @@ -89,13 +87,6 @@ jobs: :sentry-spring-boot-jakarta:integrationTest \ :sentry-spring-boot-jakarta:integrationTestOtelAgent - - 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" - - name: Test sentry-samples-spring-boot-webflux-jakarta run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 92ef48a38c..cc0b7dddc8 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -24,13 +24,10 @@ jobs: strategy: fail-fast: false matrix: - sample: [ "sentry-samples-spring-boot-jakarta" ] + sample: [ "sentry-samples-spring-boot" ] agent: [ "false" ] agent-auto-init: [ "true" ] include: - - sample: "sentry-samples-spring-boot" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-opentelemetry-noagent" agent: "false" agent-auto-init: "true" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5c2f9e217..c7c6b5e06e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" +springKafka3 = "3.3.5" sqldelight = "2.3.2" # Android @@ -219,7 +220,8 @@ springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-star 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-kafka3 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka3" } +spring-kafka3-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka3" } 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" } 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 cdc5753a9c..e3591289cd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ 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" @@ -67,34 +66,4 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot3.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") -} - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e45..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} 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 deleted file mode 100644 index 43781cf2c5..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -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/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f7..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index da954d28f2..dc70cc0c7a 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -180,6 +180,7 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryGraphql22) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryOpenfeature) @@ -200,6 +201,8 @@ testing { implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) + implementation(libs.spring.kafka3) + implementation(libs.spring.kafka3.test) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index a430bd5b54..d539470ae9 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -7,6 +7,7 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.kafka.test.context.EmbeddedKafka import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -14,11 +15,14 @@ import org.springframework.test.context.junit4.SpringRunner /** * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. * - * Subclasses share one application context and one server, so the app starts once per suite. - * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they - * produce. + * Subclasses share one application context, one server and one Kafka broker, so each starts once + * per suite. Envelopes that arrive late can outlive [server]'s reset, so tests match on values only + * they produce. */ @RunWith(SpringRunner::class) +// An in-JVM KRaft broker, so no Docker is needed. As part of the context, it starts before the +// app's Kafka listener connects and stops after it. +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") @SpringBootTest( classes = [IntegrationTestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -27,12 +31,14 @@ import org.springframework.test.context.junit4.SpringRunner "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", + "sentry.enable-queue-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + "spring.kafka.consumer.auto-offset-reset=earliest", ], ) abstract class IntegrationTest { diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt new file mode 100644 index 0000000000..cbdda82297 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +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 + +const val KAFKA_TOPIC = "sentry-topic" + +@RestController +@RequestMapping("/kafka") +open class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + open fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +open class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + open fun listen(message: String) {} +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt new file mode 100644 index 0000000000..4e42700486 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt @@ -0,0 +1,49 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test + +/** A message sent through Kafka is traced on both sides: where it's published and processed. */ +class KafkaIntegrationTest : IntegrationTest() { + + @Test + fun `publishing a message traces a queue publish span`() { + val producer = produce("publish-test") + + val span = producer.spans.single { it.op == "queue.publish" } + assertThat(span.data).containsEntry("messaging.system", "kafka") + assertThat(span.data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + @Test + fun `processing a message continues the producer's trace`() { + val traceId = produce("process-test").contexts.trace!!.traceId + + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + val data = consumer.contexts.trace!!.data + assertThat(data).containsEntry("messaging.system", "kafka") + assertThat(data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + /** Sends [message] through the app and returns the transaction of the request that sent it. */ + private fun produce(message: String): SentryTransaction { + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + return server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + } +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 33cef830a3..f875fbb295 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -73,7 +73,6 @@ "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-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", @@ -86,7 +85,6 @@ "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-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", @@ -859,7 +857,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-webflux-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-webflux", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), From 653506487ac6345090abb70737e85ce30ff20ca3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 13:21:57 +0200 Subject: [PATCH 31/48] test(spring-boot): [Sample Tests 11] Move the Boot 4 sample's tests into the module (#6297) * test(spring-boot): Move the Boot 4 sample's tests into the module Adds an integrationTest suite to sentry-spring-boot-4 that mirrors the jakarta one and ports the Boot 4 sample's Person, DistributedTracing, Todo, Cache, Metrics and GraphQL tests to it. The sample's tests were copies of the jakarta ones, so the suite is too, adjusted for Spring Boot 4: Jackson 3, the split restclient and resttestclient modules, @AutoConfigureTestRestTemplate, and sentry-spring-7's SentrySpan. The Spring Boot 4 matrix runs the suite against every Boot version it covers. The sample keeps its Kafka test until the next PR. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): Port the tracing, GraphQL and test client fixes to Boot 4 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 3 + .../io/sentry/systemtest/CacheSystemTest.kt | 51 ----- .../DistributedTracingSystemTest.kt | 197 ------------------ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ---- .../systemtest/GraphqlProjectSystemTest.kt | 66 ------ .../systemtest/GraphqlTaskSystemTest.kt | 50 ----- .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 109 ---------- .../io/sentry/systemtest/TodoSystemTest.kt | 77 ------- sentry-spring-boot-4/build.gradle.kts | 42 ++++ .../boot4/integrationtest/CacheController.kt | 43 ++++ .../integrationtest/CacheIntegrationTest.kt | 35 ++++ .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 148 +++++++++++++ .../integrationtest/GraphqlControllers.kt | 83 ++++++++ .../integrationtest/GraphqlIntegrationTest.kt | 113 ++++++++++ .../boot4/integrationtest/IntegrationTest.kt | 61 ++++++ .../IntegrationTestApplication.kt | 94 +++++++++ .../boot4/integrationtest/MetricController.kt | 33 +++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++ .../integrationtest/PersonIntegrationTest.kt | 100 +++++++++ .../boot4/integrationtest/TodoController.kt | 63 ++++++ .../integrationtest/TodoIntegrationTest.kt | 47 +++++ .../resources/graphql/schema.graphqls | 38 ++++ .../src/integrationTest/resources/schema.sql | 5 + 25 files changed, 980 insertions(+), 647 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls create mode 100644 sentry-spring-boot-4/src/integrationTest/resources/schema.sql diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index a960e00082..be80d48b1f 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -83,6 +83,9 @@ jobs: :sentry-samples:sentry-samples-spring-7:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Test sentry-spring-boot-4 integration suite + run: ./gradlew :sentry-spring-boot-4:integrationTest + - name: Run sentry-samples-spring-boot-4 run: | python3 test/system-test-runner.py test \ diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c1085..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd1600302..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024dec..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import org.junit.Before - -class GraphqlGreetingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `greeting works`() { - val response = testHelper.graphqlClient.greet("world") - - testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } - - @Test - fun `greeting error`() { - val response = testHelper.graphqlClient.greet("crash") - - testHelper.ensureErrorCount(response, 1) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Before - -class GraphqlProjectSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `project query works`() { - val response = testHelper.graphqlClient.project("proj-slug") - - testHelper.ensureNoErrors(response) - assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.project", - ) - } - } - - @Test - fun `project mutation works`() { - val response = testHelper.graphqlClient.addProject("proj-slug") - - testHelper.ensureNoErrors(response) - assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } - - @Test - fun `project mutation error`() { - val response = testHelper.graphqlClient.addProject("addprojectcrash") - - testHelper.ensureErrorCount(response, 1) - assertNull(response?.data?.addProject) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c077..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class GraphqlTaskSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `tasks and assignees query works`() { - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") - - testHelper.ensureNoErrors(response) - - assertEquals(5, response?.data?.tasks?.size) - - val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") - assertEquals("T1", firstTask.id) - assertEquals("A3", firstTask.assigneeId) - assertEquals("A3", firstTask.assignee?.id) - assertEquals("C3", firstTask.creatorId) - assertEquals("C3", firstTask.creator?.id) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.tasks", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.assignee", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.creator", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 2389734a8b..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 66600e9753..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo restclient works`() { - val restClient = testHelper.restClient - restClient.getTodoRestClient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo okhttp works`() { - val restClient = testHelper.restClient - restClient.getTodoOkHttp(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /todo-okhttp/{id}" && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index ce3a42989e..d6abe86520 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -156,3 +156,45 @@ kotlin { freeCompilerArgs.add("-Xskip-metadata-version-check") } } + +testing { + suites { + // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own + // because the unit tests' classpath carries the agent, which switches Sentry into agent mode. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryOkhttp) + implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + implementation(libs.context.propagation) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.caffeine) + implementation(libs.okhttp) + implementation(libs.springboot4.resttestclient) + implementation(libs.springboot4.starter.aspectj) + implementation(libs.springboot4.starter.cache) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.test) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.webflux) + runtimeOnly(libs.hsqldb) + } + } + } +} + +// The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example +// to resolve `#id` in a cache key. +tasks.named("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt new file mode 100644 index 0000000000..70b48bf02e --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot4.integrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/cache/") +class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt new file mode 100644 index 0000000000..9dd98145f2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheIntegrationTest : IntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt new file mode 100644 index 0000000000..5a9b8cc3ae --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot4.integrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 0000000000..3904cd7254 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,148 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.random.Random +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) + val status = + get( + "/tracing/$id", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { isForRequest(it, id) } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt new file mode 100644 index 0000000000..d91a8cae3b --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot4.integrationtest + +import java.util.UUID +import java.util.concurrent.CompletableFuture +import org.dataloader.DataLoader +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.BatchMapping +import org.springframework.graphql.data.method.annotation.MutationMapping +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.data.method.annotation.SchemaMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.stereotype.Controller +import reactor.core.publisher.Mono + +class Project(val slug: String) + +enum class ProjectStatus { + ACTIVE, + COMMUNITY, +} + +class Task(val id: String, val name: String, val assigneeId: String?, val creatorId: String?) + +class Assignee(val id: String, val name: String) + +class Creator(val id: String, val name: String) + +/** Resolvers that throw when given a "crash" argument, so tests can check error reporting. */ +@Controller +class ProjectController { + @QueryMapping + fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + fun tasks(@Argument projectSlug: String): List = + listOf( + Task("T1", "Create a new API", "A3", "C3"), + Task("T2", "Update dependencies", "A1", "C1"), + Task("T3", "Document API", "A1", "C1"), + Task("T4", "Merge community PRs", "A2", "C2"), + Task("T5", "Plan more work", null, null), + ) +} + +/** Loads a task's assignee with a batch mapping and its creator with a data loader. */ +@Controller +class TaskRelationsController(batchLoaderRegistry: BatchLoaderRegistry) { + init { + batchLoaderRegistry + .forTypePair(String::class.java, Creator::class.java) + .registerMappedBatchLoader { ids, _ -> + Mono.just(ids.associateWith { Creator(it, "Name$it") }) + } + } + + @BatchMapping(typeName = "Task", field = "assignee") + fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 0000000000..c50dca2ec1 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,113 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.event +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import tools.jackson.databind.JsonNode + +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asString()).isEqualTo("Hello world!") + awaitTransactionWithSpan("Query.greeting") + } + + @Test + fun `resolver error is reported with its transaction`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + val transaction = awaitTransactionOfError("causing an error for crash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Query.greeting")).isTrue() + } + + @Test + fun `query with a schema mapping is traced`() { + val response = graphql("""query { project(slug: "proj-slug") { slug status } }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["project"]["slug"].asString()).isEqualTo("proj-slug") + awaitTransactionWithSpan("Query.project") + } + + @Test + fun `mutation is traced`() { + val response = graphql("""mutation { addProject(slug: "proj-slug") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["addProject"].asString()).isNotEmpty() + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `mutation error is reported with its transaction`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asString(null)).isNull() + val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue() + } + + @Test + fun `batch loaded fields are traced`() { + val response = + graphql( + """query { tasks(projectSlug: "project-slug") { id assigneeId assignee { id } creatorId creator { id } } }""" + ) + + assertThat(response.has("errors")).isFalse() + val tasks = response["data"]["tasks"] + assertThat(tasks.size()).isEqualTo(5) + assertThat(tasks[0]["id"].asString()).isEqualTo("T1") + assertThat(tasks[0]["assigneeId"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creatorId"].asString()).isEqualTo("C3") + assertThat(tasks[0]["assignee"]["id"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asString()).isEqualTo("C3") + + val transaction = awaitTransactionWithSpan("Query.tasks") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.assignee")).isTrue() + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.creator")).isTrue() + } + + private fun graphql(query: String): JsonNode { + val response = + restTemplate.postForEntity("/graphql", mapOf("query" to query), JsonNode::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + return response.body!! + } + + private fun awaitTransactionWithSpan(description: String): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && + t.spans.any { span -> span.op == "graphql" && span.description == description } + } == true + } + .transaction()!! + + /** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */ + private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction { + val error = + server + .awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + .event()!! + val traceId = error.contexts.trace!!.traceId + return server + .awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId } + .transaction()!! + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt new file mode 100644 index 0000000000..9f8d8d60a8 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -0,0 +1,61 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.boot.resttestclient.TestRestTemplate +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.clients.ok-http-enabled=true", + "sentry.enable-cache-tracing=true", + "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profile-lifecycle=TRACE", + "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", + "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + ], +) +abstract class IntegrationTest { + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so + // it would add trace headers of its own to the requests tests send. Tests use a plain one. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 0000000000..b14c6d03ea --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,94 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.Sentry +import io.sentry.spring7.tracing.SentrySpan +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@SpringBootApplication +@EnableCaching +class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Traced through sentry.clients.ok-http-enabled. + @Bean fun okHttpClient(): OkHttpClient = OkHttpClient() +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() + +class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +class PersonService(private val jdbcTemplate: JdbcTemplate) { + fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt new file mode 100644 index 0000000000..77713284c9 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +class MetricController { + @GetMapping("count") + fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 0000000000..784cddfa2f --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 0000000000..735ce890e3 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt new file mode 100644 index 0000000000..e554718ff2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt @@ -0,0 +1,63 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.reactor.SentryReactorUtils +import okhttp3.OkHttpClient +import okhttp3.Request +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import tools.jackson.databind.ObjectMapper + +class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val okHttpClient: OkHttpClient, + private val objectMapper: ObjectMapper, +) { + @GetMapping("/todo/{id}") + fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + fun todoRestClient(@PathVariable("id") id: Long): Todo? = + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + + @GetMapping("/todo-okhttp/{id}") + fun todoOkHttp(@PathVariable("id") id: Long): Todo = + okHttpClient.newCall(Request.Builder().url(todoUrl(id)).build()).execute().use { response -> + objectMapper.readValue(response.body!!.byteStream(), Todo::class.java) + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +class TodoStubController { + @GetMapping("/stub/todos/{id}") + fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt new file mode 100644 index 0000000000..5e0bc31755 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.boot.test.web.server.LocalServerPort + +class TodoIntegrationTest : IntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-restclient/1", "GET /todo-restclient/{id}") + } + + @Test + fun `OkHttp request is traced`() { + assertOutgoingRequestIsTraced("/todo-okhttp/1", "GET /todo-okhttp/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls b/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..fc2a8fd4c2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls @@ -0,0 +1,38 @@ +type Query { + greeting(name: String!): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Project { + slug: ID! + status: ProjectStatus! +} + +enum ProjectStatus { + ACTIVE + COMMUNITY +} + +type Task { + id: String! + name: String! + assigneeId: String + assignee: Assignee + creatorId: String + creator: Creator +} + +type Assignee { + id: String! + name: String! +} + +type Creator { + id: String! + name: String! +} diff --git a/sentry-spring-boot-4/src/integrationTest/resources/schema.sql b/sentry-spring-boot-4/src/integrationTest/resources/schema.sql new file mode 100644 index 0000000000..7ca8a5cbf4 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); From 06931151ac90e44a07405c119d9bfd66145b38cf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 13:37:31 +0200 Subject: [PATCH 32/48] test(spring-boot): Move the Boot 4 Kafka test into the module (#6298) Ports the Boot 4 sample's KafkaQueueSystemTest to the integrationTest suite with @EmbeddedKafka, as for Boot 3. Spring Boot 4 needs its Kafka starter for the auto-configuration, and spring-kafka-test comes from the Boot BOM like spring-kafka. The Boot 4 sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 4 matrix. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 9 -- .github/workflows/system-tests-backend.yml | 3 - gradle/libs.versions.toml | 1 + .../build.gradle.kts | 32 ----- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 ------------------ .../src/test/resources/logback.xml | 17 --- sentry-spring-boot-4/build.gradle.kts | 3 + .../boot4/integrationtest/IntegrationTest.kt | 12 +- .../boot4/integrationtest/KafkaController.kt | 28 +++++ .../integrationtest/KafkaIntegrationTest.kt | 49 ++++++++ test/system-test-runner.py | 3 - 12 files changed, 90 insertions(+), 196 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index be80d48b1f..b6674080ba 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -71,8 +71,6 @@ jobs: - name: Build sample artifacts run: | ./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 \ @@ -86,13 +84,6 @@ jobs: - name: Test sentry-spring-boot-4 integration suite run: ./gradlew :sentry-spring-boot-4:integrationTest - - 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" - - name: Run sentry-samples-spring-boot-4-webflux run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index cc0b7dddc8..e8caec9626 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -67,9 +67,6 @@ jobs: - sample: "sentry-samples-jul" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-log4j2" agent: "false" agent-auto-init: "true" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7c6b5e06e..b437b33799 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -223,6 +223,7 @@ spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = " spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka3" } spring-kafka3-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka3" } spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } +spring-kafka4-test = { module = "org.springframework.kafka:spring-kafka-test" } 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" } 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 00b85d45bd..7d83c62b2b 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -6,7 +6,6 @@ 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" @@ -61,35 +60,4 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot4.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") -} - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } } diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e45..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} 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 deleted file mode 100644 index 43781cf2c5..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -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-4/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f7..0000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index d6abe86520..663633b199 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -169,6 +169,7 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryGraphql22) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) @@ -184,11 +185,13 @@ testing { implementation(libs.springboot4.starter.cache) implementation(libs.springboot4.starter.graphql) implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.kafka) implementation(libs.springboot4.starter.restclient) implementation(libs.springboot4.starter.test) implementation(libs.springboot4.starter.web) implementation(libs.springboot4.starter.webclient) implementation(libs.springboot4.starter.webflux) + implementation(libs.spring.kafka4.test) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt index 9f8d8d60a8..f61ad6c382 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -7,6 +7,7 @@ import org.springframework.boot.restclient.RestTemplateBuilder import org.springframework.boot.resttestclient.TestRestTemplate import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.kafka.test.context.EmbeddedKafka import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -14,11 +15,14 @@ import org.springframework.test.context.junit4.SpringRunner /** * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. * - * Subclasses share one application context and one server, so the app starts once per suite. - * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they - * produce. + * Subclasses share one application context, one server and one Kafka broker, so each starts once + * per suite. Envelopes that arrive late can outlive [server]'s reset, so tests match on values only + * they produce. */ @RunWith(SpringRunner::class) +// An in-JVM KRaft broker, so no Docker is needed. As part of the context, it starts before the +// app's Kafka listener connects and stops after it. +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") @SpringBootTest( classes = [IntegrationTestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -27,12 +31,14 @@ import org.springframework.test.context.junit4.SpringRunner "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", + "sentry.enable-queue-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + "spring.kafka.consumer.auto-offset-reset=earliest", ], ) abstract class IntegrationTest { diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt new file mode 100644 index 0000000000..4f628d2069 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot4.integrationtest + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +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 + +const val KAFKA_TOPIC = "sentry-topic" + +@RestController +@RequestMapping("/kafka") +class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + fun listen(message: String) {} +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt new file mode 100644 index 0000000000..c92e79d876 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt @@ -0,0 +1,49 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test + +/** A message sent through Kafka is traced on both sides: where it's published and processed. */ +class KafkaIntegrationTest : IntegrationTest() { + + @Test + fun `publishing a message traces a queue publish span`() { + val producer = produce("publish-test") + + val span = producer.spans.single { it.op == "queue.publish" } + assertThat(span.data).containsEntry("messaging.system", "kafka") + assertThat(span.data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + @Test + fun `processing a message continues the producer's trace`() { + val traceId = produce("process-test").contexts.trace!!.traceId + + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + val data = consumer.contexts.trace!!.data + assertThat(data).containsEntry("messaging.system", "kafka") + assertThat(data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + /** Sends [message] through the app and returns the transaction of the request that sent it. */ + private fun produce(message: String): SentryTransaction { + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + return server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + } +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index f875fbb295..44b5ed4d49 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -76,7 +76,6 @@ "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", @@ -88,7 +87,6 @@ "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", @@ -862,7 +860,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-webflux", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-4", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), From 6d9e4717bb49dae7e512a774ae7b4f457a662a7d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 14:34:20 +0200 Subject: [PATCH 33/48] test(spring-boot): [Sample Tests 13] Move the Boot 2 sample's tests into the module (#6299) * test(spring-boot): Move the Boot 2 sample's tests into the module Adds an integrationTest suite to sentry-spring-boot that mirrors the jakarta one and ports the Boot 2 sample's Person, DistributedTracing, Todo, Cache and Metrics tests to it. Spring Boot 2 has no RestClient, so the tracing forwards through RestTemplate and Todo covers three clients, and WebClient is called on the request thread because Reactor 3.4 lacks automatic context propagation. The Person test is the jakarta one, which also checks the logged error and a span's feature flag that the Boot 2 sample never asserted. The Spring Boot 2 matrix runs the suite from 2.4 to 2.7. Below 2.7 the sample's remaining GraphQL and Kafka tests are excluded, so its systemTest no longer fails on finding none. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): Port the tracing and test client fixes to Boot 2 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-2-matrix.yml | 3 + .../build.gradle.kts | 2 + .../io/sentry/systemtest/CacheSystemTest.kt | 51 ----- .../DistributedTracingSystemTest.kt | 197 ------------------ .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 99 --------- .../io/sentry/systemtest/TodoSystemTest.kt | 62 ------ sentry-spring-boot/build.gradle.kts | 49 +++++ .../boot/integrationtest/CacheController.kt | 43 ++++ .../integrationtest/CacheIntegrationTest.kt | 35 ++++ .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 148 +++++++++++++ .../boot/integrationtest/IntegrationTest.kt | 61 ++++++ .../IntegrationTestApplication.kt | 94 +++++++++ .../boot/integrationtest/MetricController.kt | 33 +++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++ .../integrationtest/PersonIntegrationTest.kt | 100 +++++++++ .../boot/integrationtest/TodoController.kt | 44 ++++ .../integrationtest/TodoIntegrationTest.kt | 39 ++++ .../src/integrationTest/resources/schema.sql | 5 + 20 files changed, 728 insertions(+), 460 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheController.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricController.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoController.kt create mode 100644 sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTest/resources/schema.sql diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 8a723dd994..7216b13917 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -87,6 +87,9 @@ jobs: :sentry-samples:sentry-samples-spring:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Test sentry-spring-boot integration suite + run: ./gradlew :sentry-spring-boot:integrationTest + - name: Test sentry-samples-spring-boot run: | python3 test/system-test-runner.py test \ diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 316e98ebdb..02ce31a41a 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -153,6 +153,8 @@ tasks.register("systemTest").configure { if (!includeKafka) { excludeTestsMatching("io.sentry.systemtest.Kafka*") } + // The other tests moved into sentry-spring-boot, so without GraphQL and Kafka none are left. + isFailOnNoMatchingTests = includeGraphql || includeKafka } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c1085..0000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd1600302..0000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c..0000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index a8ff439acf..0000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 220c175f0d..0000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo okhttp works`() { - val restClient = testHelper.restClient - restClient.getTodoOkHttp(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /todo-okhttp/{id}" && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index c5537accef..ab4bc83cde 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -18,6 +18,21 @@ tasks.named("compileKotlin") { compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } +// Spring scans the integration test apps' own bytecode with its repackaged ASM at startup, and +// the ASM in Spring before 5.3.26 fails on class files as new as the toolchain's: "Unsupported +// class file major version 65". In the Spring Boot 2.x matrix, 2.4.13 (Spring 5.3.13) reads up +// to Java 18 and 2.7.0 (Spring 5.3.20) up to Java 19, so the suites target 17. Remove this once +// the matrix drops 2.4.13 and moves 2.7.0 to 2.7.10 or later. +// The suites are Kotlin only. Their Java tasks keep the toolchain's level, because it decides +// which Java version their classpaths ask for, and sentry-test-support needs the toolchain's. +tasks + .withType() + .matching { it.name !in setOf("compileKotlin", "compileTestKotlin") } + .configureEach { + compilerOptions.jvmTarget = JvmTarget.JVM_17 + jvmTargetValidationMode = org.jetbrains.kotlin.gradle.dsl.jvm.JvmTargetValidationMode.IGNORE + } + dependencies { constraints { testImplementation(libs.okhttp) { version { strictly(libs.versions.okhttp.get()) } } @@ -120,3 +135,37 @@ tasks.jar { ) } } + +testing { + suites { + // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own + // because the unit tests' classpath carries the agent, which switches Sentry into agent mode. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(libs.springboot2.bom)) + implementation(projects.sentrySpringBootStarter) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryOkhttp) + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.caffeine) + implementation(libs.okhttp) + implementation(libs.springboot.starter.aop) + implementation(libs.springboot.starter.cache) + implementation(libs.springboot.starter.jdbc) + implementation(libs.springboot.starter.test) + implementation(libs.springboot.starter.web) + implementation(libs.springboot.starter.webflux) + runtimeOnly(libs.hsqldb) + } + } + } +} + +// The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example +// to resolve `#id` in a cache key. +tasks.named("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheController.kt new file mode 100644 index 0000000000..b94f84b958 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot.integrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/cache/") +open class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +open class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + open fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + open fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheIntegrationTest.kt new file mode 100644 index 0000000000..dffa30364d --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheIntegrationTest : IntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingController.kt new file mode 100644 index 0000000000..7782cfd64c --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot.integrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestTemplate + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +open class DistributedTracingController(private val restTemplate: RestTemplate) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person? = + restTemplate.getForObject(selfUrl("/person/$id"), Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person): Person? = + restTemplate.postForObject(selfUrl("/person/"), person, Person::class.java) +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 0000000000..ac2cec2b17 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,148 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.random.Random +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) + val status = + get( + "/tracing/$id", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { isForRequest(it, id) } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt new file mode 100644 index 0000000000..c31e9ceadf --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt @@ -0,0 +1,61 @@ +package io.sentry.spring.boot.integrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.clients.ok-http-enabled=true", + "sentry.enable-cache-tracing=true", + "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profile-lifecycle=TRACE", + "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", + "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + ], +) +abstract class IntegrationTest { + @Value("\${local.server.port}") private var port = 0 + + // The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so + // it would add trace headers of its own to the requests tests send. Tests use a plain one. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 0000000000..4c7a1774dd --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,94 @@ +package io.sentry.spring.boot.integrationtest + +import io.sentry.Sentry +import io.sentry.spring.tracing.SentrySpan +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +@SpringBootApplication +@EnableCaching +open class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Traced through sentry.clients.ok-http-enabled. + @Bean open fun okHttpClient(): OkHttpClient = OkHttpClient() +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + open fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +open class PersonService(private val jdbcTemplate: JdbcTemplate) { + open fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricController.kt new file mode 100644 index 0000000000..cc57e344db --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 0000000000..edfc5459e7 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 0000000000..3e8b05b25b --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoController.kt new file mode 100644 index 0000000000..7bf9296600 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoController.kt @@ -0,0 +1,44 @@ +package io.sentry.spring.boot.integrationtest + +import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.OkHttpClient +import okhttp3.Request +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient + +open class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +open class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val okHttpClient: OkHttpClient, + private val objectMapper: ObjectMapper, +) { + @GetMapping("/todo/{id}") + open fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + @GetMapping("/todo-webclient/{id}") + open fun todoWebClient(@PathVariable("id") id: Long): Todo? = + webClient.get().uri(todoUrl(id)).retrieve().bodyToMono(Todo::class.java).block() + + @GetMapping("/todo-okhttp/{id}") + open fun todoOkHttp(@PathVariable("id") id: Long): Todo = + okHttpClient.newCall(Request.Builder().url(todoUrl(id)).build()).execute().use { response -> + objectMapper.readValue(response.body!!.byteStream(), Todo::class.java) + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +open class TodoStubController { + @GetMapping("/stub/todos/{id}") + open fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoIntegrationTest.kt new file mode 100644 index 0000000000..efe6a0562b --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/TodoIntegrationTest.kt @@ -0,0 +1,39 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test + +class TodoIntegrationTest : IntegrationTest() { + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `OkHttp request is traced`() { + assertOutgoingRequestIsTraced("/todo-okhttp/1", "GET /todo-okhttp/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET ${restTemplate.rootUri}/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot/src/integrationTest/resources/schema.sql b/sentry-spring-boot/src/integrationTest/resources/schema.sql new file mode 100644 index 0000000000..7ca8a5cbf4 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); From e2a270b7ca6180cfc6260eecd8c122ab4b4a8c12 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 23 Oct 2026 14:51:05 +0200 Subject: [PATCH 34/48] feat(android): Nav3 support for multi-Activity and multi-Fragment applications (JAVA-785) (#6301) Introduces Nav3 support for app architectures involving multiple NavDisplay <> SentryNavEffect pairs, subject to certain limitations (see the "Still not supported" list below). Prior to this commit, we assumed only one NavDisplay <> SentryNavEffect pair would be active at a time. Supported ----------- - A single-Activity app with one NavDisplay <> SentryNavEffect pair. - A multi-Activity app where each Activity has its own NavDisplay <> SentryNavEffect pair. - A multi-Fragment app where each Fragment has its own NavDisplay <> SentryNavEffect pair. - A ViewPager2 whose pages are Fragments, each with its own NavDisplay <> SentryNavEffect pair. - An Activity or Fragment with its own NavDisplay that stays visible beneath a DialogFragment or translucent Activity with its own NavDisplay. Before this PR stack, we only supported (1) and its functional equivalents. Still not supported ------------------ - Nested NavDisplay <> SentryNavEffect pairs in the same Activity or Fragment. - Side-by-side NavDisplay <> SentryNavEffect pairs in the same Activity or Fragment. - SentryNavEffect instances in multiple Activities or Fragments that users can interact with at the same time, such as two Activities from the same app in multi-resume mode. - SentryNavEffect instances in pages or tabs that share their host's lifecycle, such as Compose HorizontalPager pages, ViewPager2 pages that aren't Fragments, or tabs kept in the composition at once. (Those pages or tabs share one LifecycleOwner, so at most one can call SentryNavEffect.) The basic approach ------------------- Supporting the new setups listed above requires coordinating multiple, simultaneously active SentryNavEffects and their updates to the current (mutable + shared) IScope vended by IScopes. Like all forms of state management around Android lifecycles + shared mutable state, that code isn't exactly simple. To keep things manageable (and easy to update), we were careful to sequester all coordination logic to a new .navigation3.lifecycle package. The basic idea was to replace the IScopes instance our BackStackObserver already talked to with a smarter version (NavScopes) that could toggle between the shared underlying scope and a per-instance staged version, depending on whether the observer was associated with the incoming or outgoing SentryNavEffect. (Under the hood, NavScopes relies on lease-based ownership to decide who can update the scope, which is overseen by the new NavLeaseCoordinator.) That^^ let us keep updates to SentryNavEffect, BackStackObserver, etc. to a minimum, and it makes it easy to swap out our implementation in the future, should we ever hide our IScopes API behind a LocalSentrySpan-esque system (or any of a number of other possibilities). --- CHANGELOG.md | 5 + gradle/libs.versions.toml | 2 + sentry-android-navigation3/README.md | 2 +- sentry-android-navigation3/build.gradle.kts | 3 + .../compose/navigation3/BackStackConverter.kt | 4 +- .../compose/navigation3/BackStackObserver.kt | 118 +-- .../CoordinatedBackStackObserver.kt | 55 ++ .../compose/navigation3/SentryNavEffect.kt | 40 +- .../compose/navigation3/SentryNavOptions.kt | 2 +- .../lifecycle/NavLeaseCoordinator.kt | 461 +++++++++ .../compose/navigation3/lifecycle/NavScope.kt | 156 ++++ .../navigation3/lifecycle/NavScopes.kt | 375 ++++++++ .../compose/navigation3/lifecycle/README.md | 12 + .../navigation3/lifecycle/StagedNavData.kt | 106 +++ .../navigation3/BackStackObserverTest.kt | 69 +- .../CoordinatedBackStackObserverTest.kt | 174 ++++ ...SentryNavEffectLifecycleIntegrationTest.kt | 817 ++++++++++++++++ .../navigation3/SentryNavEffectTest.kt | 303 +++++- .../lifecycle/NavLeaseCoordinatorTest.kt | 873 ++++++++++++++++++ .../navigation3/lifecycle/NavScopeTest.kt | 321 +++++++ .../navigation3/lifecycle/NavScopesTest.kt | 208 +++++ .../lifecycle/StagedNavDataTest.kt | 221 +++++ .../sentry-samples-android/build.gradle.kts | 4 + .../src/main/AndroidManifest.xml | 24 + .../android/navigation/nav3/Nav3Activity.kt | 42 +- .../navigation/nav3/Nav3MultiActivity.kt | 493 ++++++++++ .../navigation/nav3/Nav3MultiFragment.kt | 420 +++++++++ .../navigation/nav3/Nav3RouteScreens.kt | 96 +- .../android/navigation/nav3/Nav3Routes.kt | 34 + .../navigation/nav3/Nav3SampleTracing.kt | 8 +- .../navigation/nav3/Nav3SetupActivity.kt | 1 + .../navigation/nav3/Nav3TransactionHistory.kt | 66 +- .../nav3/Nav3TransactionHistorySheet.kt | 29 +- .../src/main/res/anim/nav3_slow_fade_in.xml | 7 + .../src/main/res/anim/nav3_slow_hold.xml | 6 + .../src/main/res/values/ids.xml | 3 + .../src/main/res/values/styles.xml | 6 + 37 files changed, 5405 insertions(+), 161 deletions(-) create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 465da8d957..ce5e378b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Improvements + +- Support Navigation 3 integration for multi-Activity and multi-Fragment applications, where each Activity or Fragment has its own `SentryNavEffect` ([#6305](https://github.com/getsentry/sentry-java/pull/6305)) + - See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) for a detailed list of supported vs unsupported app setups. + ### Fixes - Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b437b33799..533bd1546d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -111,11 +111,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } +androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" } 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" } diff --git a/sentry-android-navigation3/README.md b/sentry-android-navigation3/README.md index bdfec781fc..95bbbeb006 100644 --- a/sentry-android-navigation3/README.md +++ b/sentry-android-navigation3/README.md @@ -9,4 +9,4 @@ Because Nav3 back stacks can have arbitrary key types, host apps need to provide keys and the data Sentry should display for each (see `BackStackEntryMapper`). See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including -installation, configuration, and limitations. +configuration, supported setups, and limitations. diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index b9d02bb49d..9612fded9d 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -60,12 +60,15 @@ kotlin { explicitApi() } dependencies { implementation(projects.sentry) + implementation(libs.androidx.lifecycle.runtime.compose) compileOnly(libs.androidx.compose.runtime) testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.fragment.ktx) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.viewpager2) testImplementation(libs.google.truth) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt index f567f7c8c5..6100c70620 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt @@ -395,7 +395,9 @@ internal data class NormalizedSentryBackStackEntry( val name: String, /** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */ val arguments: Map = emptyMap(), - /** The reason why the SDK dropped host-provided arguments. */ + /** + * The reason why the SDK dropped host-provided arguments, or null if no arguments were dropped. + */ val argumentDropReason: ArgumentDropReason? = null, ) { diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt index 37f694350f..8e2806686e 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt @@ -2,13 +2,11 @@ package io.sentry.compose.navigation3 import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IScope -import io.sentry.IScopes import io.sentry.ITransaction -import io.sentry.PropagationContext import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions @@ -16,6 +14,9 @@ import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty +import io.sentry.compose.navigation3.lifecycle.INavScope +import io.sentry.compose.navigation3.lifecycle.INavScopes +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -47,10 +48,11 @@ private const val NAVIGATION_OP: String = "navigation" * **Thread safety** * * This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged] and - * [cleanup] (e.g., via invocation from an `*Effect` or another form of thread confinement). + * [dispose] (e.g., via invocation from an `*Effect` or another form of thread confinement). */ +@Suppress("TooManyFunctions") internal class BackStackObserver( - private val scopes: IScopes, + private val scopes: INavScopes, private val options: SentryNavOptions, entryMapper: ForwardingBackStackEntryMapper, ) { @@ -110,23 +112,14 @@ internal class BackStackObserver( scopes.configureScope { scope -> applyChange(scope, change) } } - internal fun cleanup() { - previousTop = null - previousTopNormalized = null - - scopes.configureScope { scope -> - navTransaction.stop(scope) - navScreen.clear(scope) - - if (options.captureBackStack) { - // This observer owns the nav context while it's in the composition, and cleanup removes - // it to avoid leaking stale back stack data after observation stops. If the host app - // replaces one observer with another, there may be a brief gap where events lack nav - // context. Apps should keep the observer at the nav root so cleanup only runs when the - // navigation session is ending, not during normal destination changes. - navContext.clear(scope) - } - } + /** + * Clears the nav data this observer recorded. + * + * Call it when observation ends so that no stale screen, nav context, or nav transaction outlives + * it. + */ + internal fun dispose() { + scopes.configureScope { scope -> tearDown(scope) } } private fun prepareChange(backStack: List): PreparedChange { @@ -140,7 +133,7 @@ internal class BackStackObserver( } } - private fun applyChange(scope: IScope, change: PreparedChange) { + private fun applyChange(scope: INavScope, change: PreparedChange) { when (change) { is BackStackIsEmpty -> handleEmptyBackStack(scope) is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack) @@ -185,7 +178,7 @@ internal class BackStackObserver( } private fun handleNewTop( - scope: IScope, + scope: INavScope, previousTop: NormalizedSentryBackStackEntry?, currentBackStack: BackStackData, ) { @@ -212,23 +205,19 @@ internal class BackStackObserver( ) ?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) } } else { - // Rotate the propagation context. - scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) } + scope.rotatePropagationContext() } storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized) } - private fun handleSameTop(scope: IScope, backStack: BackStackData) { + private fun handleSameTop(scope: INavScope, backStack: BackStackData) { navContext.update(scope, backStack.capturedEntriesNormalized) storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized) } - private fun handleEmptyBackStack(scope: IScope) { - navTransaction.stop(scope) - navContext.clear(scope) - navScreen.clear(scope) - clearPreviousTop() + private fun handleEmptyBackStack(scope: INavScope) { + tearDown(scope) } private fun storeAsPreviousTop(topEntry: T, topEntryNormalized: NormalizedSentryBackStackEntry) { @@ -240,12 +229,19 @@ internal class BackStackObserver( previousTop = null previousTopNormalized = null } + + private fun tearDown(scope: INavScope) { + navTransaction.stop(scope) + navContext.clear(scope) + navScreen.clear(scope) + clearPreviousTop() + } } /** * A model for applying one back stack update. * - * Lets us separate change preparation from its application so that the [IScopes.configureScope] + * Lets us separate change preparation from its application so that the [INavScopes.configureScope] * callback in charge of application can use already-computed navigation state. Otherwise, any * exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad * `catch` clause. @@ -281,7 +277,7 @@ private data class BackStackData( ) /** A helper class for managing nav transactions. */ -private class NavTransaction(private val scopes: IScopes) { +private class NavTransaction(private val scopes: INavScopes) { private companion object { private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" @@ -291,11 +287,11 @@ private class NavTransaction(private val scopes: IScopes) { /** Starts an idle navigation transaction, or no-ops if another transaction is already active. */ fun start( - scope: IScope, + scope: INavScope, name: String, arguments: Map, ): ITransaction? { - clearTransactionIfFinished(scope) + scope.clearFinishedTransaction() if (scope.transaction != null) { scopes.options.logger.log( @@ -323,6 +319,7 @@ private class NavTransaction(private val scopes: IScopes) { transactionOptions, ) + // Not an error: INavScopes.startTransaction may decline to start a transaction by design. if (transaction.isNoOp) { return null } @@ -335,61 +332,39 @@ private class NavTransaction(private val scopes: IScopes) { } } - scope.withTransaction { tx -> - if (tx == null) { - scope.transaction = transaction - } - } + scope.bindTransaction(transaction) return transaction } /** Finishes and unsets the active navigation transaction, if one exists. */ - fun stop(scope: IScope) { + fun stop(scope: INavScope) { val transaction = activeNavTransaction ?: return val status = transaction.status ?: SpanStatus.OK transaction.finish(status) - - scope.withTransaction { tx -> - if (tx == transaction) { - scope.clearTransaction() - } - } - + scope.unbindTransaction(transaction) activeNavTransaction = null } - - /** Clears a stale finished transaction that's still bound to the default scope. */ - private fun clearTransactionIfFinished(scope: IScope) { - scope.withTransaction { tx -> - if (tx?.isFinished == true) { - scope.clearTransaction() - } - } - } } /** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */ -private class NavContext(private val scopes: IScopes, private val options: SentryNavOptions) { +private class NavContext(private val scopes: INavScopes, private val options: SentryNavOptions) { private companion object { private const val BACKSTACK_KEY = "Back Stack" - private const val NAVIGATION_CONTEXT_KEY = "Navigation" } - fun update(scope: IScope, backStackEntries: List) { + fun update(scope: INavScope, backStackEntries: List) { if (backStackEntries.isEmpty()) { clear(scope) return } - scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap()) + scope.setNavigationContext(backStackEntries.toBackStackMap()) } - fun clear(scope: IScope) { - // We purposefully don't call IScope.removeContexts(), as it doesn't notify IScopeObserver and - // therefore doesn't write its updates to disk ĀÆ\_ (惄)_/ĀÆ. - scope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + fun clear(scope: INavScope) { + scope.setNavigationContext(null) } /** @@ -403,12 +378,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr */ fun updateTransaction( transaction: ITransaction, - scope: IScope, + scope: INavScope, backStack: BackStackData, ) { if (scopes.options.isEnableScreenTracking) { - val appContext = - transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App() + val appContext = transaction.contexts.app ?: scope.app ?: App() appContext.viewNames = listOf(backStack.topEntryNormalized.name) transaction.contexts.setApp(appContext) @@ -432,12 +406,12 @@ private class NavScreen { private var lastScreenName: String? = null - fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) { + fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) { scope.screen = currentEntry.name lastScreenName = currentEntry.name } - fun clear(scope: IScope) { + fun clear(scope: INavScope) { val screenName = lastScreenName ?: return if (scope.screen == screenName) { scope.screen = null @@ -447,7 +421,7 @@ private class NavScreen { } /** A helper class for generating nav breadcrumbs. */ -private class NavBreadcrumbs(private val scopes: IScopes) { +private class NavBreadcrumbs(private val scopes: INavScopes) { fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) { val breadcrumb = diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt new file mode 100644 index 0000000000..8cf9c6e1be --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt @@ -0,0 +1,55 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes + +/** + * A [BackStackObserver] whose updates to a shared mutable [IScope] are coordinated with other + * `BackStackObserver`s. + * + * Coordination is achieved via [NavScopes]. + * + * **Usage** + * + * Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends. + * + * Instances targeting the same [IScope] must be created with the same [NavLeaseCoordinator]. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Invocations should be confined to the same thread as the + * [LifecycleEventObserver] callbacks implemented by [NavScopes]. + */ +internal class CoordinatedBackStackObserver( + scopes: IScopes, + options: SentryNavOptions, + entryMapper: ForwardingBackStackEntryMapper, + coordinator: NavLeaseCoordinator, +) { + + private val navScopes = NavScopes(scopes, coordinator) + private val observer = BackStackObserver(navScopes, options, entryMapper) + + fun attach(lifecycle: Lifecycle) { + navScopes.attach(lifecycle) + } + + fun onBackStackChanged(backStack: List) { + navScopes.onBackStackUpdate() + observer.onBackStackChanged(backStack) + } + + fun dispose() { + // Call disposal on both the observer and navScopes in case their (currently overlapping) + // implementations drift over time. + observer.dispose() + navScopes.dispose() + } +} + +/** The [NavLeaseCoordinator] shared by default by all [SentryNavEffect]s. */ +internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator() diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt index 8b6269752b..c666122efc 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt @@ -4,9 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.LocalLifecycleOwner import io.sentry.IScopes import io.sentry.ScopesAdapter import io.sentry.SentryOptions +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.jetbrains.annotations.ApiStatus /** @@ -41,8 +43,7 @@ import org.jetbrains.annotations.ApiStatus * * **Data generated** * - * By default, the following data is produced every time the top of the provided [backStack] - * changes: + * By default, the following are produced every time the top of the provided [backStack] changes: * * - a new navigation transaction (unless another transaction is already active on the current * scope) @@ -55,20 +56,23 @@ import org.jetbrains.annotations.ApiStatus * * **Limitations** * - * `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In - * particular, it has no awareness of + * *Tracks the back stack, not scenes*: `SentryNavEffect` generates all Sentry data based solely on + * the top entry of your back stack. In particular, it has no awareness of * [Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes). Transaction routes, * breadcrumbs, and screen names are all derived from the top entry of the back stack and are * updated as it changes. * - * `SentryNavEffect` doesn't make any special accommodations for + * *No accommodation for predictive back*: `SentryNavEffect` doesn't make any special accommodations + * for * [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) * gestures. That means that spans produced by predictively rendered composables can show up under * the current destination's transaction. * - * Multiple simultaneously active `SentryNavEffect` instances writing to the same Sentry scope are - * not supported. Violating this restriction can result in interleaved breadcrumbs, clobbered screen - * names and back stacks, and transactions that interfere with one another. + * *Limited support for simultaneously active `SentryNavEffect`s*: Multiple `SentryNavEffect`s are + * supported only in certain setups: roughly, one effect per Activity or Fragment, placed next to + * (and not inside) its `NavDisplay`. See the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) + * for a detailed list of supported vs unsupported configurations. * * @param backStack The navigation backstack to observe. * @param backStackEntryMapper Maps each entry of the [backStack] to a name and optional arguments @@ -87,6 +91,7 @@ public fun SentryNavEffect( backStackEntryMapper = backStackEntryMapper, options = options, scopes = ScopesAdapter.getInstance(), + coordinator = defaultNavLeaseCoordinator, ) } @@ -94,20 +99,29 @@ public fun SentryNavEffect( internal fun SentryNavEffect( backStack: List, backStackEntryMapper: BackStackEntryMapper, - options: SentryNavOptions = SentryNavOptions(), + options: SentryNavOptions, scopes: IScopes, + coordinator: NavLeaseCoordinator, ) { val currentBackStackEntryMapper = rememberUpdatedState(backStackEntryMapper) + val lifecycle = LocalLifecycleOwner.current.lifecycle val observer = - remember(scopes, options) { - BackStackObserver( + remember(scopes, options, lifecycle, coordinator) { + CoordinatedBackStackObserver( scopes = scopes, options = options, entryMapper = ForwardingBackStackEntryMapper { currentBackStackEntryMapper.value }, + coordinator = coordinator, ) } + // Register first so the initial prepared back stack update can acquire navigation ownership. + DisposableEffect(observer) { + observer.attach(lifecycle) + onDispose { observer.dispose() } + } + // Intentionally don't remember this copy. Snapshot-backed lists mutate in place, so // remember(backStack) { backStack.toList() } would cache a stale copy. (The key reference // retained by remember() and the backStack reference passed to this effect would point to the @@ -120,10 +134,6 @@ internal fun SentryNavEffect( observer.onBackStackChanged(backStack = copy) onDispose {} } - - DisposableEffect(observer) { - onDispose { observer.cleanup() } - } } /** diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt index 9a571d47de..4b45d7c5b2 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt @@ -3,7 +3,7 @@ package io.sentry.compose.navigation3 import androidx.compose.runtime.Immutable import org.jetbrains.annotations.ApiStatus -// Keep the default low: every captured entry may require argument extraction and recursive +// Keep the default low, as every captured entry may require argument extraction and recursive // sanitization when navigation changes are observed. private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 5 diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt new file mode 100644 index 0000000000..87b0f8f75b --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt @@ -0,0 +1,461 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.SpanStatus + +/** + * Manages a lease in order to determine which [NavScopes] instance can bind nav transactions and + * publish nav data to a shared [IScope]. + * + * `NavScopes` instances register as [candidates][Candidate] for lease ownership via + * [registerCandidate]. + * + * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle events the candidate's + * `NavScopes` relays. + * + * **Exception semantics** + * + * Methods in this class with an `IScope` parameter can throw arbitrary exceptions†, which propagate + * to the caller. Each method updates its own bookkeeping before calling into the scope, so the + * coordinator remains usable if the exception is caught. + * + * † E.g., `IScope` can throw arbitrary exceptions when scope mutators notify registered observers – + * including the host app – in an unguarded loop, or when `finish()` is called on a retained nav + * transaction, which can trigger the host app's event processors. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. All `NavScopes` instances using this coordinator must + * confine their access to the same thread. + */ +@Suppress("TooManyFunctions") +internal class NavLeaseCoordinator { + + private val candidates = mutableListOf() + + /** Candidate responsible for the most recently published nav data in the shared scope. */ + private var publishedDataCandidate: Candidate? = null + + private var leaseHolder: Candidate? = null + + /** + * Counter that orders candidates by when this coordinator most recently observed them in resumed + * state. + * + * Each resume, including registration of an already-resumed owner, assigns the next value to that + * candidate's [Candidate.resumeOrder]. This lets fallback selection prefer the most recently + * resumed eligible candidate without relying on clock timestamps. Zero is reserved for candidates + * that have not been observed resumed and therefore cannot be chosen as fallbacks. + */ + private var resumeOrder: Long = 0 + + /** + * Registers [owner] as a candidate for the nav lease. No-ops if `owner` instance is already + * registered or [lifecycleState] is [destroyed][Lifecycle.State.DESTROYED]. + * + * [onPublishData] publishes `owner`'s staged nav data to the given scope when `owner` acquires + * the lease. [onClearPublishedData] clears `owner`'s published nav data from the given scope when + * its data is the most recently published and either another owner acquires the lease or `owner` + * is unregistered. + */ + fun registerCandidate( + owner: Any, + lifecycleState: Lifecycle.State, + onPublishData: (IScope) -> Unit, + onClearPublishedData: (IScope) -> Unit, + ) { + if (findCandidate(owner) != null) { + return + } + + if (lifecycleState == Lifecycle.State.DESTROYED) { + return + } + + val initialResumeOrder = if (lifecycleState == Lifecycle.State.RESUMED) ++resumeOrder else 0 + candidates += + Candidate(owner, onPublishData, onClearPublishedData, lifecycleState, initialResumeOrder) + } + + /** Records that [owner] started. */ + fun startCandidate(owner: Any) { + findCandidate(owner)?.start() + } + + /** + * Records that [owner] resumed. + * + * Restores the lease to `owner` if it already applied a back stack update and [scope] is + * non-null. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun resumeCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.resume(++resumeOrder) + + if (scope != null && it.hasAppliedInitialBackStackUpdate) { + transferLeaseTo(it, scope) + } + } + } + + /** + * Records that [owner] paused. + * + * Hands the lease to another owner only if that owner is resumed (e.g., a Fragment whose dialog + * is being dismissed); otherwise keeps it. A paused leaseholder may still be the topmost visible + * owner (e.g., beneath a translucent window or in picture-in-picture mode), and any other paused + * owner is beneath it. A displaced owner gets the lease back when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun pauseCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.pause() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(excluding = it, resumedOnly = true)?.let { next -> + handOffLease(next, scope) + } + } + } + } + + /** + * Records that [owner] stopped. + * + * As with pausing, hands the lease to another owner only if that owner is resumed; otherwise the + * stopped owner keeps it. Keeping it lets a lone owner go on recording navigation that happens + * while it isn't resumed (e.g., while the app is in the background, or after it restarts but + * before it resumes), including starting nav transactions. + * + * A paused owner doesn't get the lease: when the leaseholder stops before it, the app is + * typically going to the background (e.g., a translucent leaseholder stops before the owner + * beneath it), and that owner isn't what the user last saw. A paused owner that comes back to the + * foreground gets the lease when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun stopCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.stop() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(resumedOnly = true)?.let { next -> handOffLease(next, scope) } + } + } + } + + /** + * Stops tracking [owner] (e.g., because it was destroyed or its effect left the composition). + * No-ops if `owner` instance isn't registered. + * + * Finishes and unbinds `owner`'s nav transaction, and clears its published nav data if that data + * is the most recently published. If `owner` holds the lease, hands it to the eligible candidate + * most recently observed resumed, even if that candidate is now only started or paused, or + * releases it if there's none. + * + * A `null` scope (e.g., the SDK is disabled) still removes the candidate, but its transaction is + * left to time out, its published data is left in place, and the lease is released rather than + * handed off. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. Coordinator + * references to `owner` are released before any scope call, so they're released even if scope + * cleanup or lease handoff throws. The remaining scope cleanup is then skipped. + */ + fun unregisterCandidate(owner: Any, scope: IScope?) { + val candidate = findCandidate(owner) ?: return + val transaction = candidate.transaction + val wasPublisher = publishedDataCandidate === candidate + val wasLeaseHolder = leaseHolder === candidate + + candidates.remove(candidate) + candidate.transaction = null + if (wasPublisher) { + publishedDataCandidate = null + } + if (wasLeaseHolder) { + leaseHolder = null + } + + // No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as + // the transaction can't be sent and it will time out anyway. Not finishing it has the added + // advantage of keeping SDK work inside `configureScope`'s error handling. + if (scope == null) { + return + } + + finishTransaction(transaction, scope) + if (wasPublisher) { + candidate.onClearPublishedData(scope) + } + if (wasLeaseHolder) { + transferLeaseTo(mostRecentlyResumedCandidate(), scope) + } + } + + fun hasLease(owner: Any): Boolean = leaseHolder?.owner === owner + + /** + * Records that the registered [owner] is about to apply a back-stack update and acquires the nav + * lease if the owner's lifecycle permits it. + * + * An owner that isn't resumed doesn't take the lease from a resumed leaseholder, as it isn't the + * one the user is interacting with (e.g., a preloaded ViewPager2 page next to the current one). + * Instead, it acquires the lease when it resumes. + * + * If the lease transfers, ownership and transaction binding are updated before the candidate's + * staged nav data is published. Publication completes before this method returns, so the caller + * can then apply the back-stack update. Later calls for the same [owner] do nothing. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun onBackStackUpdate(owner: Any, scope: IScope) { + val candidate = findCandidate(owner) ?: return + if (candidate.hasAppliedInitialBackStackUpdate) { + return + } + + candidate.hasAppliedInitialBackStackUpdate = true + + val holder = leaseHolder + val wouldDisplaceResumedHolder = + holder != null && + holder.state == Lifecycle.State.RESUMED && + candidate.state != Lifecycle.State.RESUMED + + if (candidate.canAcquireLeaseOnFirstUpdate && !wouldDisplaceResumedHolder) { + transferLeaseTo(candidate, scope) + } + } + + /** Whether [transaction] is a nav transaction retained for any registered owner. */ + fun isNavTransaction(transaction: ITransaction): Boolean = candidates.any { + it.transaction === transaction + } + + /** + * Records [transaction] as the nav transaction of [owner], which must hold the lease. Ignored + * otherwise, as only the leaseholder starts nav transactions. + * + * If [owner] loses the lease, the transaction is unbound but retained: it's rebound if [owner] + * regains the lease while it's still unfinished and the scope is free, and finished when [owner] + * is [unregistered][unregisterCandidate]. It's forgotten once [owner] retires it; see + * [releaseTransaction]. + */ + fun retainTransaction(owner: Any, transaction: ITransaction) { + val candidate = leaseHolder ?: return + if (candidate.owner !== owner) { + return + } + candidate.transaction = transaction + } + + /** + * Forgets [transaction] as the nav transaction of [owner], if it's the one retained. Called when + * [owner] retires the transaction (e.g., it navigated away), so it's never rebound later. + * + * Retiring is distinct from losing the lease: a transaction that's still waiting for child spans + * isn't [finished][ITransaction.isFinished] yet, so without this, regaining the lease would + * rebind a transaction whose destination [owner] has already left. + */ + fun releaseTransaction(owner: Any, transaction: ITransaction) { + val candidate = findCandidate(owner) ?: return + if (candidate.transaction === transaction) { + candidate.transaction = null + } + } + + private fun findCandidate(owner: Any): Candidate? = candidates.firstOrNull { it.owner === owner } + + /** + * The eligible candidate most recently observed resumed, other than [excluding]. With + * [resumedOnly], only candidates that are resumed right now are considered; otherwise, started + * and paused ones are too. + */ + private fun mostRecentlyResumedCandidate( + excluding: Candidate? = null, + resumedOnly: Boolean = false, + ): Candidate? = + candidates + .filter { + it !== excluding && + it.hasAppliedInitialBackStackUpdate && + it.state.isAtLeast(Lifecycle.State.STARTED) && + it.resumeOrder > 0 && + (!resumedOnly || it.state == Lifecycle.State.RESUMED) + } + .maxByOrNull { it.resumeOrder } + + /** + * Hands the current lease to [next]. Without a [scope], the lease is released instead, as [next] + * could not publish its staged data. + */ + private fun handOffLease(next: Candidate?, scope: IScope?) { + if (scope != null) { + transferLeaseTo(next, scope) + } else { + leaseHolder = null + } + } + + /** + * Hands the lease to [candidate], or releases it given `null`. + * + * Maintains the invariant that, while a lease is held, the leaseholder is also the candidate + * recorded as having published the shared scope's nav data. The invariant holds even when a + * publish or clear callback throws part way through the handoff. See "Exception semantics" in the + * class KDoc. + */ + private fun transferLeaseTo(candidate: Candidate?, scope: IScope) { + if (leaseHolder === candidate) { + return + } + + if (candidate != null) { + // Unbind another owner's transaction without finishing it, so it can be restored if that + // owner returns. If this owner's retained transaction has since finished, discard it + // instead of binding a finished transaction to the shared scope. + scope.withTransaction { current -> + if (current != null && candidates.any { it !== candidate && it.transaction === current }) { + scope.clearTransaction() + } + } + + val transaction = candidate.transaction + if (transaction?.isFinished == true) { + candidate.transaction = null + clearTransactionFromScope(transaction, scope) + } else if (transaction != null) { + // The navigation lease doesn't give this owner control over an unrelated transaction + // already bound to the shared scope. Restore the owner's retained nav transaction only if + // the scope is free or already holds that same transaction. + scope.withTransaction { current -> + if (current == null || current === transaction) { + scope.transaction = transaction + } + } + } + } + + val previousPublisher = publishedDataCandidate + leaseHolder = candidate + if (candidate != null) { + // Record the candidate as the publisher before calling into the scope: it owns the scope's + // nav data from the moment it takes the lease. If clearing the previous owner's data then + // throws, the next handoff still clears the candidate's data rather than never clearing it. + // + // Clear the previous publisher's nav data before publishing the candidate's, so the + // previous owner's values don't linger where the candidate has nothing staged to replace + // them. + publishedDataCandidate = candidate + if (previousPublisher != null && previousPublisher !== candidate) { + previousPublisher.onClearPublishedData(scope) + } + candidate.onPublishData(scope) + } + } + + /** + * Finishes [transaction] if it's unfinished, and unbinds it from [scope]. + * + * Callers must forget the transaction first. + */ + private fun finishTransaction(transaction: ITransaction?, scope: IScope) { + if (transaction == null) { + return + } + if (!transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + clearTransactionFromScope(transaction, scope) + } + + private fun clearTransactionFromScope(transaction: ITransaction, scope: IScope) { + scope.withTransaction { current -> + if (current === transaction) { + scope.clearTransaction() + } + } + } + + /** A [LifecycleOwner] that has registered itself as a candidate for lease ownership. */ + private class Candidate( + val owner: Any, + val onPublishData: (IScope) -> Unit, + val onClearPublishedData: (IScope) -> Unit, + initialState: Lifecycle.State, + initialResumeOrder: Long, + ) { + + /** The candidate's lifecycle state, as last observed. */ + var state: Lifecycle.State = initialState + private set + + /** + * Whether the candidate has been observed STOPPED since + * [registration][NavLeaseCoordinator.registerCandidate]. + */ + private var wasStopped: Boolean = false + + var transaction: ITransaction? = null + + /** + * See [NavLeaseCoordinator.resumeOrder]. Non-zero once the candidate has been observed RESUMED + * since [registration][NavLeaseCoordinator.registerCandidate]. + */ + var resumeOrder: Long = initialResumeOrder + private set + + var hasAppliedInitialBackStackUpdate: Boolean = false + + /** + * Whether the candidate's lifecycle permits its first back stack update since + * [registration][NavLeaseCoordinator.registerCandidate] to acquire the nav lease, including + * taking it from another owner that isn't resumed: it's resumed, or it has neither resumed nor + * stopped since registration (i.e., it's still coming up for the first time). A paused or + * restarted candidate is returning to the screen rather than arriving on it. + * + * This checks the candidate's own lifecycle only; it does not indicate whether the candidate + * has already applied its first update, nor account for a resumed leaseholder (see + * [NavLeaseCoordinator.onBackStackUpdate]). + */ + val canAcquireLeaseOnFirstUpdate: Boolean + get() = state == Lifecycle.State.RESUMED || (resumeOrder == 0L && !wasStopped) + + fun start() { + // Attaching to an already-resumed lifecycle replays ON_START before ON_RESUME, so only move + // up from CREATED. + if (!state.isAtLeast(Lifecycle.State.STARTED)) { + state = Lifecycle.State.STARTED + } + } + + fun resume(order: Long) { + resumeOrder = order + state = Lifecycle.State.RESUMED + } + + fun pause() { + state = Lifecycle.State.STARTED + } + + fun stop() { + state = Lifecycle.State.CREATED + wasStopped = true + } + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt new file mode 100644 index 0000000000..f67c0b9b32 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt @@ -0,0 +1,156 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.protocol.App +import io.sentry.protocol.Contexts + +/** + * An [INavScope] implementation that writes to and reads from [delegate] only when [owner] holds + * the nav lease; otherwise it falls back to [stagedNavData]. + */ +internal class NavScope( + private val delegate: IScope, + private val stagedNavData: StagedNavData, + private val coordinator: NavLeaseCoordinator, + private val owner: Any, +) : INavScope { + + private val hasLease: Boolean + get() = coordinator.hasLease(owner) + + override var screen: String? + get() = if (hasLease) delegate.screen else stagedNavData.screen + set(value) { + stagedNavData.stageScreen(value) + if (hasLease) { + stagedNavData.publishScreen(delegate) + } + } + + override fun setNavigationContext(value: Any?) { + stagedNavData.stageNavigationContext(value) + if (hasLease) { + stagedNavData.publishNavigationContext(delegate) + } + } + + /** + * Copies the whole of [IScope.getContexts] in order to return a copy of its [App]. + * + * [App]'s copy constructor isn't public, so an `App` can't be copied on its own. Going through + * [Contexts] deep-copies every context on the scope and discards all of it but the `App`. + * + * Called once per navigation that changes the top of the back stack, so the cost is bounded. + * Don't add call sites without reading [INavScope.app] first. + */ + override val app: App? + get() = Contexts(delegate.contexts).app + + /** + * The transaction bound to the scope. Without the lease, another owner's nav transaction isn't + * reported, so [owner]'s attempt to start a nav transaction reaches [NavScopes.startTransaction], + * which declines it and defers the propagation context rotation it implies. Non-nav transactions + * (e.g., the host's) are always reported, so they still block nav transactions as usual. + */ + override val transaction: ITransaction? + get() = + delegate.transaction?.takeUnless { current -> + !hasLease && coordinator.isNavTransaction(current) + } + + override fun bindTransaction(transaction: ITransaction) { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current == null) { + delegate.transaction = transaction + } + } + } + + override fun unbindTransaction(transaction: ITransaction) { + // Coordinator is called before clearTransaction() because the latter notifies the host app's + // scope observer(s), which could throw. Clearing the transaction first would cause the + // coordinator to retain a stale transaction. + coordinator.releaseTransaction(owner, transaction) + delegate.withTransaction { current -> + if (current === transaction) { + delegate.clearTransaction() + } + } + } + + override fun clearFinishedTransaction() { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current?.isFinished == true) { + delegate.clearTransaction() + } + } + } + + override fun rotatePropagationContext() { + if (hasLease) { + delegate.withPropagationContext { delegate.propagationContext = PropagationContext() } + } else { + stagedNavData.deferPropagationContextRotation() + } + } +} + +/** + * The subset of the [IScope] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScope` + * interface. New operations against the underlying `IScope` instance must be added here. Operations + * not directly delegable to `IScope` should ***not*** be added. + */ +internal interface INavScope { + + var screen: String? + + /** + * A copy of the scope's app context, if any. + * + * A copy rather than the live instance, because callers mutate the result before binding it to a + * transaction, and mutating the scope's own [App] would corrupt shared scope state. + * + * Implementations may have to copy more than the [App] to produce it, so treat this as expensive: + * read it once where it's needed rather than per frame or in a loop. See [NavScope.app]. + */ + val app: App? + + /** + * The transaction bound to the scope, if any, as far as starting a nav transaction is concerned: + * see [NavScope.transaction]. + */ + val transaction: ITransaction? + + /** Binds [transaction] to the scope if no transaction is bound to it. */ + fun bindTransaction(transaction: ITransaction) + + /** + * Unbinds [transaction] from the scope if it's the bound transaction, and ensures it won't be + * bound to the scope again. + */ + fun unbindTransaction(transaction: ITransaction) + + /** Unbinds the scope's transaction if it has finished. */ + fun clearFinishedTransaction() + + /** + * Sets or, given `null`, clears the value stored under [NAVIGATION_CONTEXT_KEY]. + * + * Implementations should clear via [IScope.setContexts] rather than [IScope.removeContexts], as + * the latter doesn't notify scope observers and therefore doesn't persist the removal to disk. + */ + fun setNavigationContext(value: Any?) + + /** Replaces the scope's propagation context with a new one. */ + fun rotatePropagationContext() +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt new file mode 100644 index 0000000000..92fdb80b52 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt @@ -0,0 +1,375 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** The key under which nav context (i.e., the captured back stack) is stored in a scope. */ +internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" + +/** + * An [INavScopes] implementation that lets us coordinate updates to navigation state across + * multiple `SentryNavEffect`s. (Also supports the trivial scenario where only a single + * `SentryNavEffect` exists.) + * + * Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default and will + * clobber or interleave one another's updates unless managed properly: + * ``` + * LifecycleOwner (e.g., Activity or Fragment) + * ↓ owns + * SentryNavEffect + * ↓ + * CoordinatedBackStackObserver (wrapper) + * ā”œā”€ā”€ BackStackObserver — computes nav data and nav transactions + * └── NavScopes — controls if nav data + txns reach the real scope + * ↓ + * NavLeaseCoordinator — arbitrates ownership across all observers + * ↓ + * shared IScope + * ``` + * + * **Limitations** + * + * Multi-effect support is restricted to the scenarios documented in the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups). + * + * This class implements the policy that underlies those scenarios. It assumes that, at any moment, + * the user is interacting with at most one registered [LifecycleOwner] in the host app, and that + * the `LifecycleOwner` being interacted with is the one that was most recently resumed. + * + * Concretely, a host app can safely use more than one `SentryNavEffect` only if: + * + * 1. each `SentryNavEffect` is called in the same composable as the `NavDisplay` it observes; + * 2. no `SentryNavEffect` is called from inside a `NavDisplay`, e.g., from a nav entry, scene, or + * entry decorator†; + * 3. each Activity or Fragment calls at most one `SentryNavEffect` from its own content (sibling + * Fragments may each have their own effect, but not alongside one in the Activity hosting them); + * and + * 4. the user can interact with only one of those Activities or Fragments at a time. + * + * † Meant to ensure each effect's `LocalLifecycleOwner` is an Activity or a Fragment and not a + * `NavDisplay`-provided owner. `NavDisplay` provides its own `LocalLifecycleOwner` to the entries + * and scenes it renders. That owner drops to STARTED state during transitions and while overlays + * are shown. An effect called inside a `NavDisplay` would therefore (wrongly) gain and lose the nav + * lease this class manages as navigation animates, rather than as its Activity or Fragment comes + * and goes. + * + * **A fundamental tradeoff** + * + * During `LifecycleOwner` overlap, the nav transaction and scoped nav data can reflect + * **_either_**: + * + * 1. the outgoing owner that's still visible, or + * 2. the incoming owner that may crash before it becomes visible, + * + * but not both. This class opts for (2) so that the incoming destination populates crash context + * and any work performed is associated with it† – the idea being that start-up logic is more likely + * to cause a crash or produce interesting spans than an outgoing destination's steady state. + * + * † Associating work with the incoming transaction means spans produced by the outgoing owner may + * appear as if they were produced by the incoming owner (or vice versa, were our policy reversed). + * A possible solution would be to implement a parent span delivery system similar to + * `LocalSentrySpan`, but that's a task for another day. + * + * **Only leaseholders publish nav state or start nav transactions** + * + * This class works by only allowing `NavScopes` instances holding a lease to start nav transactions + * and publish their navigation data to the shared `IScope`. + * + * `NavScopes` instances without a lease write to their own copy of [stagedNavData], which lets them + * preserve their latest updates without overwriting another instance's published data. When an + * instance acquires the lease, it publishes its staged data to the shared `IScope`. + * + * **Lease handoff policy** + * + * The lease is acquired preemptively by an incoming owner's `NavScopes` instance when it applies + * its first [back stack update][onBackStackUpdate].† + * + * A displaced owner remains eligible for lease restoration until it [stops][ON_STOP]. It gets the + * lease back when it [resumes][ON_RESUME], or sooner if the new owner leaves or pauses while the + * displaced owner is still visible. If restored, the displaced owner's nav data and unfinished + * transaction are reinstated. + * + * † There are some exceptions, e.g., the leaseholder is resumed and the incoming owner isn't (e.g., + * a preloaded ViewPager2 Fragment page next to the current one). Such an owner acquires the lease + * when it resumes instead. + * + * The following diagram illustrates how handoff works: + * ``` + * Time + * │ + * ā–¼ + * + * SentryNavEffect A Shared state SentryNavEffect B + * ───────────────── ──────────── ───────────────── + * + * attach() Navigation lease: none + * │ + * Initial back stack update + * │ + * ā”œā”€ā”€ acquire lease ────────► Navigation lease: A + * ā”œā”€ā”€ create & bind txA ────► Scope transaction: txA + * ā”œā”€ā”€ publish nav data ─────► Published data: A + * │ + * ON_RESUME A is eligible for restoration + * │ + * ON_PAUSE + * │ + * ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–ŗ Navigation lease: A (no other resumed owner) + * │ A remains eligible for restoration + * │ + * │ attach() + * │ │ + * │ Navigation lease: A ◄────────────────────┤ + * │ Scope transaction: txA │ + * │ Published data: A │ + * │ B registered, awaiting initial back │ + * │ stack update │ + * │ │ + * │ Initial back stack update + * │ │ + * │ Suspend & unbind txA ◄───────────────────┤ + * │ Navigation lease: B │ + * │ Published data: B │ + * │ Scope transaction: txB │ + * │ (rollback point) │ + * │ │ + * │ ON_RESUME + * │ │ + * │ B eligible for restoration ◄─────────────┤ + * │ │ + * ON_STOP │ + * │ │ + * ā”œā”€ā”€ retain txA; timers continue │ + * ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–ŗ A no longer eligible for restoration │ + * │ │ + * ON_DESTROY │ + * │ │ + * ā”œā”€ā”€ finish retained txA if unfinished │ + * └────────────────────────► A no longer tracked │ + * ON_PAUSE + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner) │ + * │ + * ON_STOP + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner); B keeps recording navigation │ + * B no longer eligible for restoration │ + * │ + * ON_DESTROY + * │ + * Finish txB if unfinished; ◄──────────────┤ + * unbind if still bound │ + * Clear B's remaining screen/back stack │ + * Retain recorded breadcrumbs │ + * Navigation lease: none │ + * ā–¼ + * + * Alternative at rollback point: B disposes before A stops + * Finish & unbind txB ◄────── dispose B ───┤ + * Navigation lease: A │ + * Restore txA if unfinished and scope free │ + * Republish A's staged data │ + * ``` + * + * **Usage** + * + * 1. Create a `NavScopes` instance per `SentryNavEffect`. + * + * 2. Pass the same [NavLeaseCoordinator] instance to all `NavScopes` whose effects must be + * coordinated (i.e., that target the same `IScope`). + * + * 3. Call [attach] with the lifecycle that manages the effect before the first back stack update. + * + * 4. Call [onBackStackUpdate] before applying each back stack update. + * + * 5. Call [dispose] when the effect leaves the composition. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Lifecycle callbacks and method invocations should be + * confined to the same thread. + */ +internal class NavScopes( + private val delegate: IScopes, + private val coordinator: NavLeaseCoordinator, +) : INavScopes, LifecycleEventObserver { + + override val options: SentryOptions + get() = delegate.options + + private val owner: Any = Any() + private var attachedLifecycle: Lifecycle? = null + private val stagedNavData = StagedNavData() + + /** + * Registers this [NavScopes] instance with [lifecycle]. No-ops if `lifecycle` has already been + * attached or is [destroyed][Lifecycle.Event.ON_DESTROY]. + * + * Attaching to a second, different lifecycle is unsupported; it's logged and ignored. + */ + fun attach(lifecycle: Lifecycle) { + val currentLifecycle = attachedLifecycle + if (currentLifecycle != null && currentLifecycle !== lifecycle) { + delegate.options.logger.log( + SentryLevel.WARNING, + "NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.", + ) + return + } + + if (currentLifecycle === lifecycle) { + return + } + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + return + } + + attachedLifecycle = lifecycle + + coordinator.registerCandidate( + owner = owner, + lifecycleState = lifecycle.currentState, + onPublishData = stagedNavData::publishTo, + onClearPublishedData = stagedNavData::clearPublishedStateFrom, + ) + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> coordinator.startCandidate(owner) + ON_RESUME -> withScopeOrNull { coordinator.resumeCandidate(owner, it) } + ON_PAUSE -> withScopeOrNull { coordinator.pauseCandidate(owner, it) } + ON_STOP -> withScopeOrNull { coordinator.stopCandidate(owner, it) } + ON_DESTROY -> dispose() + else -> Unit + } + } + + /** + * Announces that a back stack update is about to be applied, so this instance can acquire the nav + * lease if it's eligible to. + * + * Must be called before each update. If [configureScope][IScopes.configureScope] skips its + * callback (e.g., the SDK is disabled), the update itself can't be applied either, so nothing is + * recorded and the next update applied is treated as this instance's first. + */ + fun onBackStackUpdate() { + delegate.configureScope { scope -> coordinator.onBackStackUpdate(owner, scope) } + } + + override fun configureScope(callback: (INavScope) -> Unit) { + delegate.configureScope { scope -> + callback(NavScope(scope, stagedNavData, coordinator, owner)) + } + } + + /** + * Starts a nav transaction, provided this instance holds the nav lease. + * + * Without the lease, returns a no-op transaction and defers the propagation context rotation a + * new transaction would have implied until the lease is acquired. A transaction started without + * the lease couldn't be bound to the scope, so its destination's spans would attach to the + * leaseholder's bound transaction instead, and the empty transaction would be dropped. + * + * The [coordinator] retains the leaseholder's transaction, so it can be restored if this instance + * loses the lease and regains it while the transaction is still unfinished. + */ + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.hasLease(owner)) { + stagedNavData.deferPropagationContextRotation() + return NoOpTransaction.getInstance() + } + + return delegate.startTransaction(transactionContext, transactionOptions).also { transaction -> + if (!transaction.isNoOp) { + coordinator.retainTransaction(owner, transaction) + } + } + } + + /** Records [breadcrumb] immediately; breadcrumbs only append, so they aren't lease-protected. */ + override fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) { + delegate.addBreadcrumb(breadcrumb, hint) + } + + /** + * Releases the lease, clears remaining published state, and finishes this instance's transaction. + * + * Also stops observing the attached [LifecycleOwner] and cleans up memory references. + */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + // Clear nav state, unbind the nav transaction, and avoid leaking the LifecycleOwner. + withScopeOrNull { coordinator.unregisterCandidate(owner, it) } + } + + /** + * Runs [block] with the real scope, or with `null` if [configureScope][IScopes.configureScope] + * skips its callback (e.g., the SDK is disabled), so coordinator bookkeeping still happens. + * + * A callback that ran but threw is not retried, as retrying could let its exception escape into + * the host app. + */ + private fun withScopeOrNull(block: (IScope?) -> Unit) { + var callbackInvoked = false + delegate.configureScope { scope -> + callbackInvoked = true + block(scope) + } + + if (!callbackInvoked) { + block(null) + } + } +} + +/** + * The subset of the [IScopes] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScopes` + * interface. New operations against the underlying `IScopes` instance must be added here. + * Operations not directly delegable to `IScopes` should ***not*** be added. + */ +internal interface INavScopes { + + val options: SentryOptions + + /** Runs [callback] with the current [INavScope], if the SDK provides a scope. */ + fun configureScope(callback: (INavScope) -> Unit) + + /** + * Starts a transaction, or returns a no-op transaction if none can be started (e.g., tracing is + * disabled, or the implementation declines to start one; see [NavScopes.startTransaction]). + */ + fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction + + fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md new file mode 100644 index 0000000000..7d697812ec --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md @@ -0,0 +1,12 @@ +# `io.sentry.compose.navigation3.lifecycle` + +Package contains code for coordinating multiple `SentryNavEffect`s and their access to a shared `IScope`, so that overlapping effects (e.g., +during Activity or Fragment transitions) don't clobber one another's screen names, nav context, or nav transactions. + +See the `NavScopes` KDoc for the lease policy that decides which effect may write to the scope. + +## Encapsulation policy + +This package is meant to encapsulate knowledge about the possibility of multiple `SentryNavEffect`s and the shared mutable state issues it +raises. Addressing those concerns is complicated. Confining them here makes them easier to reason about and easier to swap for alternative +logic should we someday need to. diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt new file mode 100644 index 0000000000..a32cc74d50 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt @@ -0,0 +1,106 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.PropagationContext + +/** + * Holds navigation state staged for publication. + * + * Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e., + * breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are + * recorded immediately, irrespective of lease ownership. + * + * Propagation context isn't staged either, since an earlier propagation context mustn't overwrite + * the scope's current one. Instead, a rotation requested without the lease, or implied by a nav + * transaction that couldn't be started without it, is [deferred][deferPropagationContextRotation] + * and performed once when the lease is acquired. As a result, any destination reached in the + * meantime still gets a fresh trace. + */ +internal class StagedNavData { + + /** The staged screen name, published or not. */ + var screen: String? = null + private set + + private var navigationContext: Any? = null + + private var hasNavigationContextUpdate = false + private var hasScreenUpdate = false + + /** Whether a propagation context rotation was requested while this instance lacked the lease. */ + private var hasDeferredPropagationContextRotation = false + + // These may differ from the staged values after background updates. We keep the exact published + // references so cleanup preserves host replacements, including structurally equal replacements. + private var publishedNavigationContext: Any? = null + private var publishedScreen: String? = null + + /** Stages [screen] for publication, replacing any previously staged screen. */ + fun stageScreen(screen: String?) { + this.screen = screen + hasScreenUpdate = true + } + + /** Stages [value] as the nav context for publication, replacing any previously staged value. */ + fun stageNavigationContext(value: Any?) { + navigationContext = value + hasNavigationContextUpdate = true + } + + /** Requests a propagation context rotation for when this instance next [publishes][publishTo]. */ + fun deferPropagationContextRotation() { + hasDeferredPropagationContextRotation = true + } + + /** + * Publishes the staged screen and nav context, and performs any deferred propagation context + * rotation. + * + * Only staged values are published, so values the host app or other integrations set (e.g., a + * screen name while screen tracking is off) survive. Values published by the previous leaseholder + * are cleared separately, via its [clearPublishedStateFrom]. + */ + fun publishTo(realScope: IScope) { + if (hasScreenUpdate) { + publishScreen(realScope) + } + if (hasNavigationContextUpdate) { + publishNavigationContext(realScope) + } + if (hasDeferredPropagationContextRotation) { + realScope.withPropagationContext { realScope.propagationContext = PropagationContext() } + hasDeferredPropagationContextRotation = false + } + } + + /** Publishes the staged screen to [realScope]. */ + fun publishScreen(realScope: IScope) { + publishedScreen = screen + realScope.screen = screen + } + + /** Publishes the staged nav context to [realScope]. */ + fun publishNavigationContext(realScope: IScope) { + publishedNavigationContext = navigationContext + realScope.setContexts(NAVIGATION_CONTEXT_KEY, navigationContext) + } + + /** + * Clears the screen and nav context this instance published, unless something else has replaced + * them in [realScope] since. + */ + fun clearPublishedStateFrom(realScope: IScope) { + if (publishedScreen != null && realScope.screen === publishedScreen) { + realScope.screen = null + } + if ( + publishedNavigationContext != null && + realScope.contexts[NAVIGATION_CONTEXT_KEY] === publishedNavigationContext + ) { + // Use setContexts so scope observers also receive the removal. + realScope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + publishedScreen = null + publishedNavigationContext = null + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt index 0c7cdc9f53..d331afabe6 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt @@ -1,5 +1,8 @@ package io.sentry.compose.navigation3 +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Hint @@ -16,6 +19,9 @@ import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions import io.sentry.compose.navigation3.ArgumentDropReason.Companion.ARGUMENT_DROP_REASON_KEY +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import org.junit.Test @@ -90,7 +96,7 @@ class BackStackObserverTest { scope.options.isEnableScreenTracking = config.enableScreenTracking return BackStackObserver( - scopes = scopes, + scopes = leaseholderNavScopes(), options = SentryNavOptions { enableNavigationBreadcrumbs = config.enableNavigationBreadcrumbs @@ -102,6 +108,23 @@ class BackStackObserverTest { ) } + /** + * Real [NavScopes] that hold the nav lease for the whole test, so [BackStackObserver] writes + * straight through to [scope] without lease coordination getting in the way. + */ + private fun leaseholderNavScopes(): NavScopes { + val owner = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + return NavScopes(scopes, NavLeaseCoordinator()).apply { + attach(owner.lifecycle) + // The sole resumed owner's first update acquires the lease, which it then keeps. + onBackStackUpdate() + } + } + private companion object { fun createOptions(logger: ILogger): SentryOptions = SentryOptions().apply { @@ -741,22 +764,60 @@ class BackStackObserverTest { } @Test - fun `cleanup clears observer owned tracked state`() { + fun `dispose finishes the active nav transaction and unbinds it from the scope`() { val fixture = Fixture() val sut = fixture.getSut() - sut.onBackStackChanged(listOf(HomeScreen())) val transaction = fixture.startedTransactions.single() - sut.cleanup() + sut.dispose() assertThat(transaction.isFinished).isTrue() assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose clears the screen name and back stack context it recorded`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen(), ProfileScreen("123"))) + + sut.dispose() + assertThat(fixture.scope.screen).isNull() assertThat(fixture.scope.contexts.app?.viewNames).isNull() assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } + @Test + fun `dispose keeps a screen name that was set after the observer recorded its own`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen())) + fixture.scope.screen = "/HostScreen" + + sut.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/HostScreen") + } + + @Test + fun `dispose makes the next back stack update a first navigation with no from entry`() { + val fixture = Fixture() + val sut = fixture.getSut() + val home = HomeScreen() + sut.onBackStackChanged(listOf(home)) + + sut.dispose() + sut.onBackStackChanged(listOf(home)) + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data).containsExactly("to", "/HomeScreen") + assertThat(fixture.startedTransactions).hasSize(2) + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.last()) + assertThat(fixture.scope.screen).isEqualTo("/HomeScreen") + } + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt new file mode 100644 index 0000000000..08c2003fbb --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt @@ -0,0 +1,174 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CoordinatedBackStackObserverTest { + + @Test + fun `onBackStackChanged() - acquires the nav lease before applying the first update`() { + val fixture = Fixture() + fixture.givenPausedLeaseholder() + val sut = fixture.getSut() + + sut.onBackStackChanged(listOf("Home")) + + // An update applied before the lease is acquired is staged and gets no nav transaction. + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + } + + @Test + fun `a resumed owner doesn't rebind a nav transaction it retired by navigating without the nav lease`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val pickerObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + val homeTransaction = fixture.startedTransaction("/Home") + // A pending child span keeps the nav transaction, which waits for its children, unfinished. + homeTransaction.startChild("http.client") + homeLifecycle.currentState = Lifecycle.State.STARTED + pickerObserver.onBackStackChanged(listOf("Picker")) + homeObserver.onBackStackChanged(listOf("Home", "Result")) + check(!homeTransaction.isFinished) + + homeLifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(fixture.scope.screen).isEqualTo("/Result") + // "/Result" was reached without the nav lease, so it has no nav transaction to bind. + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose() - clears the nav data it published`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf("Home")) + + sut.dispose() + + assertThat(fixture.scope.screen).isNull() + } + + @Test + fun `dispose() - without the nav lease, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeObserver = fixture.getSut() + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + // The settings observer's owner is also resumed, so it takes the nav lease from the home one. + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + + homeObserver.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + @Test + fun `dispose() - after its lifecycle is destroyed, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + homeLifecycle.currentState = Lifecycle.State.STARTED + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + // Destroying the lifecycle before the composition is disposed unregisters the home observer's + // owner from the coordinator ahead of its dispose() call. + homeLifecycle.currentState = Lifecycle.State.DESTROYED + + homeObserver.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + private class Fixture { + val scope = + Scope( + SentryOptions.empty().apply { + dsn = "http://key@localhost/proj" + tracesSampleRate = 1.0 + idleTimeout = null + deadlineTimeout = 0 + } + ) + val coordinator = NavLeaseCoordinator() + val startedTransactions = mutableListOf() + private val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(scope.options) + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + doAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, scopes, transactionOptions) + .also(startedTransactions::add) + } + .whenever(scopes) + .startTransaction(any(), any()) + } + + fun startedTransaction(name: String): SentryTracer = startedTransactions.single { + it.name == name + } + + /** Registers another owner that takes the nav lease and then pauses, keeping the lease. */ + fun givenPausedLeaseholder() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, scope) + coordinator.pauseCandidate(other, scope) + check(coordinator.hasLease(other)) + } + + fun getSut(lifecycle: Lifecycle = resumedLifecycle()): CoordinatedBackStackObserver = + CoordinatedBackStackObserver( + scopes = scopes, + options = SentryNavOptions(), + entryMapper = + ForwardingBackStackEntryMapper { + BackStackEntryMapper { SentryBackStackEntry(it) } + }, + coordinator = coordinator, + ) + .apply { attach(lifecycle) } + + fun resumedLifecycle(): LifecycleRegistry = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + .lifecycle + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt new file mode 100644 index 0000000000..7034ba29f6 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt @@ -0,0 +1,817 @@ +package io.sentry.compose.navigation3 + +import android.app.Activity +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.common.truth.Truth.assertThat +import io.sentry.IScope +import io.sentry.ISentryClient +import io.sentry.Scope +import io.sentry.ScopeType +import io.sentry.Scopes +import io.sentry.SentryOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +/** + * Drives real Activities and Fragments through Robolectric so that each `SentryNavEffect` observes + * the lifecycle events, and the composition timing, that Android produces when screens hand off to + * one another. Lets us test the multi-[SentryNavEffect] policy implemented by our + * `io.sentry.compose.navigation3.lifecycle` package. + * + * Activity transitions are driven through [ActivityController]s in the order the framework uses + * (e.g., the outgoing Activity pauses before the incoming one is created, and stops after the + * incoming one resumes and becomes visible). Fragment transitions are left entirely to the real + * FragmentManager (and, for ViewPager2 pages, to FragmentStateAdapter). Compositions run when a + * ComposeView attaches to its window, as on a device. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryNavEffectLifecycleIntegrationTest { + + @After + fun tearDown() { + Fixture.current = null + } + + // Activity handoffs + @Test + fun `an incoming activity publishes its own nav data and transaction over the outgoing activity's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Checkout:ON_RESUME", "Checkout:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Checkout") + .inOrder() + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity stopping`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity being destroyed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + val propagationContext = fixture.scope.propagationContext + + home.stop() + home.destroy() + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.propagationContext).isSameInstanceAs(propagationContext) + } + + @Test + fun `going back to a stopped activity restores its nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + home.stop() + fixture.idle() + + checkout.pause() + home.restart() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `closing an activity that only paused the one beneath it restores the one beneath's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + checkout.pause() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.events).doesNotContain("Home:ON_STOP") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + // Activity recreation + @Test + fun `a recreated activity publishes its own nav data and transaction over the destroyed instance's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + home.recreate() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_DESTROY", "Home:ON_CREATE", "Home:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `recreating an activity finishes the destroyed instance's transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + + home.recreate() + fixture.idle() + + assertThat(destroyedTransaction.isFinished).isTrue() + } + + // A single Activity going to the background and back + @Test + fun `a backgrounded activity's nav data and transaction stay on the scope while the activity is stopped`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + @Test + fun `navigating after an activity returns to the foreground publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + home.resume() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `navigating before a restarted activity resumes publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Home:ON_STOP", "Home:ON_START") + .inOrder() + assertThat(fixture.events.last()).isEqualTo("Home:ON_START") + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `destroying an activity that navigated before resuming finishes its transaction and clears its nav data`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + fixture.backStack("Home") += "Profile" + fixture.idle() + val profileTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + home.destroy() + fixture.idle() + + assertThat(fixture.events.takeLast(2)) + .containsExactly("Home:ON_STOP", "Home:ON_DESTROY") + .inOrder() + assertThat(profileTransaction.name).isEqualTo("/Profile") + assertThat(profileTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + // Fragment replacement + @Test + fun `replacing a fragment publishes the incoming fragment's nav data after the outgoing view is destroyed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Checkout:composed(CREATED)", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `popping a replaced fragment restores the first fragment's nav data`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Checkout:view:ON_STOP", + "Home:composed(CREATED)", + "Checkout:view:ON_DESTROY", + ) + .inOrder() + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + // ViewPager2 pages + @Test + fun `a preloaded view pager page does not displace the current page's nav data and transaction`() { + val fixture = Fixture.create() + + Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Home:view:ON_RESUME", + "Checkout:composed(CREATED)", + "Checkout:view:ON_START", + ) + .inOrder() + assertThat(fixture.events).doesNotContain("Checkout:view:ON_RESUME") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `switching to a preloaded view pager page publishes the incoming page's nav data`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + fixture.events.clear() + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.events) + .containsExactly("Home:view:ON_PAUSE", "Checkout:view:ON_RESUME") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `switching to a preloaded view pager page publishes a new propagation context instead of a nav transaction`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + val propagationContext = fixture.scope.propagationContext + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(propagationContext) + } + + // Dialogs + @Test + fun `a dialog fragment shown over a fragment publishes its own nav data while the fragment stays resumed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(host.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:view:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(host.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:view:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `a dialog fragment shown over an activity publishes its own nav data while the activity stays resumed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(home.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed activity's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(home.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + // Screens without an effect + + @Test + fun `an activity without an effect keeps the outgoing activity's nav data and transaction on the scope`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Plain:ON_RESUME", "Home:ON_STOP") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `navigating after closing an activity without an effect publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val plain = Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + plain.pause() + home.restart() + home.resume() + fixture.idle() + plain.stop() + plain.destroy() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `replacing a fragment with one without an effect clears the outgoing fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Plain:view:ON_CREATE", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + @Test + fun `popping a fragment without an effect republishes the replaced fragment's nav data with a new transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Plain:view:ON_STOP", "Home:composed(CREATED)", "Plain:view:ON_DESTROY") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.transaction?.isFinished).isFalse() + } + + private fun IScope.navigationEntries(): List? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + val backStack = navigationContext["Back Stack"] as? List<*> ?: return null + return backStack.map { (it as? Map<*, *>)?.get("entry") } + } + + internal open class NavEffectActivity(private val route: String) : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val fixture = Fixture.require() + lifecycle.addObserver(fixture.eventRecorder(route)) + setContentView(navEffectView(this, route)) + } + } + + internal class HomeActivity : NavEffectActivity("Home") + + internal class CheckoutActivity : NavEffectActivity("Checkout") + + internal class PlainActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("Plain")) + setContentView(FrameLayout(this)) + } + } + + internal class FragmentHostActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this).apply { id = CONTAINER_ID }) + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().add(CONTAINER_ID, HomeFragment()).commit() + } + } + + companion object { + val CONTAINER_ID: Int = View.generateViewId() + } + } + + internal class ViewPagerActivity : FragmentActivity() { + + lateinit var viewPager: ViewPager2 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("ViewPager")) + viewPager = + ViewPager2(this).apply { + // Preload the page next to the current one. FragmentStateAdapter caps a preloaded page + // at STARTED and resumes only the current page. + offscreenPageLimit = 1 + adapter = PagesAdapter(this@ViewPagerActivity) + } + setContentView(viewPager) + } + + private class PagesAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment = + if (position == 0) HomeFragment() else CheckoutFragment() + } + } + + internal open class NavEffectFragment(private val route: String) : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), route) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("$route:view")) + } + } + + internal class HomeFragment : NavEffectFragment("Home") + + internal class CheckoutFragment : NavEffectFragment("Checkout") + + internal class PlainFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = FrameLayout(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Plain:view")) + } + } + + internal class PickerDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), "Picker") + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Picker:view")) + } + } + + private class Fixture private constructor() { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + isEnableScreenTracking = true + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation lifecycle test") + val coordinator = NavLeaseCoordinator() + val navOptions = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + + /** Lifecycle events and first compositions, in the order they were observed. */ + val events = mutableListOf() + + private val backStacks = mutableMapOf>() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } + + fun idle() { + shadowOf(Looper.getMainLooper()).idle() + } + + /** + * The back stack observed by the effect of each Activity or Fragment showing [route], which + * starts out holding just `route`. + */ + fun backStack(route: String): SnapshotStateList = + backStacks.getOrPut(route) { mutableStateListOf(route) } + + fun eventRecorder(name: String) = LifecycleEventObserver { _, event -> + events += "$name:$event" + } + + /** + * The host-app setup under test: one `SentryNavEffect` in an Activity's or Fragment's content. + */ + @Composable + fun NavEffect(route: String) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(Unit) { + events += "$route:composed(${lifecycle.currentState})" + onDispose {} + } + SentryNavEffect(backStack(route), mapper, navOptions, scopes, coordinator) + } + + companion object { + var current: Fixture? = null + + fun require(): Fixture = checkNotNull(current) { "Fixture.create() was not called" } + + fun create(): Fixture { + val application: Application = ApplicationProvider.getApplicationContext() + val packageManager = shadowOf(application.packageManager) + listOf( + HomeActivity::class.java, + CheckoutActivity::class.java, + PlainActivity::class.java, + FragmentHostActivity::class.java, + ViewPagerActivity::class.java, + ) + .forEach { activityClass: Class -> + packageManager.addActivityIfNotPresent( + ComponentName(application.packageName, activityClass.name) + ) + } + return Fixture().also { current = it } + } + } + } + + private companion object { + + fun navEffectView(context: Context, route: String): View { + val fixture = Fixture.require() + return ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { fixture.NavEffect(route) } + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt index 7b8a4b9946..32536d28cc 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt @@ -3,11 +3,16 @@ package io.sentry.compose.navigation3 import android.app.Application import android.content.ComponentName import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat @@ -15,13 +20,18 @@ import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.IScope import io.sentry.IScopes +import io.sentry.ISentryClient import io.sentry.ITransaction +import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType +import io.sentry.Scopes import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher @@ -36,6 +46,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) +@Suppress("LargeClass") class SentryNavEffectTest { private val defaultEntryMapper = @@ -73,6 +84,7 @@ class SentryNavEffectTest { } val scope = Scope(options) val scopes = mock() + val coordinator = NavLeaseCoordinator() val breadcrumbs = mutableListOf() val transactions = mutableListOf() @@ -112,6 +124,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } @@ -135,6 +148,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -162,6 +176,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -189,6 +204,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -222,6 +238,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -255,6 +272,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -278,6 +296,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -314,6 +333,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) val currentTop = backStack.last() @@ -348,6 +368,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -385,6 +406,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -417,6 +439,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -459,6 +482,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -496,6 +520,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -525,7 +550,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -543,14 +570,231 @@ class SentryNavEffectTest { assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } - private fun IScope.navigationBackStack(): List>? { - val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + @Test + fun `effect publishes initial navigation before its lifecycle owner resumes`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle() + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + owner.resume() + } + composeRule.runOnIdle { + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + } - @Suppress("UNCHECKED_CAST") - return navigationContext[BACKSTACK_KEY] as? List> + @Test + fun `effect records navigation while its lifecycle owner is paused`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + val backStack = mutableStateListOf("Home") + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + owner.pause() + backStack += "Profile" + } + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Profile") + .inOrder() + } } - private fun ITransaction.navigationBackStack(): List>? { + @Test + fun `navigating without the lease records breadcrumbs, then publishes with a fresh trace on resume`() { + val fixture = NavTestFixture() + val ownerA = NavTestLifecycle(Lifecycle.State.RESUMED) + val ownerB = NavTestLifecycle() + val backStackA = mutableStateListOf("Home") + val showB = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides ownerA) { + SentryNavEffect(backStackA, mapper, options, fixture.scopes, fixture.coordinator) + } + if (showB.value) { + CompositionLocalProvider(LocalLifecycleOwner provides ownerB) { + SentryNavEffect(listOf("Picker"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A starts B: A pauses, B is created and takes over, B resumes, then A stops. + composeRule.runOnIdle { + ownerA.pause() + showB.value = true + } + composeRule.runOnIdle { + ownerB.resume() + ownerA.stop() + } + // B finishes with a result: B pauses, then A restarts and navigates before it resumes. + composeRule.runOnIdle { + ownerB.pause() + ownerA.start() + backStackA += "Result" + } + lateinit var contextBeforeResume: PropagationContext + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Picker", "/Result") + .inOrder() + contextBeforeResume = fixture.scope.propagationContext + ownerA.resume() + } + + // A gets no nav transaction for /Result: one started without the lease would have collected no + // spans of its own. It publishes its screen and gets the fresh trace a transaction would have. + composeRule.runOnIdle { + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Result") + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(contextBeforeResume) + } + } + + @Test + fun `backgrounding with a translucent owner on top keeps the top owner's data published`() { + val fixture = NavTestFixture() + val beneath = NavTestLifecycle(Lifecycle.State.RESUMED) + val translucent = NavTestLifecycle() + val showTranslucent = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides beneath) { + SentryNavEffect(listOf("Products"), mapper, options, fixture.scopes, fixture.coordinator) + } + if (showTranslucent.value) { + CompositionLocalProvider(LocalLifecycleOwner provides translucent) { + SentryNavEffect(listOf("Checkout"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A translucent owner opens on top: the owner beneath pauses but stays visible. + composeRule.runOnIdle { + beneath.pause() + showTranslucent.value = true + } + lateinit var checkoutTransaction: ITransaction + composeRule.runOnIdle { + translucent.resume() + checkoutTransaction = requireNotNull(fixture.scope.transaction) + assertThat(checkoutTransaction.name).isEqualTo("/Checkout") + } + + // The app goes to the background: the top owner stops before the one beneath it. + composeRule.runOnIdle { + translucent.pause() + translucent.stop() + beneath.stop() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }) + .containsExactly("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + } + } + + @Test + fun `regaining the lease keeps a host screen name when screen tracking is off`() { + val fixture = NavTestFixture() + fixture.options.isEnableScreenTracking = false + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + fixture.scope.screen = "HostScreen" + + // E.g., the app goes to the background and comes back. + owner.pause() + owner.stop() + owner.start() + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("HostScreen") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }).containsExactly("/Home") + } + } + + @Test + fun `replacing lifecycle owner disposes the old session and observes the new owner`() { + val fixture = NavTestFixture() + val originalOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val replacementOwner = NavTestLifecycle() + val owner = mutableStateOf(originalOwner) + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner.value) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + lateinit var originalTransaction: ITransaction + composeRule.runOnIdle { + originalTransaction = requireNotNull(fixture.scope.transaction) + owner.value = replacementOwner + } + composeRule.runOnIdle { + assertThat(originalTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNotSameInstanceAs(originalTransaction) + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isEqualTo("/Home") + originalOwner.pause() + originalOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + replacementOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + } + + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null @Suppress("UNCHECKED_CAST") @@ -562,3 +806,52 @@ class SentryNavEffectTest { const val BACKSTACK_KEY = "Back Stack" } } + +private class NavTestLifecycle(state: Lifecycle.State = Lifecycle.State.CREATED) : LifecycleOwner { + + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { + if (state == Lifecycle.State.DESTROYED) currentState = Lifecycle.State.CREATED + currentState = state + } + + fun resume() { + lifecycle.currentState = Lifecycle.State.RESUMED + } + + fun pause() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun start() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun stop() { + lifecycle.currentState = Lifecycle.State.CREATED + } +} + +/** Real SDK state with only the event delivery boundary stubbed. No timers or global SDK state. */ +private class NavTestFixture { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation test") + + val coordinator = NavLeaseCoordinator() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt new file mode 100644 index 0000000000..44986848b5 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt @@ -0,0 +1,873 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.SentryOptions +import io.sentry.SpanStatus +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@Suppress("LargeClass") +class NavLeaseCoordinatorTest { + + private val owner = Any() + private val incoming = Any() + private val incumbent = Any() + private val previous = Any() + + private val transaction = mock() + private val unrelatedTransaction = mock() + + @Test + fun `a registered created owner acquires the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering the same owner twice preserves its first publication callback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + { fixture.published += "duplicate" }, + { fixture.cleared += "duplicate" }, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering a destroyed owner does not make it eligible for the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + + fixture.register(owner, Lifecycle.State.DESTROYED) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `equal but distinct owners do not share the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val registeredOwner = String(charArrayOf('a')) + val equalOwner = String(charArrayOf('a')) + fixture.acquireLease(registeredOwner) + + val result = navLeaseCoordinator.hasLease(equalOwner) + + assertThat(result).isFalse() + assertThat(navLeaseCoordinator.hasLease(registeredOwner)).isTrue() + } + + @Test + fun `starting a created owner leaves it eligible to acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `restarting a stopped owner does not take the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + fixture.acquireLease(incumbent) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `resuming an owner before its first back stack update leaves the nav lease unassigned`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `resuming a stopped owner after its first back stack update transfers the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a paused owner cannot acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `an owner that isn't resumed doesn't take the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `an owner kept from the nav lease by a resumed leaseholder acquires it when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a paused leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a stopped leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + navLeaseCoordinator.stopCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a resumed owner takes the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `acquiring the nav lease clears the previous owner's published nav data before publishing`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val calls = mutableListOf() + navLeaseCoordinator.registerCandidate( + previous, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish previous" }, + onClearPublishedData = { calls += "clear previous" }, + ) + navLeaseCoordinator.onBackStackUpdate(previous, fixture.scope) + navLeaseCoordinator.registerCandidate( + incoming, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish incoming" }, + onClearPublishedData = { calls += "clear incoming" }, + ) + + navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope) + + assertThat(calls) + .containsExactly("publish previous", "clear previous", "publish incoming") + .inOrder() + } + + @Test + fun `regaining the nav lease doesn't clear the owner's own published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + fixture.cleared.clear() + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `releasing the nav lease leaves the owner's published nav data in place`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.cleared).isEmpty() + } + + /** + * The coordinator records the incoming owner as the publisher before calling into the scope, so a + * clear callback that throws mid-handoff doesn't strand the incoming owner's nav data: the next + * handoff still clears it. + */ + @Test + fun `if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val failure = IllegalStateException("clear failed") + var failClear = true + val firstOwner = previous + val secondOwner = incoming + val thirdOwner = owner + fixture.acquireLease( + firstOwner, + onClearPublishedData = { + if (failClear) { + failClear = false + throw failure + } + fixture.cleared += firstOwner + }, + ) + fixture.register(secondOwner, Lifecycle.State.RESUMED) + + val thrown = + assertThrows(IllegalStateException::class.java) { + navLeaseCoordinator.onBackStackUpdate(secondOwner, fixture.scope) + } + + assertThat(thrown).isSameInstanceAs(failure) + assertThat(navLeaseCoordinator.hasLease(secondOwner)).isTrue() + + fixture.acquireLease(thirdOwner) + + assertThat(fixture.cleared).containsExactly(secondOwner) + } + + @Test + fun `later back stack updates do not republish nav data or reclaim the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val first = Any() + val second = Any() + fixture.acquireLease(first) + fixture.acquireLease(second, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.onBackStackUpdate(first, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(second)).isTrue() + assertThat(fixture.published).containsExactly(first, second).inOrder() + } + + @Test + fun `pausing the leaseholder hands the nav lease to the most recently resumed owner that's still resumed`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + val recent = Any() + fixture.acquireLease(older) + fixture.acquireLease(recent) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(recent)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(recent) + } + + @Test + fun `pausing the leaseholder keeps the nav lease when other eligible owners are only paused`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming).inOrder() + } + + @Test + fun `a displaced paused owner regains the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming, owner).inOrder() + } + + @Test + fun `pausing the leaseholder skips a resumed owner without a back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val eligible = Any() + val uninitialized = Any() + fixture.acquireLease(eligible) + fixture.register(uninitialized, Lifecycle.State.RESUMED) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(eligible)).isTrue() + assertThat(navLeaseCoordinator.hasLease(uninitialized)).isFalse() + } + + @Test + fun `pausing the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `pausing the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming a paused leaseholder does not republish its nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the leaseholder restores a resumed owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + } + + @Test + fun `stopping the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `stopping the leaseholder doesn't hand the nav lease to a paused owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).containsExactly(previous) + } + + @Test + fun `a paused owner passed over when the leaseholder stopped gets the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(previous, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, previous).inOrder() + } + + @Test + fun `restarting a stopped leaseholder keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.startCandidate(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping an owner excludes it from later nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + navLeaseCoordinator.stopCandidate(previous, fixture.scope) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `resuming without a scope keeps the owner eligible for nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.resumeCandidate(owner, null) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + fixture.acquireLease(incoming, state = Lifecycle.State.CREATED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming without a scope does not transfer the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `pausing the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `stopping the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `an unregistered owner's nav transaction is not retained`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val other = Any() + fixture.acquireLease(owner) + + navLeaseCoordinator.retainTransaction(other, transaction) + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `a nav transaction recorded without the lease is ignored`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.retainTransaction(owner, transaction) + navLeaseCoordinator.unregisterCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(fixture.scope.transaction).isNull() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `an unfinished nav transaction is restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(fixture.scope.transaction).isNull() + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + verify(transaction, never()).finish(any()) + } + + @Test + fun `restoring a nav transaction preserves an unrelated scope transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + } + + @Test + fun `a finished nav transaction is discarded when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + whenever(transaction.isFinished).thenReturn(true) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `a released unfinished nav transaction is not restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, transaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `releasing a nav transaction other than the retained one keeps the retained one restorable`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, unrelatedTransaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `a displaced owner's retained nav transaction remains identifiable until unregistering`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(unrelatedTransaction)).isFalse() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `acquiring the nav lease binds its retained transaction before publishing nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val transactionAtPublication = mutableListOf() + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + onPublishData = { transactionAtPublication += it.transaction }, + onClearPublishedData = {}, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + navLeaseCoordinator.retainTransaction(owner, transaction) + fixture.scope.transaction = transaction + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(transactionAtPublication).containsExactly(null, transaction).inOrder() + } + + @Test + fun `unregistering the leaseholder finishes its transaction and clears its published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.cleared).containsExactly(owner) + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + } + + @Test + fun `unregistering a former leaseholder preserves the current owner's published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder hands the nav lease to the most recently resumed eligible owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + fixture.acquireLease(older) + navLeaseCoordinator.pauseCandidate(older, fixture.scope) + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + assertThat(fixture.cleared).containsExactly(older, previous, owner).inOrder() + } + + @Test + fun `unregistering the leaseholder without a scope releases the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder without a scope doesn't finish its nav transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, null) + + verify(transaction, never()).finish(any()) + } + + @Test + fun `unregistering a former leaseholder finishes its retained transaction without clearing an unrelated one`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + whenever(transaction.status).thenReturn(SpanStatus.CANCELLED) + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.CANCELLED) + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder forgets the owner when finishing its transaction throws`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val failure = IllegalStateException("finish failed") + fixture.acquireLease(owner, transaction = transaction) + doThrow(failure).whenever(transaction).finish(SpanStatus.OK) + + val thrown = + assertThrows(IllegalStateException::class.java) { + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + } + + assertThat(thrown).isSameInstanceAs(failure) + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + + fixture.published.clear() + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + + assertThat(fixture.published).containsExactly(incoming) + assertThat(fixture.cleared).isEmpty() + } + + private class Fixture { + val navLeaseCoordinator = NavLeaseCoordinator() + val scope = Scope(SentryOptions()) + val published = mutableListOf() + val cleared = mutableListOf() + + fun register( + owner: Any, + state: Lifecycle.State, + onClearPublishedData: (IScope) -> Unit = { cleared += owner }, + ) { + navLeaseCoordinator.registerCandidate( + owner, + state, + onPublishData = { published += owner }, + onClearPublishedData = onClearPublishedData, + ) + } + + fun acquireLease( + owner: Any, + state: Lifecycle.State = Lifecycle.State.RESUMED, + transaction: ITransaction? = null, + onClearPublishedData: (IScope) -> Unit = { cleared += owner }, + ) { + register(owner, state, onClearPublishedData) + navLeaseCoordinator.onBackStackUpdate(owner, scope) + if (transaction != null) { + navLeaseCoordinator.retainTransaction(owner, transaction) + scope.transaction = transaction + } + } + + companion object { + fun create(): Fixture = Fixture() + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt new file mode 100644 index 0000000000..be66fb5b16 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt @@ -0,0 +1,321 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryOptions +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class NavScopeTest { + + @Test + fun `getScreen() - reads the real screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + navScope.screen = "Staged" + fixture.realScope.screen = "Host" + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Host") + } + + @Test + fun `getScreen() - reads the staged screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + fixture.stagedNavData.stageScreen("Staged") + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Staged") + } + + @Test + fun `setScreen() - publishes the screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Profile") + } + + @Test + fun `setScreen() - stages the screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Host") + assertThat(navScope.screen).isEqualTo("Profile") + } + + @Test + fun `setNavigationContext() - publishes the navigation context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + val navigationContext = mapOf("route" to "Profile") + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `setNavigationContext() - stages the navigation context without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + val hostContext = mapOf("route" to "Host") + val navigationContext = mapOf("route" to "Profile") + fixture.realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `rotatePropagationContext() - replaces the real context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `rotatePropagationContext() - defers rotating the real context without the nav lease`() { + val fixture = Fixture.create() + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isSameInstanceAs(realContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `bindTransaction() - binds to a free scope with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + + fixture.navScope.bindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `bindTransaction() - preserves an already bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `bindTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - unbinds the given transaction without the nav lease`() { + val fixture = Fixture.create() + val transaction = mock() + fixture.realScope.transaction = transaction + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction without the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.giveLeaseToAnotherOwner() + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the retained nav transaction when a scope observer throws`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.realScope.transaction = transaction + // Registered last, so that only clearTransaction() reaches it. + fixture.realScope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + throw ThrowingScopeObserverException() + } + } + ) + + assertThrows(ThrowingScopeObserverException::class.java) { + fixture.navScope.unbindTransaction(transaction) + } + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - preserves a different bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.unbindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `clearFinishedTransaction() - unbinds a finished transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + fixture.realScope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `clearFinishedTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + val finished = mock().also { whenever(it.isFinished).thenReturn(true) } + fixture.realScope.transaction = finished + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isSameInstanceAs(finished) + } + + @Test + fun `transaction - hides another owner's nav transaction without the nav lease`() { + val fixture = Fixture.create() + val other = Any() + val otherTransaction = mock() + fixture.coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + fixture.coordinator.onBackStackUpdate(other, fixture.realScope) + fixture.coordinator.retainTransaction(other, otherTransaction) + fixture.realScope.transaction = otherTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isNull() + } + + @Test + fun `transaction - reports a non-nav transaction without the nav lease`() { + val fixture = Fixture.create() + val hostTransaction = mock() + fixture.realScope.transaction = hostTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `transaction - reports the bound transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val boundTransaction = mock() + fixture.realScope.transaction = boundTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(boundTransaction) + } + + /** Thrown by a scope observer, to stand in for one the host app registered. */ + private class ThrowingScopeObserverException : RuntimeException() + + private class Fixture + private constructor( + val realScope: Scope, + val stagedNavData: StagedNavData, + val coordinator: NavLeaseCoordinator, + val navScope: NavScope, + private val owner: Any, + ) { + + /** Retains [transaction] as the owner's nav transaction; the owner must hold the nav lease. */ + fun retainTransaction(transaction: ITransaction) { + coordinator.retainTransaction(owner, transaction) + check(coordinator.isNavTransaction(transaction)) + } + + /** Lets another resumed owner take the nav lease from the owner. */ + fun giveLeaseToAnotherOwner() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, realScope) + check(!coordinator.hasLease(owner)) + } + + companion object { + + fun create(ownsLease: Boolean = false): Fixture { + val realScope = Scope(SentryOptions.empty()) + val stagedNavData = StagedNavData() + val coordinator = NavLeaseCoordinator() + val owner = Any() + coordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + stagedNavData::publishTo, + stagedNavData::clearPublishedStateFrom, + ) + if (ownsLease) { + coordinator.onBackStackUpdate(owner, realScope) + } + + return Fixture( + realScope, + stagedNavData, + coordinator, + NavScope(realScope, stagedNavData, coordinator, owner), + owner, + ) + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt new file mode 100644 index 0000000000..4470072b35 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt @@ -0,0 +1,208 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test +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 + +class NavScopesTest { + + @Test + fun `attach() - ignores a second, different lifecycle`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val first = TestLifecycleOwner() + val second = TestLifecycleOwner() + navScopes.attach(first.lifecycle) + + navScopes.attach(second.lifecycle) + + assertThat(first.lifecycle.observerCount).isEqualTo(1) + assertThat(second.lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `lifecycle callbacks are recorded while scope callbacks are skipped`() { + val fixture = Fixture() + val outgoing = fixture.navScopes() + val outgoingOwner = TestLifecycleOwner() + outgoing.attach(outgoingOwner.lifecycle) + fixture.isEnabled = false + outgoingOwner.lifecycle.currentState = Lifecycle.State.RESUMED + fixture.isEnabled = true + outgoing.applyFirstBackStackUpdate() + + val incoming = fixture.navScopes() + val incomingOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + incoming.attach(incomingOwner.lifecycle) + incoming.applyFirstBackStackUpdate() + assertThat(fixture.hasLease(incoming)).isTrue() + + incoming.dispose() + + assertThat(fixture.hasLease(outgoing)).isTrue() + } + + @Test + fun `onBackStackUpdate() - an update skipped by the scope does not count as the first update`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val owner = TestLifecycleOwner() + navScopes.attach(owner.lifecycle) + fixture.isEnabled = false + navScopes.applyFirstBackStackUpdate() + fixture.isEnabled = true + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + } + + @Test + fun `addBreadcrumb() - records breadcrumbs immediately without the nav lease`() { + val fixture = Fixture() + val leaseholder = fixture.navScopes() + leaseholder.attach(TestLifecycleOwner().lifecycle) + leaseholder.applyFirstBackStackUpdate() + val other = fixture.navScopes() + other.attach(TestLifecycleOwner().lifecycle) + val breadcrumb = Breadcrumb() + val hint = Hint() + + other.addBreadcrumb(breadcrumb, hint) + + assertThat(fixture.hasLease(other)).isFalse() + verify(fixture.scopes).addBreadcrumb(breadcrumb, hint) + } + + @Test + fun `startTransaction() - returns a no-op transaction without the nav lease`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + + val transaction = + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(fixture.hasLease(covered)).isFalse() + assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `startTransaction() - declining without the nav lease defers a propagation context rotation`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + @Test + fun `startTransaction() - returns a no-op transaction after dispose`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + navScopes.attach(TestLifecycleOwner().lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.dispose() + + val transaction = + navScopes.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + } + + @Test + fun `rotatePropagationContext() - a rotation requested without the nav lease happens once the lease is acquired`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.configureScope { it.rotatePropagationContext() } + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + private fun NavScopes.applyFirstBackStackUpdate() { + onBackStackUpdate() + } + + private class TestLifecycleOwner(initialState: Lifecycle.State = Lifecycle.State.CREATED) : + LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + } + + private class Fixture { + var isEnabled = true + val scope = Scope(SentryOptions()) + val coordinator = NavLeaseCoordinator() + val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(SentryOptions()) + doAnswer { + if (isEnabled) { + (it.arguments[0] as ScopeCallback).run(scope) + } + null + } + .whenever(scopes) + .configureScope(any()) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { mock() } + } + + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + + private var leaseProbeCount = 0 + + /** + * Whether [navScopes] holds the nav lease, observed through whether a screen name it sets + * reaches the real [scope]. Overwrites the scope's screen. + */ + fun hasLease(navScopes: NavScopes): Boolean { + val probe = "lease probe ${++leaseProbeCount}" + navScopes.configureScope { it.screen = probe } + return scope.screen == probe + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt new file mode 100644 index 0000000000..a5b74ff4a2 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt @@ -0,0 +1,221 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.Scope +import io.sentry.SentryOptions +import org.junit.Test + +class StagedNavDataTest { + + private val realScope = Scope(SentryOptions.empty()) + private val stagedNavData = StagedNavData() + + @Test + fun `stageScreen() - stages the screen without changing the real scope`() { + realScope.screen = "Host" + + stagedNavData.stageScreen("Profile") + + assertThat(stagedNavData.screen).isEqualTo("Profile") + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishScreen() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishScreen(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `stageNavigationContext() - leaves the real navigation context unchanged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishNavigationContext() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishNavigationContext(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - republishes an already published screen that was since replaced`() { + stagedNavData.stageScreen("Profile") + stagedNavData.publishScreen(realScope) + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - leaves the real screen alone when no screen was staged`() { + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishTo() - clears the real screen when the staged screen is null`() { + realScope.screen = "Previous" + stagedNavData.stageScreen(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isNull() + } + + @Test + fun `publishTo() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - republishes an already published navigation context that was since replaced`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + stagedNavData.publishNavigationContext(realScope) + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Host")) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - leaves the real navigation context alone when none was staged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - clears the real navigation context when the staged context is null`() { + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Previous")) + stagedNavData.stageNavigationContext(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves a host screen set after publication without a staged screen`() { + stagedNavData.publishTo(realScope) + realScope.screen = "Host" + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `clearPublishedStateFrom() - clears screen and navigation context published by this holder`() { + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + stagedNavData.publishTo(realScope) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - clears the published screen and navigation context after newer values are staged`() { + stagedNavData.stageScreen("Home") + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves equal but distinct host replacements`() { + stagedNavData.stageScreen(String(charArrayOf('H', 'o', 'm', 'e'))) + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + val hostScreen = String(charArrayOf('H', 'o', 'm', 'e')) + val hostContext = mapOf("route" to "Home") + realScope.screen = hostScreen + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isSameInstanceAs(hostScreen) + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `clearPublishedStateFrom() - preserves the real screen and navigation context before publication`() { + realScope.screen = "Host" + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + stagedNavData.stageScreen("Staged") + stagedNavData.stageNavigationContext(mapOf("route" to "Staged")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - leaves the real propagation context alone when no rotation was deferred`() { + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + + assertThat(realScope.propagationContext).isSameInstanceAs(realContext) + } + + @Test + fun `publishTo() - performs a deferred propagation context rotation once`() { + stagedNavData.deferPropagationContextRotation() + stagedNavData.deferPropagationContextRotation() + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + val rotatedContext = realScope.propagationContext + stagedNavData.publishTo(realScope) + + assertThat(rotatedContext).isNotSameInstanceAs(realContext) + assertThat(realScope.propagationContext).isSameInstanceAs(rotatedContext) + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index cf4b054e35..a9d4ea8ac5 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -208,6 +208,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) @@ -230,6 +231,9 @@ dependencies { debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) + + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) } abstract class ToggleNativeLoggingTask : Exec() { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 4e80ddfd86..da8b00d97a 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -123,6 +123,30 @@ android:screenOrientation="portrait" android:theme="@style/AppTheme.Main" /> + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt index 5b935a01e0..3fad5ffe27 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import io.sentry.compose.navigation3.SentryBackStackEntry @@ -75,9 +77,6 @@ class Nav3Activity : ComponentActivity() { private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) - private var isTransactionHistoryActive = false - private val transactionHistory = - NavigationTransactionHistory(isActive = { isTransactionHistoryActive }) private var showTransactionHistorySheet by mutableStateOf(false) private var showActivityUiLoadTransactionDelayMessage = false @@ -99,14 +98,14 @@ class Nav3Activity : ComponentActivity() { cancelCurrentActivityUiLoadTransaction() } - transactionHistory.install() + NavigationTransactionHistory.install() setContent { Nav3SampleTheme { Nav3SampleApp( performanceState = performanceState, configuration = configuration, - transactions = transactionHistory.transactions, + transactions = NavigationTransactionHistory.transactions, showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, routeWorkOptions = routeWorkOptions, showTransactionHistorySheet = showTransactionHistorySheet, @@ -124,13 +123,7 @@ class Nav3Activity : ComponentActivity() { } } - override fun onStart() { - super.onStart() - isTransactionHistoryActive = true - } - override fun onStop() { - isTransactionHistoryActive = false performanceState.stopAutomaticWork() super.onStop() } @@ -139,7 +132,6 @@ class Nav3Activity : ComponentActivity() { if (isFinishing) { previousConfig.applyToCurrentOptions() } - transactionHistory.uninstall() super.onDestroy() } @@ -361,7 +353,9 @@ private fun Nav3SampleApp( } Scaffold( - modifier = Modifier.fillMaxSize().safeDrawingPadding(), + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, containerColor = MaterialTheme.colorScheme.background, topBar = { Nav3TopBar( @@ -464,6 +458,28 @@ private fun Nav3SampleApp( ) } } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiActivityRoute { + activity?.startActivity( + Intent(activity, Nav3ProductsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiFragmentRoute { + activity?.startActivity( + Intent(activity, Nav3FragmentsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } entry { route -> TracedNav3Route(route, selectedScenario) { Nav3RouteWorkEffect(route, routeWorkOptions) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt new file mode 100644 index 0000000000..5e6ca0b9d0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt @@ -0,0 +1,493 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +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.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.navigation3.SentryBackStackEntry +import io.sentry.compose.navigation3.SentryNavEffect +import io.sentry.compose.navigation3.SentryNavOptions +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/* + * Activities that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), which let us + * exercise how separate effects hand off during Activity transactions. + */ + +/** Keeps its navigation effect alive while checkout runs in another Activity. */ +class Nav3ProductsActivity : ComponentActivity() { + + private var backStack: SnapshotStateList? = null + + /** An order ID returned before [backStack] exists (e.g., after Activity recreation). */ + private var pendingOrderId: String? = null + + // Pushes a destination from the result callback itself, which runs before this Activity resumes, + // like an app that navigates in response to an Activity result. + private val checkoutForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val orderId = result.data?.getStringExtra(EXTRA_ORDER_ID) ?: return@registerForActivityResult + backStack?.add(Nav3Route.Confirmation(orderId)) ?: run { pendingOrderId = orderId } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList) + SideEffect { + backStack = stack + pendingOrderId?.let { orderId -> + pendingOrderId = null + stack.add(Nav3Route.Confirmation(orderId)) + } + } + Nav3SampleTheme { + MultiRootNavDisplay( + activity = this, + title = "Products Activity", + initialRoute = Nav3Route.ProductList, + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + backStack = stack, + onCheckout = { productId -> startActivity(checkoutIntent(productId, configuration)) }, + productDetailActions = + listOf( + ProductDetailAction( + label = "Go to Translucent Checkout", + testTag = nav3TestTag("product_detail_go_to_translucent_checkout"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .setClass(this, Nav3TranslucentCheckoutActivity::class.java) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout for Result", + testTag = nav3TestTag("product_detail_go_to_checkout_for_result"), + onClick = { productId -> + checkoutForResult.launch( + checkoutIntent(productId, configuration).putExtra(EXTRA_RETURN_RESULT, true) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout (Crash on First Composition)", + testTag = nav3TestTag("product_detail_go_to_checkout_crash"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .putExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, true) + ) + }, + ), + ), + ) + } + } + } + + override fun onDestroy() { + backStack = null + super.onDestroy() + } + + private fun checkoutIntent(productId: String, configuration: NavigationSampleConfig): Intent = + Intent(this, Nav3CheckoutActivity::class.java) + .putExtra(EXTRA_PRODUCT_ID, productId) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) +} + +/** Owns the checkout back stack independently of the Products Activity. */ +open class Nav3CheckoutActivity : ComponentActivity() { + + /** Whether to show checkout as a sheet, leaving the Activity underneath partly visible. */ + protected open val showAsSheet: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42" + val returnsResult = intent.getBooleanExtra(EXTRA_RETURN_RESULT, false) + // Only crash on the first launch, not when the system restores this Activity. + val crashOnFirstComposition = + savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + if (showAsSheet) { + CheckoutSheet { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } else { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } + } + } + + @Composable + private fun CheckoutRoot( + productId: String, + configuration: NavigationSampleConfig, + returnsResult: Boolean, + crashOnFirstComposition: Boolean, + ) { + MultiRootNavDisplay( + activity = this, + title = if (showAsSheet) "Translucent Checkout Activity" else "Checkout Activity", + initialRoute = Nav3Route.Checkout(productId), + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + onCompleteOrder = + if (returnsResult) { + { orderId -> + setResult(RESULT_OK, Intent().putExtra(EXTRA_ORDER_ID, orderId)) + finish() + } + } else { + null + }, + crashOnFirstComposition = crashOnFirstComposition, + ) + } +} + +/** + * Checkout in a translucent Activity. The Products Activity underneath stays visible, so it's + * paused rather than stopped while checkout is open. + */ +class Nav3TranslucentCheckoutActivity : Nav3CheckoutActivity() { + override val showAsSheet: Boolean = true +} + +/** Shows [content] as a bottom sheet over a scrim, leaving the top of the screen see-through. */ +@Composable +private fun CheckoutSheet(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f))) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.7f).align(Alignment.BottomCenter)) { + content() + } + } +} + +/** An extra Product Detail button, given the product's ID when clicked. */ +internal class ProductDetailAction( + val label: String, + val testTag: String, + val onClick: (productId: String) -> Unit, +) + +@Composable +internal fun MultiActivityRoute(onOpenProducts: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiActivity.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_activity"), + cardContent = { + RouteButton( + "Open products Activity", + onClick = onOpenProducts, + testTag = nav3TestTag("multi_activity_open_products"), + ) + }, + ) +} + +/** + * A self-contained Nav3 root (back stack, [SentryNavEffect], and [NavDisplay]) shared by the + * Multi-Activity and Multi-Fragment samples, so each Activity or Fragment owns its own effect. + * + * @param backStack This root's back stack. Pass one to navigate it from outside the composition. + * @param onCheckout Opens checkout outside this root (e.g., another Activity or Fragment). When + * `null`, checkout is pushed onto this root's own back stack. + * @param productDetailActions Additional Product Detail buttons. Product Detail always offers a + * button that pushes a confirmation after a delay, so the push can happen while this root is + * covered by another one. + * @param onCompleteOrder Handles Checkout's "Complete Order" outside this root (e.g., by returning + * a result). When `null`, the confirmation is pushed onto this root's own back stack. + * @param crashOnFirstComposition Crashes when the Checkout destination's first composition is + * applied, so its navigation transaction and tags can attribute the crash to that screen. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun MultiRootNavDisplay( + activity: ComponentActivity, + title: String, + initialRoute: Nav3Route, + scenario: Nav3Scenario, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + backStack: SnapshotStateList = rememberSaveableNav3BackStack(initialRoute), + onCheckout: ((String) -> Unit)? = null, + productDetailActions: List = emptyList(), + onCompleteOrder: ((orderId: String) -> Unit)? = null, + crashOnFirstComposition: Boolean = false, +) { + val coroutineScope = rememberCoroutineScope() + var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) } + var showTransactionHistorySheet by remember { mutableStateOf(false) } + var showCrashConfirmation by remember { mutableStateOf(false) } + val options = + remember(configuration) { + SentryNavOptions { + enableNavigationBreadcrumbs = configuration.enableNavigationBreadcrumbs + enableNavigationTransactions = configuration.enableNavigationTransactions + captureBackStack = configuration.captureBackStack + maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries + } + } + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = { route -> + SentryBackStackEntry(name = route.routeName, arguments = route.arguments) + }, + options = options, + ) + val onBack: () -> Unit = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onFinish() + } + } + Scaffold( + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, + topBar = { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { showTransactionHistorySheet = true }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Recent Transactions")) + .testTag(nav3TestTag("top_bar_recent_transactions")), + ) { + Icon(Icons.Filled.AccountTree, contentDescription = "Recent transactions") + } + IconButton( + onClick = { + showRouteWorkDialog(activity, routeWorkOptions) { routeWorkOptions = it } + }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Route Work Settings")) + .testTag(nav3TestTag("top_bar_route_work_settings")), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Route work settings") + } + } + Text("Current route: ${backStack.last().displayRoute()}") + } + }, + bottomBar = { + SentryControls( + onCaptureException = { captureSampleException("Nav3 ${scenario.label}") }, + onCrashApp = { showCrashConfirmation = true }, + ) + }, + ) { padding -> + // Only handle back while this root is resumed. Otherwise, a root that's still composed but no + // longer current (e.g., a Fragment animating out, or a preloaded ViewPager2 page) could consume + // back presses meant for the current root, popping a back stack the user can't see. + val isResumed = + LocalLifecycleOwner.current.lifecycle + .currentStateAsState() + .value + .isAtLeast(Lifecycle.State.RESUMED) + // Pass the parent explicitly: it's null while this root's view is detached (e.g., a recycled + // ViewPager2 page that still recomposes), where the default parameter would throw. + val parentDispatcherOwner = LocalNavigationEventDispatcherOwner.current + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner provides + rememberNavigationEventDispatcherOwner(enabled = isResumed, parent = parentDispatcherOwner) + ) { + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = onBack, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute( + route = route, + backStack = backStack, + onCheckout = { + onCheckout?.invoke(route.productId) + ?: backStack.add(Nav3Route.Checkout(route.productId)) + }, + showOverlays = false, + extraActions = { + RouteButton( + "Push Confirmation in $DELAYED_NAVIGATION_SECONDS s", + onClick = { + coroutineScope.launch { + delay(DELAYED_NAVIGATION_SECONDS * 1_000L) + backStack += Nav3Route.Confirmation("delayed-${route.productId}") + } + }, + testTag = nav3TestTag("product_detail_delayed_confirmation"), + ) + productDetailActions.forEach { action -> + RouteButton( + action.label, + onClick = { action.onClick(route.productId) }, + testTag = action.testTag, + ) + } + }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + if (crashOnFirstComposition) { + SideEffect { + throw RuntimeException( + "Fatal Nav3 ${scenario.label} crash after first composition" + ) + } + } + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute( + route = route, + backStack = backStack, + onCompleteOrder = onCompleteOrder ?: { backStack += Nav3Route.Confirmation(it) }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ConfirmationRoute(route, backStack, rootRoute = initialRoute) + } + } + }, + ) + } + } + if (showCrashConfirmation) { + AlertDialog( + onDismissRequest = { showCrashConfirmation = false }, + title = { Text("Crash app?") }, + text = { Text("This will throw an uncaught exception and close the sample app.") }, + dismissButton = { + TextButton(onClick = { showCrashConfirmation = false }) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + showCrashConfirmation = false + crashSampleApp("Nav3 ${scenario.label}") + } + ) { + Text("Crash") + } + }, + ) + } + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = NavigationTransactionHistory.transactions, + showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions, + onDismissRequest = { showTransactionHistorySheet = false }, + onOpenTransaction = { url -> + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + onDumpTransactionUrl = { url -> + Log.i("Nav3MultiActivity", "Sentry transaction URL: $url") + Toast.makeText(activity, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show() + }, + onCopyTransactionUrl = { url -> + val clipboard = activity.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url)) + Toast.makeText(activity, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show() + }, + ) + } +} + +private const val DELAYED_NAVIGATION_SECONDS = 5 +private const val EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id" +private const val EXTRA_RETURN_RESULT = "io.sentry.samples.android.navigation.nav3.return_result" +private const val EXTRA_ORDER_ID = "io.sentry.samples.android.navigation.nav3.order_id" +private const val EXTRA_CRASH_ON_FIRST_COMPOSITION = + "io.sentry.samples.android.navigation.nav3.crash_on_first_composition" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt new file mode 100644 index 0000000000..1ca11284ee --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt @@ -0,0 +1,420 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavigationSampleConfig + +/** + * Hosts Fragments that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), to + * exercise how separate effects hand off during Fragment transactions: + * + * - `replace` + back stack (Products → Checkout), optionally with a slow enter animation; + * - a full-screen `DialogFragment` overlay whose host Fragment stays resumed; + * - a `ViewPager2` of Fragments, where neighboring pages are preloaded at STARTED. + */ +// FragmentActivity rather than AppCompatActivity: AppCompatActivity.setContentView doesn't install +// the NavigationEventDispatcherOwner that NavDisplay needs on the view tree. +class Nav3FragmentsActivity : FragmentActivity() { + + /** When true, Fragment `replace` uses a slow enter animation. */ + internal var slowTransitions by mutableStateOf(false) + + internal val configuration: NavigationSampleConfig + get() = (intent.extras ?: Bundle()).nav3SampleConfiguration() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + + val controls = + ComposeView(this).apply { + setContent { + Nav3SampleTheme { + MultiFragmentControls( + slowTransitions = slowTransitions, + onSlowTransitionsChanged = { slowTransitions = it }, + onOpenCheckoutSheet = ::openCheckoutSheet, + onOpenPager = ::openPager, + ) + } + } + } + val container = FragmentContainerView(this).apply { id = R.id.nav3_multi_fragment_container } + // The controls above already pad for the status bar; keep Fragments from padding for it again. + ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets -> + WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + .setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE) + .build() + } + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(controls, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(container, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + ) + + if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products()) + } + } + } + + internal fun openCheckoutFragment(productId: String) { + supportFragmentManager.commit { + setReorderingAllowed(true) + if (slowTransitions) { + setCustomAnimations( + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + ) + } else { + setCustomAnimations( + android.R.anim.fade_in, + android.R.anim.fade_out, + android.R.anim.fade_in, + android.R.anim.fade_out, + ) + } + replace(R.id.nav3_multi_fragment_container, Nav3RootFragment.checkout(productId)) + addToBackStack("checkout") + } + } + + private fun openPager() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.nav3_multi_fragment_container, Nav3PagerFragment()) + addToBackStack("pager") + } + } + + private fun openCheckoutSheet() { + Nav3CheckoutSheetFragment().show(supportFragmentManager, "checkout_sheet") + } + + /** Pops this Activity's Fragment back stack, or finishes the Activity when it's empty. */ + internal fun finishRoot() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } +} + +/** A Fragment that owns one Nav3 root. Pager pages are child Fragments of [Nav3PagerFragment]. */ +class Nav3RootFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val args = requireArguments() + val host = requireNotNull(args.getString(ARG_HOST)) + val activity = requireActivity() as Nav3FragmentsActivity + val initialRoute = + when (args.getString(ARG_KIND)) { + KIND_CHECKOUT -> Nav3Route.Checkout(requireNotNull(args.getString(ARG_PRODUCT_ID))) + KIND_PAGE -> pageRoots[args.getInt(ARG_PAGE)] + else -> Nav3Route.ProductList + } + val opensCheckoutFragment = args.getString(ARG_KIND) == KIND_PRODUCTS + + return nav3RootComposeView( + activity = activity, + host = host, + initialRoute = initialRoute, + configuration = activity.configuration, + onFinish = activity::finishRoot, + onCheckout = if (opensCheckoutFragment) activity::openCheckoutFragment else null, + ) + } + + internal companion object { + private const val ARG_KIND = "kind" + private const val ARG_HOST = "host" + private const val ARG_PRODUCT_ID = "product_id" + private const val ARG_PAGE = "page" + private const val KIND_PRODUCTS = "products" + private const val KIND_CHECKOUT = "checkout" + private const val KIND_PAGE = "page" + + internal val pageRoots: List = + listOf(Nav3Route.ProductList, Nav3Route.Checkout("7"), Nav3Route.Confirmation("page-2")) + + fun products() = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PRODUCTS, ARG_HOST to "ProductsFragment") + } + + fun checkout(productId: String) = + Nav3RootFragment().apply { + arguments = + bundleOf( + ARG_KIND to KIND_CHECKOUT, + ARG_HOST to "CheckoutFragment", + ARG_PRODUCT_ID to productId, + ) + } + + fun page(index: Int) = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PAGE, ARG_HOST to "Page$index", ARG_PAGE to index) + } + } +} + +/** A full-screen dialog with its own Nav3 root. The Fragment underneath stays resumed. */ +class Nav3CheckoutSheetFragment : DialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.AppTheme_Main) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val activity = requireActivity() as Nav3FragmentsActivity + return nav3RootComposeView( + activity = activity, + host = "CheckoutSheet", + initialRoute = Nav3Route.Checkout("42"), + configuration = activity.configuration, + onFinish = ::dismiss, + onCheckout = null, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout(MATCH_PARENT, MATCH_PARENT) + } +} + +/** + * A `ViewPager2` whose pages each own a Nav3 root. With `offscreenPageLimit = 1`, the neighbors of + * the current page are created and capped at STARTED, while only the current page is RESUMED. + */ +class Nav3PagerFragment : Fragment() { + + private var currentPage by mutableIntStateOf(0) + private var pager: ViewPager2? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val viewPager = + ViewPager2(requireContext()).apply { + id = R.id.nav3_multi_fragment_pager + offscreenPageLimit = 1 + } + pager = viewPager + val header = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + PagerHeader(currentPage = currentPage, onSelectPage = { viewPager.currentItem = it }) + } + } + } + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(header, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(viewPager, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val viewPager = requireNotNull(pager) + viewPager.adapter = + object : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { + override fun getItemCount(): Int = Nav3RootFragment.pageRoots.size + + override fun createFragment(position: Int): Fragment = Nav3RootFragment.page(position) + } + viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + currentPage = position + } + } + ) + } + + override fun onDestroyView() { + pager = null + super.onDestroyView() + } +} + +@Composable +internal fun MultiFragmentRoute(onOpenFragments: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiFragment.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_fragment"), + cardContent = { + RouteButton( + "Open fragments Activity", + onClick = onOpenFragments, + testTag = nav3TestTag("multi_fragment_open_fragments"), + ) + }, + ) +} + +private fun Fragment.nav3RootComposeView( + activity: ComponentActivity, + host: String, + initialRoute: Nav3Route, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + onCheckout: ((String) -> Unit)?, +): ComposeView = + ComposeView(requireContext()).apply { + // Dispose with the Fragment's view, so the root's SentryNavEffect leaves the composition when + // the Fragment's view is destroyed (e.g., when it's replaced and put on the back stack). + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + CompositionLocalProvider(LocalNav3SampleHost provides host) { + MultiRootNavDisplay( + activity = activity, + title = host, + initialRoute = initialRoute, + scenario = Nav3Scenario.MULTI_FRAGMENT, + configuration = configuration, + onFinish = onFinish, + onCheckout = onCheckout, + ) + } + } + } + } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MultiFragmentControls( + slowTransitions: Boolean, + onSlowTransitionsChanged: (Boolean) -> Unit, + onOpenCheckoutSheet: () -> Unit, + onOpenPager: () -> Unit, +) { + Surface( + shadowElevation = 4.dp, + modifier = Modifier.statusBarsPadding().semantics { testTagsAsResourceId = true }, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Fragments Activity", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onOpenCheckoutSheet, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_checkout_sheet")), + ) { + Text("Checkout sheet") + } + OutlinedButton( + onClick = onOpenPager, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_pager")), + ) { + Text("Pager") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Slow Fragment transitions", modifier = Modifier.weight(1f)) + Switch( + checked = slowTransitions, + onCheckedChange = onSlowTransitionsChanged, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_slow_transitions")), + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PagerHeader(currentPage: Int, onSelectPage: (Int) -> Unit) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics { + testTagsAsResourceId = true + } + ) { + Text( + "ViewPager2 page $currentPage (neighbors preloaded at STARTED)", + modifier = Modifier.testTag(nav3TestTag("multi_fragment_current_page")), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Nav3RootFragment.pageRoots.indices.forEach { index -> + OutlinedButton( + onClick = { onSelectPage(index) }, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_page_$index")), + ) { + Text("Page $index") + } + } + } + } +} + +private const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT +private const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt index 1d4b933fdc..314d427456 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -28,25 +28,30 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.sentry.ITransaction import io.sentry.Sentry import io.sentry.compose.SentryModifier.sentryTag import io.sentry.compose.SentryTraced import io.sentry.samples.android.R +import io.sentry.samples.android.getActivity import io.sentry.samples.android.navigation.common.RouteNames import io.sentry.samples.android.navigation.common.RouteSpec import io.sentry.samples.android.navigation.common.RouteSpecs @@ -58,6 +63,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import retrofit2.HttpException +/** + * Names the Activity or Fragment hosting the current Nav3 root, for the sample's transaction tags. + * Defaults to the Activity's class name when unset. + */ +internal val LocalNav3SampleHost = staticCompositionLocalOf { null } + @ExperimentalComposeUiApi @Composable internal fun TracedNav3Route( @@ -65,7 +76,10 @@ internal fun TracedNav3Route( scenario: Nav3Scenario, content: @Composable BoxScope.() -> Unit, ) { - tagCurrentNav3Scenario(scenario) + val activityName = LocalContext.current.getActivity().javaClass.simpleName + val hostName = LocalNav3SampleHost.current ?: activityName + // Tag after composition is applied so abandoned compositions don't tag anything. + SideEffect { tagCurrentNav3Scenario(route, scenario, hostName, activityName) } SentryTraced( tag = "Nav3 /${route.routeName}", enableUserInteractionTracing = false, @@ -73,9 +87,52 @@ internal fun TracedNav3Route( ) } -private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) { - Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label) - tagCurrentNavigationSampleScenario(scenario.label) +private fun tagCurrentNav3Scenario( + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, +) { + val sessionId = NavigationTransactionHistory.sessionId + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + tagNavigationSampleTransaction( + transaction, + route, + scenario, + hostName, + activityName, + sessionId, + ) + } + } +} + +private fun tagNavigationSampleTransaction( + transaction: ITransaction?, + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, + sessionId: String, +) { + // A covered root can remain composed and re-run this SideEffect after another root acquires the + // navigation lease. Only tag this destination's transaction, or an untagged ui.load transaction + // owned by its Activity. + val activeTransaction = transaction ?: return + val ownsNavigationTransaction = activeTransaction.name == "/${route.routeName}" + val ownsUiLoadTransaction = + activeTransaction.operation == ACTIVITY_UI_LOAD_OP && + activeTransaction.name == activityName && + activeTransaction.getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) == null + if (!ownsNavigationTransaction && !ownsUiLoadTransaction) { + return + } + + activeTransaction.setTag(NAV3_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, hostName) + activeTransaction.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId) } @Composable @@ -496,6 +553,9 @@ internal fun ProductListRoute(backStack: SnapshotStateList) { internal fun ProductDetailRoute( route: Nav3Route.ProductDetail, backStack: SnapshotStateList, + onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) }, + showOverlays: Boolean = true, + extraActions: @Composable ColumnScope.() -> Unit = {}, ) { LaunchedEffect(route.productId, route.source, route.campaign) { recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) @@ -508,21 +568,24 @@ internal fun ProductDetailRoute( RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) -> RouteInfo(label, value) } - RouteButton( - "Show Promo Dialog", - onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, - testTag = nav3TestTag("product_detail_show_promo_dialog"), - ) - RouteButton( - "Open Share Sheet", - onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, - testTag = nav3TestTag("product_detail_open_share_sheet"), - ) + if (showOverlays) { + RouteButton( + "Show Promo Dialog", + onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, + testTag = nav3TestTag("product_detail_show_promo_dialog"), + ) + RouteButton( + "Open Share Sheet", + onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, + testTag = nav3TestTag("product_detail_open_share_sheet"), + ) + } RouteButton( "Go to Checkout", - onClick = { backStack.add(Nav3Route.Checkout(route.productId)) }, + onClick = onCheckout, testTag = nav3TestTag("product_detail_go_to_checkout"), ) + extraActions() }, footerContent = { RouteButton( @@ -538,6 +601,7 @@ internal fun ProductDetailRoute( internal fun CheckoutRoute( route: Nav3Route.Checkout, backStack: SnapshotStateList, + onCompleteOrder: (orderId: String) -> Unit = { backStack.add(Nav3Route.Confirmation(it)) }, ) { RouteScaffold( routeSpec = RouteSpecs.checkout, @@ -548,7 +612,7 @@ internal fun CheckoutRoute( } RouteButton( "Complete Order", - onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) }, + onClick = { onCompleteOrder("order-${route.productId}") }, testTag = nav3TestTag("checkout_complete_order"), ) }, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt index b9972a6cd7..554a5d3c3d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -26,6 +26,22 @@ internal fun Nav3Route.routeSpec(): RouteSpec = "Reuses the single-stack shopping flow while simulating a power user who starts their " + "own manual transactions.", ) + Nav3Route.MultiActivity -> + RouteSpec( + routeName = Nav3Route.MultiActivity.routeName, + title = "Multi-Activity", + description = + "Tests how SentryNavEffect uses NavScopes to coordinate state updates between " + + "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.", + ) + Nav3Route.MultiFragment -> + RouteSpec( + routeName = Nav3Route.MultiFragment.routeName, + title = "Multi-Fragment", + description = + "Tests SentryNavEffect when each Fragment owns its own SentryNavEffect and NavDisplay: " + + "Fragment replace transitions, a dialog overlay, and a ViewPager2 of Fragments.", + ) Nav3Route.DeepLink -> RouteSpec( routeName = Nav3Route.DeepLink.routeName, @@ -69,6 +85,8 @@ private fun Nav3Route.toSavedState(): Bundle = Nav3Route.Landing -> putString("type", "landing") Nav3Route.SingleStack -> putString("type", "single_stack") Nav3Route.Custom -> putString("type", "custom") + Nav3Route.MultiActivity -> putString("type", "multi_activity") + Nav3Route.MultiFragment -> putString("type", "multi_fragment") Nav3Route.DeepLink -> putString("type", "deep_link") Nav3Route.ProductList -> putString("type", "product_list") is Nav3Route.ProductDetail -> { @@ -108,6 +126,8 @@ private fun Bundle.toNav3Route(): Nav3Route { "landing" -> Nav3Route.Landing "single_stack" -> Nav3Route.SingleStack "custom" -> Nav3Route.Custom + "multi_activity" -> Nav3Route.MultiActivity + "multi_fragment" -> Nav3Route.MultiFragment "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList "product_detail" -> { @@ -148,6 +168,8 @@ internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) { Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing) Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack) Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) + Nav3Scenario.MULTI_ACTIVITY -> resetTo(Nav3Route.MultiActivity) + Nav3Scenario.MULTI_FRAGMENT -> resetTo(Nav3Route.MultiFragment) Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink) Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) @@ -211,6 +233,14 @@ internal sealed interface Nav3Route { override val routeName: String = "Custom" } + data object MultiActivity : Nav3Route { + override val routeName: String = "MultiActivity" + } + + data object MultiFragment : Nav3Route { + override val routeName: String = "MultiFragment" + } + data object DeepLink : Nav3Route { override val routeName: String = RouteNames.DEEP_LINK } @@ -282,6 +312,8 @@ internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) SINGLE_STACK("Single Stack"), DEEP_LINK("Deep Link"), CUSTOM("Custom"), + MULTI_ACTIVITY("Multi-Activity"), + MULTI_FRAGMENT("Multi-Fragment"), MULTIPANE("Multipane"), MULTIPLE_STACKS("Multistack"), PERFORMANCE("Performance"), @@ -293,6 +325,8 @@ internal val Nav3Scenario.initialRoute: Nav3Route Nav3Scenario.LANDING -> Nav3Route.Landing Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity + Nav3Scenario.MULTI_FRAGMENT -> Nav3Route.MultiFragment Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink Nav3Scenario.MULTIPANE -> Nav3Route.Multipane Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt index 4a9b77227b..a23ed37c3f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -73,7 +73,9 @@ internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L private const val BACKGROUND_WORK_MILLIS = 1000L private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario" -private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" -private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" +internal const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" +internal const val NAVIGATION_SAMPLE_ACTIVITY_TAG = "sample_navigation_activity" +internal const val NAVIGATION_SAMPLE_SESSION_TAG = "sample_navigation_session" +internal const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown" -private const val ACTIVITY_UI_LOAD_OP = "ui.load" +internal const val ACTIVITY_UI_LOAD_OP = "ui.load" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt index db945d4519..7b37bbeaca 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt @@ -35,6 +35,7 @@ class Nav3SetupActivity : AppCompatActivity() { onLaunch = { val previousOptions = currentNavigationSampleConfigSnapshot() configuration.applyToCurrentOptions() + NavigationTransactionHistory.startSession() startActivity(nav3LaunchIntent(configuration, previousOptions)) }, ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt index 38cd6d2039..21d99a50b2 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt @@ -8,12 +8,19 @@ import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.samples.android.SampleBeforeSendTransactionHook -/** State holder backing the navigation sample transaction history sheet. */ -internal class NavigationTransactionHistory(private val isActive: () -> Boolean) { +/** Process-wide state holder backing every Nav3 transaction history sheet. */ +internal object NavigationTransactionHistory { - val transactions = mutableStateListOf() + private val store = NavigationTransactionHistoryStore() + @Volatile private var sessionGeneration = 0L + + val transactions: List + get() = store.transactions + + val sessionId: String + get() = sessionGeneration.toString() - private val mainHandler = Handler(Looper.getMainLooper()) + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn -> add(transaction, dsn) } @@ -24,34 +31,56 @@ internal class NavigationTransactionHistory(private val isActive: () -> Boolean) SampleBeforeSendTransactionHook.addListener(transactionListener) } - fun uninstall() { + fun startSession() { + install() + sessionGeneration++ clear() - SampleBeforeSendTransactionHook.removeListener(transactionListener) } fun clear() { - transactions.clear() + store.clear() } private fun add(transaction: SentryTransaction, dsn: String?) { - if (!isActive()) { + val transactionSessionId = transaction.getTag(NAVIGATION_SAMPLE_SESSION_TAG) + if (transaction.getTag(NAV3_SCENARIO_TAG) == null || transactionSessionId != sessionId) { return } val trace = transaction.toNavigationTransactionTrace(dsn) if (Looper.myLooper() == Looper.getMainLooper()) { - transactions.addMostRecent(trace) + store.add(trace) } else { mainHandler.post { - if (isActive()) { - transactions.addMostRecent(trace) + // The session may have changed while this was queued. + if (transactionSessionId == sessionId) { + store.add(trace) } } } } } +internal class NavigationTransactionHistoryStore( + private val transactionLimit: Int = TRANSACTION_HISTORY_LIMIT +) { + + val transactions = mutableStateListOf() + + fun add(transaction: NavigationTransactionTrace) { + transactions.add(0, transaction) + while (transactions.size > transactionLimit) { + transactions.removeAt(transactions.lastIndex) + } + } + + fun clear() { + transactions.clear() + } +} + internal data class NavigationTransactionTrace( + val activity: String, val name: String, val operation: String, val eventId: String, @@ -73,7 +102,7 @@ internal data class NavigationTraceSpan( val children: List = emptyList(), ) -private fun SentryTransaction.toNavigationTransactionTrace( +internal fun SentryTransaction.toNavigationTransactionTrace( dsn: String? ): NavigationTransactionTrace { val trace = contexts.trace @@ -97,6 +126,7 @@ private fun SentryTransaction.toNavigationTransactionTrace( .sortedBy { span -> span.startOffsetMillis } return NavigationTransactionTrace( + activity = getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) ?: UNKNOWN_NAVIGATION_ACTIVITY, name = transaction ?: "", operation = trace?.operation ?: "transaction", eventId = eventId, @@ -109,15 +139,6 @@ private fun SentryTransaction.toNavigationTransactionTrace( ) } -private fun MutableList.addMostRecent( - transaction: NavigationTransactionTrace -) { - add(0, transaction) - while (size > TRANSACTION_HISTORY_LIMIT) { - removeAt(lastIndex) - } -} - private fun SentrySpan.toNavigationTraceSpan( transactionStartTimestamp: Double ): NavigationTraceSpan = @@ -172,5 +193,6 @@ private fun String.projectIdFromDsn(): String? = projectId.isNotEmpty() && projectId.all { it.isDigit() } } -private const val TRANSACTION_HISTORY_LIMIT = 10 +internal const val TRANSACTION_HISTORY_LIMIT = 25 private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks" +private const val UNKNOWN_NAVIGATION_ACTIVITY = "Unknown" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt index 0e0038083e..94a643ba7c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt @@ -66,7 +66,8 @@ internal fun NavigationTransactionHistorySheet( modifier = Modifier.testTag(historyTag(sampleName, "title")), ) Text( - "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.", + "Displays the last $TRANSACTION_HISTORY_LIMIT finished Nav3 transactions emitted by the SDK. " + + "Transactions with child work appear here after their work finishes.", style = MaterialTheme.typography.bodyMedium, ) Column( @@ -133,7 +134,11 @@ private fun TransactionCard( modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { Column( modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary")) ) { @@ -152,12 +157,20 @@ private fun TransactionCard( modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")), ) } - Text( - "Tab: ${transaction.tab}", - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), - ) + Column(horizontalAlignment = Alignment.End) { + Text( + transaction.activity, + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_activity")), + ) + Text( + "Tab: ${transaction.tab}", + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), + ) + } } TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall")) diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml new file mode 100644 index 0000000000..a74b152b33 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml new file mode 100644 index 0000000000..8928cea2bf --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml @@ -0,0 +1,6 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml index 918cb756c5..a87670f6c0 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml @@ -23,4 +23,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml index b01417199a..73f92d1d26 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml @@ -20,6 +20,12 @@ + + +