diff --git a/CHANGELOG.md b/CHANGELOG.md index cd25dd724f..1f200f092b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ * Feat: Include unfinished spans in transaction (#1699) * Fix: Move tags from transaction.contexts.trace.tags to transaction.tags (#1700) * Feat: Add static helpers for creating breadcrumbs (#1702) +* Feat: Performance support for Android Apollo (#1705) Breaking changes: diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 85cde5d92c..8412cc8f4f 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -91,6 +91,10 @@ object Config { private val feignVersion = "11.6" val feignCore = "io.github.openfeign:feign-core:$feignVersion" val feignGson = "io.github.openfeign:feign-gson:$feignVersion" + + private val apolloVersion = "2.5.9" + val apolloAndroid = "com.apollographql.apollo:apollo-runtime:$apolloVersion" + val apolloCoroutines = "com.apollographql.apollo:apollo-coroutines-support:$apolloVersion" } object AnnotationProcessors { @@ -111,6 +115,7 @@ object Config { val mockitoInline = "org.mockito:mockito-inline:3.10.0" val awaitility = "org.awaitility:awaitility-kotlin:4.1.0" val mockWebserver = "com.squareup.okhttp3:mockwebserver:4.9.0" + val mockWebserver3 = "com.squareup.okhttp3:mockwebserver:3.14.9" // bumping to 2.26.0 breaks tests val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.11.1" } diff --git a/sentry-apollo/api/sentry-apollo.api b/sentry-apollo/api/sentry-apollo.api new file mode 100644 index 0000000000..5bdb1df0c3 --- /dev/null +++ b/sentry-apollo/api/sentry-apollo.api @@ -0,0 +1,14 @@ +public final class io/sentry/apollo/SentryApolloInterceptor : com/apollographql/apollo/interceptor/ApolloInterceptor { + public fun ()V + public fun (Lio/sentry/IHub;)V + public fun (Lio/sentry/IHub;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V + public synthetic fun (Lio/sentry/IHub;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V + public fun dispose ()V + public fun interceptAsync (Lcom/apollographql/apollo/interceptor/ApolloInterceptor$InterceptorRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptorChain;Ljava/util/concurrent/Executor;Lcom/apollographql/apollo/interceptor/ApolloInterceptor$CallBack;)V +} + +public abstract interface class io/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lcom/apollographql/apollo/interceptor/ApolloInterceptor$InterceptorRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptor$InterceptorResponse;)Lio/sentry/ISpan; +} + diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts new file mode 100644 index 0000000000..76becc141b --- /dev/null +++ b/sentry-apollo/build.gradle.kts @@ -0,0 +1,80 @@ +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() +} + +dependencies { + api(projects.sentry) + api(projects.sentryKotlinExtensions) + + implementation(Config.Libs.apolloAndroid) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + errorproneJavac(Config.CompileOnly.errorProneJavac8) + 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.mockWebserver3) + testImplementation(Config.Libs.apolloCoroutines) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.isEnabled = true + html.isEnabled = 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") + } +} diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt new file mode 100644 index 0000000000..c4316e1925 --- /dev/null +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -0,0 +1,112 @@ +package io.sentry.apollo + +import com.apollographql.apollo.api.Mutation +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.Subscription +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptor.CallBack +import com.apollographql.apollo.interceptor.ApolloInterceptor.FetchSourceType +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorRequest +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorResponse +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import io.sentry.HubAdapter +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SentryLevel +import io.sentry.SpanStatus +import java.util.concurrent.Executor + +class SentryApolloInterceptor( + private val hub: IHub = HubAdapter.getInstance(), + private val beforeSpan: BeforeSpanCallback? = null +) : ApolloInterceptor { + + constructor(hub: IHub) : this(hub, null) + constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) + + override fun interceptAsync(request: InterceptorRequest, chain: ApolloInterceptorChain, dispatcher: Executor, callBack: CallBack) { + val activeSpan = hub.span + if (activeSpan == null) { + chain.proceedAsync(request, dispatcher, callBack) + } else { + val span = startChild(request, activeSpan) + val sentryTraceHeader = span.toSentryTrace() + + // we have no access to URI, no way to verify tracing origins + val headers = request.requestHeaders.toBuilder().addHeader(sentryTraceHeader.name, sentryTraceHeader.value).build() + val requestWithHeader = request.toBuilder().requestHeaders(headers).build() + span.setData("operationId", requestWithHeader.operation.operationId()) + span.setData("variables", requestWithHeader.operation.variables().valueMap().toString()) + + chain.proceedAsync(requestWithHeader, dispatcher, object : CallBack { + override fun onResponse(response: InterceptorResponse) { + // onResponse is called only for statuses 2xx + span.status = response.httpResponse.map { SpanStatus.fromHttpStatusCode(it.code(), SpanStatus.UNKNOWN) } + .or(SpanStatus.UNKNOWN) + + finish(span, requestWithHeader, response) + callBack.onResponse(response) + } + + override fun onFetch(sourceType: FetchSourceType) { + callBack.onFetch(sourceType) + } + + override fun onFailure(e: ApolloException) { + span.apply { + status = if (e is ApolloHttpException) SpanStatus.fromHttpStatusCode(e.code(), SpanStatus.INTERNAL_ERROR) else SpanStatus.INTERNAL_ERROR + throwable = e + } + finish(span, requestWithHeader) + callBack.onFailure(e) + } + + override fun onCompleted() { + callBack.onCompleted() + } + }) + } + } + + override fun dispose() {} + + private fun startChild(request: InterceptorRequest, activeSpan: ISpan): ISpan { + val operation = request.operation.name().name() + val operationType = when (request.operation) { + is Query -> "query" + is Mutation -> "mutation" + is Subscription -> "subscription" + else -> request.operation.javaClass.simpleName + } + val description = "$operationType $operation" + return activeSpan.startChild(operation, description) + } + + private fun finish(span: ISpan, request: InterceptorRequest, response: InterceptorResponse? = null) { + var newSpan: ISpan = span + if (beforeSpan != null) { + try { + newSpan = beforeSpan.execute(span, request, response) + } catch (e: Exception) { + hub.options.logger.log(SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", e) + } + } + newSpan.finish() + } + + /** + * The BeforeSpan callback + */ + interface BeforeSpanCallback { + /** + * Mutates span before being added. + * + * @param span the span to mutate or drop + * @param request the HTTP request executed by okHttp + * @param response the HTTP response received by okHttp + */ + fun execute(span: ISpan, request: InterceptorRequest, response: InterceptorResponse?): ISpan + } +} diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/LaunchDetailsQuery.java b/sentry-apollo/src/test/java/io/sentry/apollo/LaunchDetailsQuery.java new file mode 100644 index 0000000000..c47d65fdad --- /dev/null +++ b/sentry-apollo/src/test/java/io/sentry/apollo/LaunchDetailsQuery.java @@ -0,0 +1,570 @@ +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL plugin from the GraphQL queries it +// found. +// It should not be modified by hand. +// +package io.sentry.apollo; + +import com.apollographql.apollo.api.Operation; +import com.apollographql.apollo.api.OperationName; +import com.apollographql.apollo.api.Query; +import com.apollographql.apollo.api.Response; +import com.apollographql.apollo.api.ResponseField; +import com.apollographql.apollo.api.ScalarTypeAdapters; +import com.apollographql.apollo.api.internal.InputFieldMarshaller; +import com.apollographql.apollo.api.internal.InputFieldWriter; +import com.apollographql.apollo.api.internal.OperationRequestBodyComposer; +import com.apollographql.apollo.api.internal.QueryDocumentMinifier; +import com.apollographql.apollo.api.internal.ResponseFieldMapper; +import com.apollographql.apollo.api.internal.ResponseFieldMarshaller; +import com.apollographql.apollo.api.internal.ResponseReader; +import com.apollographql.apollo.api.internal.ResponseWriter; +import com.apollographql.apollo.api.internal.SimpleOperationResponseParser; +import com.apollographql.apollo.api.internal.UnmodifiableMapBuilder; +import com.apollographql.apollo.api.internal.Utils; +import io.sentry.apollo.type.CustomType; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import okio.Buffer; +import okio.BufferedSource; +import okio.ByteString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings({"NullAway", "Nopen", "MissingOverride"}) +public final class LaunchDetailsQuery + implements Query< + LaunchDetailsQuery.Data, LaunchDetailsQuery.Data, LaunchDetailsQuery.Variables> { + public static final String OPERATION_ID = + "022b3b829be588095d13a81e534a0e63a93d5846ce571a29999540d1cfe9870f"; + + public static final String QUERY_DOCUMENT = + QueryDocumentMinifier.minify( + "query LaunchDetails($id:ID!) {\n" + + " launch(id: $id) {\n" + + " __typename\n" + + " id\n" + + " site\n" + + " mission {\n" + + " __typename\n" + + " name\n" + + " missionPatch(size: LARGE)\n" + + " }\n" + + " }\n" + + "}"); + + public static final OperationName OPERATION_NAME = + new OperationName() { + @Override + public String name() { + return "LaunchDetails"; + } + }; + + private final LaunchDetailsQuery.Variables variables; + + public LaunchDetailsQuery(@NotNull String id) { + Utils.checkNotNull(id, "id == null"); + variables = new LaunchDetailsQuery.Variables(id); + } + + @Override + public String operationId() { + return OPERATION_ID; + } + + @Override + public String queryDocument() { + return QUERY_DOCUMENT; + } + + @Override + public LaunchDetailsQuery.Data wrapData(LaunchDetailsQuery.Data data) { + return data; + } + + @Override + public LaunchDetailsQuery.Variables variables() { + return variables; + } + + @Override + public ResponseFieldMapper responseFieldMapper() { + return new Data.Mapper(); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public OperationName name() { + return OPERATION_NAME; + } + + @Override + @NotNull + public Response parse( + @NotNull final BufferedSource source, @NotNull final ScalarTypeAdapters scalarTypeAdapters) + throws IOException { + return SimpleOperationResponseParser.parse(source, this, scalarTypeAdapters); + } + + @Override + @NotNull + public Response parse( + @NotNull final ByteString byteString, @NotNull final ScalarTypeAdapters scalarTypeAdapters) + throws IOException { + return parse(new Buffer().write(byteString), scalarTypeAdapters); + } + + @Override + @NotNull + public Response parse(@NotNull final BufferedSource source) + throws IOException { + return parse(source, ScalarTypeAdapters.DEFAULT); + } + + @Override + @NotNull + public Response parse(@NotNull final ByteString byteString) + throws IOException { + return parse(byteString, ScalarTypeAdapters.DEFAULT); + } + + @Override + @NotNull + public ByteString composeRequestBody(@NotNull final ScalarTypeAdapters scalarTypeAdapters) { + return OperationRequestBodyComposer.compose(this, false, true, scalarTypeAdapters); + } + + @NotNull + @Override + public ByteString composeRequestBody() { + return OperationRequestBodyComposer.compose(this, false, true, ScalarTypeAdapters.DEFAULT); + } + + @Override + @NotNull + public ByteString composeRequestBody( + final boolean autoPersistQueries, + final boolean withQueryDocument, + @NotNull final ScalarTypeAdapters scalarTypeAdapters) { + return OperationRequestBodyComposer.compose( + this, autoPersistQueries, withQueryDocument, scalarTypeAdapters); + } + + public static final class Builder { + private @NotNull String id; + + Builder() {} + + public Builder id(@NotNull String id) { + this.id = id; + return this; + } + + public LaunchDetailsQuery build() { + Utils.checkNotNull(id, "id == null"); + return new LaunchDetailsQuery(id); + } + } + + public static final class Variables extends Operation.Variables { + private final @NotNull String id; + + private final transient Map valueMap = new LinkedHashMap<>(); + + Variables(@NotNull String id) { + this.id = id; + this.valueMap.put("id", id); + } + + public @NotNull String id() { + return id; + } + + @Override + public Map valueMap() { + return Collections.unmodifiableMap(valueMap); + } + + @Override + public InputFieldMarshaller marshaller() { + return new InputFieldMarshaller() { + @Override + public void marshal(InputFieldWriter writer) throws IOException { + writer.writeCustom("id", CustomType.ID, id); + } + }; + } + } + + /** Data from the response after executing this GraphQL operation */ + public static class Data implements Operation.Data { + static final ResponseField[] $responseFields = { + ResponseField.forObject( + "launch", + "launch", + new UnmodifiableMapBuilder(1) + .put( + "id", + new UnmodifiableMapBuilder(2) + .put("kind", "Variable") + .put("variableName", "id") + .build()) + .build(), + true, + Collections.emptyList()) + }; + + final @Nullable Launch launch; + + private transient volatile String $toString; + + private transient volatile int $hashCode; + + private transient volatile boolean $hashCodeMemoized; + + public Data(@Nullable Launch launch) { + this.launch = launch; + } + + public @Nullable Launch launch() { + return this.launch; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public ResponseFieldMarshaller marshaller() { + return new ResponseFieldMarshaller() { + @Override + public void marshal(ResponseWriter writer) { + writer.writeObject($responseFields[0], launch != null ? launch.marshaller() : null); + } + }; + } + + @Override + public String toString() { + if ($toString == null) { + $toString = "Data{" + "launch=" + launch + "}"; + } + return $toString; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof Data) { + Data that = (Data) o; + return ((this.launch == null) ? (that.launch == null) : this.launch.equals(that.launch)); + } + return false; + } + + @Override + public int hashCode() { + if (!$hashCodeMemoized) { + int h = 1; + h *= 1000003; + h ^= (launch == null) ? 0 : launch.hashCode(); + $hashCode = h; + $hashCodeMemoized = true; + } + return $hashCode; + } + + public static final class Mapper implements ResponseFieldMapper { + final Launch.Mapper launchFieldMapper = new Launch.Mapper(); + + @Override + public Data map(ResponseReader reader) { + final Launch launch = + reader.readObject( + $responseFields[0], + new ResponseReader.ObjectReader() { + @Override + public Launch read(ResponseReader reader) { + return launchFieldMapper.map(reader); + } + }); + return new Data(launch); + } + } + } + + public static class Launch { + static final ResponseField[] $responseFields = { + ResponseField.forString( + "__typename", + "__typename", + null, + false, + Collections.emptyList()), + ResponseField.forCustomType( + "id", "id", null, false, CustomType.ID, Collections.emptyList()), + ResponseField.forString( + "site", "site", null, true, Collections.emptyList()), + ResponseField.forObject( + "mission", "mission", null, true, Collections.emptyList()) + }; + + final @NotNull String __typename; + + final @NotNull String id; + + final @Nullable String site; + + final @Nullable Mission mission; + + private transient volatile String $toString; + + private transient volatile int $hashCode; + + private transient volatile boolean $hashCodeMemoized; + + public Launch( + @NotNull String __typename, + @NotNull String id, + @Nullable String site, + @Nullable Mission mission) { + this.__typename = Utils.checkNotNull(__typename, "__typename == null"); + this.id = Utils.checkNotNull(id, "id == null"); + this.site = site; + this.mission = mission; + } + + public @NotNull String __typename() { + return this.__typename; + } + + public @NotNull String id() { + return this.id; + } + + public @Nullable String site() { + return this.site; + } + + public @Nullable Mission mission() { + return this.mission; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public ResponseFieldMarshaller marshaller() { + return new ResponseFieldMarshaller() { + @Override + public void marshal(ResponseWriter writer) { + writer.writeString($responseFields[0], __typename); + writer.writeCustom((ResponseField.CustomTypeField) $responseFields[1], id); + writer.writeString($responseFields[2], site); + writer.writeObject($responseFields[3], mission != null ? mission.marshaller() : null); + } + }; + } + + @Override + public String toString() { + if ($toString == null) { + $toString = + "Launch{" + + "__typename=" + + __typename + + ", " + + "id=" + + id + + ", " + + "site=" + + site + + ", " + + "mission=" + + mission + + "}"; + } + return $toString; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof Launch) { + Launch that = (Launch) o; + return this.__typename.equals(that.__typename) + && this.id.equals(that.id) + && ((this.site == null) ? (that.site == null) : this.site.equals(that.site)) + && ((this.mission == null) + ? (that.mission == null) + : this.mission.equals(that.mission)); + } + return false; + } + + @Override + public int hashCode() { + if (!$hashCodeMemoized) { + int h = 1; + h *= 1000003; + h ^= __typename.hashCode(); + h *= 1000003; + h ^= id.hashCode(); + h *= 1000003; + h ^= (site == null) ? 0 : site.hashCode(); + h *= 1000003; + h ^= (mission == null) ? 0 : mission.hashCode(); + $hashCode = h; + $hashCodeMemoized = true; + } + return $hashCode; + } + + public static final class Mapper implements ResponseFieldMapper { + final Mission.Mapper missionFieldMapper = new Mission.Mapper(); + + @Override + public Launch map(ResponseReader reader) { + final String __typename = reader.readString($responseFields[0]); + final String id = reader.readCustomType((ResponseField.CustomTypeField) $responseFields[1]); + final String site = reader.readString($responseFields[2]); + final Mission mission = + reader.readObject( + $responseFields[3], + new ResponseReader.ObjectReader() { + @Override + public Mission read(ResponseReader reader) { + return missionFieldMapper.map(reader); + } + }); + return new Launch(__typename, id, site, mission); + } + } + } + + public static class Mission { + static final ResponseField[] $responseFields = { + ResponseField.forString( + "__typename", + "__typename", + null, + false, + Collections.emptyList()), + ResponseField.forString( + "name", "name", null, true, Collections.emptyList()), + ResponseField.forString( + "missionPatch", + "missionPatch", + new UnmodifiableMapBuilder(1).put("size", "LARGE").build(), + true, + Collections.emptyList()) + }; + + final @NotNull String __typename; + + final @Nullable String name; + + final @Nullable String missionPatch; + + private transient volatile String $toString; + + private transient volatile int $hashCode; + + private transient volatile boolean $hashCodeMemoized; + + public Mission( + @NotNull String __typename, @Nullable String name, @Nullable String missionPatch) { + this.__typename = Utils.checkNotNull(__typename, "__typename == null"); + this.name = name; + this.missionPatch = missionPatch; + } + + public @NotNull String __typename() { + return this.__typename; + } + + public @Nullable String name() { + return this.name; + } + + public @Nullable String missionPatch() { + return this.missionPatch; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public ResponseFieldMarshaller marshaller() { + return new ResponseFieldMarshaller() { + @Override + public void marshal(ResponseWriter writer) { + writer.writeString($responseFields[0], __typename); + writer.writeString($responseFields[1], name); + writer.writeString($responseFields[2], missionPatch); + } + }; + } + + @Override + public String toString() { + if ($toString == null) { + $toString = + "Mission{" + + "__typename=" + + __typename + + ", " + + "name=" + + name + + ", " + + "missionPatch=" + + missionPatch + + "}"; + } + return $toString; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof Mission) { + Mission that = (Mission) o; + return this.__typename.equals(that.__typename) + && ((this.name == null) ? (that.name == null) : this.name.equals(that.name)) + && ((this.missionPatch == null) + ? (that.missionPatch == null) + : this.missionPatch.equals(that.missionPatch)); + } + return false; + } + + @Override + public int hashCode() { + if (!$hashCodeMemoized) { + int h = 1; + h *= 1000003; + h ^= __typename.hashCode(); + h *= 1000003; + h ^= (name == null) ? 0 : name.hashCode(); + h *= 1000003; + h ^= (missionPatch == null) ? 0 : missionPatch.hashCode(); + $hashCode = h; + $hashCodeMemoized = true; + } + return $hashCode; + } + + public static final class Mapper implements ResponseFieldMapper { + @Override + public Mission map(ResponseReader reader) { + final String __typename = reader.readString($responseFields[0]); + final String name = reader.readString($responseFields[1]); + final String missionPatch = reader.readString($responseFields[2]); + return new Mission(__typename, name, missionPatch); + } + } + } +} diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt new file mode 100644 index 0000000000..e3c00a07fe --- /dev/null +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -0,0 +1,177 @@ +package io.sentry.apollo + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.coroutines.await +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorRequest +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorResponse +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import io.sentry.ISpan +import io.sentry.Sentry +import io.sentry.SentryTraceHeader +import io.sentry.SpanStatus +import io.sentry.checkTransaction +import io.sentry.kotlin.SentryContext +import io.sentry.protocol.SentryTransaction +import io.sentry.transport.ITransport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy + +class SentryApolloInterceptorTest { + + class Fixture { + val server = MockWebServer() + val transport = mock() + var interceptor = SentryApolloInterceptor() + + @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: SentryApolloInterceptor.BeforeSpanCallback? = null + ): ApolloClient { + Sentry.init { + it.dsn = "http://key@localhost/proj" + it.tracesSampleRate = 1.0 + it.setTransportFactory { _, _ -> transport } + } + + server.enqueue(MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode)) + + if (beforeSpan != null) { + interceptor = SentryApolloInterceptor(beforeSpan) + } + return ApolloClient.builder() + .serverUrl(server.url("/")) + .addApplicationInterceptor(interceptor) + .build() + } + } + + private val fixture = Fixture() + + @Test + fun `creates a span around the successful request`() = runBlocking { + executeQuery() + + verify(fixture.transport).send(checkTransaction { + assertTransactionDetails(it) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, anyOrNull()) + } + + @Test + fun `creates a span around the failed request`() = runBlocking { + executeQuery(fixture.getSut(httpStatusCode = 403)) + + verify(fixture.transport).send(checkTransaction { + assertTransactionDetails(it) + assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) + }, anyOrNull()) + } + + @Test + fun `creates a span around the request failing with network error`() = runBlocking { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.transport).send(checkTransaction { + assertTransactionDetails(it) + assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) + }, anyOrNull()) + } + + @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() + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + } + + @Test + fun `when there is an active span, adds sentry trace headers to the request`() { + executeQuery() + val recorderRequest = fixture.server.takeRequest() + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + } + + @Test + fun `customizer modifies span`() { + executeQuery(fixture.getSut(beforeSpan = object : SentryApolloInterceptor.BeforeSpanCallback { + override fun execute(span: ISpan, request: InterceptorRequest, response: InterceptorResponse?): ISpan { + span.description = "overwritten description" + return span + } + })) + + verify(fixture.transport).send(checkTransaction { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("overwritten description", httpClientSpan.description) + }, anyOrNull()) + } + + @Test + fun `when customizer throws, exception is handled`() { + executeQuery(fixture.getSut(beforeSpan = object : SentryApolloInterceptor.BeforeSpanCallback { + override fun execute(span: ISpan, request: InterceptorRequest, response: InterceptorResponse?): ISpan { + throw RuntimeException() + } + })) + + verify(fixture.transport).send(checkTransaction { + assertEquals(1, it.spans.size) + }, anyOrNull()) + } + + private fun assertTransactionDetails(it: SentryTransaction) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("LaunchDetails", httpClientSpan.op) + assertEquals("query LaunchDetails", httpClientSpan.description) + assertNotNull(httpClientSpan.data) { + assertNotNull(it["operationId"]) + assertEquals("{id=83}", it["variables"]) + } + } + + private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true) = runBlocking { + val tx = if (isSpanActive) Sentry.startTransaction("op", "desc", true) else null + + val coroutine = launch(SentryContext()) { + try { + sut.query(LaunchDetailsQuery.builder().id("83").build()).await() + } catch (e: ApolloException) { + return@launch + } + } + coroutine.join() + tx?.finish() + } +} diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/type/CustomType.java b/sentry-apollo/src/test/java/io/sentry/apollo/type/CustomType.java new file mode 100644 index 0000000000..ecc8fd32ab --- /dev/null +++ b/sentry-apollo/src/test/java/io/sentry/apollo/type/CustomType.java @@ -0,0 +1,23 @@ +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL plugin from the GraphQL queries it +// found. +// It should not be modified by hand. +// +package io.sentry.apollo.type; + +import com.apollographql.apollo.api.ScalarType; + +public enum CustomType implements ScalarType { + ID { + @Override + public String typeName() { + return "ID"; + } + + @Override + public String className() { + return "java.lang.String"; + } + } +} diff --git a/sentry-apollo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-apollo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/sentry-apollo/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 1d6d481154..3459c320de 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ include( "sentry-android-timber", "sentry-android-okhttp", "sentry-android-fragment", + "sentry-apollo", "sentry-test-support", "sentry-log4j2", "sentry-logback",