Skip to content

Commit 64c1c55

Browse files
authored
Merge pull request #6167 from getsentry/fix/callback-error-handling-custom-callbacks
fix(integrations): [Callback Errors 9] Guard custom callbacks
2 parents 92968ab + e87fddf commit 64c1c55

27 files changed

Lines changed: 926 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
- Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166))
6666
- Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report.
6767
- Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb.
68+
- Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167))
69+
- Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request. Report lost sampled spans as `callback_error` in client reports and `OnDiscardCallback` ([#6167](https://github.com/getsentry/sentry-java/pull/6167))
6870
- Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165))
6971
- When `tracesSampler` throws, drop the transaction and record `callback_error` instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163))
7072
- When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164))

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,17 @@ private boolean isMaskingEnabled() {
112112
final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback =
113113
options.getBeforeScreenshotCaptureCallback();
114114
if (beforeCaptureCallback != null) {
115-
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
115+
try {
116+
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
117+
return event;
118+
}
119+
} catch (Exception e) {
120+
options
121+
.getLogger()
122+
.log(
123+
SentryLevel.ERROR,
124+
"The beforeScreenshotCapture callback threw an exception. Skipping screenshot capture.",
125+
e);
116126
return event;
117127
}
118128
} else if (shouldDebounce) {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,17 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options)
9191
final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback =
9292
options.getBeforeViewHierarchyCaptureCallback();
9393
if (beforeCaptureCallback != null) {
94-
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
94+
try {
95+
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
96+
return event;
97+
}
98+
} catch (Exception e) {
99+
options
100+
.getLogger()
101+
.log(
102+
SentryLevel.ERROR,
103+
"The beforeViewHierarchyCapture callback threw an exception. Skipping view hierarchy capture.",
104+
e);
95105
return event;
96106
}
97107
} else if (shouldDebounce) {

‎sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ import androidx.compose.ui.text.style.TextOverflow
2929
import androidx.compose.ui.unit.dp
3030
import androidx.compose.ui.unit.sp
3131
import androidx.test.ext.junit.runners.AndroidJUnit4
32+
import com.google.common.truth.Truth.assertThat
3233
import io.sentry.Attachment
3334
import io.sentry.Hint
35+
import io.sentry.ILogger
3436
import io.sentry.MainEventProcessor
3537
import io.sentry.SentryEvent
3638
import io.sentry.SentryIntegrationPackageStorage
39+
import io.sentry.SentryLevel
3740
import io.sentry.TypeCheckHint.ANDROID_ACTIVITY
3841
import io.sentry.protocol.SentryException
3942
import io.sentry.util.thread.IThreadChecker
@@ -48,6 +51,7 @@ import kotlin.test.assertSame
4851
import kotlin.test.assertTrue
4952
import org.junit.runner.RunWith
5053
import org.mockito.kotlin.mock
54+
import org.mockito.kotlin.verify
5155
import org.mockito.kotlin.whenever
5256
import org.robolectric.Robolectric.buildActivity
5357
import org.robolectric.Shadows.shadowOf
@@ -310,11 +314,38 @@ class ScreenshotEventProcessorTest {
310314
assertNull(hint.screenshot)
311315
}
312316

317+
@Test
318+
fun `when capture callback throws, skips screenshot and retains event`() {
319+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
320+
val logger = mock<ILogger>()
321+
fixture.options.isDebug = true
322+
fixture.options.setLogger(logger)
323+
val failure = IllegalStateException("callback failed")
324+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> throw failure }
325+
val processor = fixture.getSut(true)
326+
val event = SentryEvent().apply { exceptions = listOf(SentryException()) }
327+
val hint = Hint()
328+
329+
assertThat(processor.process(event, hint)).isSameInstanceAs(event)
330+
assertThat(hint.screenshot).isNull()
331+
verify(logger)
332+
.log(
333+
SentryLevel.ERROR,
334+
"The beforeScreenshotCapture callback threw an exception. Skipping screenshot capture.",
335+
failure,
336+
)
337+
338+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> true }
339+
val nextHint = Hint()
340+
assertThat(processor.process(event, nextHint)).isSameInstanceAs(event)
341+
assertThat(nextHint.screenshot).isNotNull()
342+
}
343+
313344
@Test
314345
fun `when capture callback returns true, a screenshot should be captured`() {
315346
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
316347

317-
fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true }
348+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> true }
318349
val processor = fixture.getSut(true)
319350

