diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d98e41ebc..6d37da44d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. ## 8.49.0 diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 43a45da7bb3..7c21eca8ef0 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,10 +1,14 @@ package io.sentry.android.timber import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion +import io.sentry.transport.ITransport import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import timber.log.Timber class SentryTimberIntegrationTest { @@ -112,4 +117,43 @@ class SentryTimberIntegrationTest { assertTrue(fixture.options.sdkVersion!!.integrationSet.contains("Timber")) } + + @Test + fun `a beforeSend callback that logs via Timber does not recurse`() { + // End-to-end guard against SDK-CRASHES-JAVA-3T3H style recursion: with a real Sentry instance, + // a beforeSend callback that logs through the planted SentryTimberTree must not loop back into + // capture forever. + val transport = mock() + val transportFactory = mock() + whenever(transportFactory.create(any(), any())).thenReturn(transport) + + var beforeSendInvocations = 0 + Sentry.init { options -> + options.dsn = "https://key@sentry.io/123" + options.setTransportFactory(transportFactory) + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } + } + Timber.plant( + SentryTimberTree( + ScopesAdapter.getInstance(), + SentryLevel.ERROR, + SentryLevel.INFO, + SentryLogLevel.INFO, + ) + ) + + try { + Timber.e("outer error") + + // Without the core re-entrancy guard this recurses until a StackOverflowError. The nested + // Timber.e is dropped before its own beforeSend, so the callback runs exactly once. + assertEquals(1, beforeSendInvocations) + } finally { + Sentry.close() + } + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d1aecf5ccfd..c623e71d08f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7924,6 +7924,11 @@ public final class io/sentry/util/ScopesUtil { public static fun printScopesChain (Lio/sentry/IScopes;)V } +public final class io/sentry/util/SentryCallbackReentrancyGuard { + public static fun enter ()Lio/sentry/ISentryLifecycleToken; + public static fun isActive ()Z +} + public final class io/sentry/util/SentryRandom { public fun ()V public static fun current ()Lio/sentry/util/Random; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..195e5b5b05a 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -16,6 +16,7 @@ import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import io.sentry.util.Pair; +import io.sentry.util.SentryCallbackReentrancyGuard; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; @@ -464,7 +465,7 @@ public Queue getBreadcrumbs() { final @NotNull SentryOptions.BeforeBreadcrumbCallback callback, @NotNull Breadcrumb breadcrumb, final @NotNull Hint hint) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { breadcrumb = callback.execute(breadcrumb, hint); } catch (Throwable e) { options @@ -493,6 +494,10 @@ public void addBreadcrumb(@NotNull Breadcrumb breadcrumb, @Nullable Hint hint) { if (breadcrumb == null || breadcrumbs instanceof DisabledQueue) { return; } + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } SentryOptions.BeforeBreadcrumbCallback callback = options.getBeforeBreadcrumb(); if (callback != null) { if (hint == null) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a0ff98a9af7..a25aa9c79e1 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -107,6 +107,11 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @NotNull SentryEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SentryEvent is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -236,7 +241,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul final SentryReplayOptions.BeforeErrorSamplingCallback beforeErrorSampling = options.getSessionReplay().getBeforeErrorSampling(); if (beforeErrorSampling != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { shouldCaptureReplay = beforeErrorSampling.execute(event, hint); } catch (Throwable e) { options @@ -312,6 +317,11 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -937,10 +947,19 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint private @NotNull SentryId sendEnvelope( @NotNull final SentryEnvelope envelope, @Nullable final Hint hint) throws IOException { + // captureEnvelope and captureCheckIn have no entry-level guard, so a callback that captures + // one of those would recurse back into beforeEnvelopeCallback. In normal flow the guard is + // already inactive by the time we get here (the before* callback has exited), so an active + // guard means a callback triggered this send. Drop silently: a log here can re-enter through a + // logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + final @Nullable SentryOptions.BeforeEnvelopeCallback beforeEnvelopeCallback = options.getBeforeEnvelopeCallback(); if (beforeEnvelopeCallback != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { beforeEnvelopeCallback.execute(envelope, hint); } catch (Throwable e) { options @@ -969,6 +988,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint final @Nullable ProfilingTraceData profilingTraceData) { Objects.requireNonNull(transaction, "Transaction is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -1187,6 +1211,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @Override public @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, @Nullable Hint hint, final @NotNull IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return SentryId.EMPTY_ID; + } + SentryEvent event = new SentryEvent(); event.getContexts().setFeedback(feedback); @@ -1297,6 +1326,11 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @ApiStatus.Experimental @Override public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } + if (logEvent != null && scope != null) { logEvent = processLogEvent(logEvent, scope.getEventProcessors()); if (logEvent == null) { @@ -1351,6 +1385,11 @@ public void captureMetric( @Nullable SentryMetricsEvent metricsEvent, final @Nullable IScope scope, @Nullable Hint hint) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } + if (hint == null) { hint = new Hint(); } @@ -1612,7 +1651,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSend = options.getBeforeSend(); if (beforeSend != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSend.execute(event, hint); } catch (Throwable e) { options @@ -1634,7 +1673,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendTransactionCallback beforeSendTransaction = options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { transaction = beforeSendTransaction.execute(transaction, hint); } catch (Throwable e) { options @@ -1655,7 +1694,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSendFeedback = options.getBeforeSendFeedback(); if (beforeSendFeedback != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendFeedback.execute(event, hint); } catch (Throwable e) { options @@ -1673,7 +1712,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryReplayEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendReplayCallback beforeSendReplay = options.getBeforeSendReplay(); if (beforeSendReplay != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendReplay.execute(event, hint); } catch (Throwable e) { options @@ -1694,7 +1733,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Logs.BeforeSendLogCallback beforeSendLog = options.getLogs().getBeforeSend(); if (beforeSendLog != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendLog.execute(event); } catch (Throwable e) { options @@ -1716,7 +1755,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Metrics.BeforeSendMetricCallback beforeSendMetric = options.getMetrics().getBeforeSend(); if (beforeSendMetric != null) { - try { + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendMetric.execute(event, hint); } catch (Throwable e) { options diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index cde0c37ba90..f10f2aede05 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3327,6 +3327,10 @@ public interface BeforeSendCallback { /** * Mutates or drop an event before being sent * + *

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

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

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

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

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

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

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

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

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

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

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

