diff --git a/CHANGELOG.md b/CHANGELOG.md index a439b35e5f..9d72a4c9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Set Apollo 3, 4, and 5 HTTP spans to `CANCELLED` instead of `INTERNAL_ERROR` when the request is cancelled ([#6284](https://github.com/getsentry/sentry-java/pull/6284)) + ## 8.61.0 ### Fixes diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 6711b165dd..22ed3a61e2 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.apollo3.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 53e4865739..d2f37b888f 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -39,6 +39,7 @@ import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils import io.sentry.util.UrlUtils import io.sentry.vendor.Base64 +import kotlin.coroutines.cancellation.CancellationException import okio.Buffer import org.jetbrains.annotations.ApiStatus @@ -90,6 +91,8 @@ constructor( } catch (e: Throwable) { // https://github.com/apollographql/apollo-kotlin/issues/4711 will change error handling in v4 when (e) { + is CancellationException -> span?.status = SpanStatus.CANCELLED + is ApolloHttpException -> { statusCode = e.statusCode span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 49d7d790a5..1c33acb038 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -1,6 +1,7 @@ package io.sentry.apollo3 import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.http.HttpMethod import com.apollographql.apollo3.api.http.HttpRequest import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.exception.ApolloException @@ -35,8 +36,10 @@ import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.util.Apollo3PlatformTestManipulator import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -210,6 +213,25 @@ class SentryApollo3InterceptorTest { ) } + @Test + fun `sets span status to cancelled and rethrows CancellationException`(): Unit = runBlocking { + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val cancellation = CancellationException("cancelled") + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = throw cancellation + } + val request = HttpRequest.Builder(HttpMethod.Post, "https://example.com/graphql").build() + val interceptor = SentryApollo3HttpInterceptor(fixture.scopes, captureFailedRequests = false) + + val thrown = assertFailsWith { interceptor.intercept(request, chain) } + + assertThat(thrown).isSameInstanceAs(cancellation) + assertThat(tx.children.single().status).isEqualTo(SpanStatus.CANCELLED) + } + @Test fun `does not add sentry trace header to the request if host is disallowed`() { fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist")) diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 9fb024a01d..feed9ea09c 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.apollo4.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index aea5d6691c..5106fa763b 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -38,6 +38,7 @@ import io.sentry.util.TracingUtils import io.sentry.util.UrlUtils import io.sentry.vendor.Base64 import java.util.Locale +import kotlin.coroutines.cancellation.CancellationException import okio.Buffer import org.jetbrains.annotations.ApiStatus @@ -90,6 +91,8 @@ constructor( // client errors don't throw anymore in v4, but we should still be able to detect all of them // by looking at the status code and/or errors in the response body when (e) { + is CancellationException -> span?.status = SpanStatus.CANCELLED + is ApolloHttpException -> { statusCode = e.statusCode span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index 210cab9032..d55d809ce7 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -4,6 +4,7 @@ import com.apollographql.apollo.ApolloCall import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.http.HttpMethod import com.apollographql.apollo.api.http.HttpRequest import com.apollographql.apollo.api.http.HttpResponse import com.apollographql.apollo.exception.ApolloException @@ -39,9 +40,11 @@ import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.util.Apollo4PlatformTestManipulator import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException import kotlin.reflect.KSuspendFunction1 import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -224,6 +227,25 @@ abstract class SentryApollo4HttpInterceptorTest( ) } + @Test + fun `sets span status to cancelled and rethrows CancellationException`(): Unit = runBlocking { + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val cancellation = CancellationException("cancelled") + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = throw cancellation + } + val request = HttpRequest.Builder(HttpMethod.Post, "https://example.com/graphql").build() + val interceptor = SentryApollo4HttpInterceptor(fixture.scopes, captureFailedRequests = false) + + val thrown = assertFailsWith { interceptor.intercept(request, chain) } + + assertThat(thrown).isSameInstanceAs(cancellation) + assertThat(tx.children.single().status).isEqualTo(SpanStatus.CANCELLED) + } + @Test fun `does not add sentry trace header to the request if host is disallowed`() { fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist")) diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt index f74d0fec11..acbcca3539 100644 --- a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt @@ -38,6 +38,7 @@ import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils import io.sentry.util.UrlUtils import java.util.Locale +import kotlin.coroutines.cancellation.CancellationException import okio.Buffer import org.jetbrains.annotations.ApiStatus @@ -93,6 +94,8 @@ constructor( // client errors don't throw anymore in v5, but we should still be able to detect all of them // by looking at the status code and/or errors in the response body when (e) { + is CancellationException -> span?.status = SpanStatus.CANCELLED + is ApolloHttpException -> { statusCode = e.statusCode span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt index 399667a764..93077aab11 100644 --- a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt @@ -39,6 +39,7 @@ import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.util.Apollo5PlatformTestManipulator import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException import kotlin.reflect.KSuspendFunction1 import kotlin.test.Test import kotlin.test.assertEquals @@ -326,6 +327,25 @@ abstract class SentryApollo5HttpInterceptorTest( return bodyRead } + @Test + fun `sets span status to cancelled and rethrows CancellationException`(): Unit = runBlocking { + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val cancellation = CancellationException("cancelled") + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = throw cancellation + } + val request = HttpRequest.Builder(HttpMethod.Post, "https://example.com/graphql").build() + val interceptor = SentryApollo5HttpInterceptor(fixture.scopes, captureFailedRequests = false) + + val thrown = assertFailsWith { interceptor.intercept(request, chain) } + + assertThat(thrown).isSameInstanceAs(cancellation) + assertThat(tx.children.single().status).isEqualTo(SpanStatus.CANCELLED) + } + @Test fun `does not add sentry trace header to the request if host is disallowed`() { fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist"))