320351
val event = SentryEvent().apply { exceptions = listOf(SentryException()) }

‎sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import android.view.View
55
import android.view.ViewGroup
66
import android.view.Window
77
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import com.google.common.truth.Truth.assertThat
89
import io.sentry.Hint
910
import io.sentry.JsonSerializable
1011
import io.sentry.JsonSerializer
1112
import io.sentry.SentryEvent
1213
import io.sentry.SentryIntegrationPackageStorage
14+
import io.sentry.SentryLevel
1315
import io.sentry.TypeCheckHint
1416
import io.sentry.protocol.SentryException
1517
import io.sentry.util.thread.IThreadChecker
@@ -342,6 +344,31 @@ class ViewHierarchyEventProcessorTest {
342344
assertNull(hint.viewHierarchy)
343345
}
344346

347+
@Test
348+
fun `when capture callback throws, skips view hierarchy and retains event`() {
349+
fixture.options.isDebug = true
350+
fixture.options.setLogger(fixture.logger)
351+
val failure = IllegalStateException("callback failed")
352+
fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> throw failure }
353+
val processor = fixture.getSut(true)
354+
val event = SentryEvent().apply { exceptions = listOf(SentryException()) }
355+
val hint = Hint()
356+
357+
assertThat(processor.process(event, hint)).isSameInstanceAs(event)
358+
assertThat(hint.viewHierarchy).isNull()
359+
verify(fixture.logger)
360+
.log(
361+
SentryLevel.ERROR,
362+
"The beforeViewHierarchyCapture callback threw an exception. Skipping view hierarchy capture.",
363+
failure,
364+
)
365+
366+
fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true }
367+
val nextHint = Hint()
368+
assertThat(processor.process(event, nextHint)).isSameInstanceAs(event)
369+
assertThat(nextHint.viewHierarchy).isNotNull()
370+
}
371+
345372
@Test
346373
fun `when capture callback returns true, a view hierarchy should be captured`() {
347374
fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true }

‎sentry-apollo-3/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434
testImplementation(kotlin(Config.kotlinStdLib))
3535
testImplementation(libs.apollo3.kotlin)
3636
testImplementation(libs.kotlin.test.junit)
37+
testImplementation(libs.google.truth)
3738
testImplementation(libs.kotlinx.coroutines)
3839
testImplementation(libs.mockito.kotlin)
3940
testImplementation(libs.mockito.inline)

