diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dbfec961..f81d76ab60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) - This consists of an empty list when only the default profile is active - Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) +- Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index ac379bcb41..0a3c62a155 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -157,6 +157,7 @@ object Config { val composeCoil = "io.coil-kt:coil-compose:2.6.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" + val apolloKotlin4 = "com.apollographql.apollo:apollo-runtime:4.1.1" val sentryNativeNdk = "io.sentry:sentry-native-ndk:0.7.20" @@ -250,6 +251,7 @@ object Config { val SENTRY_SPRING_BOOT_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot.jakarta" val SENTRY_OPENTELEMETRY_AGENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agent" val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" + val SENTRY_APOLLO4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo4" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" val SENTRY_GRAPHQL22_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql22" diff --git a/sentry-apollo-4/README.md b/sentry-apollo-4/README.md new file mode 100644 index 0000000000..e9b4ad2efe --- /dev/null +++ b/sentry-apollo-4/README.md @@ -0,0 +1,5 @@ +# sentry-apollo-4 + +This module provides an integration for [Apollo Kotlin 4](https://www.apollographql.com/docs/kotlin/v4). + +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/apollo4/) or [Java](https://docs.sentry.io/platforms/java/tracing/instrumentation/apollo4/). diff --git a/sentry-apollo-4/api/sentry-apollo-4.api b/sentry-apollo-4/api/sentry-apollo-4.api new file mode 100644 index 0000000000..ec7f6ff051 --- /dev/null +++ b/sentry-apollo-4/api/sentry-apollo-4.api @@ -0,0 +1,50 @@ +public final class io/sentry/apollo4/BuildConfig { + public static final field SENTRY_APOLLO4_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/apollo4/SentryApollo4ClientException : java/lang/Exception { + public static final field Companion Lio/sentry/apollo4/SentryApollo4ClientException$Companion; + public fun (Ljava/lang/String;)V +} + +public final class io/sentry/apollo4/SentryApollo4ClientException$Companion { +} + +public final class io/sentry/apollo4/SentryApollo4HttpInterceptor : com/apollographql/apollo/network/http/HttpInterceptor { + public static final field Companion Lio/sentry/apollo4/SentryApollo4HttpInterceptor$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/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;Z)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)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/apollo4/SentryApollo4HttpInterceptor$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/apollo4/SentryApollo4HttpInterceptor$Companion { +} + +public final class io/sentry/apollo4/SentryApollo4Interceptor : 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/apollo4/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/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$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/apollo4/SentryApollo4HttpInterceptor$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/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo/ApolloClient$Builder; +} + diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts new file mode 100644 index 0000000000..6e8c292966 --- /dev/null +++ b/sentry-apollo-4/build.gradle.kts @@ -0,0 +1,89 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion +} + +dependencies { + api(projects.sentry) + api(projects.sentryKotlinExtensions) + + compileOnly(Config.Libs.apolloKotlin4) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(Config.Libs.coroutinesCore) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.mockWebserver) + testImplementation(Config.Libs.apolloKotlin4) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.apollo4") + buildConfigField("String", "SENTRY_APOLLO4_SDK_NAME", "\"${Config.Sentry.SENTRY_APOLLO4_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt new file mode 100644 index 0000000000..dd599c5e6e --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt @@ -0,0 +1,17 @@ +package io.sentry.apollo4 + +/** + * Common constants used across the module + */ +internal const val OPERATION_ID_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-ID" +internal const val OPERATION_NAME_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-NAME" +internal const val OPERATION_TYPE_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-TYPE" +internal const val VARIABLES_HEADER_NAME = "SENTRY-APOLLO-4-VARIABLES" +internal val INTERNAL_HEADER_NAMES by lazy { + listOf( + OPERATION_ID_HEADER_NAME, + OPERATION_NAME_HEADER_NAME, + OPERATION_TYPE_HEADER_NAME, + VARIABLES_HEADER_NAME + ) +} diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt new file mode 100644 index 0000000000..11f6440dc8 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt @@ -0,0 +1,11 @@ +package io.sentry.apollo4 + +/** + * Used for holding an Apollo4 client error, for example. An integration that does not throw when API + * returns 4xx, 5xx or the `errors` field. + */ +class SentryApollo4ClientException(message: String?) : Exception(message) { + companion object { + private const val serialVersionUID = 4312160066430858144L + } +} 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 new file mode 100644 index 0000000000..a4e31431bb --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -0,0 +1,453 @@ +package io.sentry.apollo4 + +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.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 io.sentry.vendor.Base64 +import okio.Buffer +import org.jetbrains.annotations.ApiStatus +import java.util.Locale + +private const val TRACE_ORIGIN = "auto.graphql.apollo4" + +class SentryApollo4HttpInterceptor @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("Apollo4") + if (captureFailedRequests) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("Apollo4ClientError") + } + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-apollo-4", BuildConfig.VERSION_NAME) + } + + 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 operationId = decodeHeaderValue(request, OPERATION_ID_HEADER_NAME) + val operationName = decodeHeaderValue(request, OPERATION_NAME_HEADER_NAME) + val operationType = decodeHeaderValue(request, OPERATION_TYPE_HEADER_NAME) + + var span: ISpan? = null + + if (activeSpan != null) { + span = startChild(request, activeSpan, operationName, operationType, operationId) + } + + 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) { + // 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 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 { + var cleanedHeaders = removeSentryInternalHeaders(request.headers).toMutableList() + + if (!isIgnored()) { + TracingUtils.traceIfAllowed(scopes, request.url, request.headers.filter { it.name == BaggageHeader.BAGGAGE_HEADER }.map { it.value }, span)?.let { + cleanedHeaders.add(HttpHeader(it.sentryTraceHeader.name, it.sentryTraceHeader.value)) + it.baggageHeader?.let { baggageHeader -> + cleanedHeaders = cleanedHeaders.filterNot { it.name == BaggageHeader.BAGGAGE_HEADER }.toMutableList().apply { + add(HttpHeader(baggageHeader.name, baggageHeader.value)) + } + } + } + } + + val requestBuilder = request.newBuilder().apply { + headers(cleanedHeaders) + } + + return requestBuilder.build() + } + + private fun isIgnored(): Boolean { + return SpanUtils.isIgnored(scopes.getOptions().ignoredSpanOrigins, TRACE_ORIGIN) + } + + private fun removeSentryInternalHeaders(headers: List): List { + return headers.filterNot { header -> + INTERNAL_HEADER_NAMES.any { internalHeader -> header.name.equals(internalHeader, true) } + } + } + + private fun startChild( + request: HttpRequest, + activeSpan: ISpan, + operationName: String?, + operationType: String?, + operationId: 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 variables = decodeHeaderValue(request, VARIABLES_HEADER_NAME) + + 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 decodeHeaderValue(request: HttpRequest, headerName: String): String? { + return getHeader(headerName, request.headers)?.let { + try { + String(Base64.decode(it, Base64.NO_WRAP)) + } catch (e: Throwable) { + scopes.options.logger.log( + SentryLevel.ERROR, + "Error decoding internal apolloHeader $headerName", + e + ) + return null + } + } + } + + 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) { + 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? { + return 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 { + // 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: Throwable) { + 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 + } + + // 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 if it's not a target match + if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { + return + } + + val mechanism = Mechanism().apply { + type = "SentryApollo4Interceptor" + } + + 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 = SentryApollo4ClientException(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: Throwable) { + 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) { + 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 + } +} diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt new file mode 100644 index 0000000000..5a57eccefc --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt @@ -0,0 +1,56 @@ +package io.sentry.apollo4 + +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 io.sentry.vendor.Base64 +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus + +/** + * Interceptor that adds the GraphQL request information to the outgoing HTTP request's headers so that + * the information can be accessed by {@link SentryApollo4HttpInterceptor} + */ +class SentryApollo4Interceptor @JvmOverloads constructor( + @ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance() +) : ApolloInterceptor { + + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain + ): Flow> { + val builder = request.newBuilder() + .addHttpHeader(OPERATION_ID_HEADER_NAME, encodeHeaderValue(request.operation.id())) + .addHttpHeader(OPERATION_NAME_HEADER_NAME, encodeHeaderValue(request.operation.name())) + .addHttpHeader(OPERATION_TYPE_HEADER_NAME, encodeHeaderValue(operationType(request))) + + request.scalarAdapters?.let { + builder.addHttpHeader(VARIABLES_HEADER_NAME, encodeHeaderValue(request.operation.variables(it).valueMap.toString())) + } + + return chain.proceed(builder.build()) + } +} + +private fun encodeHeaderValue(value: String): String { + return Base64.encodeToString(value.toByteArray(), Base64.NO_WRAP) +} + +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-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt new file mode 100644 index 0000000000..a0e07225d1 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt @@ -0,0 +1,39 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.ApolloClient +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.apollo4.SentryApollo4HttpInterceptor.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: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null +): ApolloClient.Builder { + addInterceptor(SentryApollo4Interceptor()) + addHttpInterceptor( + SentryApollo4HttpInterceptor( + 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: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null +): ApolloClient.Builder { + return sentryTracing( + scopes = ScopesAdapter.getInstance(), + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + beforeSpan = beforeSpan + ) +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt new file mode 100644 index 0000000000..d7df80cb03 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -0,0 +1,399 @@ +package io.sentry.apollo4 + +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.apollo4.SentryApollo4HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId +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 +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 + +class SentryApollo4BuilderExtensionsClientErrorsTestWithV4Implementation : SentryApollo4BuilderExtensionsClientErrorsTest(ApolloCall<*>::execute) +class SentryApollo4BuilderExtensionsClientErrorsTestWithV3Implementation : SentryApollo4BuilderExtensionsClientErrorsTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4BuilderExtensionsClientErrorsTest( + 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()) + .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 Apollo4ClientError + + @Test + fun `does not add Apollo4ClientError integration if captureFailedRequests is disabled`() { + fixture.getSut(captureFailedRequests = false) + + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo4ClientError")) + } + + @Test + fun `adds Apollo4ClientError integration if captureFailedRequests is enabled`() { + fixture.getSut() + + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo4ClientError")) + } + + // 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 SentryApollo4Interceptor mechanism`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertEquals("SentryApollo4Interceptor", 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 escapeDolar = "\$id" + + @Test + fun `capture errors with request context`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + val body = + """ +{"operationName":"LaunchDetails","variables":{"id":"83"},"query":"query LaunchDetails($escapeDolar: ID!) { launch(id: $escapeDolar) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }"} + """.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-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt new file mode 100644 index 0000000000..5098e241af --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt @@ -0,0 +1,220 @@ +package io.sentry.apollo4 + +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.exception.ApolloException +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.ITransaction +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.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SentryTransaction +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.TimeUnit +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentryApollo4BuilderExtensionsTestWithV4Implementation : SentryApollo4BuilderExtensionsTest(ApolloCall<*>::execute) +class SentryApollo4BuilderExtensionsTestWithV3Implementation : SentryApollo4BuilderExtensionsTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4BuilderExtensionsTest( + 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 + ): ApolloClient { + whenever(scopes.options).thenReturn( + SentryOptions().apply { + dsn = "http://key@localhost/proj" + } + ) + + server.enqueue( + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + return ApolloClient.Builder().serverUrl(server.url("/").toString()) + .sentryTracing(scopes = scopes, beforeSpan = beforeSpan, captureFailedRequests = false) + .build() + } + } + + private val fixture = Fixture() + + @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() + ) + } + + @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("query", it.data["operation_type"]) + }, + 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("query", it.data["operation_type"]) + }, + anyOrNull() + ) + } + + @Test + fun `handles non-ascii header values correctly`() { + executeQuery(id = "รก") + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it) + 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)!! + for (sentryHeader in INTERNAL_HEADER_NAMES) { + assertTrue(recordedRequest.headers.none { header -> header.first.equals(sentryHeader, true) }) + } + } + + private fun assertTransactionDetails(it: SentryTransaction) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("http.graphql.query", httpClientSpan.op) + assertEquals("query LaunchDetails", httpClientSpan.description) + assertEquals("auto.graphql.apollo4", httpClientSpan.origin) + assertNotNull(httpClientSpan.data) { + assertNotNull(it["operationId"]) + assertNotNull(it["variables"]) + } + } + + 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.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-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt new file mode 100644 index 0000000000..f0344f62ec --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -0,0 +1,388 @@ +package io.sentry.apollo4 + +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 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.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.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryTransaction +import io.sentry.util.Apollo4PlatformTestManipulator +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +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 +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 + +class SentryApollo4HttpInterceptorTestWithV4Implementation : SentryApollo4HttpInterceptorTest(ApolloCall<*>::execute) +class SentryApollo4HttpInterceptorTestWithV3Implementation : SentryApollo4HttpInterceptorTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4HttpInterceptorTest( + 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 = SentryApollo4HttpInterceptor(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 = SentryApollo4HttpInterceptor(scopes, beforeSpan, captureFailedRequests = false) + } + + val builder = ApolloClient.Builder() + .serverUrl(server.url("/").toString()) + .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() { + Apollo4PlatformTestManipulator.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 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.apollo4")) + 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 `adds breadcrumb when http calls succeeds`() { + 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"]) + }, + anyOrNull() + ) + } + + @Test + fun `sets SDKVersion Info`() { + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("Apollo4")) + val packageInfo = fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo-4" } + assertNotNull(packageInfo) + assert(packageInfo.version == BuildConfig.VERSION_NAME) + } + + @Test + fun `attaches to root transaction on Android`() { + Apollo4PlatformTestManipulator.pretendIsAndroid(true) + executeQuery(fixture.getSut()) + verify(fixture.scopes).transaction + } + + @Test + fun `attaches to child span on non-Android`() { + Apollo4PlatformTestManipulator.pretendIsAndroid(false) + executeQuery(fixture.getSut()) + verify(fixture.scopes).span + } + + 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-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt new file mode 100644 index 0000000000..5fe0b05021 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt @@ -0,0 +1,89 @@ +package io.sentry.apollo4.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.apollo4.generated.adapter.LaunchDetailsQuery_ResponseAdapter +import io.sentry.apollo4.generated.adapter.LaunchDetailsQuery_VariablesAdapter +import io.sentry.apollo4.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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt new file mode 100644 index 0000000000..8926f7d4f1 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt @@ -0,0 +1,166 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt new file mode 100644 index 0000000000..8e2e96c92a --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt new file mode 100644 index 0000000000..de8836c330 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt @@ -0,0 +1,82 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.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.apollo4.generated.type.GraphQLID +import io.sentry.apollo4.generated.type.GraphQLString +import io.sentry.apollo4.generated.type.Launch +import io.sentry.apollo4.generated.type.Mission +import io.sentry.apollo4.generated.type.Query.Companion.type +import io.sentry.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt new file mode 100644 index 0000000000..939d391e3f --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt @@ -0,0 +1,17 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt new file mode 100644 index 0000000000..4aea4184a1 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt new file mode 100644 index 0000000000..96394bfe4d --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt new file mode 100644 index 0000000000..066c5a323d --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt new file mode 100644 index 0000000000..070fa9258f --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt new file mode 100644 index 0000000000..ca72e33147 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt new file mode 100644 index 0000000000..3d43df676f --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/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.apollo4.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-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt b/sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt new file mode 100644 index 0000000000..f47438550e --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt @@ -0,0 +1,8 @@ +package io.sentry.util + +object Apollo4PlatformTestManipulator { + + fun pretendIsAndroid(isAndroid: Boolean) { + Platform.isAndroid = isAndroid + } +} diff --git a/sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/sentry-apollo-4/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 28644604f0..e5fdc079f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ include( "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", + "sentry-apollo-4", "sentry-test-support", "sentry-log4j2", "sentry-logback",