From 426da00bcfd678a422a0f7829c28b86f82cdfc74 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 20 Jul 2023 08:58:43 +0200 Subject: [PATCH 01/18] Improve server side graphql support for DGS and spring-graphql --- buildSrc/src/main/java/Config.kt | 3 + sentry-graphql/api/sentry-graphql.api | 42 +++- .../io/sentry/graphql/ExceptionReporter.java | 146 ++++++++++++++ .../io/sentry/graphql/GraphqlStringUtils.java | 41 ++++ .../graphql/NoOpSubscriptionHandler.java | 24 +++ .../SentryDataFetcherExceptionHandler.java | 27 +-- .../SentryGraphqlExceptionHandler.java | 40 ++++ .../sentry/graphql/SentryInstrumentation.java | 185 ++++++++++++++++-- .../graphql/SentrySubscriptionHandler.java | 14 ++ .../build.gradle.kts | 1 + .../samples/netflix/dgs/ActorsDataloader.java | 30 +++ .../netflix/dgs/NetlixDgsApplication.java | 3 +- .../samples/netflix/dgs/ShowsDatafetcher.java | 52 ++++- .../netflix/dgs/graphql/DgsConstants.java | 22 +++ .../netflix/dgs/graphql/types/Actor.java | 77 ++++++++ .../netflix/dgs/graphql/types/Show.java | 29 ++- .../src/main/resources/application.properties | 1 + .../src/main/resources/schema/schema.graphqls | 15 ++ .../build.gradle.kts | 2 + .../boot/jakarta/GreetingController.java | 17 ++ .../main/resources/graphql/schema.graphqls | 29 +++ .../build.gradle.kts | 2 + .../spring/boot/GreetingController.java | 19 ++ .../spring/boot/ProjectController.java | 153 +++++++++++++++ .../src/main/resources/application.properties | 3 + .../main/resources/graphql/schema.graphqls | 58 ++++++ .../build.gradle.kts | 3 + .../spring/boot/GreetingController.java | 17 ++ .../spring/boot/ProjectController.java | 169 ++++++++++++++++ .../src/main/resources/application.properties | 2 + .../main/resources/graphql/schema.graphqls | 58 ++++++ sentry-spring-boot-starter/build.gradle.kts | 2 + .../spring/boot/SentryAutoConfiguration.java | 14 ++ sentry-spring/api/sentry-spring.api | 43 ++++ sentry-spring/build.gradle.kts | 3 +- .../graphql/SentryBatchLoaderRegistry.java | 115 +++++++++++ ...ryDataFetcherExceptionResolverAdapter.java | 43 ++++ .../graphql/SentryDgsSubscriptionHandler.java | 29 +++ .../SentryGraphqlBeanPostProcessor.java | 15 ++ .../graphql/SentryGraphqlConfiguration.java | 38 ++++ .../SentrySpringSubscriptionHandler.java | 35 ++++ sentry/api/sentry.api | 4 + .../src/main/java/io/sentry/Breadcrumb.java | 107 ++++++++++ .../main/java/io/sentry/util/StringUtils.java | 8 + 44 files changed, 1698 insertions(+), 42 deletions(-) create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java create mode 100644 sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/ActorsDataloader.java create mode 100644 sentry-samples/sentry-samples-netflix-dgs/src/main/java/io/sentry/samples/netflix/dgs/graphql/types/Actor.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/graphql/schema.graphqls create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 6d54ef9296a..aa9e374db56 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -73,14 +73,17 @@ 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 springBoot3StarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$springBoot3Version" diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index c39cccdb491..b4f371996b0 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -3,23 +3,61 @@ 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;)V + public fun (Lio/sentry/IHub;Lgraphql/schema/DataFetchingEnvironment;)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/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 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;Z)V + public fun (Lio/sentry/graphql/SentrySubscriptionHandler;)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 beginSubscribedFieldEvent (Lgraphql/execution/instrumentation/parameters/InstrumentationFieldParameters;)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/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..6ad676c6b04 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -0,0 +1,146 @@ +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.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.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ExceptionReporter { + private final boolean isSpring; + + public ExceptionReporter(final boolean isSpring) { + this.isSpring = isSpring; + } + + 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 Mechanism mechanism = new Mechanism(); + mechanism.setType(MECHANISM_TYPE); + mechanism.setHandled(false); + final Throwable mechanismException = + new ExceptionMechanismException(mechanism, throwable, Thread.currentThread()); + final SentryEvent event = new SentryEvent(mechanismException); + event.setLevel(SentryLevel.FATAL); + + final Hint hint = new Hint(); + setRequestDetailsOnEvent(hub, exceptionDetails, event); + + if (result != null) { + @NotNull Response response = new Response(); + Map responseBody = result.toSpecification(); + response.setData(responseBody); + event.getContexts().setResponse(response); + } + + hub.captureEvent(event, hint); + } + + private void setRequestDetailsOnEvent( + final @NotNull IHub hub, + final @NotNull ExceptionDetails exceptionDetails, + final @NotNull SentryEvent event) { + hub.configureScope( + (scope) -> { + final @Nullable Request scopeRequest = scope.getRequest(); + if (scopeRequest != null) { + setDetailsOnRequest(hub, exceptionDetails, scopeRequest); + event.setRequest(scopeRequest); + } else { + Request newRequest = new Request(); + setDetailsOnRequest(hub, exceptionDetails, newRequest); + event.setRequest(newRequest); + } + }); + } + + private void setDetailsOnRequest( + final @NotNull IHub hub, + final @NotNull ExceptionDetails exceptionDetails, + final @NotNull Request request) { + request.setApiTarget("graphql"); + + if (exceptionDetails.isSubscription() || !isSpring) { + final @NotNull Map data = new HashMap<>(); + + data.put("data", exceptionDetails.getQuery()); + + if (hub.getOptions().isSendDefaultPii()) { + data.put("variables", exceptionDetails.getVariables()); + } + + // for Spring this will be replaced by RequestBodyExtractingEventProcessor + 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) { + this.hub = hub; + this.instrumentationExecutionParameters = instrumentationExecutionParameters; + dataFetchingEnvironment = null; + isSubscription = false; + } + + public ExceptionDetails( + final @NotNull IHub hub, final @Nullable DataFetchingEnvironment dataFetchingEnvironment) { + this.hub = hub; + this.dataFetchingEnvironment = dataFetchingEnvironment; + instrumentationExecutionParameters = null; + isSubscription = true; + } + + 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 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..a44e72d07fa --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java @@ -0,0 +1,41 @@ +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.NotNull; +import org.jetbrains.annotations.Nullable; + +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..598bd18bd75 --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java @@ -0,0 +1,24 @@ +package io.sentry.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IHub; + +public final class NoOpSubscriptionHandler implements SentrySubscriptionHandler { + + private static final NoOpSubscriptionHandler instance = new NoOpSubscriptionHandler(); + + private NoOpSubscriptionHandler() {} + + public static NoOpSubscriptionHandler getInstance() { + return instance; + } + + @Override + public Object onSubscriptionResult( + Object result, + IHub hub, + ExceptionReporter exceptionReporter, + 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..86001668bf8 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java @@ -1,42 +1,35 @@ package io.sentry.graphql; -import static io.sentry.TypeCheckHint.GRAPHQL_HANDLER_PARAMETERS; - import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.DataFetcherExceptionHandlerParameters; import graphql.execution.DataFetcherExceptionHandlerResult; -import io.sentry.Hint; -import io.sentry.HubAdapter; import io.sentry.IHub; -import io.sentry.util.Objects; 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 SentryDataFetcherExceptionHandler implements DataFetcherExceptionHandler { - private final @NotNull IHub hub; - private final @NotNull DataFetcherExceptionHandler delegate; + private final @NotNull SentryGraphqlExceptionHandler handler; 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"); + final @Nullable IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { + this.handler = new SentryGraphqlExceptionHandler(delegate); } public SentryDataFetcherExceptionHandler(final @NotNull DataFetcherExceptionHandler delegate) { - this(HubAdapter.getInstance(), delegate); + this(null, delegate); } @Override @SuppressWarnings("deprecation") - public DataFetcherExceptionHandlerResult onException( + public @Nullable DataFetcherExceptionHandlerResult onException( final @NotNull DataFetcherExceptionHandlerParameters handlerParameters) { - final Hint hint = new Hint(); - hint.set(GRAPHQL_HANDLER_PARAMETERS, handlerParameters); - - hub.captureException(handlerParameters.getException(), hint); - return delegate.onException(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..b19377616fd --- /dev/null +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java @@ -0,0 +1,40 @@ +package io.sentry.graphql; + +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.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryGraphqlExceptionHandler { + private final @Nullable DataFetcherExceptionHandler delegate; + + 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) { + final @NotNull List exceptions = + graphQlContext.getOrDefault("sentry.exceptions", new CopyOnWriteArrayList()); + exceptions.add(throwable); + graphQlContext.put("sentry.exceptions", 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 8def4f3a102..ea3eb2d6892 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -1,49 +1,73 @@ 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.execution.instrumentation.parameters.InstrumentationFieldParameters; +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; public final class SentryInstrumentation extends SimpleInstrumentation { - private final @NotNull IHub hub; + + private static final @NotNull List ERROR_TYPES_HANDLED_BY_DATA_FETCHERS = + Arrays.asList("INTERNAL", "INTERNAL_ERROR"); private final @Nullable BeforeSpanCallback beforeSpan; + private final @NotNull SentrySubscriptionHandler subscriptionHandler; + + private final @NotNull ExceptionReporter exceptionReporter; + + // TODO ctor that takes a hub + public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { + this(beforeSpan, NoOpSubscriptionHandler.getInstance(), false); + } public SentryInstrumentation( - final @NotNull IHub hub, final @Nullable BeforeSpanCallback beforeSpan) { - this.hub = Objects.requireNonNull(hub, "hub is required"); + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean isSpring) { this.beforeSpan = beforeSpan; + this.subscriptionHandler = subscriptionHandler; + this.exceptionReporter = new ExceptionReporter(isSpring); SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-graphql", BuildConfig.VERSION_NAME); } - public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { - this(HubAdapter.getInstance(), beforeSpan); - } - - public SentryInstrumentation(final @NotNull IHub hub) { - this(hub, null); + public SentryInstrumentation(final @Nullable IHub hub) { + this((BeforeSpanCallback) null); } - public SentryInstrumentation() { - this(HubAdapter.getInstance()); + public SentryInstrumentation(final @NotNull SentrySubscriptionHandler subscriptionHandler) { + this(null, subscriptionHandler, false); } @Override @@ -55,10 +79,107 @@ 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", 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", new CopyOnWriteArrayList()); + for (Throwable throwable : exceptions) { + exceptionReporter.captureThrowable( + throwable, + new ExceptionReporter.ExceptionDetails( + hubFromContext(graphQLContext), parameters), + 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), + result); + } + } + } + } + if (exception != null) { + exceptionReporter.captureThrowable( + exception, + new ExceptionReporter.ExceptionDetails( + hubFromContext(parameters.getGraphQLContext()), parameters), + 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) { + Object extensionErrorType = extensions.get("errorType"); + if (extensionErrorType != null) { + return extensionErrorType.toString(); + } + } + 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", NoOpHub.getInstance()); + } + @Override @SuppressWarnings("FutureReturnValueIgnored") public @NotNull DataFetcher instrumentDataFetcher( @@ -70,12 +191,30 @@ 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 Object result = + tmpResult == null + ? null + : subscriptionHandler.onSubscriptionResult( + tmpResult, + hubFromContext(environment.getGraphQlContext()), + exceptionReporter, + parameters); if (result instanceof CompletableFuture) { ((CompletableFuture) result) .whenComplete( @@ -100,11 +239,25 @@ public SentryInstrumentation() { throw e; } } else { - return dataFetcher.get(environment); + final Object result = dataFetcher.get(environment); + if (result != null) { + return subscriptionHandler.onSubscriptionResult( + result, + hubFromContext(environment.getGraphQlContext()), + exceptionReporter, + parameters); + } + return null; } }; } + @Override + public InstrumentationContext beginSubscribedFieldEvent( + InstrumentationFieldParameters parameters) { + return super.beginSubscribedFieldEvent(parameters); + } + 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-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..b65424b073f 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 @@ -3,6 +3,7 @@ import com.netflix.graphql.dgs.exceptions.DefaultDataFetcherExceptionHandler; import io.sentry.graphql.SentryDataFetcherExceptionHandler; 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,7 +17,7 @@ public static void main(String[] args) { @Bean SentryInstrumentation sentryInstrumentation() { - return new SentryInstrumentation(); + return new SentryInstrumentation(new SentryDgsSubscriptionHandler()); } @Bean 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..5c5cf5d4b2f 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 director(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/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 811b6dd83da..a848d6f25a8 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,7 @@ repositories { dependencies { implementation(Config.Libs.springBoot3StarterSecurity) implementation(Config.Libs.springBoot3StarterWeb) + implementation(Config.Libs.springBoot3StarterGraphql) implementation(Config.Libs.springBoot3StarterWebflux) implementation(Config.Libs.springBoot3StarterAop) implementation(Config.Libs.aspectj) @@ -29,6 +30,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/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java new file mode 100644 index 00000000000..76cf5c03412 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot.jakarta; + +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/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..6a85641fd95 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,29 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project +} + +""" 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! +} + +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/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/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java new file mode 100644 index 00000000000..a95e743a4c4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot; + +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/java/io/sentry/samples/spring/boot/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java new file mode 100644 index 00000000000..d2fc000e14c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java @@ -0,0 +1,153 @@ +package io.sentry.samples.spring.boot; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +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.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.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Controller +public class ProjectController { + + public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, Assignee.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Acrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading task"); + } + map.put(key, new Assignee(key, "Name" + key)); + } + + return map; + }); + }); + } + + @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")); + tasks.add(new Task("T2", "Update dependencies", "A1")); + tasks.add(new Task("T3", "Document API", "A1")); + tasks.add(new Task("T4", "Merge community PRs", "A2")); + tasks.add(new Task("T5", "Plan more work", null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash")); + } + return tasks; + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture assignee( + final Task task, final DataLoader dataLoader) { + if (task.assigneeId == null) { + return null; + } + return dataLoader.load(task.assigneeId); + } + + @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"; + Random random = new Random(); + return Flux.interval(Duration.ofSeconds(1)) + .map(num -> new Task("T" + random.nextInt(1000, 9999), "A new task arrived ", assigneeId)); + } + + class Task { + private String id; + private String name; + private String assigneeId; + + public Task(final String id, final String name, final String assigneeId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + } + } + + class Assignee { + private String id; + private String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + class Project { + private String slug; + } + + enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} 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..f2ab84b1c20 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,58 @@ +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 +} + +""" An Assignee """ +type Assignee { + """ 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/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/GreetingController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/GreetingController.java new file mode 100644 index 00000000000..95ebabb65e2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot; + +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/ProjectController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java new file mode 100644 index 00000000000..10733cf4087 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java @@ -0,0 +1,169 @@ +package io.sentry.samples.spring.boot; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.BatchMapping; +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; +import reactor.core.publisher.Mono; + +@Controller +public class ProjectController { + + // public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { + // // using mapped BatchLoader to not have to deal with correct ordering of items + // batchLoaderRegistry.forTypePair(String.class, + // Assignee.class).registerMappedBatchLoader((Set keys, BatchLoaderEnvironment env) -> { + // return Mono.fromCallable(() -> { + // final @NotNull Map map = new HashMap<>(); + // for (String key : keys) { + // if ("Acrash".equalsIgnoreCase(key)) { + // throw new RuntimeException("Causing an error while loading assignee"); + // } + // map.put(key, new Assignee(key, "Name" + key)); + // } + // + // return map; + // }); + // }); + // } + + @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")); + tasks.add(new Task("T2", "Update dependencies", "A1")); + tasks.add(new Task("T3", "Document API", "A1")); + tasks.add(new Task("T4", "Merge community PRs", "A2")); + tasks.add(new Task("T5", "Plan more work", null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash")); + } + return tasks; + } + + // @SchemaMapping(typeName="Task") + // public @Nullable CompletableFuture assignee(final Task task, final + // DataLoader dataLoader) { + // if (task.assigneeId == null) { + // return null; + // } + // return dataLoader.load(task.assigneeId); + // } + + @BatchMapping(typeName = "Task") + public Mono> assignee(final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (final @NotNull Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading task"); + } + map.put(task.assigneeId, new Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + + return map; + }); + } + + @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 @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); + }); + } + + class Task { + private String id; + private String name; + private String assigneeId; + + public Task(final String id, final String name, final String assigneeId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + } + } + + class Assignee { + private String id; + private String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + class Project { + private String slug; + } + + enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} 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..f2ab84b1c20 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,58 @@ +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 +} + +""" An Assignee """ +type Assignee { + """ 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/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 7dfaa491992..a3cac95aca8 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..9b1111ff077 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.SentryDataFetcherExceptionHandler; 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({ + SentryDataFetcherExceptionHandler.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/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index c7ee1d0c704..c05ef7bd894 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -88,6 +88,49 @@ 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 sourceBuilderCustomizer ()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..bb372ba0045 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) 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..a293521804e --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java @@ -0,0 +1,115 @@ +package io.sentry.spring.graphql; + +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.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +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", 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..9d7de08db83 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java @@ -0,0 +1,43 @@ +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.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; + +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..7e6b1a7da93 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -0,0 +1,29 @@ +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 reactor.core.publisher.Flux; + +public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + + @Override + public Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment()); + 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..8376a9fdf92 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java @@ -0,0 +1,15 @@ +package io.sentry.spring.graphql; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.graphql.execution.BatchLoaderRegistry; + +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..30592e944ed --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java @@ -0,0 +1,38 @@ +package io.sentry.spring.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.graphql.SentryInstrumentation; +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 { + + /** + * 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. + */ + @Bean + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() { + return (builder) -> + builder.configureGraphQl( + graphQlBuilder -> + graphQlBuilder.instrumentation( + new SentryInstrumentation(null, new SentrySpringSubscriptionHandler(), true))); + } + + @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..f7d66523e47 --- /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 Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment()); + 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/api/sentry.api b/sentry/api/sentry.api index 3ec27fa85b7..edec93e872d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -105,6 +105,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; @@ -4198,6 +4201,7 @@ public final class io/sentry/util/StringUtils { public static fun join (Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String; public static fun normalizeUUID (Ljava/lang/String;)Ljava/lang/String; public static fun removeSurrounding (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public static fun toString (Ljava/lang/Object;)Ljava/lang/String; } public final class io/sentry/util/TracingUtils { diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index de48d0d3d78..c6d0a1b741e 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -188,6 +188,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 type of GraphQL operation (e.g. query, mutation, subscription) + * @param type - the ID of the GraphQL operation + * @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("data_fetcher"); + + if (path != null) { + // TODO key? + breadcrumb.setData("graphql.path", path); + } + if (field != null) { + // TODO key? + breadcrumb.setData("graphql.field", field); + } + if (type != null) { + // TODO key? + breadcrumb.setData("graphql.type", type); + } + if (objectType != null) { + // TODO key? + breadcrumb.setData("graphql.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("data_loader"); + + breadcrumb.setData("keys", keys); + + 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/main/java/io/sentry/util/StringUtils.java b/sentry/src/main/java/io/sentry/util/StringUtils.java index 0eef8e9cb39..4ca466421a7 100644 --- a/sentry/src/main/java/io/sentry/util/StringUtils.java +++ b/sentry/src/main/java/io/sentry/util/StringUtils.java @@ -181,4 +181,12 @@ public static String join( return stringBuilder.toString(); } + + public static @Nullable String toString(final @Nullable Object obj) { + if (obj != null) { + return obj.toString(); + } + + return null; + } } From 10194c850feed2fef1f4c1b343dbed41b792ee7a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Jul 2023 15:30:55 +0200 Subject: [PATCH 02/18] Add tests; PR feedback --- sentry-graphql/api/sentry-graphql.api | 11 +- sentry-graphql/build.gradle.kts | 3 + .../io/sentry/graphql/ExceptionReporter.java | 31 +- .../io/sentry/graphql/GraphqlStringUtils.java | 2 + .../SentryGraphqlExceptionHandler.java | 9 +- .../sentry/graphql/SentryInstrumentation.java | 122 ++++-- .../sentry/graphql/ExceptionReporterTest.kt | 194 ++++++++++ .../sentry/graphql/GraphqlStringUtilsTest.kt | 59 +++ .../SentryDataFetcherExceptionHandlerTest.kt | 22 +- .../SentryInstrumentationAnotherTest.kt | 346 ++++++++++++++++++ .../graphql/SentryInstrumentationTest.kt | 154 +++++--- .../netflix/dgs/NetlixDgsApplication.java | 2 +- .../spring/boot/ProjectController.java | 2 +- sentry-spring/build.gradle.kts | 3 + .../graphql/SentryBatchLoaderRegistry.java | 6 +- ...ryDataFetcherExceptionResolverAdapter.java | 2 + .../graphql/SentryDgsSubscriptionHandler.java | 2 +- .../SentryGraphqlBeanPostProcessor.java | 2 + .../SentrySpringSubscriptionHandler.java | 2 +- .../SentrySpringSubscriptionHandlerTest.kt | 79 ++++ .../src/main/java/io/sentry/Breadcrumb.java | 4 +- 21 files changed, 952 insertions(+), 105 deletions(-) create mode 100644 sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt create mode 100644 sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt create mode 100644 sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/graphql/SentrySpringSubscriptionHandlerTest.kt diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index b4f371996b0..0db65abcc5c 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -9,8 +9,8 @@ public final class io/sentry/graphql/ExceptionReporter { } public final class io/sentry/graphql/ExceptionReporter$ExceptionDetails { - public fun (Lio/sentry/IHub;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)V - public fun (Lio/sentry/IHub;Lgraphql/schema/DataFetchingEnvironment;)V + 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; @@ -41,10 +41,15 @@ public final class io/sentry/graphql/SentryGraphqlExceptionHandler { } 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;)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 beginSubscribedFieldEvent (Lgraphql/execution/instrumentation/parameters/InstrumentationFieldParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 5e20682a8dc..5bb3013e159 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") } configure { diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java index 6ad676c6b04..6e1ee69a84f 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -14,9 +14,11 @@ 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 isSpring; @@ -59,14 +61,9 @@ private void setRequestDetailsOnEvent( hub.configureScope( (scope) -> { final @Nullable Request scopeRequest = scope.getRequest(); - if (scopeRequest != null) { - setDetailsOnRequest(hub, exceptionDetails, scopeRequest); - event.setRequest(scopeRequest); - } else { - Request newRequest = new Request(); - setDetailsOnRequest(hub, exceptionDetails, newRequest); - event.setRequest(newRequest); - } + final @NotNull Request request = scopeRequest == null ? new Request() : scopeRequest; + setDetailsOnRequest(hub, exceptionDetails, request); + event.setRequest(request); }); } @@ -79,13 +76,14 @@ private void setDetailsOnRequest( if (exceptionDetails.isSubscription() || !isSpring) { final @NotNull Map data = new HashMap<>(); - data.put("data", exceptionDetails.getQuery()); + data.put("query", exceptionDetails.getQuery()); if (hub.getOptions().isSendDefaultPii()) { data.put("variables", exceptionDetails.getVariables()); } - // for Spring this will be replaced by RequestBodyExtractingEventProcessor + // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor + // for non subscription (websocket) errors request.setData(data); } } @@ -100,19 +98,22 @@ public static final class ExceptionDetails { public ExceptionDetails( final @NotNull IHub hub, - final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters) { + final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters, + final boolean isSubscription) { this.hub = hub; this.instrumentationExecutionParameters = instrumentationExecutionParameters; dataFetchingEnvironment = null; - isSubscription = false; + this.isSubscription = isSubscription; } public ExceptionDetails( - final @NotNull IHub hub, final @Nullable DataFetchingEnvironment dataFetchingEnvironment) { + final @NotNull IHub hub, + final @Nullable DataFetchingEnvironment dataFetchingEnvironment, + final boolean isSubscription) { this.hub = hub; this.dataFetchingEnvironment = dataFetchingEnvironment; instrumentationExecutionParameters = null; - isSubscription = true; + this.isSubscription = isSubscription; } public @Nullable String getQuery() { @@ -139,7 +140,7 @@ public boolean isSubscription() { return isSubscription; } - public IHub getHub() { + 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 index a44e72d07fa..20b7f543bb1 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java @@ -5,9 +5,11 @@ 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) { diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java index b19377616fd..283011a16c7 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java @@ -1,5 +1,7 @@ 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; @@ -7,9 +9,11 @@ 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; @@ -26,9 +30,10 @@ public SentryGraphqlExceptionHandler(final @Nullable DataFetcherExceptionHandler final @Nullable GraphQLContext graphQlContext = environment.getGraphQlContext(); if (graphQlContext != null) { final @NotNull List exceptions = - graphQlContext.getOrDefault("sentry.exceptions", new CopyOnWriteArrayList()); + graphQlContext.getOrDefault( + SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); exceptions.add(throwable); - graphQlContext.put("sentry.exceptions", exceptions); + graphQlContext.put(SENTRY_EXCEPTIONS_CONTEXT_KEY, exceptions); } } if (delegate != 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 ea3eb2d6892..bc7e53a84d0 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -35,39 +35,92 @@ 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", "INTERNAL_ERROR"); + 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 final @Nullable BeforeSpanCallback beforeSpan; private final @NotNull SentrySubscriptionHandler subscriptionHandler; private final @NotNull ExceptionReporter exceptionReporter; - // TODO ctor that takes a hub + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") + public SentryInstrumentation() { + this(null, NoOpSubscriptionHandler.getInstance(), false); + } + + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") + public SentryInstrumentation(final @Nullable IHub hub) { + this(null, NoOpSubscriptionHandler.getInstance(), false); + } + + /** + * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { this(beforeSpan, NoOpSubscriptionHandler.getInstance(), false); } + /** + * @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(), false); + } + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param isSpring true if using spring-graphql, false for Netflix DGS an non Spring + */ public SentryInstrumentation( final @Nullable BeforeSpanCallback beforeSpan, final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean isSpring) { + this(beforeSpan, subscriptionHandler, new ExceptionReporter(isSpring)); + } + + @TestOnly + public SentryInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter) { this.beforeSpan = beforeSpan; this.subscriptionHandler = subscriptionHandler; - this.exceptionReporter = new ExceptionReporter(isSpring); + this.exceptionReporter = exceptionReporter; SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-graphql", BuildConfig.VERSION_NAME); } - public SentryInstrumentation(final @Nullable IHub hub) { - this((BeforeSpanCallback) null); - } - - public SentryInstrumentation(final @NotNull SentrySubscriptionHandler subscriptionHandler) { - this(null, subscriptionHandler, false); + /** + * @param subscriptionHandler can report subscription errors + * @param isSpring true if using spring-graphql, false for Netflix DGS an non Spring + */ + public SentryInstrumentation( + final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean isSpring) { + this(null, subscriptionHandler, isSpring); } @Override @@ -81,7 +134,7 @@ public SentryInstrumentation(final @NotNull SentrySubscriptionHandler subscripti final TracingState tracingState = parameters.getInstrumentationState(); final @NotNull IHub currentHub = Sentry.getCurrentHub(); tracingState.setTransaction(currentHub.getSpan()); - parameters.getGraphQLContext().put("sentry.hub", currentHub); + parameters.getGraphQLContext().put(SENTRY_HUB_CONTEXT_KEY, currentHub); return super.beginExecution(parameters); } @@ -96,12 +149,12 @@ public CompletableFuture instrumentExecutionResult( if (graphQLContext != null) { final @NotNull List exceptions = graphQLContext.getOrDefault( - "sentry.exceptions", new CopyOnWriteArrayList()); + SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); for (Throwable throwable : exceptions) { exceptionReporter.captureThrowable( throwable, new ExceptionReporter.ExceptionDetails( - hubFromContext(graphQLContext), parameters), + hubFromContext(graphQLContext), parameters, false), result); } } @@ -116,7 +169,7 @@ public CompletableFuture instrumentExecutionResult( exceptionReporter.captureThrowable( new RuntimeException(error.getMessage()), new ExceptionReporter.ExceptionDetails( - hubFromContext(graphQLContext), parameters), + hubFromContext(graphQLContext), parameters, false), result); } } @@ -126,7 +179,7 @@ public CompletableFuture instrumentExecutionResult( exceptionReporter.captureThrowable( exception, new ExceptionReporter.ExceptionDetails( - hubFromContext(parameters.getGraphQLContext()), parameters), + hubFromContext(parameters.getGraphQLContext()), parameters, false), null); } }); @@ -177,7 +230,7 @@ public CompletableFuture instrumentExecutionResult( if (context == null) { return NoOpHub.getInstance(); } - return context.getOrDefault("sentry.hub", NoOpHub.getInstance()); + return context.getOrDefault(SENTRY_HUB_CONTEXT_KEY, NoOpHub.getInstance()); } @Override @@ -207,14 +260,8 @@ public CompletableFuture instrumentExecutionResult( final ISpan span = createSpan(transaction, parameters); try { final @Nullable Object tmpResult = dataFetcher.get(environment); - final Object result = - tmpResult == null - ? null - : subscriptionHandler.onSubscriptionResult( - tmpResult, - hubFromContext(environment.getGraphQlContext()), - exceptionReporter, - parameters); + final @Nullable Object result = + maybeCallSubscriptionHandler(parameters, environment, tmpResult); if (result instanceof CompletableFuture) { ((CompletableFuture) result) .whenComplete( @@ -240,18 +287,31 @@ public CompletableFuture instrumentExecutionResult( } } else { final Object result = dataFetcher.get(environment); - if (result != null) { - return subscriptionHandler.onSubscriptionResult( - result, - hubFromContext(environment.getGraphQlContext()), - exceptionReporter, - parameters); - } - return null; + 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; + } + @Override public InstrumentationContext beginSubscribedFieldEvent( InstrumentationFieldParameters 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..de80ef9d1fc --- /dev/null +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -0,0 +1,194 @@ +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 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 = SentryOptions(), isSpring: Boolean = false): ExceptionReporter { + whenever(hub.options).thenReturn(options) + scope = Scope(options) + val exceptionReporter = ExceptionReporter(isSpring) + 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 + assertNull(data["variables"]) + assertEquals(fixture.query, data["query"]) + assertEquals("graphql", request.apiTarget) + }, + any() + ) + } + + @Test + fun `attaches variables if sendDefaultPii = true`() { + val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }) + 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(SentryOptions().also { it.isSendDefaultPii = true }) + 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(SentryOptions().also { it.isSendDefaultPii = true }, true) + 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(SentryOptions().also { it.isSendDefaultPii = true }, true) + 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/SentryDataFetcherExceptionHandlerTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt index 0157c638199..393bc09edf7 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt @@ -1,28 +1,38 @@ package io.sentry.graphql +import graphql.GraphQLContext import graphql.execution.DataFetcherExceptionHandler import graphql.execution.DataFetcherExceptionHandlerParameters -import io.sentry.Hint +import graphql.schema.DataFetchingEnvironmentImpl import io.sentry.IHub -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull class SentryDataFetcherExceptionHandlerTest { @Test - fun `passes exception to Sentry and invokes delegate`() { + fun `collects exception into GraphQLContext and invokes delegate`() { val hub = mock() val delegate = mock() val handler = SentryDataFetcherExceptionHandler(hub, delegate) val exception = RuntimeException() - val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).build() + val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).dataFetchingEnvironment( + DataFetchingEnvironmentImpl.newDataFetchingEnvironment().graphQLContext( + GraphQLContext.of( + emptyMap() + ) + ).build() + ).build() handler.onException(parameters) - verify(hub).captureException(eq(exception), anyOrNull()) + 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..2e53100d1f1 --- /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["graphql.path"]) + assertEquals("myFieldName", breadcrumb.data["graphql.field"]) + assertEquals("MyResponseType", breadcrumb.data["graphql.type"]) + assertEquals("QUERY", breadcrumb.data["graphql.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 7604bc8ff49..7629df6a4f2 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(), false)) .build() if (isTransactionActive) { @@ -70,55 +87,63 @@ class SentryInstrumentationTest { fun `when transaction is active, creates inner spans`() { val sut = fixture.getSut() - 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) - assertTrue(span.isFinished) - assertEquals(SpanStatus.OK, span.status) + 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) + 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) + } } } @@ -126,24 +151,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/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 b65424b073f..ccc6d6e3914 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 @@ -17,7 +17,7 @@ public static void main(String[] args) { @Bean SentryInstrumentation sentryInstrumentation() { - return new SentryInstrumentation(new SentryDgsSubscriptionHandler()); + return new SentryInstrumentation(new SentryDgsSubscriptionHandler(), false); } @Bean diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java index 10733cf4087..2d242efc081 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java @@ -103,7 +103,7 @@ public Mono> assignee(final @NotNull Set tasks) { final @NotNull Map map = new HashMap<>(); for (final @NotNull Task task : tasks) { if ("Acrash".equalsIgnoreCase(task.assigneeId)) { - throw new RuntimeException("Causing an error while loading task"); + throw new RuntimeException("Causing an error while loading assignee"); } map.put(task.assigneeId, new Assignee(task.assigneeId, "Name" + task.assigneeId)); } diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index bb372ba0045..75a33d0767b 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryGraphql) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) @@ -54,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 index a293521804e..62a8669f892 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryBatchLoaderRegistry.java @@ -1,5 +1,7 @@ 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; @@ -12,12 +14,14 @@ 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; @@ -106,7 +110,7 @@ public void registerMappedBatchLoader( Object context = environment.getContext(); if (context instanceof GraphQLContext) { GraphQLContext graphqlContext = (GraphQLContext) context; - return graphqlContext.getOrDefault("sentry.hub", NoOpHub.getInstance()); + 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 index 9d7de08db83..c52823653e4 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDataFetcherExceptionResolverAdapter.java @@ -5,10 +5,12 @@ 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; 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 index 7e6b1a7da93..63236c54c93 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -20,7 +20,7 @@ public Object onSubscriptionResult( return flux.doOnError( throwable -> { ExceptionReporter.ExceptionDetails exceptionDetails = - new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment()); + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); exceptionReporter.captureThrowable(throwable, exceptionDetails, null); }); } 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 index 8376a9fdf92..89b0cf430cb 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlBeanPostProcessor.java @@ -1,9 +1,11 @@ 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 { 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 index f7d66523e47..6bcd2de4b8c 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java @@ -21,7 +21,7 @@ public Object onSubscriptionResult( return flux.doOnError( throwable -> { ExceptionReporter.ExceptionDetails exceptionDetails = - new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment()); + new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); if (throwable instanceof SubscriptionPublisherException && throwable.getCause() != null) { exceptionReporter.captureThrowable(throwable.getCause(), exceptionDetails, null); 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/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index c6d0a1b741e..77b22427ce9 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -237,7 +237,7 @@ public static Breadcrumb fromMap( final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("graphql"); - breadcrumb.setCategory("data_fetcher"); + breadcrumb.setCategory("graphql.fetcher"); if (path != null) { // TODO key? @@ -276,7 +276,7 @@ public static Breadcrumb fromMap( final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("graphql"); - breadcrumb.setCategory("data_loader"); + breadcrumb.setCategory("graphql.data_loader"); breadcrumb.setData("keys", keys); From 2ec2bbba77ba3cbd32c78479cffb13c8b3d40b69 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Jul 2023 15:56:27 +0200 Subject: [PATCH 03/18] Duplicate spring into spring-jakarta --- buildSrc/src/main/java/Config.kt | 1 + .../build.gradle.kts | 1 + .../boot/jakarta/ProjectController.java | 169 ++++++++++++++++++ .../src/main/resources/application.properties | 2 + .../main/resources/graphql/schema.graphqls | 31 +++- .../build.gradle.kts | 2 + .../boot/jakarta/SentryAutoConfiguration.java | 14 ++ .../api/sentry-spring-jakarta.api | 43 +++++ sentry-spring-jakarta/build.gradle.kts | 5 + .../graphql/SentryBatchLoaderRegistry.java | 119 ++++++++++++ ...ryDataFetcherExceptionResolverAdapter.java | 45 +++++ .../graphql/SentryDgsSubscriptionHandler.java | 29 +++ .../SentryGraphqlBeanPostProcessor.java | 17 ++ .../graphql/SentryGraphqlConfiguration.java | 38 ++++ .../SentrySpringSubscriptionHandler.java | 35 ++++ .../SentrySpringSubscriptionHandlerTest.kt | 80 +++++++++ 16 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryBatchLoaderRegistry.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDataFetcherExceptionResolverAdapter.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlBeanPostProcessor.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandler.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHandlerTest.kt diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index aa9e374db56..be82ce3c3a9 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -86,6 +86,7 @@ object Config { 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-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index a848d6f25a8..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,7 @@ 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) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java new file mode 100644 index 00000000000..f34fb94c454 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java @@ -0,0 +1,169 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.BatchMapping; +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; +import reactor.core.publisher.Mono; + +@Controller +public class ProjectController { + + // public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { + // // using mapped BatchLoader to not have to deal with correct ordering of items + // batchLoaderRegistry.forTypePair(String.class, + // Assignee.class).registerMappedBatchLoader((Set keys, BatchLoaderEnvironment env) -> { + // return Mono.fromCallable(() -> { + // final @NotNull Map map = new HashMap<>(); + // for (String key : keys) { + // if ("Acrash".equalsIgnoreCase(key)) { + // throw new RuntimeException("Causing an error while loading assignee"); + // } + // map.put(key, new Assignee(key, "Name" + key)); + // } + // + // return map; + // }); + // }); + // } + + @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")); + tasks.add(new Task("T2", "Update dependencies", "A1")); + tasks.add(new Task("T3", "Document API", "A1")); + tasks.add(new Task("T4", "Merge community PRs", "A2")); + tasks.add(new Task("T5", "Plan more work", null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash")); + } + return tasks; + } + + // @SchemaMapping(typeName="Task") + // public @Nullable CompletableFuture assignee(final Task task, final + // DataLoader dataLoader) { + // if (task.assigneeId == null) { + // return null; + // } + // return dataLoader.load(task.assigneeId); + // } + + @BatchMapping(typeName = "Task") + public Mono> assignee(final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (final @NotNull Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + map.put(task.assigneeId, new Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + + return map; + }); + } + + @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 @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); + }); + } + + class Task { + private String id; + private String name; + private String assigneeId; + + public Task(final String id, final String name, final String assigneeId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + } + } + + class Assignee { + private String id; + private String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + class Project { + private String slug; + } + + enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} 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 index 6a85641fd95..f2ab84b1c20 100644 --- 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 @@ -1,6 +1,15 @@ 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 """ @@ -8,13 +17,33 @@ type Project { """ Unique string id used in URLs """ slug: ID! """ Project name """ - name: String! + 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 +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + enum ProjectStatus { """ Actively supported by the Spring team """ ACTIVE diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 3500a75d06c..71bac4b19ca 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(projects.sentrySpringJakarta) compileOnly(projects.sentryLogback) compileOnly(projects.sentryApacheHttpClient5) + compileOnly(projects.sentryGraphql) implementation(Config.Libs.springBoot3Starter) implementation(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(Config.Libs.springWeb) @@ -34,6 +35,7 @@ dependencies { 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..83bd014a71f 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.SentryDataFetcherExceptionHandler; 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({ + SentryDataFetcherExceptionHandler.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 32f76fbf7c2..a66af43031f 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -88,6 +88,49 @@ 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 sourceBuilderCustomizer ()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..c45adc18747 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryDgsSubscriptionHandler.java @@ -0,0 +1,29 @@ +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 reactor.core.publisher.Flux; + +public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + + @Override + public Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + 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..fbc8d2e1db6 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/graphql/SentryGraphqlConfiguration.java @@ -0,0 +1,38 @@ +package io.sentry.spring.jakarta.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.graphql.SentryInstrumentation; +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 { + + /** + * 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. + */ + @Bean + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() { + return (builder) -> + builder.configureGraphQl( + graphQlBuilder -> + graphQlBuilder.instrumentation( + new SentryInstrumentation(null, new SentrySpringSubscriptionHandler(), true))); + } + + @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..bf371f833ea --- /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 Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IHub hub, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + 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() + ) + } +} From 65fd0a367d3f460c564e9575f6edafa82fa7f6d0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Jul 2023 16:32:20 +0200 Subject: [PATCH 04/18] Fix build --- .../test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt | 1 + 1 file changed, 1 insertion(+) 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 7c90379a5fc..b4abb180500 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -99,6 +99,7 @@ class SentryInstrumentationTest { assertTrue(span.isFinished) assertEquals(SpanStatus.OK, span.status) } + } @Test fun `when transaction is active, and data fetcher throws, creates inner spans`() { From 0f2e9297a6d3617ba3b447ab320e1fd168c2668e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Jul 2023 16:34:06 +0200 Subject: [PATCH 05/18] Changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19b035c75f..765d88c1b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Features - Add TraceOrigin to Transactions and Spans ([#2803](https://github.com/getsentry/sentry-java/pull/2803)) +- Improve server side GraphQL support for spring-graphql and Nextflix DGS ([#2856](https://github.com/getsentry/sentry-java/pull/2856)) + - 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 From 53e14860fe3e4b9ec401bc353bdbd7efd8af7675 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Jul 2023 09:13:54 +0200 Subject: [PATCH 06/18] Attach request body for webflux; Cleanup samples --- .../io/sentry/graphql/ExceptionReporter.java | 13 +- .../sentry/graphql/SentryInstrumentation.java | 30 ++-- .../sentry/graphql/ExceptionReporterTest.kt | 8 +- .../graphql/SentryInstrumentationTest.kt | 2 +- .../netflix/dgs/NetlixDgsApplication.java | 2 +- .../jakarta/graphql/AssigneeController.java | 34 ++++ .../jakarta/graphql}/GreetingController.java | 2 +- .../jakarta/graphql}/ProjectController.java | 100 ++++-------- .../graphql/TaskCreatorController.java | 49 ++++++ .../main/resources/graphql/schema.graphqls | 12 ++ .../build.gradle.kts | 2 + .../jakarta/graphql/GreetingController.java | 19 +++ .../main/resources/graphql/schema.graphqls | 70 ++++++++ .../spring/boot/ProjectController.java | 153 ------------------ .../{ => graphql}/GreetingController.java | 2 +- .../main/resources/graphql/schema.graphqls | 12 ++ .../boot/graphql/AssigneeController.java | 34 ++++ .../boot/graphql}/GreetingController.java | 2 +- .../boot/graphql}/ProjectController.java | 100 ++++-------- .../boot/graphql/TaskCreatorController.java | 49 ++++++ .../main/resources/graphql/schema.graphqls | 12 ++ .../api/sentry-spring-jakarta.api | 3 +- .../graphql/SentryGraphqlConfiguration.java | 19 ++- sentry-spring/api/sentry-spring.api | 3 +- .../graphql/SentryGraphqlConfiguration.java | 19 ++- .../src/main/java/io/sentry/Breadcrumb.java | 4 +- 26 files changed, 434 insertions(+), 321 deletions(-) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java rename sentry-samples/{sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot => sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql}/GreetingController.java (89%) rename sentry-samples/{sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot => sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql}/ProjectController.java (56%) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java rename sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/{ => graphql}/GreetingController.java (92%) create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/AssigneeController.java rename sentry-samples/{sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta => sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql}/GreetingController.java (90%) rename sentry-samples/{sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta => sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql}/ProjectController.java (56%) create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/TaskCreatorController.java diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java index 6e1ee69a84f..7aa6c6cad4f 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -20,10 +20,10 @@ @ApiStatus.Internal public final class ExceptionReporter { - private final boolean isSpring; + private final boolean captureRequestBodyForNonSubscriptions; - public ExceptionReporter(final boolean isSpring) { - this.isSpring = isSpring; + public ExceptionReporter(final boolean captureRequestBodyForNonSubscriptions) { + this.captureRequestBodyForNonSubscriptions = captureRequestBodyForNonSubscriptions; } private static final @NotNull String MECHANISM_TYPE = "GraphqlInstrumentation"; @@ -73,13 +73,16 @@ private void setDetailsOnRequest( final @NotNull Request request) { request.setApiTarget("graphql"); - if (exceptionDetails.isSubscription() || !isSpring) { + if (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions) { final @NotNull Map data = new HashMap<>(); data.put("query", exceptionDetails.getQuery()); if (hub.getOptions().isSendDefaultPii()) { - data.put("variables", exceptionDetails.getVariables()); + Map variables = exceptionDetails.getVariables(); + if (variables != null && !variables.isEmpty()) { + data.put("variables", variables); + } } // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor 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 c533306c7aa..1c951de723f 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -59,7 +59,7 @@ public final class SentryInstrumentation extends SimpleInstrumentation { @Deprecated @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation() { - this(null, NoOpSubscriptionHandler.getInstance(), false); + this(null, NoOpSubscriptionHandler.getInstance(), true); } /** @@ -68,7 +68,7 @@ public SentryInstrumentation() { @Deprecated @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation(final @Nullable IHub hub) { - this(null, NoOpSubscriptionHandler.getInstance(), false); + this(null, NoOpSubscriptionHandler.getInstance(), true); } /** @@ -77,7 +77,7 @@ public SentryInstrumentation(final @Nullable IHub hub) { @Deprecated @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { - this(beforeSpan, NoOpSubscriptionHandler.getInstance(), false); + this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); } /** @@ -87,19 +87,25 @@ public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { @SuppressWarnings("InlineMeSuggester") public SentryInstrumentation( final @Nullable IHub hub, final @Nullable BeforeSpanCallback beforeSpan) { - this(beforeSpan, NoOpSubscriptionHandler.getInstance(), false); + this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); } /** * @param beforeSpan callback when a span is created * @param subscriptionHandler can report subscription errors - * @param isSpring true if using spring-graphql, false for Netflix DGS an non Spring + * @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 isSpring) { - this(beforeSpan, subscriptionHandler, new ExceptionReporter(isSpring)); + final boolean captureRequestBodyForNonSubscriptions) { + this( + beforeSpan, + subscriptionHandler, + new ExceptionReporter(captureRequestBodyForNonSubscriptions)); } @TestOnly @@ -117,11 +123,15 @@ public SentryInstrumentation( /** * @param subscriptionHandler can report subscription errors - * @param isSpring true if using spring-graphql, false for Netflix DGS an non Spring + * @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 isSpring) { - this(null, subscriptionHandler, isSpring); + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions) { + this(null, subscriptionHandler, captureRequestBodyForNonSubscriptions); } @Override diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index de80ef9d1fc..ea53b17e8b1 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -41,10 +41,10 @@ class ExceptionReporterTest { val query = """query greeting(name: "somename")""" val variables = mapOf("variableA" to "value a") - fun getSut(options: SentryOptions = SentryOptions(), isSpring: Boolean = false): ExceptionReporter { + fun getSut(options: SentryOptions = SentryOptions(), captureRequestBodyForNonSubscriptions: Boolean = true): ExceptionReporter { whenever(hub.options).thenReturn(options) scope = Scope(options) - val exceptionReporter = ExceptionReporter(isSpring) + val exceptionReporter = ExceptionReporter(captureRequestBodyForNonSubscriptions) executionResult = ExecutionResultImpl.newExecutionResult() .data("raw result") .addError( @@ -152,7 +152,7 @@ class ExceptionReporterTest { @Test fun `does not attach query or variables if spring`() { - val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, true) + 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( @@ -172,7 +172,7 @@ class ExceptionReporterTest { @Test fun `attaches query and variables if spring and subscription`() { - val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, true) + val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, false) exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, true), fixture.executionResult) verify(fixture.hub).captureEvent( 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 b4abb180500..19b7b3732b8 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -57,7 +57,7 @@ class SentryInstrumentationTest { val graphQLSchema = SchemaGenerator().makeExecutableSchema(SchemaParser().parse(schema), buildRuntimeWiring(dataFetcherThrows)) val graphQL = GraphQL.newGraphQL(graphQLSchema) - .instrumentation(SentryInstrumentation(beforeSpan, NoOpSubscriptionHandler.getInstance(), false)) + .instrumentation(SentryInstrumentation(beforeSpan, NoOpSubscriptionHandler.getInstance(), true)) .build() if (isTransactionActive) { 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 ccc6d6e3914..27c4c502b28 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 @@ -17,7 +17,7 @@ public static void main(String[] args) { @Bean SentryInstrumentation sentryInstrumentation() { - return new SentryInstrumentation(new SentryDgsSubscriptionHandler(), false); + return new SentryInstrumentation(new SentryDgsSubscriptionHandler(), true); } @Bean 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/src/main/java/io/sentry/samples/spring/boot/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java similarity index 89% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/GreetingController.java rename to sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java index 95ebabb65e2..bfc383c9122 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/GreetingController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java @@ -1,4 +1,4 @@ -package io.sentry.samples.spring.boot; +package io.sentry.samples.spring.boot.jakarta.graphql; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.QueryMapping; diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java similarity index 56% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java rename to sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java index 2d242efc081..2d4b686b916 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/ProjectController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java @@ -1,46 +1,23 @@ -package io.sentry.samples.spring.boot; +package io.sentry.samples.spring.boot.jakarta.graphql; import java.nio.file.NoSuchFileException; import java.time.Duration; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; 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.BatchMapping; 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; -import reactor.core.publisher.Mono; @Controller public class ProjectController { - // public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { - // // using mapped BatchLoader to not have to deal with correct ordering of items - // batchLoaderRegistry.forTypePair(String.class, - // Assignee.class).registerMappedBatchLoader((Set keys, BatchLoaderEnvironment env) -> { - // return Mono.fromCallable(() -> { - // final @NotNull Map map = new HashMap<>(); - // for (String key : keys) { - // if ("Acrash".equalsIgnoreCase(key)) { - // throw new RuntimeException("Causing an error while loading assignee"); - // } - // map.put(key, new Assignee(key, "Name" + key)); - // } - // - // return map; - // }); - // }); - // } - @QueryMapping public Project project(final @Argument String slug) throws Exception { if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { @@ -76,42 +53,17 @@ public String addProject(@Argument String slug) { @QueryMapping public List tasks(final @Argument String projectSlug) { List tasks = new ArrayList<>(); - tasks.add(new Task("T1", "Create a new API", "A3")); - tasks.add(new Task("T2", "Update dependencies", "A1")); - tasks.add(new Task("T3", "Document API", "A1")); - tasks.add(new Task("T4", "Merge community PRs", "A2")); - tasks.add(new Task("T5", "Plan more work", null)); + 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")); + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); } return tasks; } - // @SchemaMapping(typeName="Task") - // public @Nullable CompletableFuture assignee(final Task task, final - // DataLoader dataLoader) { - // if (task.assigneeId == null) { - // return null; - // } - // return dataLoader.load(task.assigneeId); - // } - - @BatchMapping(typeName = "Task") - public Mono> assignee(final @NotNull Set tasks) { - return Mono.fromCallable( - () -> { - final @NotNull Map map = new HashMap<>(); - for (final @NotNull Task task : tasks) { - if ("Acrash".equalsIgnoreCase(task.assigneeId)) { - throw new RuntimeException("Causing an error while loading assignee"); - } - map.put(task.assigneeId, new Assignee(task.assigneeId, "Name" + task.assigneeId)); - } - - return map; - }); - } - @SubscriptionMapping public Flux notifyNewTask(@Argument String projectSlug) { if ("crash".equalsIgnoreCase(projectSlug)) { @@ -121,6 +73,7 @@ public Flux notifyNewTask(@Argument String 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( @@ -129,25 +82,28 @@ public Flux notifyNewTask(@Argument String projectSlug) { 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); + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); }); } - class Task { - private String id; - private String name; - private String assigneeId; + 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) { + 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; } } - class Assignee { - private String id; - private String name; + public static class Assignee { + public String id; + public String name; public Assignee(final String id, final String name) { this.id = id; @@ -155,11 +111,21 @@ public Assignee(final String id, final String name) { } } - class Project { - private String slug; + 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; } - enum ProjectStatus { + public enum ProjectStatus { ACTIVE, COMMUNITY, INCUBATING, 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/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/graphql/schema.graphqls index f2ab84b1c20..d76aca4756a 100644 --- 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 @@ -34,6 +34,10 @@ type Task { assigneeId: String """ Assignee """ assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator } """ An Assignee """ @@ -44,6 +48,14 @@ type Assignee { name: String! } +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + enum ProjectStatus { """ Actively supported by the Spring team """ ACTIVE 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/src/main/java/io/sentry/samples/spring/boot/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java deleted file mode 100644 index d2fc000e14c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/ProjectController.java +++ /dev/null @@ -1,153 +0,0 @@ -package io.sentry.samples.spring.boot; - -import java.nio.file.NoSuchFileException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.UUID; -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.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.graphql.execution.BatchLoaderRegistry; -import org.springframework.stereotype.Controller; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -@Controller -public class ProjectController { - - public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { - // using mapped BatchLoader to not have to deal with correct ordering of items - batchLoaderRegistry - .forTypePair(String.class, Assignee.class) - .registerMappedBatchLoader( - (Set keys, BatchLoaderEnvironment env) -> { - return Mono.fromCallable( - () -> { - final @NotNull Map map = new HashMap<>(); - for (String key : keys) { - if ("Acrash".equalsIgnoreCase(key)) { - throw new RuntimeException("Causing an error while loading task"); - } - map.put(key, new Assignee(key, "Name" + key)); - } - - return map; - }); - }); - } - - @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")); - tasks.add(new Task("T2", "Update dependencies", "A1")); - tasks.add(new Task("T3", "Document API", "A1")); - tasks.add(new Task("T4", "Merge community PRs", "A2")); - tasks.add(new Task("T5", "Plan more work", null)); - if ("crash".equalsIgnoreCase(projectSlug)) { - tasks.add(new Task("T6", "Fix crash", "Acrash")); - } - return tasks; - } - - @SchemaMapping(typeName = "Task") - public @Nullable CompletableFuture assignee( - final Task task, final DataLoader dataLoader) { - if (task.assigneeId == null) { - return null; - } - return dataLoader.load(task.assigneeId); - } - - @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"; - Random random = new Random(); - return Flux.interval(Duration.ofSeconds(1)) - .map(num -> new Task("T" + random.nextInt(1000, 9999), "A new task arrived ", assigneeId)); - } - - class Task { - private String id; - private String name; - private String assigneeId; - - public Task(final String id, final String name, final String assigneeId) { - this.id = id; - this.name = name; - this.assigneeId = assigneeId; - } - } - - class Assignee { - private String id; - private String name; - - public Assignee(final String id, final String name) { - this.id = id; - this.name = name; - } - } - - class Project { - private String slug; - } - - enum ProjectStatus { - ACTIVE, - COMMUNITY, - INCUBATING, - ATTIC, - EOL; - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java similarity index 92% rename from sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java rename to sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java index a95e743a4c4..a7bf18be977 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/GreetingController.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java @@ -1,4 +1,4 @@ -package io.sentry.samples.spring.boot; +package io.sentry.samples.spring.boot.graphql; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.QueryMapping; 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 index f2ab84b1c20..d76aca4756a 100644 --- 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 @@ -34,6 +34,10 @@ type Task { assigneeId: String """ Assignee """ assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator } """ An Assignee """ @@ -44,6 +48,14 @@ type Assignee { name: String! } +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + enum ProjectStatus { """ Actively supported by the Spring team """ ACTIVE 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-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java similarity index 90% rename from sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java rename to sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java index 76cf5c03412..b252f8b9b0f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/GreetingController.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/GreetingController.java @@ -1,4 +1,4 @@ -package io.sentry.samples.spring.boot.jakarta; +package io.sentry.samples.spring.boot.graphql; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.QueryMapping; diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java similarity index 56% rename from sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java rename to sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java index f34fb94c454..fdffdbaf1d2 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProjectController.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/graphql/ProjectController.java @@ -1,46 +1,23 @@ -package io.sentry.samples.spring.boot.jakarta; +package io.sentry.samples.spring.boot.graphql; import java.nio.file.NoSuchFileException; import java.time.Duration; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; 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.BatchMapping; 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; -import reactor.core.publisher.Mono; @Controller public class ProjectController { - // public ProjectController(final BatchLoaderRegistry batchLoaderRegistry) { - // // using mapped BatchLoader to not have to deal with correct ordering of items - // batchLoaderRegistry.forTypePair(String.class, - // Assignee.class).registerMappedBatchLoader((Set keys, BatchLoaderEnvironment env) -> { - // return Mono.fromCallable(() -> { - // final @NotNull Map map = new HashMap<>(); - // for (String key : keys) { - // if ("Acrash".equalsIgnoreCase(key)) { - // throw new RuntimeException("Causing an error while loading assignee"); - // } - // map.put(key, new Assignee(key, "Name" + key)); - // } - // - // return map; - // }); - // }); - // } - @QueryMapping public Project project(final @Argument String slug) throws Exception { if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { @@ -76,42 +53,17 @@ public String addProject(@Argument String slug) { @QueryMapping public List tasks(final @Argument String projectSlug) { List tasks = new ArrayList<>(); - tasks.add(new Task("T1", "Create a new API", "A3")); - tasks.add(new Task("T2", "Update dependencies", "A1")); - tasks.add(new Task("T3", "Document API", "A1")); - tasks.add(new Task("T4", "Merge community PRs", "A2")); - tasks.add(new Task("T5", "Plan more work", null)); + 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")); + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); } return tasks; } - // @SchemaMapping(typeName="Task") - // public @Nullable CompletableFuture assignee(final Task task, final - // DataLoader dataLoader) { - // if (task.assigneeId == null) { - // return null; - // } - // return dataLoader.load(task.assigneeId); - // } - - @BatchMapping(typeName = "Task") - public Mono> assignee(final @NotNull Set tasks) { - return Mono.fromCallable( - () -> { - final @NotNull Map map = new HashMap<>(); - for (final @NotNull Task task : tasks) { - if ("Acrash".equalsIgnoreCase(task.assigneeId)) { - throw new RuntimeException("Causing an error while loading assignee"); - } - map.put(task.assigneeId, new Assignee(task.assigneeId, "Name" + task.assigneeId)); - } - - return map; - }); - } - @SubscriptionMapping public Flux notifyNewTask(@Argument String projectSlug) { if ("crash".equalsIgnoreCase(projectSlug)) { @@ -121,6 +73,7 @@ public Flux notifyNewTask(@Argument String 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( @@ -129,25 +82,28 @@ public Flux notifyNewTask(@Argument String projectSlug) { 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); + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); }); } - class Task { - private String id; - private String name; - private String assigneeId; + 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) { + 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; } } - class Assignee { - private String id; - private String name; + public static class Assignee { + public String id; + public String name; public Assignee(final String id, final String name) { this.id = id; @@ -155,11 +111,21 @@ public Assignee(final String id, final String name) { } } - class Project { - private String slug; + 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; } - enum ProjectStatus { + public enum ProjectStatus { ACTIVE, COMMUNITY, INCUBATING, 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/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot/src/main/resources/graphql/schema.graphqls index f2ab84b1c20..d76aca4756a 100644 --- 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 @@ -34,6 +34,10 @@ type Task { assigneeId: String """ Assignee """ assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator } """ An Assignee """ @@ -44,6 +48,14 @@ type Assignee { name: String! } +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + enum ProjectStatus { """ Actively supported by the Spring team """ ACTIVE diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index e52b4041cb3..8f35b6075f2 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -123,7 +123,8 @@ 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 sourceBuilderCustomizer ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; + 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 { 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 index fbc8d2e1db6..207ef1b00f8 100644 --- 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 @@ -2,6 +2,7 @@ import com.jakewharton.nopen.annotation.Open; 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; @@ -12,17 +13,29 @@ @Open public class SentryGraphqlConfiguration { + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebmvc() { + return sourceBuilderCustomizer(false); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { + 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. */ - @Bean - public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() { + private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { return (builder) -> builder.configureGraphQl( graphQlBuilder -> graphQlBuilder.instrumentation( - new SentryInstrumentation(null, new SentrySpringSubscriptionHandler(), true))); + new SentryInstrumentation( + null, new SentrySpringSubscriptionHandler(), captureRequestBody))); } @Bean diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index c05ef7bd894..7efeb59f569 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -123,7 +123,8 @@ 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 sourceBuilderCustomizer ()Lorg/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer; + 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 { 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 index 30592e944ed..f0f55d16115 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java @@ -2,6 +2,7 @@ import com.jakewharton.nopen.annotation.Open; 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; @@ -12,17 +13,29 @@ @Open public class SentryGraphqlConfiguration { + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebmvc() { + return sourceBuilderCustomizer(false); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { + 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. */ - @Bean - public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() { + private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { return (builder) -> builder.configureGraphQl( graphQlBuilder -> graphQlBuilder.instrumentation( - new SentryInstrumentation(null, new SentrySpringSubscriptionHandler(), true))); + new SentryInstrumentation( + null, new SentrySpringSubscriptionHandler(), captureRequestBody))); } @Bean diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index bd15594a5a4..7e1078e1dfc 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -224,8 +224,8 @@ public static Breadcrumb fromMap( * Creates a breadcrumb for a GraphQL data fetcher. * * @param path - the name of the GraphQL operation - * @param field - the type of GraphQL operation (e.g. query, mutation, subscription) - * @param type - the ID 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 */ From 3ca6c276b653b7acfae7ac9ade1fe40e6a3ad253 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Jul 2023 12:27:11 +0200 Subject: [PATCH 07/18] Use toString for data loader keys in breadcrumb --- .../jakarta/graphql/ProjectController.java | 5 +++ .../boot/graphql/ProjectController.java | 5 +++ .../src/main/java/io/sentry/Breadcrumb.java | 8 +++- .../src/test/java/io/sentry/BreadcrumbTest.kt | 40 +++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) 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 index 2d4b686b916..63790bca628 100644 --- 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 @@ -99,6 +99,11 @@ public Task( this.assigneeId = assigneeId; this.creatorId = creatorId; } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } } public static class Assignee { 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 index fdffdbaf1d2..70e65ad7e5e 100644 --- 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 @@ -99,6 +99,11 @@ public Task( this.assigneeId = assigneeId; this.creatorId = creatorId; } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } } public static class Assignee { diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 7e1078e1dfc..56dbfdf692a 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; @@ -278,7 +280,11 @@ public static Breadcrumb fromMap( breadcrumb.setType("graphql"); breadcrumb.setCategory("graphql.data_loader"); - breadcrumb.setData("keys", keys); + 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()); 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() + } + } } From 86e964abcb610f410d4789f177c494769319c0b9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Jul 2023 16:11:51 +0200 Subject: [PATCH 08/18] Fix key naming for breadcrumb --- .../graphql/SentryInstrumentationAnotherTest.kt | 8 ++++---- sentry/src/main/java/io/sentry/Breadcrumb.java | 12 ++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt index 2e53100d1f1..2b78036ef37 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt @@ -235,10 +235,10 @@ class SentryInstrumentationAnotherTest { org.mockito.kotlin.check { breadcrumb -> assertEquals("graphql", breadcrumb.type) assertEquals("graphql.fetcher", breadcrumb.category) - assertEquals("/child", breadcrumb.data["graphql.path"]) - assertEquals("myFieldName", breadcrumb.data["graphql.field"]) - assertEquals("MyResponseType", breadcrumb.data["graphql.type"]) - assertEquals("QUERY", breadcrumb.data["graphql.object_type"]) + assertEquals("/child", breadcrumb.data["path"]) + assertEquals("myFieldName", breadcrumb.data["field"]) + assertEquals("MyResponseType", breadcrumb.data["type"]) + assertEquals("QUERY", breadcrumb.data["object_type"]) } ) } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 56dbfdf692a..fe2055c336c 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -242,20 +242,16 @@ public static Breadcrumb fromMap( breadcrumb.setCategory("graphql.fetcher"); if (path != null) { - // TODO key? - breadcrumb.setData("graphql.path", path); + breadcrumb.setData("path", path); } if (field != null) { - // TODO key? - breadcrumb.setData("graphql.field", field); + breadcrumb.setData("field", field); } if (type != null) { - // TODO key? - breadcrumb.setData("graphql.type", type); + breadcrumb.setData("type", type); } if (objectType != null) { - // TODO key? - breadcrumb.setData("graphql.object_type", objectType); + breadcrumb.setData("object_type", objectType); } return breadcrumb; From 2864d0cfc68d5cd12b60531180540230fc7bcc59 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 28 Jul 2023 07:14:05 +0200 Subject: [PATCH 09/18] move changelog --- CHANGELOG.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fdf3228aa..4c4e3f03875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,20 @@ # Changelog +## Unreleased + +### Features + +- Improve server side GraphQL support for spring-graphql and Nextflix DGS ([#2856](https://github.com/getsentry/sentry-java/pull/2856)) + - 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` + ## 6.27.0 ### Features - Add TraceOrigin to Transactions and Spans ([#2803](https://github.com/getsentry/sentry-java/pull/2803)) -- Improve server side GraphQL support for spring-graphql and Nextflix DGS ([#2856](https://github.com/getsentry/sentry-java/pull/2856)) - - 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 From 797e46c0554c7534791859dbd8abacc5be077d8f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 10:14:40 +0200 Subject: [PATCH 10/18] Keep legacy exception handler to not break users upgrading the SDK --- sentry-graphql/api/sentry-graphql.api | 6 +++ .../SentryDataFetcherExceptionHandler.java | 31 +++++++++----- ...tryGenericDataFetcherExceptionHandler.java | 36 ++++++++++++++++ .../SentryDataFetcherExceptionHandlerTest.kt | 22 +++------- ...yGenericDataFetcherExceptionHandlerTest.kt | 41 +++++++++++++++++++ .../netflix/dgs/NetlixDgsApplication.java | 6 +-- .../boot/jakarta/SentryAutoConfiguration.java | 4 +- .../spring/boot/SentryAutoConfiguration.java | 4 +- 8 files changed, 117 insertions(+), 33 deletions(-) create mode 100644 sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java create mode 100644 sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index 0db65abcc5c..b3bf9d3de3d 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -35,6 +35,12 @@ public final class io/sentry/graphql/SentryDataFetcherExceptionHandler : graphql 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; 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 86001668bf8..c9b3a32ad30 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java @@ -1,35 +1,46 @@ package io.sentry.graphql; +import static io.sentry.TypeCheckHint.GRAPHQL_HANDLER_PARAMETERS; + import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.DataFetcherExceptionHandlerParameters; import graphql.execution.DataFetcherExceptionHandlerResult; +import io.sentry.Hint; +import io.sentry.HubAdapter; import io.sentry.IHub; +import io.sentry.util.Objects; 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. + * + * @deprecated please use {@link SentryGenericDataFetcherExceptionHandler} in combination with + * {@link SentryInstrumentation} instead. */ +@Deprecated public final class SentryDataFetcherExceptionHandler implements DataFetcherExceptionHandler { - private final @NotNull SentryGraphqlExceptionHandler handler; + private final @NotNull IHub hub; + private final @NotNull DataFetcherExceptionHandler delegate; public SentryDataFetcherExceptionHandler( - final @Nullable IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { - this.handler = new SentryGraphqlExceptionHandler(delegate); + final @NotNull IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { + this.hub = Objects.requireNonNull(hub, "hub is required"); + this.delegate = Objects.requireNonNull(delegate, "delegate is required"); } public SentryDataFetcherExceptionHandler(final @NotNull DataFetcherExceptionHandler delegate) { - this(null, delegate); + this(HubAdapter.getInstance(), delegate); } @Override @SuppressWarnings("deprecation") - public @Nullable DataFetcherExceptionHandlerResult onException( + public DataFetcherExceptionHandlerResult onException( final @NotNull DataFetcherExceptionHandlerParameters handlerParameters) { - return handler.onException( - handlerParameters.getException(), - handlerParameters.getDataFetchingEnvironment(), - handlerParameters); + final Hint hint = new Hint(); + hint.set(GRAPHQL_HANDLER_PARAMETERS, handlerParameters); + + hub.captureException(handlerParameters.getException(), hint); + return delegate.onException(handlerParameters); } } 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/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt index 393bc09edf7..0157c638199 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt @@ -1,38 +1,28 @@ package io.sentry.graphql -import graphql.GraphQLContext import graphql.execution.DataFetcherExceptionHandler import graphql.execution.DataFetcherExceptionHandlerParameters -import graphql.schema.DataFetchingEnvironmentImpl +import io.sentry.Hint import io.sentry.IHub +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull class SentryDataFetcherExceptionHandlerTest { @Test - fun `collects exception into GraphQLContext and invokes delegate`() { + fun `passes exception to Sentry and invokes delegate`() { val hub = mock() val delegate = mock() val handler = SentryDataFetcherExceptionHandler(hub, delegate) val exception = RuntimeException() - val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).dataFetchingEnvironment( - DataFetchingEnvironmentImpl.newDataFetchingEnvironment().graphQLContext( - GraphQLContext.of( - emptyMap() - ) - ).build() - ).build() + val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).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(hub).captureException(eq(exception), anyOrNull()) verify(delegate).onException(parameters) } } 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-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 27c4c502b28..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,7 +1,7 @@ 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; @@ -21,8 +21,8 @@ SentryInstrumentation sentryInstrumentation() { } @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-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 83bd014a71f..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 @@ -10,7 +10,7 @@ import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; -import io.sentry.graphql.SentryDataFetcherExceptionHandler; +import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; import io.sentry.spring.jakarta.ContextTagsEventProcessor; @@ -161,7 +161,7 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { @Import(SentryGraphqlConfiguration.class) @Open @ConditionalOnClass({ - SentryDataFetcherExceptionHandler.class, + SentryGraphqlExceptionHandler.class, DataFetcherExceptionResolverAdapter.class, GraphQLError.class }) 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 9b1111ff077..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 @@ -10,7 +10,7 @@ import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; -import io.sentry.graphql.SentryDataFetcherExceptionHandler; +import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; import io.sentry.spring.ContextTagsEventProcessor; @@ -161,7 +161,7 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { @Import(SentryGraphqlConfiguration.class) @Open @ConditionalOnClass({ - SentryDataFetcherExceptionHandler.class, + SentryGraphqlExceptionHandler.class, DataFetcherExceptionResolverAdapter.class, GraphQLError.class }) From 2ce76e0d8bbe58737805004a577330cfa41d284e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 13:20:58 +0200 Subject: [PATCH 11/18] add integrations to package storage --- .../io/sentry/graphql/SentryDataFetcherExceptionHandler.java | 2 ++ .../spring/jakarta/graphql/SentryGraphqlConfiguration.java | 2 ++ .../sentry/spring/graphql/SentryDgsSubscriptionHandler.java | 5 +++++ .../io/sentry/spring/graphql/SentryGraphqlConfiguration.java | 2 ++ 4 files changed, 11 insertions(+) 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 c9b3a32ad30..1ce3412d720 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java @@ -8,6 +8,7 @@ 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; @@ -27,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-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 index 207ef1b00f8..0355f3cd009 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -30,6 +31,7 @@ public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { * resolver adapter below. This way Springs handler can still forward to other resolver adapters. */ private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringGrahQL"); return (builder) -> builder.configureGraphQl( graphQlBuilder -> 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 index 63236c54c93..4050b40d415 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,10 @@ public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + public SentryDgsSubscriptionHandler() { + SentryIntegrationPackageStorage.getInstance().addIntegration("NetflixDGSGrahQL"); + } + @Override public Object onSubscriptionResult( final @NotNull Object result, 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 index f0f55d16115..62fd00f6282 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java @@ -1,6 +1,7 @@ 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; @@ -30,6 +31,7 @@ public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { * resolver adapter below. This way Springs handler can still forward to other resolver adapters. */ private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringGrahQL"); return (builder) -> builder.configureGraphQl( graphQlBuilder -> From eada4d329ccdb984ceecdb2acb7a3447429bf813 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 15:50:09 +0200 Subject: [PATCH 12/18] Improve changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4e3f03875..1fa78debbac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - 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) From 34e560b28042235b7f750a2541e6809c9e20c65f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 15:52:59 +0200 Subject: [PATCH 13/18] remove overload that is not needed --- sentry-graphql/api/sentry-graphql.api | 1 - .../main/java/io/sentry/graphql/SentryInstrumentation.java | 7 ------- 2 files changed, 8 deletions(-) diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index b3bf9d3de3d..5d4b907f146 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -58,7 +58,6 @@ public final class io/sentry/graphql/SentryInstrumentation : graphql/execution/i 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 beginSubscribedFieldEvent (Lgraphql/execution/instrumentation/parameters/InstrumentationFieldParameters;)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; 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 1c951de723f..f1010f18f3b 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -12,7 +12,6 @@ import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; -import graphql.execution.instrumentation.parameters.InstrumentationFieldParameters; import graphql.language.OperationDefinition; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; @@ -323,12 +322,6 @@ public CompletableFuture instrumentExecutionResult( return tmpResult; } - @Override - public InstrumentationContext beginSubscribedFieldEvent( - InstrumentationFieldParameters parameters) { - return super.beginSubscribedFieldEvent(parameters); - } - private void finish( final @NotNull ISpan span, final @NotNull DataFetchingEnvironment environment, From dbd68dc8bff8b5fe8f420f1676ee13fce7afc3d5 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 15:57:58 +0200 Subject: [PATCH 14/18] Better deprecation message --- .../io/sentry/graphql/SentryDataFetcherExceptionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1ce3412d720..1e06d0b3220 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java @@ -17,7 +17,7 @@ * exception handler. * * @deprecated please use {@link SentryGenericDataFetcherExceptionHandler} in combination with - * {@link SentryInstrumentation} instead. + * {@link SentryInstrumentation} instead for better error reporting. */ @Deprecated public final class SentryDataFetcherExceptionHandler implements DataFetcherExceptionHandler { From ff29b475d1263491cc3f37d061019288a48e0c3f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 15:59:03 +0200 Subject: [PATCH 15/18] Use lock for exceptions in context --- .../graphql/SentryGraphqlExceptionHandler.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java index 283011a16c7..527935f863e 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java @@ -16,6 +16,7 @@ @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; @@ -29,11 +30,13 @@ public SentryGraphqlExceptionHandler(final @Nullable DataFetcherExceptionHandler if (environment != null) { final @Nullable GraphQLContext graphQlContext = environment.getGraphQlContext(); if (graphQlContext != null) { - final @NotNull List exceptions = - graphQlContext.getOrDefault( - SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); - exceptions.add(throwable); - graphQlContext.put(SENTRY_EXCEPTIONS_CONTEXT_KEY, exceptions); + 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) { From 2e01876d298c2b11dd42ebde7d27419a8264ad49 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 16:03:04 +0200 Subject: [PATCH 16/18] Use StringUtils --- .../main/java/io/sentry/graphql/SentryInstrumentation.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 f1010f18f3b..c5f36d7228e 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -205,10 +205,7 @@ public CompletableFuture instrumentExecutionResult( } final @Nullable Map extensions = error.getExtensions(); if (extensions != null) { - Object extensionErrorType = extensions.get("errorType"); - if (extensionErrorType != null) { - return extensionErrorType.toString(); - } + return StringUtils.toString(extensions.get("errorType")); } return null; } From e50321bddc389cb9dc24d2f494df67d23d7bdc73 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Aug 2023 16:04:39 +0200 Subject: [PATCH 17/18] More detailed integration names --- .../spring/jakarta/graphql/SentryDgsSubscriptionHandler.java | 5 +++++ .../spring/jakarta/graphql/SentryGraphqlConfiguration.java | 3 ++- .../sentry/spring/graphql/SentryDgsSubscriptionHandler.java | 2 +- .../io/sentry/spring/graphql/SentryGraphqlConfiguration.java | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) 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 index c45adc18747..ec741236441 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,10 @@ public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + public SentryDgsSubscriptionHandler() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring6NetflixDGSGrahQL"); + } + @Override public Object onSubscriptionResult( final @NotNull Object result, 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 index 0355f3cd009..9d8224e88c9 100644 --- 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 @@ -17,12 +17,14 @@ 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); } @@ -31,7 +33,6 @@ public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { * resolver adapter below. This way Springs handler can still forward to other resolver adapters. */ private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { - SentryIntegrationPackageStorage.getInstance().addIntegration("SpringGrahQL"); return (builder) -> builder.configureGraphQl( graphQlBuilder -> 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 index 4050b40d415..3f3dcb20cda 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -11,7 +11,7 @@ public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { public SentryDgsSubscriptionHandler() { - SentryIntegrationPackageStorage.getInstance().addIntegration("NetflixDGSGrahQL"); + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring5NetflixDGSGrahQL"); } @Override 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 index 62fd00f6282..b938e6817da 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryGraphqlConfiguration.java @@ -17,12 +17,14 @@ 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); } @@ -31,7 +33,6 @@ public GraphQlSourceBuilderCustomizer sourceBuilderCustomizerWebflux() { * resolver adapter below. This way Springs handler can still forward to other resolver adapters. */ private GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(final boolean captureRequestBody) { - SentryIntegrationPackageStorage.getInstance().addIntegration("SpringGrahQL"); return (builder) -> builder.configureGraphQl( graphQlBuilder -> From ab741eb6827c6656d5db9c59de37e1e99c7da9d7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 3 Aug 2023 11:17:22 +0200 Subject: [PATCH 18/18] CR changes --- .../io/sentry/graphql/ExceptionReporter.java | 32 +++--- .../graphql/NoOpSubscriptionHandler.java | 15 +-- .../sentry/graphql/ExceptionReporterTest.kt | 66 +++++++---- .../sentry-samples-netflix-dgs/README.md | 90 +++++++++++++++ .../samples/netflix/dgs/ShowsDatafetcher.java | 2 +- .../README.md | 103 +++++++++++++++++ .../README.md | 25 +++++ .../README.md | 25 +++++ .../sentry-samples-spring-boot/README.md | 104 ++++++++++++++++++ .../graphql/SentryDgsSubscriptionHandler.java | 6 +- .../SentrySpringSubscriptionHandler.java | 6 +- .../graphql/SentryDgsSubscriptionHandler.java | 6 +- .../SentrySpringSubscriptionHandler.java | 6 +- 13 files changed, 431 insertions(+), 55 deletions(-) create mode 100644 sentry-samples/sentry-samples-netflix-dgs/README.md diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java index 7aa6c6cad4f..30ccb214256 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -8,6 +8,7 @@ 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; @@ -33,20 +34,20 @@ public void captureThrowable( final @NotNull ExceptionDetails exceptionDetails, final @Nullable ExecutionResult result) { final @NotNull IHub hub = exceptionDetails.getHub(); - final Mechanism mechanism = new Mechanism(); + final @NotNull Mechanism mechanism = new Mechanism(); mechanism.setType(MECHANISM_TYPE); mechanism.setHandled(false); - final Throwable mechanismException = + final @NotNull Throwable mechanismException = new ExceptionMechanismException(mechanism, throwable, Thread.currentThread()); - final SentryEvent event = new SentryEvent(mechanismException); + final @NotNull SentryEvent event = new SentryEvent(mechanismException); event.setLevel(SentryLevel.FATAL); - final Hint hint = new Hint(); + final @NotNull Hint hint = new Hint(); setRequestDetailsOnEvent(hub, exceptionDetails, event); - if (result != null) { - @NotNull Response response = new Response(); - Map responseBody = result.toSpecification(); + if (result != null && isAllowedToAttachBody(hub)) { + final @NotNull Response response = new Response(); + final @NotNull Map responseBody = result.toSpecification(); response.setData(responseBody); event.getContexts().setResponse(response); } @@ -54,6 +55,12 @@ public void captureThrowable( 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, @@ -73,16 +80,15 @@ private void setDetailsOnRequest( final @NotNull Request request) { request.setApiTarget("graphql"); - if (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions) { + if (isAllowedToAttachBody(hub) + && (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions)) { final @NotNull Map data = new HashMap<>(); data.put("query", exceptionDetails.getQuery()); - if (hub.getOptions().isSendDefaultPii()) { - Map variables = exceptionDetails.getVariables(); - if (variables != null && !variables.isEmpty()) { - data.put("variables", variables); - } + final @Nullable Map variables = exceptionDetails.getVariables(); + if (variables != null && !variables.isEmpty()) { + data.put("variables", variables); } // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java index 598bd18bd75..df241ce35b2 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java @@ -2,23 +2,24 @@ import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; import io.sentry.IHub; +import org.jetbrains.annotations.NotNull; public final class NoOpSubscriptionHandler implements SentrySubscriptionHandler { - private static final NoOpSubscriptionHandler instance = new NoOpSubscriptionHandler(); + private static final @NotNull NoOpSubscriptionHandler instance = new NoOpSubscriptionHandler(); private NoOpSubscriptionHandler() {} - public static NoOpSubscriptionHandler getInstance() { + public static @NotNull NoOpSubscriptionHandler getInstance() { return instance; } @Override - public Object onSubscriptionResult( - Object result, - IHub hub, - ExceptionReporter exceptionReporter, - InstrumentationFieldFetchParameters parameters) { + public @NotNull Object onSubscriptionResult( + @NotNull Object result, + @NotNull IHub hub, + @NotNull ExceptionReporter exceptionReporter, + @NotNull InstrumentationFieldFetchParameters parameters) { return result; } } diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index ea53b17e8b1..af469f8e021 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -33,6 +33,10 @@ 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 @@ -41,7 +45,7 @@ class ExceptionReporterTest { val query = """query greeting(name: "somename")""" val variables = mapOf("variableA" to "value a") - fun getSut(options: SentryOptions = SentryOptions(), captureRequestBodyForNonSubscriptions: Boolean = true): ExceptionReporter { + fun getSut(options: SentryOptions = defaultOptions, captureRequestBodyForNonSubscriptions: Boolean = true): ExceptionReporter { whenever(hub.options).thenReturn(options) scope = Scope(options) val exceptionReporter = ExceptionReporter(captureRequestBodyForNonSubscriptions) @@ -94,7 +98,7 @@ class ExceptionReporterTest { assertNotNull(it.request) val request = it.request!! val data = request.data as Map - assertNull(data["variables"]) + assertEquals(fixture.variables, data["variables"]) assertEquals(fixture.query, data["query"]) assertEquals("graphql", request.apiTarget) }, @@ -103,8 +107,34 @@ class ExceptionReporterTest { } @Test - fun `attaches variables if sendDefaultPii = true`() { - val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }) + 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( @@ -115,9 +145,7 @@ class ExceptionReporterTest { 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"]) + assertNull(request.data) assertEquals("graphql", request.apiTarget) }, any() @@ -125,10 +153,8 @@ class ExceptionReporterTest { } @Test - fun `uses requests on scope as base`() { - val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }) - val headers = mapOf("some-header" to "some-header-value") - fixture.scope.request = Request().also { it.headers = headers } + 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( @@ -137,22 +163,18 @@ class ExceptionReporterTest { 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) + assertNotNull(it.request) + val request = it.request!! + assertNull(request.data) + assertEquals("graphql", 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(SentryOptions().also { it.isSendDefaultPii = true }, false) + 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( @@ -172,7 +194,7 @@ class ExceptionReporterTest { @Test fun `attaches query and variables if spring and subscription`() { - val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, false) + val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, true), fixture.executionResult) verify(fixture.hub).captureEvent( 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/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 5c5cf5d4b2f..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 @@ -63,7 +63,7 @@ public Publisher notifyNewShow(Integer releaseYear) { } @DgsData(parentType = "Show", field = "actor") - public CompletableFuture director(DataFetchingEnvironment dfe) { + public CompletableFuture actor(DataFetchingEnvironment dfe) { DataLoader dataLoader = dfe.getDataLoader("actors"); // does not work, thanks docs 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-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/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/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-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 index ec741236441..83a090954d1 100644 --- 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 @@ -15,16 +15,16 @@ public SentryDgsSubscriptionHandler() { } @Override - public Object onSubscriptionResult( + public @NotNull Object onSubscriptionResult( final @NotNull Object result, final @NotNull IHub hub, final @NotNull ExceptionReporter exceptionReporter, final @NotNull InstrumentationFieldFetchParameters parameters) { if (result instanceof Flux) { - Flux flux = (Flux) result; + final @NotNull Flux flux = (Flux) result; return flux.doOnError( throwable -> { - ExceptionReporter.ExceptionDetails exceptionDetails = + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); exceptionReporter.captureThrowable(throwable, exceptionDetails, null); }); 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 index bf371f833ea..4c519810353 100644 --- 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 @@ -11,16 +11,16 @@ public final class SentrySpringSubscriptionHandler implements SentrySubscriptionHandler { @Override - public Object onSubscriptionResult( + public @NotNull Object onSubscriptionResult( final @NotNull Object result, final @NotNull IHub hub, final @NotNull ExceptionReporter exceptionReporter, final @NotNull InstrumentationFieldFetchParameters parameters) { if (result instanceof Flux) { - Flux flux = (Flux) result; + final @NotNull Flux flux = (Flux) result; return flux.doOnError( throwable -> { - ExceptionReporter.ExceptionDetails exceptionDetails = + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); if (throwable instanceof SubscriptionPublisherException && throwable.getCause() != 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 index 3f3dcb20cda..fb4e09e889d 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentryDgsSubscriptionHandler.java @@ -15,16 +15,16 @@ public SentryDgsSubscriptionHandler() { } @Override - public Object onSubscriptionResult( + public @NotNull Object onSubscriptionResult( final @NotNull Object result, final @NotNull IHub hub, final @NotNull ExceptionReporter exceptionReporter, final @NotNull InstrumentationFieldFetchParameters parameters) { if (result instanceof Flux) { - Flux flux = (Flux) result; + final @NotNull Flux flux = (Flux) result; return flux.doOnError( throwable -> { - ExceptionReporter.ExceptionDetails exceptionDetails = + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); exceptionReporter.captureThrowable(throwable, exceptionDetails, null); }); 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 index 6bcd2de4b8c..a7809eb230b 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java +++ b/sentry-spring/src/main/java/io/sentry/spring/graphql/SentrySpringSubscriptionHandler.java @@ -11,16 +11,16 @@ public final class SentrySpringSubscriptionHandler implements SentrySubscriptionHandler { @Override - public Object onSubscriptionResult( + public @NotNull Object onSubscriptionResult( final @NotNull Object result, final @NotNull IHub hub, final @NotNull ExceptionReporter exceptionReporter, final @NotNull InstrumentationFieldFetchParameters parameters) { if (result instanceof Flux) { - Flux flux = (Flux) result; + final @NotNull Flux flux = (Flux) result; return flux.doOnError( throwable -> { - ExceptionReporter.ExceptionDetails exceptionDetails = + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = new ExceptionReporter.ExceptionDetails(hub, parameters.getEnvironment(), true); if (throwable instanceof SubscriptionPublisherException && throwable.getCause() != null) {