‎sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.apollographql.apollo3.network.http.HttpInterceptor
1010
import com.apollographql.apollo3.network.http.HttpInterceptorChain
1111
import io.sentry.BaggageHeader
1212
import io.sentry.Breadcrumb
13+
import io.sentry.DataCategory
1314
import io.sentry.Hint
1415
import io.sentry.IScopes
1516
import io.sentry.ISpan
@@ -23,6 +24,7 @@ import io.sentry.SpanDataConvention.HTTP_METHOD_KEY
2324
import io.sentry.SpanStatus
2425
import io.sentry.TypeCheckHint.APOLLO_REQUEST
2526
import io.sentry.TypeCheckHint.APOLLO_RESPONSE
27+
import io.sentry.clientreport.DiscardReason
2628
import io.sentry.exception.ExceptionMechanismException
2729
import io.sentry.protocol.Mechanism
2830
import io.sentry.protocol.Request
@@ -216,13 +218,21 @@ constructor(
216218
span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it)
217219
}
218220
if (beforeSpan != null) {
221+
val wasSampled = span.isSampled == true
219222
try {
220223
val result = beforeSpan.execute(span, request, response)
221224
if (result == null) {
222225
// Span is dropped
223226
span.spanContext.sampled = false
224227
}
225228
} catch (e: Throwable) {
229+
span.spanContext.sampled = false
230+
if (wasSampled) {
231+
scopes.options.clientReportRecorder.recordLostEvent(
232+
DiscardReason.CALLBACK_ERROR,
233+
DataCategory.Span,
234+
)
235+
}
226236
scopes.options.logger.log(
227237
SentryLevel.ERROR,
228238
"An error occurred while executing beforeSpan on ApolloInterceptor",

‎sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import com.apollographql.apollo3.exception.ApolloException
77
import com.apollographql.apollo3.exception.ApolloHttpException
88
import com.apollographql.apollo3.network.http.HttpInterceptor
99
import com.apollographql.apollo3.network.http.HttpInterceptorChain
10+
import com.google.common.truth.Truth.assertThat
1011
import io.sentry.BaggageHeader
1112
import io.sentry.Breadcrumb
13+
import io.sentry.DataCategory
14+
import io.sentry.ILogger
1215
import io.sentry.IScopes
1316
import io.sentry.ITransaction
1417
import io.sentry.Scope
1518
import io.sentry.ScopeCallback
19+
import io.sentry.SentryLevel
1620
import io.sentry.SentryOptions
1721
import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS
1822
import io.sentry.SentryTraceHeader
@@ -25,6 +29,7 @@ import io.sentry.TracesSamplingDecision
2529
import io.sentry.TransactionContext
2630
import io.sentry.W3CTraceparentHeader
2731
import io.sentry.apollo3.SentryApollo3HttpInterceptor.BeforeSpanCallback
32+
import io.sentry.clientreport.DiscardReason
2833
import io.sentry.mockServerRequestTimeoutMillis
2934
import io.sentry.protocol.SdkVersion
3035
import io.sentry.protocol.SentryTransaction
@@ -47,6 +52,7 @@ import org.mockito.kotlin.check
4752
import org.mockito.kotlin.doAnswer
4853
import org.mockito.kotlin.mock
4954
import org.mockito.kotlin.verify
55+
import org.mockito.kotlin.verifyNoMoreInteractions
5056
import org.mockito.kotlin.whenever
5157

5258
class SentryApollo3InterceptorTest {
@@ -291,7 +297,10 @@ class SentryApollo3InterceptorTest {
291297

292298
@Test
293299
fun `returning null in beforeSpan callback drops span`() {
300+
val onDiscard = mock<SentryOptions.OnDiscardCallback>()
301+
fixture.options.onDiscard = onDiscard
294302
executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> null }))
303+
verifyNoMoreInteractions(onDiscard)
295304

296305
verify(fixture.scopes)
297306
.captureTransaction(
@@ -303,16 +312,68 @@ class SentryApollo3InterceptorTest {
303312
}
304313

305314
@Test
306-
fun `when customizer throws, exception is handled`() {
307-
executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> throw RuntimeException() }))
315+
fun `reports callback errors only for sampled spans`(): Unit = runBlocking {
316+
for (sampled in listOf(true, false, null)) {
317+
val onDiscard = mock<SentryOptions.OnDiscardCallback>()
318+
fixture.options.onDiscard = onDiscard
319+
val tx = SentryTracer(TransactionContext("op", "desc"), fixture.scopes)
320+
tx.spanContext.sampled = sampled
321+
whenever(fixture.scopes.span).thenReturn(tx)
322+
val sut =
323+
fixture.getSut(
324+
beforeSpan = { span, _, _ ->
325+
span.spanContext.sampled = false
326+
throw IllegalStateException("callback failed")
327+
}
328+
)
329+
330+
assertThat(sut.query(LaunchDetailsQuery("83")).execute().data).isNotNull()
331+
tx.finish()
332+
333+
if (sampled == true) {
334+
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1)
335+
}
336+
verifyNoMoreInteractions(onDiscard)
337+
}
338+
}
339+
340+
@Test
341+
fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking {
342+
val failure = IllegalStateException("callback failed")
343+
val logger = mock<ILogger>()
344+
fixture.options.isDebug = true
345+
fixture.options.setLogger(logger)
346+
val tx =
347+
SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes)
348+
whenever(fixture.scopes.span).thenReturn(tx)
349+
val sut =
350+
fixture.getSut(
351+
beforeSpan = { span, _, _ ->
352+
span.description = "partially modified"
353+
throw failure
354+
}
355+
)
308356

357+
val response = sut.query(LaunchDetailsQuery("83")).execute()
358+
assertThat(response.data).isNotNull()
359+
val span = tx.children.single()
360+
assertThat(span.isSampled).isFalse()
361+
assertThat(span.isFinished).isTrue()
362+
tx.finish()
309363
verify(fixture.scopes)
310364
.captureTransaction(
311-
check { assertEquals(1, it.spans.size) },
365+
check { assertThat(it.spans).isEmpty() },
312366
anyOrNull<TraceContext>(),
313367
anyOrNull(),
314368
anyOrNull(),
315369
)
370+
verify(fixture.scopes).addBreadcrumb(any<Breadcrumb>(), anyOrNull())
371+
verify(logger)
372+
.log(
373+
SentryLevel.ERROR,
374+
"An error occurred while executing beforeSpan on ApolloInterceptor",
375+
failure,
376+
)
316377
}
317378

