diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java index c316774c045..ebecb0cb521 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -25,6 +25,7 @@ import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.TypeCheckHint; +import io.sentry.util.ExceptionUtils; import io.sentry.util.StringUtils; import java.util.Arrays; import java.util.List; @@ -123,8 +124,7 @@ public void instrumentExecutionResultComplete( final @NotNull List errors = result.getErrors(); if (errors != null) { for (GraphQLError error : errors) { - String errorType = getErrorType(error); - if (!isIgnored(errorType)) { + if (!isIgnored(error)) { exceptionReporter.captureThrowable( new RuntimeException(error.getMessage()), new ExceptionReporter.ExceptionDetails( @@ -154,19 +154,35 @@ private boolean isIgnored(final @Nullable String errorType) { || ignoredErrorTypes.contains(errorType); } - private @Nullable String getErrorType(final @Nullable GraphQLError error) { + private boolean isIgnored(final @Nullable GraphQLError error) { if (error == null) { - return null; + return false; } final @Nullable ErrorClassification errorType = error.getErrorType(); if (errorType != null) { - return errorType.toString(); + if (isIgnored(errorType.toString())) { + return true; + } + try { + final @Nullable Object specification = errorType.toSpecification(error); + if (specification instanceof String && isIgnored((String) specification)) { + return true; + } + if (specification instanceof Map) { + final @Nullable Object specificationType = ((Map) specification).get("type"); + return specificationType instanceof String && isIgnored((String) specificationType); + } + } catch (Throwable throwable) { + ExceptionUtils.rethrowIfFatal(throwable); + // Fall back to capturing the error if a custom classification cannot be serialized. + } + return false; } final @Nullable Map extensions = error.getExtensions(); if (extensions != null) { - return StringUtils.toString(extensions.get("errorType")); + return isIgnored(StringUtils.toString(extensions.get("errorType"))); } - return null; + return false; } public void beginExecuteOperation( diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt new file mode 100644 index 00000000000..be27d267d42 --- /dev/null +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt @@ -0,0 +1,150 @@ +package io.sentry.graphql + +import graphql.ErrorClassification +import graphql.ExecutionResultImpl +import graphql.GraphQLContext +import graphql.GraphQLError +import graphql.GraphqlErrorException +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import io.sentry.IScopes +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryGraphqlInstrumentationTest { + private val scopes = mock() + private val exceptionReporter = mock() + private val parameters = mock() + + init { + whenever(parameters.graphQLContext) + .thenReturn( + GraphQLContext.newContext() + .of(SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY, scopes) + .build() + ) + } + + @Test + fun `ignores error by classification string`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("type" to "OTHER_ERROR"))), + ignoredErrorTypes = listOf("SOME_ERROR"), + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `ignores error by classification specification type`() { + captureError( + error( + CustomErrorClassification( + "graphql.validation.interpolation.ResourceBundleMessageInterpolator\$ValidationErrorType@1", + mapOf("type" to "ExtendedValidationError"), + ) + ), + ignoredErrorTypes = listOf("ExtendedValidationError"), + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `ignores error by string classification specification`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", "SPECIFICATION_ERROR")), + ignoredErrorTypes = listOf("SPECIFICATION_ERROR"), + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification has no type`() { + captureError(error(CustomErrorClassification("SOME_ERROR", mapOf("constraint" to "@Size")))) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification is null`() { + captureError(error(CustomErrorClassification("SOME_ERROR", null))) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification type is unexpected`() { + captureError(error(CustomErrorClassification("SOME_ERROR", mapOf("type" to 42)))) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification throws`() { + captureError(error(ThrowingErrorClassification)) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `ignores error by extensions when classification is null`() { + val error = mock() + whenever(error.errorType).thenReturn(null) + whenever(error.extensions).thenReturn(mapOf("errorType" to "EXTENSION_ERROR")) + + captureError(error, ignoredErrorTypes = listOf("EXTENSION_ERROR")) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures non-ignored error`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("type" to "SPECIFICATION_ERROR"))), + ignoredErrorTypes = listOf("OTHER_ERROR"), + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + private fun captureError(error: GraphQLError, ignoredErrorTypes: List = emptyList()) { + val instrumentation = + SentryGraphqlInstrumentation( + null, + NoOpSubscriptionHandler.getInstance(), + exceptionReporter, + ignoredErrorTypes, + "manual.test", + ) + val result = ExecutionResultImpl.newExecutionResult().addError(error).build() + + instrumentation.instrumentExecutionResultComplete(parameters, result, null) + } + + private fun error(errorClassification: ErrorClassification): GraphQLError = + GraphqlErrorException.newErrorException() + .message("exception message") + .errorClassification(errorClassification) + .build() + + private class CustomErrorClassification( + private val stringValue: String, + private val specification: Any?, + ) : ErrorClassification { + override fun toSpecification(error: GraphQLError): Any? = specification + + override fun toString(): String = stringValue + } + + private object ThrowingErrorClassification : ErrorClassification { + override fun toSpecification(error: GraphQLError): Any = + throw AssertionError("failed to create specification") + + override fun toString(): String = "SOME_ERROR" + } +}