diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index 8355d75a43b..facb16e7281 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -13,6 +13,8 @@ body: - sentry-jdbc - sentry-apollo - sentry-apollo-3 + - sentry-apollo-4 + - sentry-apollo-5 - sentry-kotlin-extensions - sentry-opentelemetry-agent - sentry-opentelemetry-agentless diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ef4c0bac8..d403afd0438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add `sentry-apollo-5` integration for Apollo Kotlin 5, providing HTTP tracing and failed GraphQL request reporting ([#6074](https://github.com/getsentry/sentry-java/pull/6074)) + ### Fixes - `SentryTraced` now checks for its owning transaction dynamically rather than once per app process. The latter caused `SentryTraced` spans to be dropped process-wide once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057)) diff --git a/README.md b/README.md index 849aaf74457..b3a7cc93061 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Sentry SDK for Java and Android | sentry-apollo | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo) | 21 | | sentry-apollo-3 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-3?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-3) | 21 | | sentry-apollo-4 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-4?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-4) | 21 | +| sentry-apollo-5 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-5?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-5) | 21 | | sentry-kotlin-extensions | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-kotlin-extensions?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-kotlin-extensions) | 21 | | sentry-ktor-client | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-ktor-client?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-ktor-client) | 21 | | sentry-servlet | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-servlet?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-servlet) | | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 09d2869988b..97ac541db29 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -68,6 +68,7 @@ object Config { val SENTRY_OPENFEIGN_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeign" val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" val SENTRY_APOLLO4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo4" + val SENTRY_APOLLO5_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo5" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" val SENTRY_GRAPHQL_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql-core" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e95a4cd132..31a8aa36dfc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,6 +83,9 @@ apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-supp apollo2-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } apollo3-kotlin = { module = "com.apollographql.apollo3:apollo-runtime", version = "3.8.6" } apollo4-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = "4.1.1" } +# sentry-apollo-5 compiles against 5.0.0 (supported floor) and tests against the latest 5.x +apollo5-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = "5.0.0" } +apollo5-kotlin-latest = { module = "com.apollographql.apollo:apollo-runtime", version = "5.1.0" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.3.0" } androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" } androidx-activity = { module = "androidx.activity:activity", version = "1.8.2" } @@ -270,6 +273,8 @@ mockito-kotlin-spring7 = { module = "org.mockito.kotlin:mockito-kotlin", version mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +# apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath +okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } diff --git a/sentry-apollo-5/README.md b/sentry-apollo-5/README.md new file mode 100644 index 00000000000..85dd96820a9 --- /dev/null +++ b/sentry-apollo-5/README.md @@ -0,0 +1,39 @@ +# sentry-apollo-5 + +This module provides HTTP tracing and failed GraphQL request reporting for [Apollo Kotlin 5](https://www.apollographql.com/docs/kotlin/). + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Android](https://docs.sentry.io/platforms/android/integrations/apollo5/) or [Java](https://docs.sentry.io/platforms/java/tracing/instrumentation/apollo5/). + +## Usage + +Add `io.sentry:sentry-apollo-5` and install the integration while building the Apollo client: + +```kotlin +val apolloClient = + ApolloClient.Builder() + .serverUrl("https://example.com/graphql") + .sentryTracing() + .build() +``` + +The builder extension installs the Apollo interceptor before the cache and the Sentry HTTP interceptor. + +Apollo rejects builder HTTP interceptors when a custom `NetworkTransport` is configured, so do not use `sentryTracing()` in that case. Add `SentryApollo5Interceptor` to the client builder and `SentryApollo5HttpInterceptor` to the custom transport manually. + +Operation metadata is propagated between these interceptors through Apollo's execution context. Apollo's default HTTP request composer copies this context automatically. A custom `HttpRequestComposer` must copy it explicitly when building the HTTP request: + +```kotlin +HttpRequest.Builder(method, url) + .addExecutionContext(apolloRequest.executionContext) + .build() +``` + +An entirely custom `NetworkTransport` must expose the metadata to `SentryApollo5HttpInterceptor` in the same way. + +## Known limitations + +- Failed GraphQL request detection matches the raw JSON response body for an `errors` field. +- Multipart and incremental responses retain the Apollo 4 failed-request inspection limitation. +- WebSocket subscriptions are not instrumented. +- Batching behavior depends on HTTP interceptor ordering. +- Normalized-cache hits do not create HTTP spans. diff --git a/sentry-apollo-5/api/sentry-apollo-5.api b/sentry-apollo-5/api/sentry-apollo-5.api new file mode 100644 index 00000000000..6eb90caa6bc --- /dev/null +++ b/sentry-apollo-5/api/sentry-apollo-5.api @@ -0,0 +1,51 @@ +public final class io/sentry/apollo5/BuildConfig { + public static final field SENTRY_APOLLO5_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/apollo5/SentryApollo5ClientException : java/lang/Exception { + public static final field Companion Lio/sentry/apollo5/SentryApollo5ClientException$Companion; + public fun (Ljava/lang/String;)V +} + +public final class io/sentry/apollo5/SentryApollo5ClientException$Companion { +} + +public final class io/sentry/apollo5/SentryApollo5HttpInterceptor : com/apollographql/apollo/network/http/HttpInterceptor { + public static final field Companion Lio/sentry/apollo5/SentryApollo5HttpInterceptor$Companion; + public static final field DEFAULT_CAPTURE_FAILED_REQUESTS Z + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;Z)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun dispose ()V + public fun intercept (Lcom/apollographql/apollo/api/http/HttpRequest;Lcom/apollographql/apollo/network/http/HttpInterceptorChain;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class io/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lcom/apollographql/apollo/api/http/HttpRequest;Lcom/apollographql/apollo/api/http/HttpResponse;)Lio/sentry/ISpan; +} + +public final class io/sentry/apollo5/SentryApollo5HttpInterceptor$Companion { +} + +public final class io/sentry/apollo5/SentryApollo5Interceptor : com/apollographql/apollo/interceptor/ApolloInterceptor { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun intercept (Lcom/apollographql/apollo/api/ApolloRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptorChain;)Lkotlinx/coroutines/flow/Flow; +} + +public final class io/sentry/apollo5/SentryApolloBuilderExtensionsKt { + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;Z)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo5/SentryApollo5HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo/ApolloClient$Builder; +} + diff --git a/sentry-apollo-5/build.gradle.kts b/sentry-apollo-5/build.gradle.kts new file mode 100644 index 00000000000..e4cd7dd0345 --- /dev/null +++ b/sentry-apollo-5/build.gradle.kts @@ -0,0 +1,73 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer.android") +} + +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 +} + +dependencies { + api(projects.sentry) + api(projects.sentryKotlinExtensions) + + compileOnly(libs.apollo5.kotlin) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.apollo5.kotlin.latest) + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.kotlinx.coroutines) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.okhttp.mockwebserver5) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.apollo5") + buildConfigField( + "String", + "SENTRY_APOLLO5_SDK_NAME", + "\"${Config.Sentry.SENTRY_APOLLO5_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_APOLLO5_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-apollo-5", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5.kt new file mode 100644 index 00000000000..b1d2b6f6639 --- /dev/null +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5.kt @@ -0,0 +1,15 @@ +package io.sentry.apollo5 + +import com.apollographql.apollo.api.ExecutionContext + +internal data class SentryApollo5OperationContext( + val operationId: String, + val operationName: String, + val operationType: String, + val variables: String?, +) : ExecutionContext.Element { + override val key: ExecutionContext.Key<*> + get() = Key + + companion object Key : ExecutionContext.Key +} diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5ClientException.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5ClientException.kt new file mode 100644 index 00000000000..ba2b874dba8 --- /dev/null +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5ClientException.kt @@ -0,0 +1,11 @@ +package io.sentry.apollo5 + +/** + * Used for holding an Apollo5 client error, for example. An integration that does not throw when + * API returns 4xx, 5xx or the `errors` field. + */ +class SentryApollo5ClientException(message: String?) : Exception(message) { + companion object { + private const val serialVersionUID = 4312160066430858144L + } +} 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 new file mode 100644 index 00000000000..4c953062dbb --- /dev/null +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt @@ -0,0 +1,414 @@ +package io.sentry.apollo5 + +import com.apollographql.apollo.api.http.HttpHeader +import com.apollographql.apollo.api.http.HttpRequest +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.network.http.HttpInterceptor +import com.apollographql.apollo.network.http.HttpInterceptorChain +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ScopesAdapter +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SpanDataConvention +import io.sentry.SpanDataConvention.HTTP_METHOD_KEY +import io.sentry.SpanStatus +import io.sentry.TypeCheckHint.APOLLO_REQUEST +import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.Mechanism +import io.sentry.protocol.Request +import io.sentry.protocol.Response +import io.sentry.util.ExceptionUtils +import io.sentry.util.HttpUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.Platform +import io.sentry.util.PropagationTargetsUtils +import io.sentry.util.SpanUtils +import io.sentry.util.TracingUtils +import io.sentry.util.UrlUtils +import java.util.Locale +import okio.Buffer +import org.jetbrains.annotations.ApiStatus + +private const val TRACE_ORIGIN = "auto.graphql.apollo5" + +class SentryApollo5HttpInterceptor +@JvmOverloads +constructor( + @ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance(), + private val beforeSpan: BeforeSpanCallback? = null, + private val captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), +) : HttpInterceptor { + init { + addIntegrationToSdkVersion("Apollo5") + if (captureFailedRequests) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Apollo5ClientError") + } + } + + private val regex: Regex by lazy { "(?i)\"errors\"\\s*:\\s*\\[".toRegex() } + + override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse { + val activeSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span + + val operationContext = request.executionContext[SentryApollo5OperationContext] + val operationId = operationContext?.operationId + val operationName = operationContext?.operationName + val operationType = operationContext?.operationType + val variables = operationContext?.variables + + var span: ISpan? = null + + if (activeSpan != null) { + span = startChild(request, activeSpan, operationName, operationType, operationId, variables) + } + + val modifiedRequest = maybeAddTracingHeaders(scopes, request, span) + var httpResponse: HttpResponse? = null + var statusCode: Int? = null + + try { + httpResponse = chain.proceed(modifiedRequest) + statusCode = httpResponse.statusCode + span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) + span?.status = SpanStatus.fromHttpStatusCode(statusCode) + + captureEvent(modifiedRequest, httpResponse, operationName, operationType) + + return httpResponse + } catch (e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) + // 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 ApolloHttpException -> { + statusCode = e.statusCode + span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) + span?.status = SpanStatus.fromHttpStatusCode(statusCode, SpanStatus.INTERNAL_ERROR) + } + + else -> span?.status = SpanStatus.INTERNAL_ERROR + } + span?.throwable = e + throw e + } finally { + finish( + span, + modifiedRequest, + httpResponse, + statusCode, + operationName, + operationType, + operationId, + ) + } + } + + private fun maybeAddTracingHeaders( + scopes: IScopes, + request: HttpRequest, + span: ISpan?, + ): HttpRequest { + if (isIgnored()) { + return request + } + + val tracingHeaders = + TracingUtils.traceIfAllowed( + scopes, + request.url, + request.headers.filter { it.name == BaggageHeader.BAGGAGE_HEADER }.map { it.value }, + span, + ) ?: return request + + var headers = request.headers.toMutableList() + headers.add( + HttpHeader(tracingHeaders.sentryTraceHeader.name, tracingHeaders.sentryTraceHeader.value) + ) + tracingHeaders.baggageHeader?.let { baggageHeader -> + headers = + headers + .filterNot { it.name == BaggageHeader.BAGGAGE_HEADER } + .toMutableList() + .apply { add(HttpHeader(baggageHeader.name, baggageHeader.value)) } + } + tracingHeaders.w3cTraceparentHeader?.let { w3cHeader -> + headers.add(HttpHeader(w3cHeader.name, w3cHeader.value)) + } + + return request.newBuilder().headers(headers).build() + } + + private fun isIgnored(): Boolean = + SpanUtils.isIgnored(scopes.getOptions().ignoredSpanOrigins, TRACE_ORIGIN) + + private fun startChild( + request: HttpRequest, + activeSpan: ISpan, + operationName: String?, + operationType: String?, + operationId: String?, + variables: String?, + ): ISpan { + val urlDetails = UrlUtils.parse(request.url) + val method = request.method.name + + val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" + + val description = "${operationType ?: method} ${operationName ?: urlDetails.urlOrFallback}" + + return activeSpan.startChild(operation, description).apply { + urlDetails.applyToSpan(this) + + spanContext.origin = TRACE_ORIGIN + + operationId?.let { setData("operationId", it) } + + variables?.let { setData("variables", it) } + setData(HTTP_METHOD_KEY, method.uppercase(Locale.ROOT)) + } + } + + private fun finish( + span: ISpan?, + request: HttpRequest, + response: HttpResponse?, + statusCode: Int?, + operationName: String?, + operationType: String?, + operationId: String?, + ) { + var responseContentLength: Long? = null + response?.body?.buffer?.size?.ifHasValidLength { responseContentLength = it } + + if (span != null) { + statusCode?.let { span.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) } + responseContentLength?.let { + span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) + } + if (beforeSpan != null) { + try { + val result = beforeSpan.execute(span, request, response) + if (result == null) { + // Span is dropped + span.spanContext.sampled = false + } + } catch (e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) + scopes.options.logger.log( + SentryLevel.ERROR, + "An error occurred while executing beforeSpan in ApolloInterceptor", + e, + ) + } + } + span.finish() + } + + val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + + request.body?.contentLength.ifHasValidLength { contentLength -> + breadcrumb.setData("request_body_size", contentLength) + } + + operationName?.let { breadcrumb.setData("operation_name", it) } + operationType?.let { breadcrumb.setData("operation_type", it) } + operationId?.let { breadcrumb.setData("operation_id", it) } + + val hint = Hint().also { it.set(APOLLO_REQUEST, request) } + + response?.let { httpResponse -> + responseContentLength?.let { breadcrumb.setData("response_body_size", it) } + + hint.set(APOLLO_RESPONSE, httpResponse) + } + + scopes.addBreadcrumb(breadcrumb, hint) + } + + // Extensions + + private fun Long?.ifHasValidLength(fn: (Long) -> Unit) { + if (this != null && this != -1L) { + fn.invoke(this) + } + } + + private fun getHeader(key: String, headers: List): String? = + headers.firstOrNull { it.name.equals(key, true) }?.value + + private fun getHeaders(headers: List): MutableMap? { + // Headers are only sent if isSendDefaultPii is enabled due to PII + if (!scopes.options.isSendDefaultPii) { + return null + } + + val headersMap = mutableMapOf() + + for (item in headers) { + val name = item.name + + // header is only sent if isn't sensitive + if (HttpUtils.containsSensitiveHeader(name)) { + continue + } + + headersMap[name] = item.value + } + return headersMap.ifEmpty { null } + } + + private fun captureEvent( + request: HttpRequest, + response: HttpResponse, + operationName: String?, + operationType: String?, + ) { + // return if the feature is disabled + if (!captureFailedRequests) { + return + } + + // wrap everything up in a try catch block so every exception is swallowed and degraded + // gracefully + try { + // not possible to get a parameterized url, but we remove at least the + // query string and the fragment. + // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query + // url will be: https://api.github.com/users/getsentry/repos/ + // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ + // but that's not possible + val urlDetails = UrlUtils.parse(request.url) + + // return before reading the response body if it's not a target match + if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { + return + } + + // we pay the price to read the response in the memory to check if there's any errors + // GraphQL does not throw status code 400+ for every type of error + val body = + try { + response.body?.peek()?.readUtf8() ?: "" + } catch (e: Exception) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the response body.", e) + // bail out because the response body has the most important information + return + } + + // if the response body does not have the errors field, do not raise an issue + if (body.isEmpty() || !regex.containsMatchIn(body)) { + return + } + + val mechanism = Mechanism().apply { type = "SentryApollo5Interceptor" } + + val fingerprints = mutableListOf() + + val builder = StringBuilder() + builder.append("GraphQL Request failed") + operationName?.let { + builder.append(", name: $it") + fingerprints.add(operationName) + } + operationType?.let { + builder.append(", type: $it") + fingerprints.add(operationType) + } + + val exception = SentryApollo5ClientException(builder.toString()) + val mechanismException = + ExceptionMechanismException(mechanism, exception, Thread.currentThread(), true) + val event = SentryEvent(mechanismException) + + val hint = Hint() + hint.set(APOLLO_REQUEST, request) + hint.set(APOLLO_RESPONSE, response) + + val sentryRequest = + Request().apply { + urlDetails.applyToRequest(this) + // Cookie is only sent if isSendDefaultPii is enabled + cookies = + if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + method = request.method.name + headers = getHeaders(request.headers) + apiTarget = "graphql" + + request.body?.let { + bodySize = it.contentLength + + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = buffer.readUtf8() + } catch (e: Exception) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) + // continue because the response body alone can already give some insights + } finally { + buffer.close() + } + } + } + + val sentryResponse = + Response().apply { + // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII + cookies = + if (scopes.options.isSendDefaultPii) { + getHeader("Set-Cookie", response.headers) + } else { + null + } + headers = getHeaders(response.headers) + statusCode = response.statusCode + + response.body?.buffer?.size?.ifHasValidLength { contentLength -> + bodySize = contentLength + } + data = body + } + + fingerprints.add(response.statusCode.toString()) + + event.request = sentryRequest + event.contexts.setResponse(sentryResponse) + event.fingerprints = fingerprints + + scopes.captureEvent(event, hint) + } catch (e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) + scopes.options.logger.log(SentryLevel.ERROR, "Error capturing the GraphQL error.", e) + } + } + + /** The BeforeSpan callback */ + fun interface BeforeSpanCallback { + /** + * Mutates span before being added. + * + * @param span the span to mutate or drop + * @param request the Apollo request object + * @param response the Apollo response object + */ + fun execute(span: ISpan, request: HttpRequest, response: HttpResponse?): ISpan? + } + + companion object { + const val DEFAULT_CAPTURE_FAILED_REQUESTS = true + + init { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-apollo-5", BuildConfig.VERSION_NAME) + } + } +} diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5Interceptor.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5Interceptor.kt new file mode 100644 index 00000000000..36614d95f76 --- /dev/null +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5Interceptor.kt @@ -0,0 +1,53 @@ +package io.sentry.apollo5 + +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Mutation +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.Subscription +import com.apollographql.apollo.api.variables +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus + +/** + * Interceptor that adds GraphQL request information to Apollo's execution context so that it can be + * accessed by {@link SentryApollo5HttpInterceptor}. + */ +class SentryApollo5Interceptor +@JvmOverloads +constructor(@ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance()) : + ApolloInterceptor { + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain, + ): Flow> { + val variables = + request.scalarAdapters?.let { request.operation.variables(it).valueMap.toString() } + val operationContext = + SentryApollo5OperationContext( + operationId = request.operation.id(), + operationName = request.operation.name(), + operationType = operationType(request), + variables = variables, + ) + + return chain.proceed(request.newBuilder().addExecutionContext(operationContext).build()) + } +} + +private fun operationType(apolloRequest: ApolloRequest) = + when (apolloRequest.operation) { + is Query -> "query" + is Mutation -> "mutation" + is Subscription -> "subscription" + else -> apolloRequest.operation.javaClass.simpleName + } + +private val ApolloRequest.scalarAdapters + get() = executionContext[CustomScalarAdapters] diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApolloBuilderExtensions.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApolloBuilderExtensions.kt new file mode 100644 index 00000000000..b4cde2354ac --- /dev/null +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApolloBuilderExtensions.kt @@ -0,0 +1,39 @@ +package io.sentry.apollo5 + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.interceptor.ApolloInterceptor +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.apollo5.SentryApollo5HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS + +@JvmOverloads +fun ApolloClient.Builder.sentryTracing( + scopes: IScopes = ScopesAdapter.getInstance(), + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + beforeSpan: SentryApollo5HttpInterceptor.BeforeSpanCallback? = null, +): ApolloClient.Builder { + addInterceptor(SentryApollo5Interceptor(), ApolloInterceptor.InsertionPoint.BeforeCache) + addHttpInterceptor( + SentryApollo5HttpInterceptor( + scopes = scopes, + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + beforeSpan = beforeSpan, + ) + ) + return this +} + +fun ApolloClient.Builder.sentryTracing( + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + beforeSpan: SentryApollo5HttpInterceptor.BeforeSpanCallback? = null, +): ApolloClient.Builder = + sentryTracing( + scopes = ScopesAdapter.getInstance(), + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + beforeSpan = beforeSpan, + ) diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsClientErrorsTest.kt new file mode 100644 index 00000000000..0eaab072c0c --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsClientErrorsTest.kt @@ -0,0 +1,409 @@ +package io.sentry.apollo5 + +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.HttpRequest +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.exception.ApolloException +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.TypeCheckHint +import io.sentry.apollo5.SentryApollo5HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS +import io.sentry.apollo5.generated.LaunchDetailsQuery +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryApollo5BuilderExtensionsClientErrorsTestWithV5Implementation : + SentryApollo5BuilderExtensionsClientErrorsTest(ApolloCall<*>::execute) + +abstract class SentryApollo5BuilderExtensionsClientErrorsTest( + private val executeQueryImplementation: + KSuspendFunction1, ApolloResponse> +) { + class Fixture { + val server = MockWebServer() + lateinit var scopes: IScopes + + private val responseBodyOk = + """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""" + + val responseBodyNotOk = + """{ + "errors": [ + { + "message": "Cannot query field \"mySite\" on type \"Launch\". Did you mean \"site\"?", + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED" + } + } + ] +}""" + + fun getSut( + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + httpStatusCode: Int = 200, + responseBody: String = responseBodyOk, + sendDefaultPii: Boolean = false, + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + ): ApolloClient { + SentryIntegrationPackageStorage.getInstance().clearStorage() + + scopes = + mock().apply { + whenever(options) + .thenReturn( + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + sdkVersion = SdkVersion("test", "1.2.3") + isSendDefaultPii = sendDefaultPii + } + ) + } + whenever(scopes.captureEvent(any(), any())).thenReturn(SentryId.EMPTY_ID) + + val response = + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + + if (sendDefaultPii) { + response.addHeader("Set-Cookie", "Test") + } + + server.enqueue(response) + + val builder = + ApolloClient.Builder() + .serverUrl(server.url("?myQuery=query#myFragment").toString()) + // keep the request body deterministic across Apollo versions for exact body assertions + .sendEnhancedClientAwareness(false) + .sentryTracing( + scopes = scopes, + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + ) + if (sendDefaultPii) { + builder.addHttpHeader("Cookie", "Test") + } + + return builder.build() + } + } + + private val fixture = Fixture() + + // region captureFailedRequests + + @Test + fun `does not capture errors if captureFailedRequests is disabled`() { + val sut = + fixture.getSut(captureFailedRequests = false, responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `capture errors if captureFailedRequests is enabled`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + // endregion + + // region Apollo5ClientError + + @Test + fun `does not add Apollo5ClientError integration if captureFailedRequests is disabled`() { + fixture.getSut(captureFailedRequests = false) + + assertFalse( + SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo5ClientError") + ) + } + + @Test + fun `adds Apollo5ClientError integration if captureFailedRequests is enabled`() { + fixture.getSut() + + assertTrue( + SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo5ClientError") + ) + } + + // endregion + + // region failedRequestTargets + + @Test + fun `does not capture errors if failedRequestTargets does not match`() { + val sut = + fixture.getSut( + failedRequestTargets = listOf("nope.com"), + responseBody = fixture.responseBodyNotOk, + ) + executeQuery(sut) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `capture errors if failedRequestTargets matches`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + // endregion + + // region SentryEvent + + @Test + fun `capture errors with SentryApollo5Interceptor mechanism`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertEquals("SentryApollo5Interceptor", throwable.exceptionMechanism.type) + }, + any(), + ) + } + + @Test + fun `capture errors with title`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertEquals( + "GraphQL Request failed, name: LaunchDetails, type: query", + throwable.throwable.message, + ) + }, + any(), + ) + } + + @Test + fun `capture errors with snapshot flag set`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertTrue(throwable.isSnapshot) + }, + any(), + ) + } + + private val escapeDollar = "\$id" + + @Test + fun `capture errors with request context`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + val body = + """ +{"query":"query LaunchDetails($escapeDollar: ID!) { launch(id: $escapeDollar) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }","operationName":"LaunchDetails","variables":{"id":"83"}} + """ + .trimIndent() + + verify(fixture.scopes) + .captureEvent( + check { + val request = it.request!! + + assertEquals("http://localhost:${fixture.server.port}/", request.url) + assertEquals("myQuery=query", request.queryString) + assertEquals("myFragment", request.fragment) + assertEquals("Post", request.method) + assertEquals("graphql", request.apiTarget) + assertEquals(193L, request.bodySize) + assertEquals(body, request.data) + assertNull(request.cookies) + assertNull(request.headers) + }, + any(), + ) + } + + @Test + fun `capture errors with more request context if sendDefaultPii is enabled`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val request = it.request!! + + assertEquals("Test", request.cookies) + assertNotNull(request.headers) + }, + any(), + ) + } + + @Test + fun `capture errors with response context`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertEquals(fixture.responseBodyNotOk, response.data) + assertNull(response.cookies) + assertNull(response.headers) + }, + any(), + ) + } + + @Test + fun `capture errors with more response context if sendDefaultPii is enabled`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + + assertEquals("Test", response.cookies) + assertNotNull(response.headers) + assertEquals(200, response.headers?.get("Content-Length")?.toInt()) + }, + any(), + ) + } + + @Test + fun `capture errors with specific fingerprints`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertEquals(listOf("LaunchDetails", "query", "200"), it.fingerprints) }, + any(), + ) + } + + // endregion + + // region errors + + @Test + fun `capture errors if response code is equal or higher than 400`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, httpStatusCode = 500) + executeQuery(sut) + + // HttpInterceptor does not throw for >= 400 + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `capture errors swallow any exception during the error transformation`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + + whenever(fixture.scopes.captureEvent(any(), any())).thenThrow(RuntimeException()) + + executeQuery(sut) + } + + // endregion + + // region hints + + @Test + fun `hints are set when capturing errors`() { + val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + any(), + check { + val request = it.get(TypeCheckHint.APOLLO_REQUEST) + assertNotNull(request) + assertTrue(request is HttpRequest) + + val response = it.get(TypeCheckHint.APOLLO_RESPONSE) + assertNotNull(response) + assertTrue(response is HttpResponse) + }, + ) + } + + // endregion + + private fun executeQuery(sut: ApolloClient, id: String = "83") = runBlocking { + val coroutine = launch { + try { + executeQueryImplementation(sut.query(LaunchDetailsQuery(id))) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsTest.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsTest.kt new file mode 100644 index 00000000000..6b7061f5661 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5BuilderExtensionsTest.kt @@ -0,0 +1,397 @@ +package io.sentry.apollo5 + +import com.apollographql.apollo.ApolloCall +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.http.DefaultHttpRequestComposer +import com.apollographql.apollo.api.http.HttpRequest +import com.apollographql.apollo.api.http.HttpRequestComposer +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.network.http.HttpInterceptor +import com.apollographql.apollo.network.http.HttpInterceptorChain +import com.apollographql.apollo.network.http.HttpNetworkTransport +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.SentryEvent +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.apollo5.SentryApollo5HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo5.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SentryTransaction +import java.util.concurrent.TimeUnit +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryApollo5BuilderExtensionsTestWithV5Implementation : + SentryApollo5BuilderExtensionsTest(ApolloCall<*>::execute) + +abstract class SentryApollo5BuilderExtensionsTest( + private val executeQueryImplementation: + KSuspendFunction1, ApolloResponse> +) { + class Fixture { + val server = MockWebServer() + val scopes = mock() + + @SuppressWarnings("LongParameterList") + fun getSut( + httpStatusCode: Int = 200, + responseBody: String = + """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + beforeSpan: BeforeSpanCallback? = null, + interceptor: HttpInterceptor? = null, + ): ApolloClient { + whenever(scopes.options) + .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + + server.enqueue( + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + val builder = + ApolloClient.Builder() + .serverUrl(server.url("/").toString()) + // keep the request body deterministic across Apollo versions for exact body assertions + .sendEnhancedClientAwareness(false) + .sentryTracing(scopes = scopes, beforeSpan = beforeSpan, captureFailedRequests = false) + + interceptor?.let { builder.addHttpInterceptor(it) } + + return builder.build() + } + + fun getSutWithCustomNetworkTransport( + manuallyInstallHttpInterceptor: Boolean, + useCustomRequestComposer: Boolean = false, + ): ApolloClient { + whenever(scopes.options) + .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + server.enqueue(MockResponse().setBody("{\"data\":{\"launch\":null}}")) + + val serverUrl = server.url("/").toString() + val transportBuilder = HttpNetworkTransport.Builder() + if (useCustomRequestComposer) { + val defaultComposer = DefaultHttpRequestComposer(serverUrl) + transportBuilder.httpRequestComposer( + object : HttpRequestComposer { + override fun compose( + apolloRequest: ApolloRequest + ): HttpRequest { + val request = defaultComposer.compose(apolloRequest) + return HttpRequest.Builder(request.method, request.url) + .addHeaders(request.headers) + .apply { request.body?.let { body(it) } } + .addExecutionContext(apolloRequest.executionContext) + .build() + } + } + ) + } else { + transportBuilder.serverUrl(serverUrl) + } + if (manuallyInstallHttpInterceptor) { + transportBuilder.addInterceptor( + SentryApollo5HttpInterceptor(scopes = scopes, captureFailedRequests = false) + ) + } + + val builder = ApolloClient.Builder().networkTransport(transportBuilder.build()) + return if (manuallyInstallHttpInterceptor) { + builder + .addInterceptor( + SentryApollo5Interceptor(scopes), + ApolloInterceptor.InsertionPoint.BeforeCache, + ) + .build() + } else { + builder.sentryTracing(scopes = scopes, captureFailedRequests = false).build() + } + } + } + + private val fixture = Fixture() + + @Test + fun `sentryTracing installs both interceptors`() { + val builder = ApolloClient.Builder().sentryTracing(captureFailedRequests = false) + + assertThat(builder.interceptors.filterIsInstance()).hasSize(1) + assertThat(builder.httpInterceptors.filterIsInstance()).hasSize(1) + } + + @Test + fun `creates span around successful request`() { + executeQuery() + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates span around failed request`() { + executeQuery(fixture.getSut(httpStatusCode = 403)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates span around request failing with network error`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `sentryTracing fails with a custom network transport`() { + assertFailsWith { + fixture.getSutWithCustomNetworkTransport(manuallyInstallHttpInterceptor = false) + } + } + + @Test + fun `custom network transport is instrumented when HTTP interceptor is installed manually`() { + executeQuery(fixture.getSutWithCustomNetworkTransport(manuallyInstallHttpInterceptor = true)) + + verify(fixture.scopes) + .captureTransaction( + check { assertTransactionDetails(it) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `custom request composer propagates operation context when copied explicitly`() { + executeQuery( + fixture.getSutWithCustomNetworkTransport( + manuallyInstallHttpInterceptor = true, + useCustomRequestComposer = true, + ) + ) + + verify(fixture.scopes) + .captureTransaction( + check { assertTransactionDetails(it) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `default request composer propagates operation context`() { + var operationContext: SentryApollo5OperationContext? = null + val recordingInterceptor = + object : HttpInterceptor { + override suspend fun intercept( + request: HttpRequest, + chain: HttpInterceptorChain, + ): HttpResponse { + operationContext = request.executionContext[SentryApollo5OperationContext] + return chain.proceed(request) + } + } + + executeQuery( + fixture.getSut(interceptor = recordingInterceptor), + initialOperationContext = + SentryApollo5OperationContext( + operationId = "stale-id", + operationName = "StaleOperation", + operationType = "mutation", + variables = null, + ), + ) + + assertThat(operationContext) + .isEqualTo( + SentryApollo5OperationContext( + operationId = LaunchDetailsQuery.OPERATION_ID, + operationName = LaunchDetailsQuery.OPERATION_NAME, + operationType = "query", + variables = "{id=83}", + ) + ) + } + + @Test + fun `adds breadcrumb when http call succeeds`() { + executeQuery(fixture.getSut()) + + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(200, it.data["status_code"]) + // response_body_size is added but mock webserver returns 0 always + assertEquals(0L, it.data["response_body_size"]) + assertEquals(193L, it.data["request_body_size"]) + assertEquals("LaunchDetails", it.data["operation_name"]) + assertEquals("query", it.data["operation_type"]) + assertEquals(LaunchDetailsQuery.OPERATION_ID, it.data["operation_id"]) + }, + anyOrNull(), + ) + } + + @Test + fun `adds breadcrumb when http call fails`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(193L, it.data["request_body_size"]) + assertEquals("LaunchDetails", it.data["operation_name"]) + assertEquals("query", it.data["operation_type"]) + assertEquals(LaunchDetailsQuery.OPERATION_ID, it.data["operation_id"]) + }, + anyOrNull(), + ) + } + + @Test + fun `handles non-ascii variables correctly`() { + executeQuery(id = "á") + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it, expectedVariables = "{id=á}") + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `does not send internal headers over the wire`() { + executeQuery(fixture.getSut()) + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + + assertThat( + recordedRequest.headers.names().none { + it.startsWith("SENTRY-APOLLO-5-", ignoreCase = true) + } + ) + .isTrue() + } + + private fun assertTransactionDetails( + it: SentryTransaction, + expectedVariables: String = "{id=83}", + ) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("http.graphql.query", httpClientSpan.op) + assertEquals("query LaunchDetails", httpClientSpan.description) + assertEquals("auto.graphql.apollo5", httpClientSpan.origin) + assertNotNull(httpClientSpan.data) { + assertEquals(LaunchDetailsQuery.OPERATION_ID, it["operationId"]) + assertEquals(expectedVariables, it["variables"]) + } + } + + private fun executeQuery( + sut: ApolloClient = fixture.getSut(), + isSpanActive: Boolean = true, + id: String = "83", + initialOperationContext: SentryApollo5OperationContext? = null, + ) = runBlocking { + var tx: ITransaction? = null + if (isSpanActive) { + tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + } + + val coroutine = launch { + try { + val call = sut.query(LaunchDetailsQuery(id)) + initialOperationContext?.let { call.addExecutionContext(it) } + executeQueryImplementation(call) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + tx?.finish() + } +} 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 new file mode 100644 index 00000000000..6d1813ad491 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt @@ -0,0 +1,470 @@ +package io.sentry.apollo5 + +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 +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.network.http.HttpInterceptor +import com.apollographql.apollo.network.http.HttpInterceptorChain +import com.google.common.truth.Truth.assertThat +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanDataConvention.HTTP_METHOD_KEY +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.W3CTraceparentHeader +import io.sentry.apollo5.SentryApollo5HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo5.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryTransaction +import io.sentry.util.Apollo5PlatformTestManipulator +import java.util.concurrent.TimeUnit +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import okio.ForwardingSource +import okio.buffer +import org.junit.Before +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryApollo5HttpInterceptorTestWithV5Implementation : + SentryApollo5HttpInterceptorTest(ApolloCall<*>::execute) + +abstract class SentryApollo5HttpInterceptorTest( + private val executeQueryImplementation: + KSuspendFunction1, ApolloResponse> +) { + class Fixture { + val server = MockWebServer() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setTracePropagationTargets(listOf(DEFAULT_PROPAGATION_TARGETS)) + sdkVersion = SdkVersion("test", "1.2.3") + } + val scope = Scope(options) + val scopes = + mock().also { + whenever(it.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(any()) + } + private var httpInterceptor = + SentryApollo5HttpInterceptor(scopes, captureFailedRequests = false) + + @SuppressWarnings("LongParameterList") + fun getSut( + httpStatusCode: Int = 200, + responseBody: String = + """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + interceptor: HttpInterceptor? = null, + addThirdPartyBaggageHeader: Boolean = false, + beforeSpan: BeforeSpanCallback? = null, + ): ApolloClient { + server.enqueue( + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + if (beforeSpan != null) { + httpInterceptor = + SentryApollo5HttpInterceptor(scopes, beforeSpan, captureFailedRequests = false) + } + + val builder = + ApolloClient.Builder() + .serverUrl(server.url("/").toString()) + // keep the request body deterministic across Apollo versions for exact body assertions + .sendEnhancedClientAwareness(false) + .addHttpInterceptor(httpInterceptor) + + interceptor?.let { builder.addHttpInterceptor(interceptor) } + + if (addThirdPartyBaggageHeader) { + builder + .addHttpHeader("baggage", "thirdPartyBaggage=someValue") + .addHttpHeader( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) + } + + return builder.build() + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + Apollo5PlatformTestManipulator.pretendIsAndroid(false) + } + + @Test + fun `creates a span around the successful request`() { + executeQuery() + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 200) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates a span around the failed request`() { + executeQuery(fixture.getSut(httpStatusCode = 403)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 403) + assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `get http status from ApolloHttpException in failed request`() { + val failingInterceptor = + object : HttpInterceptor { + override suspend fun intercept( + request: HttpRequest, + chain: HttpInterceptorChain, + ): HttpResponse = throw ApolloHttpException(404, mock(), mock(), "") + } + executeQuery(fixture.getSut(interceptor = failingInterceptor)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 404, contentLength = null) + assertEquals("POST", it.spans.first().data?.get(SpanDataConvention.HTTP_METHOD_KEY)) + assertEquals(404, it.spans.first().data?.get(SpanDataConvention.HTTP_STATUS_CODE_KEY)) + assertEquals(SpanStatus.NOT_FOUND, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates a span around the request failing with network error`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = null, contentLength = null) + assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `does not read response body if failed request target does not match`() = runBlocking { + var bodyRead = false + val body = + object : ForwardingSource(Buffer().writeUtf8("{\"errors\":[]}")) { + override fun read(sink: Buffer, byteCount: Long): Long { + bodyRead = true + return super.read(sink, byteCount) + } + } + .buffer() + val request = HttpRequest.Builder(HttpMethod.Post, "https://excluded.example/graphql").build() + val response = HttpResponse.Builder(200).body(body).build() + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = response + } + val interceptor = + SentryApollo5HttpInterceptor( + fixture.scopes, + captureFailedRequests = true, + failedRequestTargets = listOf("target.example"), + ) + + interceptor.intercept(request, chain) + + assertThat(bodyRead).isFalse() + } + + @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")) + executeQuery(isSpanActive = false) + + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is no active span, does not add sentry trace header to the request`() { + executeQuery(isSpanActive = false) + + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `does not add sentry-trace header when span origin is ignored`() { + fixture.options.setIgnoredSpanOrigins(listOf("auto.graphql.apollo5")) + executeQuery(isSpanActive = false) + + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span, adds sentry trace headers to the request`() { + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`() { + executeQuery(sut = fixture.getSut(addThirdPartyBaggageHeader = true)) + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-transaction=op")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `customizer modifies span`() { + executeQuery( + fixture.getSut( + beforeSpan = { span, request, response -> + span.description = "overwritten description" + span + } + ) + ) + + verify(fixture.scopes) + .captureTransaction( + check { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("overwritten description", httpClientSpan.description) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `returning null in beforeSpan callback drops span`() { + executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> null })) + + verify(fixture.scopes) + .captureTransaction( + check { assertEquals(0, it.spans.size) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when customizer throws, exception is handled`() { + executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> throw RuntimeException() })) + + verify(fixture.scopes) + .captureTransaction( + check { assertEquals(1, it.spans.size) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `missing operation context adds a generic HTTP breadcrumb`() { + executeQuery(fixture.getSut()) + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + // response_body_size is added but mock webserver returns 0 always + assertEquals(0L, it.data["response_body_size"]) + assertEquals(193L, it.data["request_body_size"]) + assertNull(it.data["operation_name"]) + assertNull(it.data["operation_type"]) + assertNull(it.data["operation_id"]) + }, + anyOrNull(), + ) + } + + @Test + fun `sets SDKVersion Info`() { + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("Apollo5")) + } + + @Test + fun `attaches to root transaction on Android`() { + Apollo5PlatformTestManipulator.pretendIsAndroid(true) + executeQuery(fixture.getSut()) + verify(fixture.scopes).transaction + } + + @Test + fun `attaches to child span on non-Android`() { + Apollo5PlatformTestManipulator.pretendIsAndroid(false) + executeQuery(fixture.getSut()) + verify(fixture.scopes).span + } + + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`() { + fixture.options.isPropagateTraceparent = true + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`() { + fixture.options.isPropagateTraceparent = false + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + private fun assertTransactionDetails( + it: SentryTransaction, + httpStatusCode: Int? = 200, + contentLength: Long? = 0L, + ) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("http.graphql", httpClientSpan.op) + assertEquals( + "Post http://${fixture.server.hostName}:${fixture.server.port}/", + httpClientSpan.description, + ) + assertNotNull(httpClientSpan.data) { + assertEquals("POST", it[HTTP_METHOD_KEY]) + httpStatusCode?.let { code -> + assertEquals(code, it[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + } + contentLength?.let { contentLength -> + assertEquals(contentLength, it[SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY]) + } + } + } + + private fun executeQuery( + sut: ApolloClient = fixture.getSut(), + isSpanActive: Boolean = true, + id: String = "83", + ) = runBlocking { + var tx: ITransaction? = null + if (isSpanActive) { + tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.transaction).thenReturn(tx) + whenever(fixture.scopes.span).thenReturn(tx) + } + + val coroutine = launch { + try { + executeQueryImplementation(sut.query(LaunchDetailsQuery(id))) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + tx?.finish() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/LaunchDetailsQuery.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/LaunchDetailsQuery.kt new file mode 100644 index 00000000000..66a7914710c --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/LaunchDetailsQuery.kt @@ -0,0 +1,74 @@ +package io.sentry.apollo5.generated + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CompiledField +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.json.JsonWriter +import com.apollographql.apollo.api.obj +import io.sentry.apollo5.generated.adapter.LaunchDetailsQuery_ResponseAdapter +import io.sentry.apollo5.generated.adapter.LaunchDetailsQuery_VariablesAdapter +import io.sentry.apollo5.generated.selections.LaunchDetailsQuerySelections +import kotlin.String + +public data class LaunchDetailsQuery(public val id: String) : Query { + public override fun id(): String = OPERATION_ID + + public override fun document(): String = OPERATION_DOCUMENT + + public override fun name(): String = OPERATION_NAME + + public override fun serializeVariables( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + withDefaultValues: Boolean, + ) { + LaunchDetailsQuery_VariablesAdapter.toJson(writer, customScalarAdapters, this) + } + + public override fun adapter(): Adapter = LaunchDetailsQuery_ResponseAdapter.Data.obj() + + public override fun rootField(): CompiledField = + CompiledField.Builder( + name = "data", + type = io.sentry.apollo5.generated.type.Query.type, + ) + .selections(selections = LaunchDetailsQuerySelections.root) + .build() + + public data class Data(public val launch: Launch?) : Query.Data + + public data class Launch( + public val id: String, + public val site: String?, + public val mission: Mission?, + public val rocket: Rocket?, + ) + + public data class Mission( + public val name: String?, + public val missionPatch: String?, + ) + + public data class Rocket( + public val name: String?, + public val type: String?, + ) + + public companion object { + public const val OPERATION_ID: String = + "1b3bda4a2dcb47a77aa30346e10339d4600e0cbe9fa686867e9226e463b7118d" + + /** + * The minimized GraphQL document being sent to the server to save a few bytes. The un-minimized + * version is: + * + * query LaunchDetails($id: ID!) { launch(id: $id) { id site mission { name missionPatch(size: + * LARGE) } rocket { name type } } } + */ + public const val OPERATION_DOCUMENT: String = + "query LaunchDetails(${'$'}id: ID!) { launch(id: ${'$'}id) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }" + + public const val OPERATION_NAME: String = "LaunchDetails" + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt new file mode 100644 index 00000000000..29a99e623bd --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt @@ -0,0 +1,172 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.adapter + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.NullableStringAdapter +import com.apollographql.apollo.api.StringAdapter +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import com.apollographql.apollo.api.nullable +import com.apollographql.apollo.api.obj +import io.sentry.apollo5.generated.LaunchDetailsQuery +import kotlin.String +import kotlin.collections.List + +public object LaunchDetailsQuery_ResponseAdapter { + public object Data : Adapter { + public val RESPONSE_NAMES: List = listOf("launch") + + public override fun fromJson( + reader: JsonReader, + customScalarAdapters: CustomScalarAdapters, + ): LaunchDetailsQuery.Data { + var launch: LaunchDetailsQuery.Launch? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> launch = Launch.obj().nullable().fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Data(launch = launch) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Data, + ) { + writer.name("launch") + Launch.obj().nullable().toJson(writer, customScalarAdapters, value.launch) + } + } + + public object Launch : Adapter { + public val RESPONSE_NAMES: List = listOf("id", "site", "mission", "rocket") + + public override fun fromJson( + reader: JsonReader, + customScalarAdapters: CustomScalarAdapters, + ): LaunchDetailsQuery.Launch { + var id: String? = null + var site: String? = null + var mission: LaunchDetailsQuery.Mission? = null + var rocket: LaunchDetailsQuery.Rocket? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> id = StringAdapter.fromJson(reader, customScalarAdapters) + 1 -> site = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 2 -> mission = Mission.obj().nullable().fromJson(reader, customScalarAdapters) + 3 -> rocket = Rocket.obj().nullable().fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Launch( + id = id!!, + site = site, + mission = mission, + rocket = rocket, + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Launch, + ) { + writer.name("id") + StringAdapter.toJson(writer, customScalarAdapters, value.id) + + writer.name("site") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.site) + + writer.name("mission") + Mission.obj().nullable().toJson(writer, customScalarAdapters, value.mission) + + writer.name("rocket") + Rocket.obj().nullable().toJson(writer, customScalarAdapters, value.rocket) + } + } + + public object Mission : Adapter { + public val RESPONSE_NAMES: List = listOf("name", "missionPatch") + + public override fun fromJson( + reader: JsonReader, + customScalarAdapters: CustomScalarAdapters, + ): LaunchDetailsQuery.Mission { + var name: String? = null + var missionPatch: String? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> name = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 1 -> missionPatch = NullableStringAdapter.fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Mission( + name = name, + missionPatch = missionPatch, + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Mission, + ) { + writer.name("name") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.name) + + writer.name("missionPatch") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.missionPatch) + } + } + + public object Rocket : Adapter { + public val RESPONSE_NAMES: List = listOf("name", "type") + + public override fun fromJson( + reader: JsonReader, + customScalarAdapters: CustomScalarAdapters, + ): LaunchDetailsQuery.Rocket { + var name: String? = null + var type: String? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> name = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 1 -> type = NullableStringAdapter.fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Rocket( + name = name, + type = type, + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Rocket, + ) { + writer.name("name") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.name) + + writer.name("type") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.type) + } + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt new file mode 100644 index 00000000000..c7a1177e1d9 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt @@ -0,0 +1,29 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.adapter + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.StringAdapter +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import io.sentry.apollo5.generated.LaunchDetailsQuery + +object LaunchDetailsQuery_VariablesAdapter : Adapter { + override fun fromJson( + reader: JsonReader, + customScalarAdapters: CustomScalarAdapters, + ): LaunchDetailsQuery = throw IllegalStateException("Input type used in output position") + + override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery, + ) { + writer.name("id") + StringAdapter.toJson(writer, customScalarAdapters, value.id) + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/selections/LaunchDetailsQuerySelections.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/selections/LaunchDetailsQuerySelections.kt new file mode 100644 index 00000000000..a36f4f962e8 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/selections/LaunchDetailsQuerySelections.kt @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.selections + +import com.apollographql.apollo.api.CompiledArgument +import com.apollographql.apollo.api.CompiledArgumentDefinition +import com.apollographql.apollo.api.CompiledField +import com.apollographql.apollo.api.CompiledSelection +import com.apollographql.apollo.api.notNull +import io.sentry.apollo5.generated.type.GraphQLID +import io.sentry.apollo5.generated.type.GraphQLString +import io.sentry.apollo5.generated.type.Launch +import io.sentry.apollo5.generated.type.Mission +import io.sentry.apollo5.generated.type.Query.Companion.type +import io.sentry.apollo5.generated.type.Rocket +import kotlin.collections.List + +public object LaunchDetailsQuerySelections { + private val mission: List = + listOf( + CompiledField.Builder( + name = "name", + type = GraphQLString.type, + ) + .build(), + CompiledField.Builder( + name = "missionPatch", + type = GraphQLString.type, + ) + .arguments( + listOf( + CompiledArgument.Builder(CompiledArgumentDefinition.Builder("size").build()) + .value("LARGE") + .build() + ) + ) + .build(), + ) + + private val rocket: List = + listOf( + CompiledField.Builder( + name = "name", + type = GraphQLString.type, + ) + .build(), + CompiledField.Builder( + name = "type", + type = GraphQLString.type, + ) + .build(), + ) + + private val launch: List = + listOf( + CompiledField.Builder( + name = "id", + type = GraphQLID.type.notNull(), + ) + .build(), + CompiledField.Builder( + name = "site", + type = GraphQLString.type, + ) + .build(), + CompiledField.Builder( + name = "mission", + type = Mission.type, + ) + .selections(mission) + .build(), + CompiledField.Builder( + name = "rocket", + type = Rocket.type, + ) + .selections(rocket) + .build(), + ) + + public val root: List = + listOf( + CompiledField.Builder( + name = "launch", + type = Launch.type, + ) + .arguments( + listOf( + CompiledArgument.Builder(CompiledArgumentDefinition.Builder("id").build()) + .value("id") + .build() + ) + ) + .selections(launch) + .build() + ) +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLBoolean.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLBoolean.kt new file mode 100644 index 00000000000..da00b02371c --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLBoolean.kt @@ -0,0 +1,15 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** The `Boolean` scalar type represents `true` or `false`. */ +public class GraphQLBoolean { + public companion object { + public val type: CustomScalarType = CustomScalarType("Boolean", "kotlin.Boolean") + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLID.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLID.kt new file mode 100644 index 00000000000..6a9efbdc84c --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLID.kt @@ -0,0 +1,20 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** + * The `ID` scalar type represents a unique identifier, often used to refetch an object or as key + * for a cache. The ID type appears in a JSON response as a String; however, it is not intended to + * be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as + * `4`) input value will be accepted as an ID. + */ +public class GraphQLID { + public companion object { + public val type: CustomScalarType = CustomScalarType("ID", "kotlin.String") + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLString.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLString.kt new file mode 100644 index 00000000000..07148dbcfb5 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/GraphQLString.kt @@ -0,0 +1,18 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** + * The `String` scalar type represents textual data, represented as UTF-8 character sequences. The + * String type is most often used by GraphQL to represent free-form human-readable text. + */ +public class GraphQLString { + public companion object { + public val type: CustomScalarType = CustomScalarType("String", "kotlin.String") + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Launch.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Launch.kt new file mode 100644 index 00000000000..9ca245b648a --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Launch.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Launch { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Launch").build() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Mission.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Mission.kt new file mode 100644 index 00000000000..84ba7aedca5 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Mission.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Mission { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Mission").build() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Query.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Query.kt new file mode 100644 index 00000000000..477a62b8495 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Query.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Query { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Query").build() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Rocket.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Rocket.kt new file mode 100644 index 00000000000..bda9273b777 --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/generated/type/Rocket.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo5.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Rocket { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Rocket").build() + } +} diff --git a/sentry-apollo-5/src/test/java/io/sentry/util/Apollo5PlatformTestManipulator.kt b/sentry-apollo-5/src/test/java/io/sentry/util/Apollo5PlatformTestManipulator.kt new file mode 100644 index 00000000000..826c9e27bda --- /dev/null +++ b/sentry-apollo-5/src/test/java/io/sentry/util/Apollo5PlatformTestManipulator.kt @@ -0,0 +1,7 @@ +package io.sentry.util + +object Apollo5PlatformTestManipulator { + fun pretendIsAndroid(isAndroid: Boolean) { + Platform.isAndroid = isAndroid + } +} diff --git a/sentry-apollo-5/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-apollo-5/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..1f0955d450f --- /dev/null +++ b/sentry-apollo-5/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/settings.gradle.kts b/settings.gradle.kts index 665e85477df..abc10466b29 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -57,6 +57,7 @@ include( "sentry-apollo", "sentry-apollo-3", "sentry-apollo-4", + "sentry-apollo-5", "sentry-system-test-support", "sentry-test-support", "sentry-log4j2",