318379
@Test

‎sentry-apollo-4/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434
testImplementation(kotlin(Config.kotlinStdLib))
3535
testImplementation(libs.apollo4.kotlin)
3636
testImplementation(libs.kotlin.test.junit)
37+
testImplementation(libs.google.truth)
3738
testImplementation(libs.kotlinx.coroutines)
3839
testImplementation(libs.kotlinx.coroutines.test)
3940
testImplementation(libs.mockito.kotlin)

‎sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.apollographql.apollo.network.http.HttpInterceptor
88
import com.apollographql.apollo.network.http.HttpInterceptorChain
99
import io.sentry.BaggageHeader
1010
import io.sentry.Breadcrumb
11+
import io.sentry.DataCategory
1112
import io.sentry.Hint
1213
import io.sentry.IScopes
1314
import io.sentry.ISpan
@@ -21,6 +22,7 @@ import io.sentry.SpanDataConvention.HTTP_METHOD_KEY
2122
import io.sentry.SpanStatus
2223
import io.sentry.TypeCheckHint.APOLLO_REQUEST
2324
import io.sentry.TypeCheckHint.APOLLO_RESPONSE
25+
import io.sentry.clientreport.DiscardReason
2426
import io.sentry.exception.ExceptionMechanismException
2527
import io.sentry.protocol.Mechanism
2628
import io.sentry.protocol.Request
@@ -215,13 +217,21 @@ constructor(
215217
span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it)
216218
}
217219
if (beforeSpan != null) {
220+
val wasSampled = span.isSampled == true
218221
try {
219222
val result = beforeSpan.execute(span, request, response)
220223
if (result == null) {
221224
// Span is dropped
222225
span.spanContext.sampled = false
223226
}
224227
} catch (e: Throwable) {
228+
span.spanContext.sampled = false
229+
if (wasSampled) {
230+
scopes.options.clientReportRecorder.recordLostEvent(
231+
DiscardReason.CALLBACK_ERROR,
232+
DataCategory.Span,
233+
)
234+
}
225235
scopes.options.logger.log(
226236
SentryLevel.ERROR,
227237
"An error occurred while executing beforeSpan in ApolloInterceptor",

0 commit comments

Comments
 (0)