diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8616a031d..fc6b07223ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Add HTTP response code to Spring WebFlux transactions ([#2870](https://github.com/getsentry/sentry-java/pull/2870)) - Add `sampled` to Dynamic Sampling Context ([#2869](https://github.com/getsentry/sentry-java/pull/2869)) +- Improve server side GraphQL support for spring-graphql and Nextflix DGS ([#2856](https://github.com/getsentry/sentry-java/pull/2856)) + - If you have already been using `SentryDataFetcherExceptionHandler` that still works but has been deprecated. Please use `SentryGenericDataFetcherExceptionHandler` combined with `SentryInstrumentation` instead for better error reporting. + - More exceptions and errors caught and reported to Sentry by also looking at the `ExecutionResult` (more specifically its `errors`) + - More details for Sentry events: query, variables and response (where possible) + - Breadcrumbs for operation (query, mutation, subscription), data fetchers and data loaders (Spring only) + - Better hub propagation by using `GraphQLContext` ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 6d54ef9296a..be82ce3c3a9 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -73,16 +73,20 @@ object Config { val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind" val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" + val springBootStarterGraphql = "org.springframework.boot:spring-boot-starter-graphql:$springBootVersion" val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$springBootVersion" val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBootVersion" + val springBootStarterWebsocket = "org.springframework.boot:spring-boot-starter-websocket:$springBootVersion" val springBootStarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$springBootVersion" val springBootStarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBootVersion" val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion" val springBootStarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBootVersion" val springBoot3Starter = "org.springframework.boot:spring-boot-starter:$springBoot3Version" + val springBoot3StarterGraphql = "org.springframework.boot:spring-boot-starter-graphql:$springBoot3Version" val springBoot3StarterTest = "org.springframework.boot:spring-boot-starter-test:$springBoot3Version" val springBoot3StarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBoot3Version" + val springBoot3StarterWebsocket = "org.springframework.boot:spring-boot-starter-websocket:$springBoot3Version" val springBoot3StarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$springBoot3Version" val springBoot3StarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBoot3Version" val springBoot3StarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBoot3Version" diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index c39cccdb491..5d4b907f146 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -3,23 +3,71 @@ public final class io/sentry/graphql/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } +public final class io/sentry/graphql/ExceptionReporter { + public fun (Z)V + public fun captureThrowable (Ljava/lang/Throwable;Lio/sentry/graphql/ExceptionReporter$ExceptionDetails;Lgraphql/ExecutionResult;)V +} + +public final class io/sentry/graphql/ExceptionReporter$ExceptionDetails { + public fun (Lio/sentry/IHub;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Z)V + public fun (Lio/sentry/IHub;Lgraphql/schema/DataFetchingEnvironment;Z)V + public fun getHub ()Lio/sentry/IHub; + public fun getQuery ()Ljava/lang/String; + public fun getVariables ()Ljava/util/Map; + public fun isSubscription ()Z +} + +public final class io/sentry/graphql/GraphqlStringUtils { + public fun ()V + public static fun fieldToString (Lgraphql/execution/MergedField;)Ljava/lang/String; + public static fun objectTypeToString (Lgraphql/schema/GraphQLObjectType;)Ljava/lang/String; + public static fun typeToString (Lgraphql/schema/GraphQLOutputType;)Ljava/lang/String; +} + +public final class io/sentry/graphql/NoOpSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public static fun getInstance ()Lio/sentry/graphql/NoOpSubscriptionHandler; + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + public final class io/sentry/graphql/SentryDataFetcherExceptionHandler : graphql/execution/DataFetcherExceptionHandler { public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V public fun (Lio/sentry/IHub;Lgraphql/execution/DataFetcherExceptionHandler;)V public fun onException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; } +public final class io/sentry/graphql/SentryGenericDataFetcherExceptionHandler : graphql/execution/DataFetcherExceptionHandler { + public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun (Lio/sentry/IHub;Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun onException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; +} + +public final class io/sentry/graphql/SentryGraphqlExceptionHandler { + public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun onException (Ljava/lang/Throwable;Lgraphql/schema/DataFetchingEnvironment;Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; +} + public final class io/sentry/graphql/SentryInstrumentation : graphql/execution/instrumentation/SimpleInstrumentation { + public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; + public static final field SENTRY_HUB_CONTEXT_KEY Ljava/lang/String; public fun ()V public fun (Lio/sentry/IHub;)V public fun (Lio/sentry/IHub;Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;)V public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;)V + public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;)V + public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V + public fun (Lio/sentry/graphql/SentrySubscriptionHandler;Z)V + public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; public fun createState ()Lgraphql/execution/instrumentation/InstrumentationState; public fun instrumentDataFetcher (Lgraphql/schema/DataFetcher;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Lgraphql/schema/DataFetcher; + public fun instrumentExecutionResult (Lgraphql/ExecutionResult;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)Ljava/util/concurrent/CompletableFuture; } public abstract interface class io/sentry/graphql/SentryInstrumentation$BeforeSpanCallback { public abstract fun execute (Lio/sentry/ISpan;Lgraphql/schema/DataFetchingEnvironment;Ljava/lang/Object;)Lio/sentry/ISpan; } +public abstract interface class io/sentry/graphql/SentrySubscriptionHandler { + public abstract fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 0ccd23ac800..ed1c197acd1 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -36,8 +36,11 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.mockWebserver) testImplementation(Config.Libs.okhttp) + testImplementation(Config.Libs.springBootStarterGraphql) + testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") testImplementation(Config.Libs.graphQlJava) } diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java new file mode 100644 index 00000000000..30ccb214256 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -0,0 +1,156 @@ +package io.sentry.graphql; + +import graphql.ExecutionResult; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.language.AstPrinter; +import graphql.schema.DataFetchingEnvironment; +import io.sentry.Hint; +import io.sentry.IHub; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.Request; +import io.sentry.protocol.Response; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class ExceptionReporter { + private final boolean captureRequestBodyForNonSubscriptions; + + public ExceptionReporter(final boolean captureRequestBodyForNonSubscriptions) { + this.captureRequestBodyForNonSubscriptions = captureRequestBodyForNonSubscriptions; + } + + private static final @NotNull String MECHANISM_TYPE = "GraphqlInstrumentation"; + + public void captureThrowable( + final @NotNull Throwable throwable, + final @NotNull ExceptionDetails exceptionDetails, + final @Nullable ExecutionResult result) { + final @NotNull IHub hub = exceptionDetails.getHub(); + final @NotNull Mechanism mechanism = new Mechanism(); + mechanism.setType(MECHANISM_TYPE); + mechanism.setHandled(false); + final @NotNull Throwable mechanismException = + new ExceptionMechanismException(mechanism, throwable, Thread.currentThread()); + final @NotNull SentryEvent event = new SentryEvent(mechanismException); + event.setLevel(SentryLevel.FATAL); + + final @NotNull Hint hint = new Hint(); + setRequestDetailsOnEvent(hub, exceptionDetails, event); + + if (result != null && isAllowedToAttachBody(hub)) { + final @NotNull Response response = new Response(); + final @NotNull Map responseBody = result.toSpecification(); + response.setData(responseBody); + event.getContexts().setResponse(response); + } + + hub.captureEvent(event, hint); + } + + private boolean isAllowedToAttachBody(final @NotNull IHub hub) { + final @NotNull SentryOptions options = hub.getOptions(); + return options.isSendDefaultPii() + && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); + } + + private void setRequestDetailsOnEvent( + final @NotNull IHub hub, + final @NotNull ExceptionDetails exceptionDetails, + final @NotNull SentryEvent event) { + hub.configureScope( + (scope) -> { + final @Nullable Request scopeRequest = scope.getRequest(); + final @NotNull Request request = scopeRequest == null ? new Request() : scopeRequest; + setDetailsOnRequest(hub, exceptionDetails, request); + event.setRequest(request); + }); + } + + private void setDetailsOnRequest( + final @NotNull IHub hub, + final @NotNull ExceptionDetails exceptionDetails, + final @NotNull Request request) { + request.setApiTarget("graphql"); + + if (isAllowedToAttachBody(hub) + && (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions)) { + final @NotNull Map data = new HashMap<>(); + + data.put("query", exceptionDetails.getQuery()); + + final @Nullable Map variables = exceptionDetails.getVariables(); + if (variables != null && !variables.isEmpty()) { + data.put("variables", variables); + } + + // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor + // for non subscription (websocket) errors + request.setData(data); + } + } + + public static final class ExceptionDetails { + + private final @NotNull IHub hub; + private final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters; + private final @Nullable DataFetchingEnvironment dataFetchingEnvironment; + + private final boolean isSubscription; + + public ExceptionDetails( + final @NotNull IHub hub, + final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters, + final boolean isSubscription) { + this.hub = hub; + this.instrumentationExecutionParameters = instrumentationExecutionParameters; + dataFetchingEnvironment = null; + this.isSubscription = isSubscription; + } + + public ExceptionDetails( + final @NotNull IHub hub, + final @Nullable DataFetchingEnvironment dataFetchingEnvironment, + final boolean isSubscription) { + this.hub = hub; + this.dataFetchingEnvironment = dataFetchingEnvironment; + instrumentationExecutionParameters = null; + this.isSubscription = isSubscription; + } + + public @Nullable String getQuery() { + if (instrumentationExecutionParameters != null) { + return instrumentationExecutionParameters.getQuery(); + } + if (dataFetchingEnvironment != null) { + return AstPrinter.printAst(dataFetchingEnvironment.getDocument()); + } + return null; + } + + public @Nullable Map getVariables() { + if (instrumentationExecutionParameters != null) { + return instrumentationExecutionParameters.getVariables(); + } + if (dataFetchingEnvironment != null) { + return dataFetchingEnvironment.getVariables(); + } + return null; + } + + public boolean isSubscription() { + return isSubscription; + } + + public @NotNull IHub getHub() { + return hub; + } + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java b/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java new file mode 100644 index 00000000000..20b7f543bb1 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java @@ -0,0 +1,43 @@ +package io.sentry.graphql; + +import graphql.execution.MergedField; +import graphql.schema.GraphQLNamedOutputType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import io.sentry.util.StringUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class GraphqlStringUtils { + + public static @Nullable String fieldToString(final @Nullable MergedField field) { + if (field == null) { + return null; + } + + return field.getName(); + } + + public static @Nullable String typeToString(final @Nullable GraphQLOutputType type) { + if (type == null) { + return null; + } + + if (type instanceof GraphQLNamedOutputType) { + final @NotNull GraphQLNamedOutputType namedType = (GraphQLNamedOutputType) type; + return namedType.getName(); + } + + return StringUtils.toString(type); + } + + public static @Nullable String objectTypeToString(final @Nullable GraphQLObjectType type) { + if (type == null) { + return null; + } + + return type.getName(); + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java new file mode 100644 index 00000000000..df241ce35b2 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java @@ -0,0 +1,25 @@ +package io.sentry.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import org.jetbrains.annotations.NotNull; + +public final class NoOpSubscriptionHandler implements SentrySubscriptionHandler { + + private static final @NotNull NoOpSubscriptionHandler instance = new NoOpSubscriptionHandler(); + + private NoOpSubscriptionHandler() {} + + public static @NotNull NoOpSubscriptionHandler getInstance() { + return instance; + } + + @Override + public @NotNull Object onSubscriptionResult( + @NotNull Object result, + @NotNull IHub hub, + @NotNull ExceptionReporter exceptionReporter, + @NotNull InstrumentationFieldFetchParameters parameters) { + return result; + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java index 5101b687de6..1e06d0b3220 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java @@ -8,13 +8,18 @@ import io.sentry.Hint; import io.sentry.HubAdapter; import io.sentry.IHub; +import io.sentry.SentryIntegrationPackageStorage; import io.sentry.util.Objects; import org.jetbrains.annotations.NotNull; /** * Captures exceptions that occur during data fetching, passes them to Sentry and invokes a delegate * exception handler. + * + * @deprecated please use {@link SentryGenericDataFetcherExceptionHandler} in combination with + * {@link SentryInstrumentation} instead for better error reporting. */ +@Deprecated public final class SentryDataFetcherExceptionHandler implements DataFetcherExceptionHandler { private final @NotNull IHub hub; private final @NotNull DataFetcherExceptionHandler delegate; @@ -23,6 +28,7 @@ public SentryDataFetcherExceptionHandler( final @NotNull IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { this.hub = Objects.requireNonNull(hub, "hub is required"); this.delegate = Objects.requireNonNull(delegate, "delegate is required"); + SentryIntegrationPackageStorage.getInstance().addIntegration("GrahQLLegacyExceptionHandler"); } public SentryDataFetcherExceptionHandler(final @NotNull DataFetcherExceptionHandler delegate) { diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java new file mode 100644 index 00000000000..4b7d72e2cd4 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java @@ -0,0 +1,36 @@ +package io.sentry.graphql; + +import graphql.execution.DataFetcherExceptionHandler; +import graphql.execution.DataFetcherExceptionHandlerParameters; +import graphql.execution.DataFetcherExceptionHandlerResult; +import io.sentry.IHub; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Captures exceptions that occur during data fetching, passes them to Sentry and invokes a delegate + * exception handler. + */ +public final class SentryGenericDataFetcherExceptionHandler implements DataFetcherExceptionHandler { + private final @NotNull SentryGraphqlExceptionHandler handler; + + public SentryGenericDataFetcherExceptionHandler( + final @Nullable IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { + this.handler = new SentryGraphqlExceptionHandler(delegate); + } + + public SentryGenericDataFetcherExceptionHandler( + final @NotNull DataFetcherExceptionHandler delegate) { + this(null, delegate); + } + + @Override + @SuppressWarnings("deprecation") + public @Nullable DataFetcherExceptionHandlerResult onException( + final @NotNull DataFetcherExceptionHandlerParameters handlerParameters) { + return handler.onException( + handlerParameters.getException(), + handlerParameters.getDataFetchingEnvironment(), + handlerParameters); + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java new file mode 100644 index 00000000000..527935f863e --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java @@ -0,0 +1,48 @@ +package io.sentry.graphql; + +import static io.sentry.graphql.SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY; + +import graphql.GraphQLContext; +import graphql.execution.DataFetcherExceptionHandler; +import graphql.execution.DataFetcherExceptionHandlerParameters; +import graphql.execution.DataFetcherExceptionHandlerResult; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryGraphqlExceptionHandler { + private final @Nullable DataFetcherExceptionHandler delegate; + private final @NotNull Object exceptionContextLock = new Object(); + + public SentryGraphqlExceptionHandler(final @Nullable DataFetcherExceptionHandler delegate) { + this.delegate = delegate; + } + + @SuppressWarnings("deprecation") + public @Nullable DataFetcherExceptionHandlerResult onException( + final @NotNull Throwable throwable, + final @Nullable DataFetchingEnvironment environment, + final @Nullable DataFetcherExceptionHandlerParameters handlerParameters) { + if (environment != null) { + final @Nullable GraphQLContext graphQlContext = environment.getGraphQlContext(); + if (graphQlContext != null) { + synchronized (exceptionContextLock) { + final @NotNull List exceptions = + graphQlContext.getOrDefault( + SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); + exceptions.add(throwable); + graphQlContext.put(SENTRY_EXCEPTIONS_CONTEXT_KEY, exceptions); + } + } + } + if (delegate != null) { + return delegate.onException(handlerParameters); + } else { + return null; + } + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java index 090c78c6860..c5f36d7228e 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -1,50 +1,136 @@ package io.sentry.graphql; +import graphql.ErrorClassification; import graphql.ExecutionResult; +import graphql.GraphQLContext; +import graphql.GraphQLError; +import graphql.execution.ExecutionContext; +import graphql.execution.ExecutionStepInfo; import graphql.execution.instrumentation.InstrumentationContext; import graphql.execution.instrumentation.InstrumentationState; import graphql.execution.instrumentation.SimpleInstrumentation; +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.language.OperationDefinition; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import graphql.schema.GraphQLNonNull; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; -import io.sentry.HubAdapter; +import io.sentry.Breadcrumb; import io.sentry.IHub; import io.sentry.ISpan; +import io.sentry.NoOpHub; +import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SpanStatus; -import io.sentry.util.Objects; +import io.sentry.util.StringUtils; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public final class SentryInstrumentation extends SimpleInstrumentation { + + private static final @NotNull List ERROR_TYPES_HANDLED_BY_DATA_FETCHERS = + Arrays.asList( + "INTERNAL_ERROR", // spring-graphql + "INTERNAL", // Netflix DGS + "DataFetchingException" // raw graphql-java + ); + public static final @NotNull String SENTRY_HUB_CONTEXT_KEY = "sentry.hub"; + public static final @NotNull String SENTRY_EXCEPTIONS_CONTEXT_KEY = "sentry.exceptions"; private static final String TRACE_ORIGIN = "auto.graphql.graphql"; - private final @NotNull IHub hub; private final @Nullable BeforeSpanCallback beforeSpan; + private final @NotNull SentrySubscriptionHandler subscriptionHandler; - public SentryInstrumentation( - final @NotNull IHub hub, final @Nullable BeforeSpanCallback beforeSpan) { - this.hub = Objects.requireNonNull(hub, "hub is required"); - this.beforeSpan = beforeSpan; - SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-graphql", BuildConfig.VERSION_NAME); + private final @NotNull ExceptionReporter exceptionReporter; + + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") + public SentryInstrumentation() { + this(null, NoOpSubscriptionHandler.getInstance(), true); + } + + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") + public SentryInstrumentation(final @Nullable IHub hub) { + this(null, NoOpSubscriptionHandler.getInstance(), true); } + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { - this(HubAdapter.getInstance(), beforeSpan); + this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); } - public SentryInstrumentation(final @NotNull IHub hub) { - this(hub, null); + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") + public SentryInstrumentation( + final @Nullable IHub hub, final @Nullable BeforeSpanCallback beforeSpan) { + this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); } - public SentryInstrumentation() { - this(HubAdapter.getInstance()); + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + */ + public SentryInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions) { + this( + beforeSpan, + subscriptionHandler, + new ExceptionReporter(captureRequestBodyForNonSubscriptions)); + } + + @TestOnly + public SentryInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter) { + this.beforeSpan = beforeSpan; + this.subscriptionHandler = subscriptionHandler; + this.exceptionReporter = exceptionReporter; + SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-graphql", BuildConfig.VERSION_NAME); + } + + /** + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + */ + public SentryInstrumentation( + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions) { + this(null, subscriptionHandler, captureRequestBodyForNonSubscriptions); } @Override @@ -56,10 +142,104 @@ public SentryInstrumentation() { public @NotNull InstrumentationContext beginExecution( final @NotNull InstrumentationExecutionParameters parameters) { final TracingState tracingState = parameters.getInstrumentationState(); - tracingState.setTransaction(hub.getSpan()); + final @NotNull IHub currentHub = Sentry.getCurrentHub(); + tracingState.setTransaction(currentHub.getSpan()); + parameters.getGraphQLContext().put(SENTRY_HUB_CONTEXT_KEY, currentHub); return super.beginExecution(parameters); } + @Override + public CompletableFuture instrumentExecutionResult( + ExecutionResult executionResult, InstrumentationExecutionParameters parameters) { + return super.instrumentExecutionResult(executionResult, parameters) + .whenComplete( + (result, exception) -> { + if (result != null) { + final @Nullable GraphQLContext graphQLContext = parameters.getGraphQLContext(); + if (graphQLContext != null) { + final @NotNull List exceptions = + graphQLContext.getOrDefault( + SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); + for (Throwable throwable : exceptions) { + exceptionReporter.captureThrowable( + throwable, + new ExceptionReporter.ExceptionDetails( + hubFromContext(graphQLContext), parameters, false), + result); + } + } + final @NotNull List errors = result.getErrors(); + if (errors != null) { + for (GraphQLError error : errors) { + // not capturing INTERNAL_ERRORS as they should be reported via graphQlContext + // above + String errorType = getErrorType(error); + if (errorType == null + || !ERROR_TYPES_HANDLED_BY_DATA_FETCHERS.contains(errorType)) { + exceptionReporter.captureThrowable( + new RuntimeException(error.getMessage()), + new ExceptionReporter.ExceptionDetails( + hubFromContext(graphQLContext), parameters, false), + result); + } + } + } + } + if (exception != null) { + exceptionReporter.captureThrowable( + exception, + new ExceptionReporter.ExceptionDetails( + hubFromContext(parameters.getGraphQLContext()), parameters, false), + null); + } + }); + } + + private @Nullable String getErrorType(final @Nullable GraphQLError error) { + if (error == null) { + return null; + } + final @Nullable ErrorClassification errorType = error.getErrorType(); + if (errorType != null) { + return errorType.toString(); + } + final @Nullable Map extensions = error.getExtensions(); + if (extensions != null) { + return StringUtils.toString(extensions.get("errorType")); + } + return null; + } + + @Override + public @NotNull InstrumentationContext beginExecuteOperation( + final @NotNull InstrumentationExecuteOperationParameters parameters) { + final @Nullable ExecutionContext executionContext = parameters.getExecutionContext(); + if (executionContext != null) { + final @Nullable OperationDefinition operationDefinition = + executionContext.getOperationDefinition(); + if (operationDefinition != null) { + final @Nullable OperationDefinition.Operation operation = + operationDefinition.getOperation(); + final @Nullable String operationType = + operation == null ? null : operation.name().toLowerCase(Locale.ROOT); + hubFromContext(parameters.getExecutionContext().getGraphQLContext()) + .addBreadcrumb( + Breadcrumb.graphqlOperation( + operationDefinition.getName(), + operationType, + StringUtils.toString(executionContext.getExecutionId()))); + } + } + return super.beginExecuteOperation(parameters); + } + + private @NotNull IHub hubFromContext(final @Nullable GraphQLContext context) { + if (context == null) { + return NoOpHub.getInstance(); + } + return context.getOrDefault(SENTRY_HUB_CONTEXT_KEY, NoOpHub.getInstance()); + } + @Override @SuppressWarnings("FutureReturnValueIgnored") public @NotNull DataFetcher instrumentDataFetcher( @@ -71,12 +251,24 @@ public SentryInstrumentation() { } return environment -> { + final @Nullable ExecutionStepInfo executionStepInfo = environment.getExecutionStepInfo(); + if (executionStepInfo != null) { + hubFromContext(parameters.getExecutionContext().getGraphQLContext()) + .addBreadcrumb( + Breadcrumb.graphqlDataFetcher( + StringUtils.toString(executionStepInfo.getPath()), + GraphqlStringUtils.fieldToString(executionStepInfo.getField()), + GraphqlStringUtils.typeToString(executionStepInfo.getType()), + GraphqlStringUtils.objectTypeToString(executionStepInfo.getObjectType()))); + } final TracingState tracingState = parameters.getInstrumentationState(); final ISpan transaction = tracingState.getTransaction(); if (transaction != null) { final ISpan span = createSpan(transaction, parameters); try { - final Object result = dataFetcher.get(environment); + final @Nullable Object tmpResult = dataFetcher.get(environment); + final @Nullable Object result = + maybeCallSubscriptionHandler(parameters, environment, tmpResult); if (result instanceof CompletableFuture) { ((CompletableFuture) result) .whenComplete( @@ -101,11 +293,32 @@ public SentryInstrumentation() { throw e; } } else { - return dataFetcher.get(environment); + final Object result = dataFetcher.get(environment); + return maybeCallSubscriptionHandler(parameters, environment, result); } }; } + private @Nullable Object maybeCallSubscriptionHandler( + final @NotNull InstrumentationFieldFetchParameters parameters, + final @NotNull DataFetchingEnvironment environment, + final @Nullable Object tmpResult) { + if (tmpResult == null) { + return null; + } + + if (OperationDefinition.Operation.SUBSCRIPTION.equals( + environment.getOperationDefinition().getOperation())) { + return subscriptionHandler.onSubscriptionResult( + tmpResult, + hubFromContext(environment.getGraphQlContext()), + exceptionReporter, + parameters); + } + + return tmpResult; + } + private void finish( final @NotNull ISpan span, final @NotNull DataFetchingEnvironment environment, diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java new file mode 100644 index 00000000000..bfc962b5010 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java @@ -0,0 +1,14 @@ +package io.sentry.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import org.jetbrains.annotations.NotNull; + +public interface SentrySubscriptionHandler { + @NotNull + Object onSubscriptionResult( + @NotNull Object result, + @NotNull IHub hub, + @NotNull ExceptionReporter exceptionReporter, + @NotNull InstrumentationFieldFetchParameters parameters); +} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt new file mode 100644 index 00000000000..af469f8e021 --- /dev/null +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -0,0 +1,216 @@ +package io.sentry.graphql + +import graphql.ErrorType +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.ExecutionResultImpl +import graphql.GraphqlErrorException +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.Request +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ExceptionReporterTest { + + class Fixture { + val defaultOptions = SentryOptions().also { + it.isSendDefaultPii = true + it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS + } + val exception = IllegalStateException("some exception") + val hub = mock() + lateinit var instrumentationExecutionParameters: InstrumentationExecutionParameters + lateinit var executionResult: ExecutionResult + lateinit var scope: Scope + val query = """query greeting(name: "somename")""" + val variables = mapOf("variableA" to "value a") + + fun getSut(options: SentryOptions = defaultOptions, captureRequestBodyForNonSubscriptions: Boolean = true): ExceptionReporter { + whenever(hub.options).thenReturn(options) + scope = Scope(options) + val exceptionReporter = ExceptionReporter(captureRequestBodyForNonSubscriptions) + executionResult = ExecutionResultImpl.newExecutionResult() + .data("raw result") + .addError( + GraphqlErrorException.newErrorException().message("exception message").errorClassification( + ErrorType.ValidationError + ).build() + ) + .build() + val executionInput = ExecutionInput.newExecutionInput() + .query(query) + .graphQLContext(emptyMap()) + .variables(variables) + .build() + val scalarType = GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + val field = GraphQLFieldDefinition.newFieldDefinition() + .name("myQueryFieldName") + .type(scalarType) + .build() + val schema = GraphQLSchema.newSchema().query( + GraphQLObjectType.newObject().name("QueryType").field( + field + ).build() + ).build() + val instrumentationState = SentryInstrumentation.TracingState() + instrumentationExecutionParameters = InstrumentationExecutionParameters(executionInput, schema, instrumentationState) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + + return exceptionReporter + } + } + + private val fixture = Fixture() + + @Test + fun `captures throwable`() { + val exceptionReporter = fixture.getSut() + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertNotNull(it.request) + val request = it.request!! + val data = request.data as Map + assertEquals(fixture.variables, data["variables"]) + assertEquals(fixture.query, data["query"]) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } + + @Test + fun `uses requests on scope as base`() { + val exceptionReporter = fixture.getSut() + val headers = mapOf("some-header" to "some-header-value") + fixture.scope.request = Request().also { it.headers = headers } + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertSame(fixture.scope.request, it.request) + assertEquals("graphql", it.request!!.apiTarget) + }, + any() + ) + + assertNotNull(fixture.scope.request) + val request = fixture.scope.request!! + val data = request.data as Map + assertEquals(fixture.variables, data["variables"]) + assertEquals(headers, request.headers) + } + + @Test + fun `does not attach query or variables if spring`() { + val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertNotNull(it.request) + val request = it.request!! + assertNull(request.data) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } + + @Test + fun `does not attach query or variables if no max body size is set`() { + val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, false) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertNotNull(it.request) + val request = it.request!! + assertNull(request.data) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } + + @Test + fun `does not attach query or variables if sendDefaultPii is false`() { + val exceptionReporter = fixture.getSut(SentryOptions().also { it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS }, false) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertNotNull(it.request) + val request = it.request!! + assertNull(request.data) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } + + @Test + fun `attaches query and variables if spring and subscription`() { + val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, true), fixture.executionResult) + + verify(fixture.hub).captureEvent( + org.mockito.kotlin.check { + val ex = it.throwableMechanism as ExceptionMechanismException + assertFalse(ex.exceptionMechanism.isHandled!!) + assertSame(fixture.exception, ex.throwable) + assertEquals("GraphqlInstrumentation", ex.exceptionMechanism.type) + assertNotNull(it.request) + val request = it.request!! + val data = request.data as Map + assertEquals(fixture.variables, data["variables"]) + assertEquals(fixture.query, data["query"]) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } +} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt new file mode 100644 index 00000000000..71311c7a036 --- /dev/null +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt @@ -0,0 +1,59 @@ +package io.sentry.graphql + +import graphql.execution.MergedField +import graphql.language.Field +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLScalarType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GraphqlStringUtilsTest { + @Test + fun `field to String`() { + val mergedField = + MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build() + val string = GraphqlStringUtils.fieldToString(mergedField) + assertEquals("myFieldName", string) + } + + @Test + fun `null field to String`() { + assertNull(GraphqlStringUtils.fieldToString(null)) + } + + @Test + fun `type to String`() { + val scalarType = GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + val string = GraphqlStringUtils.typeToString(scalarType) + assertEquals("MyResponseType", string) + } + + @Test + fun `null type to String`() { + assertNull(GraphqlStringUtils.typeToString(null)) + } + + @Test + fun `objectType to String`() { + val scalarType = GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + val field = GraphQLFieldDefinition.newFieldDefinition() + .name("myQueryFieldName") + .type(scalarType) + .build() + val objectType = GraphQLObjectType.newObject().name("QUERY").field(field).build() + val string = GraphqlStringUtils.objectTypeToString(objectType) + assertEquals("QUERY", string) + } + + @Test + fun `null objectType to String`() { + assertNull(GraphqlStringUtils.objectTypeToString(null)) + } +} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt new file mode 100644 index 00000000000..62ac134212c --- /dev/null +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt @@ -0,0 +1,41 @@ +package io.sentry.graphql + +import graphql.GraphQLContext +import graphql.execution.DataFetcherExceptionHandler +import graphql.execution.DataFetcherExceptionHandlerParameters +import graphql.schema.DataFetchingEnvironmentImpl +import io.sentry.IHub +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SentryGenericDataFetcherExceptionHandlerTest { + + @Test + fun `collects exception into GraphQLContext and invokes delegate`() { + val hub = mock() + val delegate = mock() + val handler = SentryGenericDataFetcherExceptionHandler( + hub, + delegate + ) + + val exception = RuntimeException() + val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).dataFetchingEnvironment( + DataFetchingEnvironmentImpl.newDataFetchingEnvironment().graphQLContext( + GraphQLContext.of( + emptyMap() + ) + ).build() + ).build() + handler.onException(parameters) + + val exceptions: List = parameters.dataFetchingEnvironment.graphQlContext[SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY] + assertNotNull(exceptions) + assertEquals(1, exceptions.size) + assertEquals(exception, exceptions.first()) + verify(delegate).onException(parameters) + } +} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt new file mode 100644 index 00000000000..2b78036ef37 --- /dev/null +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt @@ -0,0 +1,346 @@ +package io.sentry.graphql + +import graphql.ErrorType +import graphql.ExecutionInput +import graphql.ExecutionResultImpl +import graphql.GraphQLContext +import graphql.GraphqlErrorException +import graphql.execution.ExecutionContext +import graphql.execution.ExecutionContextBuilder +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.ExecutionStrategyParameters +import graphql.execution.MergedField +import graphql.execution.MergedSelectionSet +import graphql.execution.ResultPath +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Field +import graphql.language.OperationDefinition +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import graphql.schema.DataFetchingEnvironmentImpl +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.graphql.ExceptionReporter.ExceptionDetails +import io.sentry.graphql.SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY +import io.sentry.graphql.SentryInstrumentation.TracingState +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + +class SentryInstrumentationAnotherTest { + + class Fixture { + val hub = mock() + lateinit var activeSpan: SentryTracer + lateinit var dataFetcher: DataFetcher + lateinit var fieldFetchParameters: InstrumentationFieldFetchParameters + lateinit var instrumentationExecutionParameters: InstrumentationExecutionParameters + lateinit var environment: DataFetchingEnvironment + lateinit var executionContext: ExecutionContext + lateinit var executionStrategyParameters: ExecutionStrategyParameters + lateinit var executionStepInfo: ExecutionStepInfo + lateinit var graphQLContext: GraphQLContext + lateinit var subscriptionHandler: SentrySubscriptionHandler + lateinit var exceptionReporter: ExceptionReporter + internal lateinit var instrumentationState: TracingState + lateinit var instrumentationExecuteOperationParameters: InstrumentationExecuteOperationParameters + val query = """query greeting(name: "somename")""" + val variables = mapOf("variableA" to "value a") + + fun getSut(isTransactionActive: Boolean = true, operation: OperationDefinition.Operation = OperationDefinition.Operation.QUERY, graphQLContextParam: Map? = null, addTransactionToTracingState: Boolean = true): SentryInstrumentation { + whenever(hub.options).thenReturn(SentryOptions()) + activeSpan = SentryTracer(TransactionContext("name", "op"), hub) + + if (isTransactionActive) { + whenever(hub.span).thenReturn(activeSpan) + } else { + whenever(hub.span).thenReturn(null) + } + + val defaultGraphQLContext = mapOf( + SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY to hub + ) + val mergedField = + MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build() + exceptionReporter = mock() + subscriptionHandler = mock() + whenever(subscriptionHandler.onSubscriptionResult(any(), any(), any(), any())).thenReturn("result modified by subscription handler") + val instrumentation = SentryInstrumentation(null, subscriptionHandler, exceptionReporter) + dataFetcher = mock>() + whenever(dataFetcher.get(any())).thenReturn("raw result") + graphQLContext = GraphQLContext.newContext() + .of(graphQLContextParam ?: defaultGraphQLContext).build() + val scalarType = GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + val field = GraphQLFieldDefinition.newFieldDefinition() + .name("myQueryFieldName") + .type(scalarType) + .build() + val objectType = GraphQLObjectType.newObject().name("QUERY").field(field).build() + executionStepInfo = ExecutionStepInfo.newExecutionStepInfo() + .type(scalarType) + .fieldContainer(objectType) + .parentInfo(ExecutionStepInfo.newExecutionStepInfo().type(objectType).build()) + .path(ResultPath.rootPath().segment("child")) + .field(mergedField) + .build() + val operationDefinition = OperationDefinition.newOperationDefinition() + .operation(operation) + .name("operation name") + .build() + environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .graphQLContext(graphQLContext) + .executionStepInfo(executionStepInfo) + .operationDefinition(operationDefinition) + .build() + executionContext = ExecutionContextBuilder.newExecutionContextBuilder() + .executionId(ExecutionId.generate()) + .graphQLContext(graphQLContext) + .operationDefinition(operationDefinition) + .build() + executionStrategyParameters = ExecutionStrategyParameters.newParameters() + .executionStepInfo(executionStepInfo) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .field(mergedField) + .build() + instrumentationState = SentryInstrumentation.TracingState().also { + if (isTransactionActive && addTransactionToTracingState) { + it.transaction = activeSpan + } + } + fieldFetchParameters = InstrumentationFieldFetchParameters(executionContext, environment, executionStrategyParameters, false).withNewState( + instrumentationState + ) + val executionInput = ExecutionInput.newExecutionInput() + .query(query) + .graphQLContext(graphQLContextParam ?: defaultGraphQLContext) + .variables(variables) + .build() + val schema = GraphQLSchema.newSchema().query( + GraphQLObjectType.newObject().name("QueryType").field( + field + ).build() + ).build() + instrumentationExecutionParameters = InstrumentationExecutionParameters(executionInput, schema, instrumentationState) + instrumentationExecuteOperationParameters = InstrumentationExecuteOperationParameters(executionContext) + + return instrumentation + } + } + + private val fixture = Fixture() + + @Test + fun `invokes subscription handler for subscription`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.SUBSCRIPTION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("result modified by subscription handler", result) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.hub), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + } + + @Test + fun `invokes subscription handler for subscription if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = true, operation = OperationDefinition.Operation.SUBSCRIPTION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("result modified by subscription handler", result) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.hub), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + } + + @Test + fun `does not invoke subscription handler for query`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.QUERY) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for query if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.QUERY) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for mutation`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.MUTATION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for mutation if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.MUTATION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `adds a breadcrumb for operation`() { + val instrumentation = fixture.getSut() + instrumentation.beginExecuteOperation(fixture.instrumentationExecuteOperationParameters) + verify(fixture.hub).addBreadcrumb( + org.mockito.kotlin.check { breadcrumb -> + assertEquals("graphql", breadcrumb.type) + assertEquals("query", breadcrumb.category) + assertEquals("operation name", breadcrumb.data["operation_name"]) + assertEquals("query", breadcrumb.data["operation_type"]) + assertEquals(fixture.executionContext.executionId.toString(), breadcrumb.data["operation_id"]) + } + ) + } + + @Test + fun `adds a breadcrumb for data fetcher`() { + val instrumentation = fixture.getSut() + instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters).get(fixture.environment) + verify(fixture.hub).addBreadcrumb( + org.mockito.kotlin.check { breadcrumb -> + assertEquals("graphql", breadcrumb.type) + assertEquals("graphql.fetcher", breadcrumb.category) + assertEquals("/child", breadcrumb.data["path"]) + assertEquals("myFieldName", breadcrumb.data["field"]) + assertEquals("MyResponseType", breadcrumb.data["type"]) + assertEquals("QUERY", breadcrumb.data["object_type"]) + } + ) + } + + @Test + fun `stores hub in context and adds transaction to state`() { + val instrumentation = fixture.getSut(isTransactionActive = true, operation = OperationDefinition.Operation.MUTATION, graphQLContextParam = emptyMap(), addTransactionToTracingState = false) + withMockHub { + instrumentation.beginExecution(fixture.instrumentationExecutionParameters) + assertSame(fixture.hub, fixture.instrumentationExecutionParameters.graphQLContext.get(SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY)) + assertNotNull(fixture.instrumentationState.transaction) + } + } + + @Test + fun `invokes exceptionReporter for error`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResultImpl.newExecutionResult() + .data("raw result") + .addError( + GraphqlErrorException.newErrorException().message("exception message").errorClassification( + ErrorType.ValidationError + ).build() + ) + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters) + verify(fixture.exceptionReporter).captureThrowable( + org.mockito.kotlin.check { + assertEquals("exception message", it.message) + }, + org.mockito.kotlin.check { + assertSame(fixture.hub, it.hub) + assertSame(fixture.query, it.query) + assertEquals(false, it.isSubscription) + assertEquals(fixture.variables, it.variables) + }, + same(executionResult) + ) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `invokes exceptionReporter for exceptions in GraphQLContext`() { + val exception = IllegalStateException("some exception") + val instrumentation = fixture.getSut( + graphQLContextParam = mapOf( + SENTRY_EXCEPTIONS_CONTEXT_KEY to listOf(exception), + SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY to fixture.hub + ) + ) + val executionResult = ExecutionResultImpl.newExecutionResult() + .data("raw result") + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters) + verify(fixture.exceptionReporter).captureThrowable( + org.mockito.kotlin.check { + assertSame(exception, it) + }, + org.mockito.kotlin.check { + assertSame(fixture.hub, it.hub) + assertSame(fixture.query, it.query) + assertEquals(false, it.isSubscription) + assertEquals(fixture.variables, it.variables) + }, + same(executionResult) + ) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `does not invoke exceptionReporter for certain errors that should be handled by SentryDataFetcherExceptionHandler`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResultImpl.newExecutionResult() + .data("raw result") + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(ErrorType.DataFetchingException).build()) + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(org.springframework.graphql.execution.ErrorType.INTERNAL_ERROR).build()) + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(com.netflix.graphql.types.errors.ErrorType.INTERNAL).build()) + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters) + verify(fixture.exceptionReporter, never()).captureThrowable(any(), any(), any()) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `never invokes exceptionReporter if no errors`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResultImpl.newExecutionResult() + .data("raw result") + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters) + verify(fixture.exceptionReporter, never()).captureThrowable(any(), any(), any()) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + fun withMockHub(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentHub() }.thenReturn(fixture.hub) + closure.invoke() + } + + data class Show(val id: Int) +} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 09cfd7b8f1e..19b7b3732b8 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -1,14 +1,31 @@ package io.sentry.graphql import graphql.GraphQL +import graphql.GraphQLContext +import graphql.execution.ExecutionContextBuilder +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.ExecutionStrategyParameters +import graphql.execution.MergedField +import graphql.execution.MergedSelectionSet +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Field +import graphql.language.OperationDefinition +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironmentImpl +import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser import io.sentry.IHub +import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus import io.sentry.TransactionContext +import org.mockito.Mockito +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.lang.RuntimeException @@ -40,7 +57,7 @@ class SentryInstrumentationTest { val graphQLSchema = SchemaGenerator().makeExecutableSchema(SchemaParser().parse(schema), buildRuntimeWiring(dataFetcherThrows)) val graphQL = GraphQL.newGraphQL(graphQLSchema) - .instrumentation(SentryInstrumentation(hub, beforeSpan)) + .instrumentation(SentryInstrumentation(beforeSpan, NoOpSubscriptionHandler.getInstance(), true)) .build() if (isTransactionActive) { @@ -70,56 +87,64 @@ class SentryInstrumentationTest { fun `when transaction is active, creates inner spans`() { val sut = fixture.getSut() - val result = sut.execute("{ shows { id } }") - - assertTrue(result.errors.isEmpty()) - assertEquals(1, fixture.activeSpan.children.size) - val span = fixture.activeSpan.children.first() - assertEquals("graphql", span.operation) - assertEquals("Query.shows", span.description) - assertEquals("auto.graphql.graphql", span.spanContext.origin) - assertTrue(span.isFinished) - assertEquals(SpanStatus.OK, span.status) + withMockHub { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertEquals("auto.graphql.graphql", span.spanContext.origin) + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + } } @Test fun `when transaction is active, and data fetcher throws, creates inner spans`() { val sut = fixture.getSut(dataFetcherThrows = true) - val result = sut.execute("{ shows { id } }") + withMockHub { + val result = sut.execute("{ shows { id } }") - assertTrue(result.errors.isNotEmpty()) - assertEquals(1, fixture.activeSpan.children.size) - val span = fixture.activeSpan.children.first() - assertEquals("graphql", span.operation) - assertEquals("Query.shows", span.description) - assertTrue(span.isFinished) - assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertTrue(result.errors.isNotEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + } } @Test fun `when transaction is not active, does not create spans`() { val sut = fixture.getSut(isTransactionActive = false) - val result = sut.execute("{ shows { id } }") + withMockHub { + val result = sut.execute("{ shows { id } }") - assertTrue(result.errors.isEmpty()) - assertTrue(fixture.activeSpan.children.isEmpty()) + assertTrue(result.errors.isEmpty()) + assertTrue(fixture.activeSpan.children.isEmpty()) + } } @Test fun `beforeSpan can drop spans`() { val sut = fixture.getSut(beforeSpan = SentryInstrumentation.BeforeSpanCallback { _, _, _ -> null }) - val result = sut.execute("{ shows { id } }") + withMockHub { + val result = sut.execute("{ shows { id } }") - assertTrue(result.errors.isEmpty()) - assertEquals(1, fixture.activeSpan.children.size) - val span = fixture.activeSpan.children.first() - assertEquals("graphql", span.operation) - assertEquals("Query.shows", span.description) - assertNotNull(span.isSampled) { - assertFalse(it) + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertNotNull(span.isSampled) { + assertFalse(it) + } } } @@ -127,24 +152,71 @@ class SentryInstrumentationTest { fun `beforeSpan can modify spans`() { val sut = fixture.getSut(beforeSpan = SentryInstrumentation.BeforeSpanCallback { span, _, _ -> span.apply { description = "changed" } }) - val result = sut.execute("{ shows { id } }") + withMockHub { + val result = sut.execute("{ shows { id } }") - assertTrue(result.errors.isEmpty()) - assertEquals(1, fixture.activeSpan.children.size) - val span = fixture.activeSpan.children.first() - assertEquals("graphql", span.operation) - assertEquals("changed", span.description) - assertTrue(span.isFinished) + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("changed", span.description) + assertTrue(span.isFinished) + } + } + + @Test + fun `invokes subscription handler for subscription`() { + val exceptionReporter = mock() + val subscriptionHandler = mock() + whenever(subscriptionHandler.onSubscriptionResult(any(), any(), any(), any())).thenReturn("result modified by subscription handler") + val operation = OperationDefinition.Operation.SUBSCRIPTION + val instrumentation = SentryInstrumentation(null, subscriptionHandler, exceptionReporter) + val dataFetcher = mock>() + whenever(dataFetcher.get(any())).thenReturn("raw result") + val graphQLContext = GraphQLContext.newContext().build() + val executionStepInfo = ExecutionStepInfo.newExecutionStepInfo().type( + GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + ).build() + val environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .graphQLContext(graphQLContext) + .executionStepInfo(executionStepInfo) + .operationDefinition(OperationDefinition.newOperationDefinition().operation(operation).build()) + .build() + val executionContext = ExecutionContextBuilder.newExecutionContextBuilder() + .executionId(ExecutionId.generate()) + .graphQLContext(graphQLContext) + .build() + val executionStrategyParameters = ExecutionStrategyParameters.newParameters() + .executionStepInfo(executionStepInfo) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .field(MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build()) + .build() + val parameters = InstrumentationFieldFetchParameters(executionContext, environment, executionStrategyParameters, false).withNewState(SentryInstrumentation.TracingState()) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(dataFetcher, parameters) + val result = instrumentedDataFetcher.get(environment) + + assertNotNull(result) + assertEquals("result modified by subscription handler", result) } @Test fun `Integration adds itself to integration and package list`() { - val sut = fixture.getSut() - assertNotNull(fixture.hub.options.sdkVersion) - assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("GraphQL")) - val packageInfo = fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-graphql" } - assertNotNull(packageInfo) - assert(packageInfo.version == BuildConfig.VERSION_NAME) + withMockHub { + val sut = fixture.getSut() + assertNotNull(fixture.hub.options.sdkVersion) + assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("GraphQL")) + val packageInfo = + fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-graphql" } + assertNotNull(packageInfo) + assert(packageInfo.version == BuildConfig.VERSION_NAME) + } + } + + fun withMockHub(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentHub() }.thenReturn(fixture.hub) + closure.invoke() } data class Show(val id: Int) diff --git a/sentry-samples/sentry-samples-netflix-dgs/README.md b/sentry-samples/sentry-samples-netflix-dgs/README.md new file mode 100644 index 00000000000..5372ee978dc --- /dev/null +++ b/sentry-samples/sentry-samples-netflix-dgs/README.md @@ -0,0 +1,90 @@ +# Netflix DGS Sample + +## How to test + +For testing [GraphQL Playground](https://github.com/graphql/graphql-playground) can be used. + +Config for GraphQL Playground may look like this: + +``` +extensions: + endpoints: + default: + url: 'http://localhost:8080/graphql' + subscription: + url: 'ws://localhost:8080/subscriptions' +``` + +## Queries + +The following queries can be used for testing. + +### Shows +``` +{ + shows { + id + title + releaseYear + } +} +``` + +### New shows +``` +{ + newShows { + id + title + releaseYear + iDoNotExist + } +} +``` + +### Mutation +``` +mutation AddShowMutation($title: String!) { + addShow(title: $title) +} +``` +variables: +``` +{ + "title": "A new show" +} +``` + +### Subscription +``` +subscription SubscriptionNotifyNewShow($releaseYear: Int!) { + notifyNewShow(releaseYear: $releaseYear) { + id + title + releaseYear + } +} + +``` +variables: +``` +{ + "releaseYear": -1 +} +``` + +### Data loader +``` +query QueryShows { + shows { + id + title + releaseYear + actorId + actor { + id + name + } + } +} +``` diff --git a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts index 3d812ba328f..2760d7e5b84 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts +++ b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(projects.sentrySpringBootStarter) implementation(projects.sentryGraphql) implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:4.9.2")) + implementation("com.netflix.graphql.dgs:graphql-dgs-subscriptions-websockets-autoconfigure:4.9.2") implementation("com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter") testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ActorsDataloader.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ActorsDataloader.java new file mode 100644 index 00000000000..89c2514fb59 --- /dev/null +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ActorsDataloader.java @@ -0,0 +1,30 @@ +package io.sentry.samples.netflix.dgs; + +import com.netflix.graphql.dgs.DgsDataLoader; +import io.sentry.samples.netflix.dgs.graphql.types.Actor; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.dataloader.MappedBatchLoader; +import org.jetbrains.annotations.NotNull; + +@DgsDataLoader(name = "actors") +public class ActorsDataloader implements MappedBatchLoader { + + @Override + public CompletionStage> load(Set keys) { + return CompletableFuture.supplyAsync( + () -> { + final @NotNull Map map = new HashMap<>(); + for (Integer key : keys) { + if (key != null && key == -1) { + throw new RuntimeException("Causing an error while loading actor"); + } + map.put(key, new Actor(key, "Name" + key)); + } + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/NetlixDgsApplication.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/NetlixDgsApplication.java index a1760bc3d10..57d4fafc1b6 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/NetlixDgsApplication.java +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/NetlixDgsApplication.java @@ -1,8 +1,9 @@ package io.sentry.samples.netflix.dgs; import com.netflix.graphql.dgs.exceptions.DefaultDataFetcherExceptionHandler; -import io.sentry.graphql.SentryDataFetcherExceptionHandler; +import io.sentry.graphql.SentryGenericDataFetcherExceptionHandler; import io.sentry.graphql.SentryInstrumentation; +import io.sentry.spring.graphql.SentryDgsSubscriptionHandler; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -16,12 +17,12 @@ public static void main(String[] args) { @Bean SentryInstrumentation sentryInstrumentation() { - return new SentryInstrumentation(); + return new SentryInstrumentation(new SentryDgsSubscriptionHandler(), true); } @Bean - SentryDataFetcherExceptionHandler sentryDataFetcherExceptionHandler() { + SentryGenericDataFetcherExceptionHandler sentryDataFetcherExceptionHandler() { // delegate to default Netflix DGS exception handler - return new SentryDataFetcherExceptionHandler(new DefaultDataFetcherExceptionHandler()); + return new SentryGenericDataFetcherExceptionHandler(new DefaultDataFetcherExceptionHandler()); } } diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ShowsDatafetcher.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ShowsDatafetcher.java index b39f8ea01ca..bfd33364d65 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ShowsDatafetcher.java +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ShowsDatafetcher.java @@ -1,12 +1,24 @@ package io.sentry.samples.netflix.dgs; import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsMutation; import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.DgsSubscription; +import graphql.schema.DataFetchingEnvironment; import io.sentry.samples.netflix.dgs.graphql.DgsConstants; +import io.sentry.samples.netflix.dgs.graphql.types.Actor; import io.sentry.samples.netflix.dgs.graphql.types.Show; +import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; @DgsComponent public class ShowsDatafetcher { @@ -15,8 +27,8 @@ public class ShowsDatafetcher { public List shows() throws InterruptedException { Thread.sleep(new Random().nextInt(500)); return Arrays.asList( - Show.newBuilder().id(1).title("Stranger Things").releaseYear(2015).build(), - Show.newBuilder().id(2).title("Breaking Bad").releaseYear(2013).build()); + Show.newBuilder().id(1).title("Stranger Things").releaseYear(2015).actorId(1).build(), + Show.newBuilder().id(2).title("Breaking Bad").releaseYear(2013).actorId(-1).build()); } @DgsQuery(field = DgsConstants.QUERY.NewShows) @@ -24,4 +36,40 @@ public List newShows() throws InterruptedException { Thread.sleep(new Random().nextInt(500)); throw new RuntimeException("error when loading new shows"); } + + @DgsMutation(field = DgsConstants.MUTATION.AddShow) + public Integer addShow(String title) { + throw new RuntimeException("error while adding a show"); + } + + @DgsSubscription(field = DgsConstants.SUBSCRIPTION.NotifyNewShow) + public Publisher notifyNewShow(Integer releaseYear) { + if (releaseYear == -1) { + throw new RuntimeException("Causing error for subscription"); + } else if (releaseYear == -2) { + return Flux.error(new RuntimeException("Causing error for subscription with flux")); + } + final @NotNull AtomicInteger counter = new AtomicInteger(2); + return Flux.interval(Duration.ofSeconds(1)) + .map( + t -> { + int i = counter.incrementAndGet(); + if (releaseYear == -3 && i % 2 == 0) { + throw new RuntimeException( + "Causing error for subscription while producing an element"); + } + return new Show(i, "A new show has arrived " + i, releaseYear, 1); + }); + } + + @DgsData(parentType = "Show", field = "actor") + public CompletableFuture actor(DataFetchingEnvironment dfe) { + + DataLoader dataLoader = dfe.getDataLoader("actors"); + // does not work, thanks docs + // String id = dfe.getArgument("actorId"); + Show show = dfe.getSource(); + + return dataLoader.load(show.getActorId()); + } } diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/DgsConstants.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/DgsConstants.java index 4c5809bc4b5..c3bce39383a 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/DgsConstants.java +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/DgsConstants.java @@ -2,6 +2,8 @@ public class DgsConstants { public static final String QUERY_TYPE = "Query"; + public static final String MUTATION_TYPE = "Mutation"; + public static final String SUBSCRIPTION_TYPE = "Subscription"; public static class QUERY { public static final String TYPE_NAME = "Query"; @@ -11,6 +13,18 @@ public static class QUERY { public static final String NewShows = "newShows"; } + public static class MUTATION { + public static final String TYPE_NAME = "Mutation"; + + public static final String AddShow = "addShow"; + } + + public static class SUBSCRIPTION { + public static final String TYPE_NAME = "Subscription"; + + public static final String NotifyNewShow = "notifyNewShow"; + } + public static class SHOW { public static final String TYPE_NAME = "Show"; @@ -20,4 +34,12 @@ public static class SHOW { public static final String ReleaseYear = "releaseYear"; } + + public static class ACTOR { + public static final String TYPE_NAME = "Actor"; + + public static final String Id = "id"; + + public static final String Name = "name"; + } } diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Actor.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Actor.java new file mode 100644 index 00000000000..16726bc2571 --- /dev/null +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Actor.java @@ -0,0 +1,77 @@ +package io.sentry.samples.netflix.dgs.graphql.types; + +public class Actor { + private Integer id; + + private String name; + + public Actor() {} + + public Actor(Integer id, String name) { + this.id = id; + this.name = name; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "Show{" + "id='" + id + "'," + "name='" + name + "'" + "}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Actor that = (Actor) o; + return java.util.Objects.equals(id, that.id) && java.util.Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(id, name); + } + + public static Actor.Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + private Integer id; + + private String name; + + private Integer releaseYear; + + public Actor build() { + Actor result = new Actor(); + result.id = this.id; + result.name = this.name; + return result; + } + + public Actor.Builder id(Integer id) { + this.id = id; + return this; + } + + public Actor.Builder name(String name) { + this.name = name; + return this; + } + } +} diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Show.java b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Show.java index d1efcd7cba6..8247e5593b1 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Show.java +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Show.java @@ -7,12 +7,15 @@ public class Show { private Integer releaseYear; + private Integer actorId; + public Show() {} - public Show(Integer id, String title, Integer releaseYear) { + public Show(Integer id, String title, Integer releaseYear, Integer actorId) { this.id = id; this.title = title; this.releaseYear = releaseYear; + this.actorId = actorId; } public Integer getId() { @@ -39,6 +42,14 @@ public void setReleaseYear(Integer releaseYear) { this.releaseYear = releaseYear; } + public Integer getActorId() { + return actorId; + } + + public void setActorId(Integer actorId) { + this.actorId = actorId; + } + @Override public String toString() { return "Show{" @@ -50,6 +61,9 @@ public String toString() { + "'," + "releaseYear='" + releaseYear + + "'," + + "actorId='" + + actorId + "'" + "}"; } @@ -61,12 +75,13 @@ public boolean equals(Object o) { Show that = (Show) o; return java.util.Objects.equals(id, that.id) && java.util.Objects.equals(title, that.title) - && java.util.Objects.equals(releaseYear, that.releaseYear); + && java.util.Objects.equals(releaseYear, that.releaseYear) + && java.util.Objects.equals(actorId, that.actorId); } @Override public int hashCode() { - return java.util.Objects.hash(id, title, releaseYear); + return java.util.Objects.hash(id, title, releaseYear, actorId); } public static io.sentry.samples.netflix.dgs.graphql.types.Show.Builder newBuilder() { @@ -80,12 +95,15 @@ public static class Builder { private Integer releaseYear; + private Integer actorId; + public Show build() { io.sentry.samples.netflix.dgs.graphql.types.Show result = new io.sentry.samples.netflix.dgs.graphql.types.Show(); result.id = this.id; result.title = this.title; result.releaseYear = this.releaseYear; + result.actorId = this.actorId; return result; } @@ -104,5 +122,10 @@ public io.sentry.samples.netflix.dgs.graphql.types.Show.Builder releaseYear( this.releaseYear = releaseYear; return this; } + + public io.sentry.samples.netflix.dgs.graphql.types.Show.Builder actorId(Integer actorId) { + this.actorId = actorId; + return this; + } } } diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/application.properties b/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/application.properties index 513bc01a9ba..199ae97c2d3 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/application.properties @@ -4,3 +4,4 @@ sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/542 sentry.max-request-body-size=medium sentry.traces-sample-rate=1.0 sentry.debug=true +sentry.send-default-pii=true diff --git a/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/schema/schema.graphqls b/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/schema/schema.graphqls index 4b63738a031..0916f7a99d9 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/schema/schema.graphqls +++ b/sentry-samples/sentry-samples-netflix-dgs/src/main/resources/schema/schema.graphqls @@ -3,8 +3,23 @@ type Query { newShows: [Show] } +type Mutation { + addShow(title: String!): Int +} + +type Subscription { + notifyNewShow(releaseYear: Int): Show +} + type Show { id: Int title: String releaseYear: Int + actorId: Int + actor: Actor +} + +type Actor { + id: Int + name: String } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/README.md b/sentry-samples/sentry-samples-spring-boot-jakarta/README.md index 665d7a5b4ce..58b94ba8997 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/README.md +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/README.md @@ -17,3 +17,106 @@ Make an HTTP request that will trigger events: ``` curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' ``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 811b6dd83da..c326142f555 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -20,6 +20,8 @@ repositories { dependencies { implementation(Config.Libs.springBoot3StarterSecurity) implementation(Config.Libs.springBoot3StarterWeb) + implementation(Config.Libs.springBoot3StarterWebsocket) + implementation(Config.Libs.springBoot3StarterGraphql) implementation(Config.Libs.springBoot3StarterWebflux) implementation(Config.Libs.springBoot3StarterAop) implementation(Config.Libs.aspectj) @@ -29,6 +31,7 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarterJakarta) implementation(projects.sentryLogback) + implementation(projects.sentryGraphql) // database query tracing implementation(projects.sentryJdbc) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java new file mode 100644 index 00000000000..6fdf96506c8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java new file mode 100644 index 00000000000..bfc383c9122 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java new file mode 100644 index 00000000000..63790bca628 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..cb6677c0c37 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 991704d822c..8151eacc6cf 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -18,3 +18,5 @@ spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver spring.datasource.username=sa spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..d76aca4756a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,70 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/README.md b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/README.md index ea41d93aad9..7a4a36df50c 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/README.md +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/README.md @@ -17,3 +17,28 @@ Make an HTTP request that will trigger events: ``` curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' ``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 95d05deb9ff..9375c8386fd 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -19,11 +19,13 @@ repositories { dependencies { implementation(Config.Libs.springBoot3StarterWebflux) + implementation(Config.Libs.springBoot3StarterGraphql) implementation(Config.Libs.contextPropagation) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarterJakarta) implementation(projects.sentryLogback) + implementation(projects.sentryGraphql) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java new file mode 100644 index 00000000000..421631ca7a5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class GreetingController { + + @QueryMapping + public Mono greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + // return Mono.error(new RuntimeException("causing an error for " + name)); + throw new RuntimeException("causing an error for " + name); + } + return Mono.just("Hello " + name + "!"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..d76aca4756a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,70 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/README.md b/sentry-samples/sentry-samples-spring-boot-webflux/README.md index c5dd09d8a4a..ac9a9b56f0f 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/README.md +++ b/sentry-samples/sentry-samples-spring-boot-webflux/README.md @@ -17,3 +17,28 @@ Make an HTTP request that will trigger events: ``` curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' ``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 0b20b8c6ebb..faa2717a04a 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -19,10 +19,12 @@ repositories { dependencies { implementation(Config.Libs.springBootStarterWebflux) + implementation(Config.Libs.springBootStarterGraphql) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) + implementation(projects.sentryGraphql) testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java new file mode 100644 index 00000000000..a7bf18be977 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class GreetingController { + + @QueryMapping + public Mono greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + // return Mono.error(new RuntimeException("causing an error for " + name)); + throw new RuntimeException("causing an error for " + name); + } + return Mono.just("Hello " + name + "!"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties index a9a9f0f79e3..a08a498bf27 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties @@ -8,3 +8,6 @@ sentry.max-breadcrumbs=150 sentry.logging.minimum-event-level=info sentry.logging.minimum-breadcrumb-level=debug sentry.enable-tracing=true +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.graphql.schema.printer.enabled=true diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..d76aca4756a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,70 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot/README.md b/sentry-samples/sentry-samples-spring-boot/README.md index c221f0de02c..bd8d0af480a 100644 --- a/sentry-samples/sentry-samples-spring-boot/README.md +++ b/sentry-samples/sentry-samples-spring-boot/README.md @@ -17,3 +17,107 @@ Make an HTTP request that will trigger events: ``` curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' ``` + + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index a2fc7ec5d31..e5506d42294 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -20,7 +20,9 @@ repositories { dependencies { implementation(Config.Libs.springBootStarterSecurity) implementation(Config.Libs.springBootStarterWeb) + implementation(Config.Libs.springBootStarterWebsocket) implementation(Config.Libs.springBootStarterWebflux) + implementation(Config.Libs.springBootStarterGraphql) implementation(Config.Libs.springBootStarterAop) implementation(Config.Libs.aspectj) implementation(Config.Libs.springBootStarter) @@ -29,6 +31,7 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) + implementation(projects.sentryGraphql) // database query tracing implementation(projects.sentryJdbc) diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/AssigneeController.java new file mode 100644 index 00000000000..c88984c261f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java new file mode 100644 index 00000000000..b252f8b9b0f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java new file mode 100644 index 00000000000..70e65ad7e5e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..bbf123f84c3 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/TaskCreatorController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index 991704d822c..8151eacc6cf 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -18,3 +18,5 @@ spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver spring.datasource.username=sa spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..d76aca4756a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,70 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 455100e1143..0b2292d06ec 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -29,11 +29,13 @@ dependencies { compileOnly(projects.sentryApacheHttpClient5) compileOnly(Config.Libs.springBoot3Starter) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) + compileOnly(projects.sentryGraphql) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springWebflux) compileOnly(Config.Libs.servletApiJakarta) compileOnly(Config.Libs.springBoot3StarterAop) compileOnly(Config.Libs.springBoot3StarterSecurity) + compileOnly(Config.Libs.springBoot3StarterGraphql) compileOnly(Config.Libs.reactorCore) compileOnly(Config.Libs.contextPropagation) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) diff --git a/sentry-spring-boot-starter-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-starter-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 2d27bd31ae0..3ca17f75b4e 100644 --- a/sentry-spring-boot-starter-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-starter-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -1,6 +1,7 @@ package io.sentry.spring.boot.jakarta; import com.jakewharton.nopen.annotation.Open; +import graphql.GraphQLError; import io.sentry.EventProcessor; import io.sentry.HubAdapter; import io.sentry.IHub; @@ -9,6 +10,7 @@ import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; +import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; import io.sentry.spring.jakarta.ContextTagsEventProcessor; @@ -19,6 +21,7 @@ import io.sentry.spring.jakarta.SentryUserProvider; import io.sentry.spring.jakarta.SentryWebConfiguration; import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider; +import io.sentry.spring.jakarta.graphql.SentryGraphqlConfiguration; import io.sentry.spring.jakarta.tracing.SentryAdviceConfiguration; import io.sentry.spring.jakarta.tracing.SentrySpanPointcutConfiguration; import io.sentry.spring.jakarta.tracing.SentryTracingFilter; @@ -52,6 +55,7 @@ import org.springframework.context.annotation.Import; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -153,6 +157,16 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { } } + @Configuration(proxyBeanMethods = false) + @Import(SentryGraphqlConfiguration.class) + @Open + @ConditionalOnClass({ + SentryGraphqlExceptionHandler.class, + DataFetcherExceptionResolverAdapter.class, + GraphQLError.class + }) + static class GraphqlConfiguration {} + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 92f51669677..3d144730a59 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -34,8 +34,10 @@ dependencies { compileOnly(Config.Libs.servletApi) compileOnly(Config.Libs.springBootStarterAop) compileOnly(Config.Libs.springBootStarterSecurity) + compileOnly(Config.Libs.springBootStarterGraphql) compileOnly(Config.Libs.reactorCore) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) + compileOnly(projects.sentryGraphql) annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES)) annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure) diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 80647c565b1..0b51b22d605 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -1,6 +1,7 @@ package io.sentry.spring.boot; import com.jakewharton.nopen.annotation.Open; +import graphql.GraphQLError; import io.sentry.EventProcessor; import io.sentry.HubAdapter; import io.sentry.IHub; @@ -9,6 +10,7 @@ import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; +import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; import io.sentry.spring.ContextTagsEventProcessor; @@ -19,6 +21,7 @@ import io.sentry.spring.SentryUserProvider; import io.sentry.spring.SentryWebConfiguration; import io.sentry.spring.SpringSecuritySentryUserProvider; +import io.sentry.spring.graphql.SentryGraphqlConfiguration; import io.sentry.spring.tracing.SentryAdviceConfiguration; import io.sentry.spring.tracing.SentrySpanPointcutConfiguration; import io.sentry.spring.tracing.SentryTracingFilter; @@ -52,6 +55,7 @@ import org.springframework.context.annotation.Import; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -153,6 +157,16 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { } } + @Configuration(proxyBeanMethods = false) + @Import(SentryGraphqlConfiguration.class) + @Open + @ConditionalOnClass({ + SentryGraphqlExceptionHandler.class, + DataFetcherExceptionResolverAdapter.class, + GraphQLError.class + }) + static class GraphqlConfiguration {} + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 6b4f16ac9ec..8f35b6075f2 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -88,6 +88,50 @@ public final class io/sentry/spring/jakarta/SpringSecuritySentryUserProvider : i public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry : org/springframework/graphql/execution/BatchLoaderRegistry { + public fun forName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun forTypePair (Ljava/lang/Class;Ljava/lang/Class;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun registerDataLoaders (Lorg/dataloader/DataLoaderRegistry;Lgraphql/GraphQLContext;)V +} + +public final class io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry$SentryRegistrationSpec : org/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec { + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/Class;Ljava/lang/Class;)V + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/String;)V + public fun registerBatchLoader (Ljava/util/function/BiFunction;)V + public fun registerMappedBatchLoader (Ljava/util/function/BiFunction;)V + public fun withName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Ljava/util/function/Consumer;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Lorg/dataloader/DataLoaderOptions;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; +} + +public final class io/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter : org/springframework/graphql/execution/DataFetcherExceptionResolverAdapter { + public fun ()V + public fun isThreadLocalContextAware ()Z +} + +public final class io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + +public final class io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor { + public fun ()V + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public class io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter; + public fun graphqlBeanPostProcessor ()Lio/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor; + public fun sourceBuilderCustomizerWebflux ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; + public fun sourceBuilderCustomizerWebmvc ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; +} + +public final class io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + public class io/sentry/spring/jakarta/tracing/SentryAdviceConfiguration { public fun ()V public fun sentrySpanAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index 1a063fc7611..6f16d29a086 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springAop) compileOnly(Config.Libs.springSecurityWeb) + compileOnly(Config.Libs.springBoot3StarterGraphql) compileOnly(Config.Libs.aspectj) compileOnly(Config.Libs.servletApiJakarta) compileOnly(Config.Libs.slf4jApi) @@ -41,9 +42,11 @@ dependencies { errorprone(Config.CompileOnly.errorprone) errorprone(Config.CompileOnly.errorProneNullAway) compileOnly(Config.CompileOnly.jetbrainsAnnotations) + compileOnly(projects.sentryGraphql) // tests testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryGraphql) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) @@ -53,8 +56,10 @@ dependencies { testImplementation(Config.Libs.springBoot3StarterWebflux) testImplementation(Config.Libs.springBoot3StarterSecurity) testImplementation(Config.Libs.springBoot3StarterAop) + testImplementation(Config.Libs.springBoot3StarterGraphql) testImplementation(Config.Libs.contextPropagation) testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.graphQlJava) } tasks.withType { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java new file mode 100644 index 00000000000..1d5576c5964 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java @@ -0,0 +1,119 @@ +package io.sentry.spring.jakarta.graphql; + +import static io.sentry.graphql.SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY; + +import graphql.GraphQLContext; +import io.sentry.Breadcrumb; +import io.sentry.IHub; +import io.sentry.NoOpHub; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoaderOptions; +import org.dataloader.DataLoaderRegistry; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@ApiStatus.Internal +public final class SentryBatchLoaderRegistry implements BatchLoaderRegistry { + + private final @NotNull BatchLoaderRegistry delegate; + + SentryBatchLoaderRegistry(final @NotNull BatchLoaderRegistry delegate) { + this.delegate = delegate; + } + + @Override + public RegistrationSpec forTypePair(Class keyType, Class valueType) { + return new SentryRegistrationSpec( + delegate.forTypePair(keyType, valueType), keyType, valueType); + } + + @Override + public RegistrationSpec forName(String name) { + return new SentryRegistrationSpec(delegate.forName(name), name); + } + + @Override + public void registerDataLoaders(DataLoaderRegistry registry, GraphQLContext context) { + delegate.registerDataLoaders(registry, context); + } + + public static final class SentryRegistrationSpec + implements BatchLoaderRegistry.RegistrationSpec { + + private final @NotNull RegistrationSpec delegate; + private final @Nullable String name; + private final @Nullable Class keyType; + private final @Nullable Class valueType; + + public SentryRegistrationSpec( + final @NotNull RegistrationSpec delegate, Class keyType, Class valueType) { + this.delegate = delegate; + this.keyType = keyType; + this.valueType = valueType; + this.name = null; + } + + public SentryRegistrationSpec(final @NotNull RegistrationSpec delegate, String name) { + this.delegate = delegate; + this.name = name; + this.keyType = null; + this.valueType = null; + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withName(String name) { + return delegate.withName(name); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions( + Consumer optionsConsumer) { + return delegate.withOptions(optionsConsumer); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions(DataLoaderOptions options) { + return delegate.withOptions(options); + } + + @Override + public void registerBatchLoader(BiFunction, BatchLoaderEnvironment, Flux> loader) { + delegate.registerBatchLoader( + (keys, batchLoaderEnvironment) -> { + hubFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + @Override + public void registerMappedBatchLoader( + BiFunction, BatchLoaderEnvironment, Mono>> loader) { + delegate.registerMappedBatchLoader( + (keys, batchLoaderEnvironment) -> { + hubFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + private @NotNull IHub hubFromContext(final @NotNull BatchLoaderEnvironment environment) { + Object context = environment.getContext(); + if (context instanceof GraphQLContext) { + GraphQLContext graphqlContext = (GraphQLContext) context; + return graphqlContext.getOrDefault(SENTRY_HUB_CONTEXT_KEY, NoOpHub.getInstance()); + } + + return NoOpHub.getInstance(); + } + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter.java new file mode 100644 index 00000000000..c70830e52a2 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter.java @@ -0,0 +1,45 @@ +package io.sentry.spring.jakarta.graphql; + +import graphql.GraphQLError; +import graphql.execution.DataFetcherExceptionHandlerResult; +import graphql.schema.DataFetchingEnvironment; +import io.sentry.graphql.SentryGraphqlExceptionHandler; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; + +@ApiStatus.Internal +public final class SentryDataFetcherExceptionResolverAdapter + extends DataFetcherExceptionResolverAdapter { + private final @NotNull SentryGraphqlExceptionHandler handler; + + public SentryDataFetcherExceptionResolverAdapter() { + this.handler = new SentryGraphqlExceptionHandler(null); + } + + @Override + public boolean isThreadLocalContextAware() { + return true; + } + + @Override + protected @Nullable GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + List errors = resolveToMultipleErrors(ex, env); + if (errors != null && !errors.isEmpty()) { + return errors.get(0); + } + return null; + } + + @Override + protected @Nullable List resolveToMultipleErrors( + Throwable ex, DataFetchingEnvironment env) { + @Nullable DataFetcherExceptionHandlerResult result = handler.onException(ex, env, null); + if (result != null) { + return result.getErrors(); + } + return null; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler.java new file mode 100644 index 00000000000..83a090954d1 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler.java @@ -0,0 +1,34 @@ +package io.sentry.spring.jakarta.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Flux; + +public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + + public SentryDgsSubscriptionHandler() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring6NetflixDGSGrahQL"); + } + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + }); + } + return result; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java new file mode 100644 index 00000000000..6f2bd82ecf8 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java @@ -0,0 +1,17 @@ +package io.sentry.spring.jakarta.graphql; + +import org.jetbrains.annotations.ApiStatus; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.graphql.execution.BatchLoaderRegistry; + +@ApiStatus.Internal +public final class SentryGraphqlBeanPostProcessor implements BeanPostProcessor { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof BatchLoaderRegistry) { + return new SentryBatchLoaderRegistry((BatchLoaderRegistry) bean); + } + return bean; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration.java new file mode 100644 index 00000000000..9d8224e88c9 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration.java @@ -0,0 +1,54 @@ +package io.sentry.spring.jakarta.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryInstrumentation; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphqlConfiguration { + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebmvc() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring6GrahQLWebMVC"); + return sourceBuilderCustomizer(false); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring6GrahQLWebFlux"); + return sourceBuilderCustomizer(true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { + return (builder) -> + builder.configureGraphQl( + graphQlBuilder -> + graphQlBuilder.instrumentation( + new SentryInstrumentation( + null, new SentrySpringSubscriptionHandler(), captureRequestBody))); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandler.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandler.java new file mode 100644 index 00000000000..4c519810353 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandler.java @@ -0,0 +1,35 @@ +package io.sentry.spring.jakarta.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.execution.SubscriptionPublisherException; +import reactor.core.publisher.Flux; + +public final class SentrySpringSubscriptionHandler implements SentrySubscriptionHandler { + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); + if (throwable instanceof SubscriptionPublisherException + && throwable.getCause() != null) { + exceptionReporter.captureThrowable(throwable.getCause(), exceptionDetails, null); + } else { + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + } + }); + } + return result; + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandlerTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandlerTest.kt new file mode 100644 index 00000000000..3f71eaf23e1 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandlerTest.kt @@ -0,0 +1,80 @@ +package io.sentry.spring.graphql + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Document +import graphql.language.OperationDefinition +import graphql.schema.DataFetchingEnvironment +import io.sentry.IHub +import io.sentry.graphql.ExceptionReporter +import io.sentry.spring.jakarta.graphql.SentrySpringSubscriptionHandler +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.graphql.execution.SubscriptionPublisherException +import reactor.core.publisher.Flux +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class SentrySpringSubscriptionHandlerTest { + + @Test + fun `reports exception`() { + val exception = IllegalStateException("some exception") + val hub = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = Document.newDocument() + .definition(OperationDefinition.newOperationDefinition().operation(OperationDefinition.Operation.QUERY).name("testQuery").build()) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = SentrySpringSubscriptionHandler().onSubscriptionResult(Flux.error(exception), hub, exceptionReporter, parameters) + assertThrows { + (resultObject as Flux).blockFirst() + } + + verify(exceptionReporter).captureThrowable( + same(exception), + org.mockito.kotlin.check { + assertEquals(true, it.isSubscription) + assertSame(hub, it.hub) + assertEquals("query testQuery\n", it.query) + }, + anyOrNull() + ) + } + + @Test + fun `unwraps SubscriptionPublisherException and reports cause`() { + val exception = IllegalStateException("some exception") + val wrappedException = SubscriptionPublisherException(emptyList(), exception) + val hub = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = Document.newDocument() + .definition(OperationDefinition.newOperationDefinition().operation(OperationDefinition.Operation.QUERY).name("testQuery").build()) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = SentrySpringSubscriptionHandler().onSubscriptionResult(Flux.error(wrappedException), hub, exceptionReporter, parameters) + assertThrows { + (resultObject as Flux).blockFirst() + } + + verify(exceptionReporter).captureThrowable( + same(exception), + org.mockito.kotlin.check { + assertEquals(true, it.isSubscription) + assertSame(hub, it.hub) + assertEquals("query testQuery\n", it.query) + }, + anyOrNull() + ) + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index c7ee1d0c704..7efeb59f569 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -88,6 +88,50 @@ public final class io/sentry/spring/SpringSecuritySentryUserProvider : io/sentry public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/graphql/SentryBatchLoaderRegistry : org/springframework/graphql/execution/BatchLoaderRegistry { + public fun forName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun forTypePair (Ljava/lang/Class;Ljava/lang/Class;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun registerDataLoaders (Lorg/dataloader/DataLoaderRegistry;Lgraphql/GraphQLContext;)V +} + +public final class io/sentry/spring/graphql/SentryBatchLoaderRegistry$SentryRegistrationSpec : org/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec { + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/Class;Ljava/lang/Class;)V + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/String;)V + public fun registerBatchLoader (Ljava/util/function/BiFunction;)V + public fun registerMappedBatchLoader (Ljava/util/function/BiFunction;)V + public fun withName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Ljava/util/function/Consumer;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Lorg/dataloader/DataLoaderOptions;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; +} + +public final class io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter : org/springframework/graphql/execution/DataFetcherExceptionResolverAdapter { + public fun ()V + public fun isThreadLocalContextAware ()Z +} + +public final class io/sentry/spring/graphql/SentryDgsSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + +public final class io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor { + public fun ()V + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public class io/sentry/spring/graphql/SentryGraphqlConfiguration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter; + public fun graphqlBeanPostProcessor ()Lio/sentry/spring/graphql/SentryGraphqlBeanPostProcessor; + public fun sourceBuilderCustomizerWebflux ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; + public fun sourceBuilderCustomizerWebmvc ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; +} + +public final class io/sentry/spring/graphql/SentrySpringSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + public class io/sentry/spring/tracing/SentryAdviceConfiguration { public fun ()V public fun sentrySpanAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index cc23000561e..75a33d0767b 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -33,8 +33,9 @@ dependencies { compileOnly(Config.Libs.aspectj) compileOnly(Config.Libs.servletApi) compileOnly(Config.Libs.slf4jApi) - compileOnly(Config.Libs.springWebflux) + compileOnly(Config.Libs.springBootStarterGraphql) + compileOnly(projects.sentryGraphql) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) @@ -44,6 +45,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryGraphql) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) @@ -53,7 +55,9 @@ dependencies { testImplementation(Config.Libs.springBootStarterWebflux) testImplementation(Config.Libs.springBootStarterSecurity) testImplementation(Config.Libs.springBootStarterAop) + testImplementation(Config.Libs.springBootStarterGraphql) testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.graphQlJava) } configure { diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java new file mode 100644 index 00000000000..62a8669f892 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java @@ -0,0 +1,119 @@ +package io.sentry.spring.graphql; + +import static io.sentry.graphql.SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY; + +import graphql.GraphQLContext; +import io.sentry.Breadcrumb; +import io.sentry.IHub; +import io.sentry.NoOpHub; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoaderOptions; +import org.dataloader.DataLoaderRegistry; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@ApiStatus.Internal +public final class SentryBatchLoaderRegistry implements BatchLoaderRegistry { + + private final @NotNull BatchLoaderRegistry delegate; + + SentryBatchLoaderRegistry(final @NotNull BatchLoaderRegistry delegate) { + this.delegate = delegate; + } + + @Override + public RegistrationSpec forTypePair(Class keyType, Class valueType) { + return new SentryRegistrationSpec( + delegate.forTypePair(keyType, valueType), keyType, valueType); + } + + @Override + public RegistrationSpec forName(String name) { + return new SentryRegistrationSpec(delegate.forName(name), name); + } + + @Override + public void registerDataLoaders(DataLoaderRegistry registry, GraphQLContext context) { + delegate.registerDataLoaders(registry, context); + } + + public static final class SentryRegistrationSpec + implements BatchLoaderRegistry.RegistrationSpec { + + private final @NotNull RegistrationSpec delegate; + private final @Nullable String name; + private final @Nullable Class keyType; + private final @Nullable Class valueType; + + public SentryRegistrationSpec( + final @NotNull RegistrationSpec delegate, Class keyType, Class valueType) { + this.delegate = delegate; + this.keyType = keyType; + this.valueType = valueType; + this.name = null; + } + + public SentryRegistrationSpec(final @NotNull RegistrationSpec delegate, String name) { + this.delegate = delegate; + this.name = name; + this.keyType = null; + this.valueType = null; + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withName(String name) { + return delegate.withName(name); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions( + Consumer optionsConsumer) { + return delegate.withOptions(optionsConsumer); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions(DataLoaderOptions options) { + return delegate.withOptions(options); + } + + @Override + public void registerBatchLoader(BiFunction, BatchLoaderEnvironment, Flux> loader) { + delegate.registerBatchLoader( + (keys, batchLoaderEnvironment) -> { + hubFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + @Override + public void registerMappedBatchLoader( + BiFunction, BatchLoaderEnvironment, Mono>> loader) { + delegate.registerMappedBatchLoader( + (keys, batchLoaderEnvironment) -> { + hubFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + private @NotNull IHub hubFromContext(final @NotNull BatchLoaderEnvironment environment) { + Object context = environment.getContext(); + if (context instanceof GraphQLContext) { + GraphQLContext graphqlContext = (GraphQLContext) context; + return graphqlContext.getOrDefault(SENTRY_HUB_CONTEXT_KEY, NoOpHub.getInstance()); + } + + return NoOpHub.getInstance(); + } + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java new file mode 100644 index 00000000000..c52823653e4 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java @@ -0,0 +1,45 @@ +package io.sentry.spring.graphql; + +import graphql.GraphQLError; +import graphql.execution.DataFetcherExceptionHandlerResult; +import graphql.schema.DataFetchingEnvironment; +import io.sentry.graphql.SentryGraphqlExceptionHandler; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; + +@ApiStatus.Internal +public final class SentryDataFetcherExceptionResolverAdapter + extends DataFetcherExceptionResolverAdapter { + private final @NotNull SentryGraphqlExceptionHandler handler; + + public SentryDataFetcherExceptionResolverAdapter() { + this.handler = new SentryGraphqlExceptionHandler(null); + } + + @Override + public boolean isThreadLocalContextAware() { + return true; + } + + @Override + protected @Nullable GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + List errors = resolveToMultipleErrors(ex, env); + if (errors != null && !errors.isEmpty()) { + return errors.get(0); + } + return null; + } + + @Override + protected @Nullable List resolveToMultipleErrors( + Throwable ex, DataFetchingEnvironment env) { + @Nullable DataFetcherExceptionHandlerResult result = handler.onException(ex, env, null); + if (result != null) { + return result.getErrors(); + } + return null; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java new file mode 100644 index 00000000000..fb4e09e889d --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -0,0 +1,34 @@ +package io.sentry.spring.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Flux; + +public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + + public SentryDgsSubscriptionHandler() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring5NetflixDGSGrahQL"); + } + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + }); + } + return result; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java new file mode 100644 index 00000000000..89b0cf430cb --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java @@ -0,0 +1,17 @@ +package io.sentry.spring.graphql; + +import org.jetbrains.annotations.ApiStatus; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.graphql.execution.BatchLoaderRegistry; + +@ApiStatus.Internal +public final class SentryGraphqlBeanPostProcessor implements BeanPostProcessor { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof BatchLoaderRegistry) { + return new SentryBatchLoaderRegistry((BatchLoaderRegistry) bean); + } + return bean; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java new file mode 100644 index 00000000000..b938e6817da --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java @@ -0,0 +1,54 @@ +package io.sentry.spring.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryInstrumentation; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphqlConfiguration { + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebmvc() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring5GrahQLWebMVC"); + return sourceBuilderCustomizer(false); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring5GrahQLWebFlux"); + return sourceBuilderCustomizer(true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { + return (builder) -> + builder.configureGraphQl( + graphQlBuilder -> + graphQlBuilder.instrumentation( + new SentryInstrumentation( + null, new SentrySpringSubscriptionHandler(), captureRequestBody))); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java new file mode 100644 index 00000000000..a7809eb230b --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java @@ -0,0 +1,35 @@ +package io.sentry.spring.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.execution.SubscriptionPublisherException; +import reactor.core.publisher.Flux; + +public final class SentrySpringSubscriptionHandler implements SentrySubscriptionHandler { + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); + if (throwable instanceof SubscriptionPublisherException + && throwable.getCause() != null) { + exceptionReporter.captureThrowable(throwable.getCause(), exceptionDetails, null); + } else { + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + } + }); + } + return result; + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/graphql/SentrySpringSubscriptionHandlerTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/graphql/SentrySpringSubscriptionHandlerTest.kt new file mode 100644 index 00000000000..df2df5b3ef6 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/graphql/SentrySpringSubscriptionHandlerTest.kt @@ -0,0 +1,79 @@ +package io.sentry.spring.graphql + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Document +import graphql.language.OperationDefinition +import graphql.schema.DataFetchingEnvironment +import io.sentry.IHub +import io.sentry.graphql.ExceptionReporter +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.graphql.execution.SubscriptionPublisherException +import reactor.core.publisher.Flux +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class SentrySpringSubscriptionHandlerTest { + + @Test + fun `reports exception`() { + val exception = IllegalStateException("some exception") + val hub = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = Document.newDocument() + .definition(OperationDefinition.newOperationDefinition().operation(OperationDefinition.Operation.QUERY).name("testQuery").build()) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = SentrySpringSubscriptionHandler().onSubscriptionResult(Flux.error(exception), hub, exceptionReporter, parameters) + assertThrows { + (resultObject as Flux).blockFirst() + } + + verify(exceptionReporter).captureThrowable( + same(exception), + org.mockito.kotlin.check { + assertEquals(true, it.isSubscription) + assertSame(hub, it.hub) + assertEquals("query testQuery\n", it.query) + }, + anyOrNull() + ) + } + + @Test + fun `unwraps SubscriptionPublisherException and reports cause`() { + val exception = IllegalStateException("some exception") + val wrappedException = SubscriptionPublisherException(emptyList(), exception) + val hub = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = Document.newDocument() + .definition(OperationDefinition.newOperationDefinition().operation(OperationDefinition.Operation.QUERY).name("testQuery").build()) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = SentrySpringSubscriptionHandler().onSubscriptionResult(Flux.error(wrappedException), hub, exceptionReporter, parameters) + assertThrows { + (resultObject as Flux).blockFirst() + } + + verify(exceptionReporter).captureThrowable( + same(exception), + org.mockito.kotlin.check { + assertEquals(true, it.isSubscription) + assertSame(hub, it.hub) + assertEquals("query testQuery\n", it.query) + }, + anyOrNull() + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5e6caaf2fd3..6ce4538b4af 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -109,6 +109,9 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun getTimestamp ()Ljava/util/Date; public fun getType ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; + public static fun graphqlDataFetcher (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; + public static fun graphqlDataLoader (Ljava/lang/Iterable;Ljava/lang/Class;Ljava/lang/Class;Ljava/lang/String;)Lio/sentry/Breadcrumb; + public static fun graphqlOperation (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public fun hashCode ()I public static fun http (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/sentry/Breadcrumb; diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 402fd727dd7..fe2055c336c 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -5,8 +5,10 @@ import io.sentry.util.UrlUtils; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -188,6 +190,113 @@ public static Breadcrumb fromMap( return breadcrumb; } + /** + * Creates a breadcrumb for a GraphQL operation. + * + * @param operationName - the name of the GraphQL operation + * @param operationType - the type of GraphQL operation (e.g. query, mutation, subscription) + * @param operationId - the ID of the GraphQL operation + * @return the breadcrumb + */ + public static @NotNull Breadcrumb graphqlOperation( + final @Nullable String operationName, + final @Nullable String operationType, + final @Nullable String operationId) { + final Breadcrumb breadcrumb = new Breadcrumb(); + + breadcrumb.setType("graphql"); + + if (operationName != null) { + breadcrumb.setData("operation_name", operationName); + } + if (operationType != null) { + breadcrumb.setData("operation_type", operationType); + breadcrumb.setCategory(operationType); + } else { + breadcrumb.setCategory("graphql.operation"); + } + if (operationId != null) { + breadcrumb.setData("operation_id", operationId); + } + + return breadcrumb; + } + + /** + * Creates a breadcrumb for a GraphQL data fetcher. + * + * @param path - the name of the GraphQL operation + * @param field - the field being fetched + * @param type - the type being fetched + * @param objectType - the object type of the GraphQL data fetch operation + * @return the breadcrumb + */ + public static @NotNull Breadcrumb graphqlDataFetcher( + final @Nullable String path, + final @Nullable String field, + final @Nullable String type, + final @Nullable String objectType) { + final Breadcrumb breadcrumb = new Breadcrumb(); + + breadcrumb.setType("graphql"); + breadcrumb.setCategory("graphql.fetcher"); + + if (path != null) { + breadcrumb.setData("path", path); + } + if (field != null) { + breadcrumb.setData("field", field); + } + if (type != null) { + breadcrumb.setData("type", type); + } + if (objectType != null) { + breadcrumb.setData("object_type", objectType); + } + + return breadcrumb; + } + + /** + * Creates a breadcrumb for a GraphQL data loader. + * + * @param keys - keys to be fetched by the data loader + * @param keyType - class of the data loaders key(s) + * @param valueType - class of the data loaders value(s) + * @param name - name of the data loader + * @return the breadcrumb + */ + public static @NotNull Breadcrumb graphqlDataLoader( + final @NotNull Iterable keys, + final @Nullable Class keyType, + final @Nullable Class valueType, + final @Nullable String name) { + final Breadcrumb breadcrumb = new Breadcrumb(); + + breadcrumb.setType("graphql"); + breadcrumb.setCategory("graphql.data_loader"); + + final List serializedKeys = new ArrayList<>(); + for (Object key : keys) { + serializedKeys.add(key.toString()); + } + breadcrumb.setData("keys", serializedKeys); + + if (keyType != null) { + breadcrumb.setData("key_type", keyType.getName()); + } + + if (valueType != null) { + breadcrumb.setData("value_type", valueType.getName()); + } + + if (name != null) { + breadcrumb.setData("name", name); + } + + return breadcrumb; + } + /** * Creates navigation breadcrumb - a navigation event can be a URL change in a web application, or * a UI transition in a mobile or desktop application, etc. diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index a710e54ec6e..2c15a13abee 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -5,6 +5,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertNull class BreadcrumbTest { @@ -193,4 +194,43 @@ class BreadcrumbTest { assertEquals("message", breadcrumb.message) assertEquals(SentryLevel.ERROR, breadcrumb.level) } + + @Test + fun `serializes String keys for graphql data loader breadcrumb`() { + val breadcrumb = Breadcrumb.graphqlDataLoader(listOf("key1", "key2"), String::class.java, Throwable::class.java, null) + assertEquals("graphql", breadcrumb.type) + assertEquals("graphql.data_loader", breadcrumb.category) + assertEquals(listOf("key1", "key2"), breadcrumb.data["keys"] as? Iterable) + assertEquals("java.lang.String", breadcrumb.data["key_type"]) + assertEquals("java.lang.Throwable", breadcrumb.data["value_type"]) + assertNull(breadcrumb.data["name"]) + } + + @Test + fun `serializes Long keys for graphql data loader breadcrumb`() { + val breadcrumb = Breadcrumb.graphqlDataLoader(listOf(java.lang.Long.valueOf(1), java.lang.Long.valueOf(2)), java.lang.Long::class.java, Throwable::class.java, null) + assertEquals("graphql", breadcrumb.type) + assertEquals("graphql.data_loader", breadcrumb.category) + assertEquals(listOf("1", "2"), breadcrumb.data["keys"] as? Iterable) + assertEquals("java.lang.Long", breadcrumb.data["key_type"]) + assertEquals("java.lang.Throwable", breadcrumb.data["value_type"]) + assertNull(breadcrumb.data["name"]) + } + + @Test + fun `serializes object keys using toString for graphql data loader breadcrumb`() { + val breadcrumb = Breadcrumb.graphqlDataLoader(listOf(TestKey(1L), TestKey(2L)), TestKey::class.java, Throwable::class.java, null) + assertEquals("graphql", breadcrumb.type) + assertEquals("graphql.data_loader", breadcrumb.category) + assertEquals(listOf("1", "2"), breadcrumb.data["keys"] as? Iterable) + assertEquals("io.sentry.BreadcrumbTest\$TestKey", breadcrumb.data["key_type"]) + assertEquals("java.lang.Throwable", breadcrumb.data["value_type"]) + assertNull(breadcrumb.data["name"]) + } + + class TestKey(val id: Long) { + override fun toString(): String { + return id.toString() + } + } }