{@link #enter()} returns an {@link ISentryLifecycleToken} so callers can use + * try-with-resources instead of a manual {@code finally exit()}. The token is a shared singleton + * (its {@code close()} just decrements the counter), so no allocation happens per callback. + */ +@ApiStatus.Internal +public final class SentryCallbackReentrancyGuard { + + private static final ThreadLocal depth = new ThreadLocal<>(); + + private static final ISentryLifecycleToken TOKEN = SentryCallbackReentrancyGuard::exit; + + private SentryCallbackReentrancyGuard() {} + + /** + * Whether a user callback is currently executing on this thread. When {@code true}, capture entry + * points must drop the capture and return without logging — see the class Javadoc for why logging + * on the drop path re-opens the recursion. + */ + public static boolean isActive() { + final @Nullable Integer current = depth.get(); + return current != null && current > 0; + } + + /** + * Marks that a user callback is starting to execute on this thread. Close the returned token (via + * try-with-resources) once the callback returns. + */ + public static @NotNull ISentryLifecycleToken enter() { + final @Nullable Integer current = depth.get(); + depth.set(current == null ? 1 : current + 1); + return TOKEN; + } + + private static void exit() { + final @Nullable Integer current = depth.get(); + if (current == null || current <= 1) { + depth.remove(); + } else { + depth.set(current - 1); + } + } +} diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 86aaf6f8f24..7af4f6ccdca 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -371,6 +371,27 @@ class ScopeTest { assertFalse(called) } + @Test + fun `when beforeBreadcrumb adds another breadcrumb, the nested breadcrumb is dropped and does not recurse`() { + var invocations = 0 + lateinit var scope: Scope + val options = + SentryOptions().apply { + beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + invocations++ + scope.addBreadcrumb(Breadcrumb()) + breadcrumb + } + } + + scope = Scope(options) + scope.addBreadcrumb(Breadcrumb()) + + // Callback runs only for the outer breadcrumb; the nested one is dropped before its callback. + assertEquals(1, invocations) + assertEquals(1, scope.breadcrumbs.count()) + } + @Test fun `when adding breadcrumb and maxBreadcrumb is not 0, beforeBreadcrumb is executed`() { var called = false diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5cfa4b6b619..fa37cb0b70b 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -284,6 +284,108 @@ class SentryClientTest { ) } + @Test + fun `when beforeSend captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // Callback runs only for the outer event; the nested capture is dropped before its callback. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSend captures a log, the nested log is dropped`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // The shared guard spans capture types: a log emitted from beforeSend is dropped too. + verify(fixture.loggerBatchProcessor, never()).add(any()) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendLog logs again, the nested log is dropped and does not recurse`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.logs.setBeforeSend { l -> + invocations++ + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + l + } + sut = fixture.getSut() + + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "outer", SentryLogLevel.WARN), + scope, + ) + + assertEquals(1, invocations) + verify(fixture.loggerBatchProcessor, times(1)).add(any()) + } + + @Test + fun `when beforeSend captures feedback before an event, the guard is not cleared prematurely`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + // Capturing feedback must not clear the re-entrancy guard for captures that follow it in the + // same callback, otherwise the captureEvent below would recurse. + sut.captureFeedback(Feedback("feedback"), null, scope) + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendFeedback captures feedback again, the nested capture is dropped and does not recurse`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSendFeedback { e, _ -> + invocations++ + sut.captureFeedback(Feedback("nested"), null, scope) + e + } + sut = fixture.getSut() + + sut.captureFeedback(Feedback("outer"), null, scope) + + assertEquals(1, invocations) + } + @Test fun `when beforeSendLog is set, callback is invoked`() { val scope = createScope() @@ -3188,6 +3290,44 @@ class SentryClientTest { assertTrue(beforeEnvelopeCalled) } + @Test + fun `when beforeEnvelopeCallback captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeEnvelopeCallback captures an envelope, the nested envelope is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEnvelope(SentryEnvelope(SentryId(UUID.randomUUID()), null, setOf())) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + // Callback runs only for the outer envelope; the nested captureEnvelope is dropped in + // sendEnvelope before its callback would run. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { val sut = fixture.getSut { options -> @@ -3450,6 +3590,24 @@ class SentryClientTest { assertFalse(called) } + @Test + fun `when beforeErrorSampling captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + true + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeErrorSampling returning false skips captureReplay`() { var called = false