Skip to content

Commit 73e4642

Browse files
mhliddmcculls
andauthored
Add Custom Exception Handler to Unwrap CompletionException for GraphQL Instrumentations (#10389)
* init * muzzle * move exceptionunwrap instrumentation to graphql-java-common * removing 1 layer of CompletionException * pr comments * Use separate muzzle directive for graphql-java-common --------- Co-authored-by: Stuart McCulloch <[email protected]>
1 parent 0e6e189 commit 73e4642

File tree

10 files changed

+319
-3
lines changed

10 files changed

+319
-3
lines changed

dd-java-agent/instrumentation/graphql-java/graphql-java-14.0/src/main/java/datadog/trace/instrumentation/graphqljava14/GraphQLJavaInstrumentation.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ public String[] helperClassNames() {
3838
"datadog.trace.instrumentation.graphqljava.State",
3939
packageName + ".GraphQLInstrumentation",
4040
"datadog.trace.instrumentation.graphqljava.GraphQLQuerySanitizer",
41-
"datadog.trace.instrumentation.graphqljava.InstrumentedDataFetcher"
41+
"datadog.trace.instrumentation.graphqljava.InstrumentedDataFetcher",
42+
"datadog.trace.instrumentation.graphqljava.AsyncExceptionUnwrapper"
4243
};
4344
}
4445

dd-java-agent/instrumentation/graphql-java/graphql-java-14.0/src/test/groovy/GraphQLTest.groovy

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import datadog.trace.api.DDSpanTypes
33
import datadog.trace.api.Trace
44
import datadog.trace.bootstrap.instrumentation.api.Tags
55
import datadog.trace.test.util.Flaky
6+
import graphql.ExceptionWhileDataFetching
67
import graphql.ExecutionResult
78
import graphql.GraphQL
89
import graphql.schema.DataFetcher
@@ -15,6 +16,7 @@ import spock.lang.Shared
1516

1617
import java.nio.charset.StandardCharsets
1718
import java.util.concurrent.CompletableFuture
19+
import java.util.concurrent.CompletionException
1820
import java.util.concurrent.CompletionStage
1921
import java.util.concurrent.TimeUnit
2022

@@ -62,6 +64,16 @@ abstract class GraphQLTest extends VersionedNamingTestBase {
6264
throw new IllegalStateException("TEST")
6365
}
6466
}))
67+
.type(newTypeWiring("Book").dataFetcher("asyncCover", new DataFetcher<CompletionStage<String>>() {
68+
@Override
69+
CompletionStage<String> get(DataFetchingEnvironment environment) throws Exception {
70+
// Simulate the "async resolver failed" shape seen in the wild: nested CompletionException wrappers.
71+
// This avoids scheduling work on the common pool while still exercising graphql-java's unwrapping logic.
72+
def future = new CompletableFuture<String>()
73+
future.completeExceptionally(new CompletionException(new CompletionException(new IllegalStateException("ASYNC_TEST"))))
74+
return future
75+
}
76+
}))
6577
.type(newTypeWiring("Book").dataFetcher("bookHash", new DataFetcher<CompletableFuture<Integer>>() {
6678
@Override
6779
CompletableFuture<Integer> get(DataFetchingEnvironment environment) throws Exception {
@@ -546,6 +558,119 @@ abstract class GraphQLTest extends VersionedNamingTestBase {
546558
}
547559
}
548560

561+
def "query async fetch error unwraps nested CompletionException wrappers"() {
562+
setup:
563+
def query = 'query findBookById {\n' +
564+
' bookById(id: "book-1") {\n' +
565+
' id #test\n' +
566+
' asyncCover\n' +
567+
' }\n' +
568+
'}'
569+
def expectedQuery = 'query findBookById {\n' +
570+
' bookById(id: {String}) {\n' +
571+
' id\n' +
572+
' asyncCover\n' +
573+
' }\n' +
574+
'}\n'
575+
ExecutionResult result = graphql.execute(query)
576+
577+
expect:
578+
!result.getErrors().isEmpty()
579+
result.getErrors().get(0).getMessage().contains("ASYNC_TEST")
580+
result.getErrors().get(0) instanceof ExceptionWhileDataFetching
581+
// Note that GraphQL 14.0 does not do unwrapping of exceptions on their own, so nested CompletionExceptions will result in removing only one of them
582+
((ExceptionWhileDataFetching) result.getErrors().get(0)).getException() instanceof CompletionException
583+
((ExceptionWhileDataFetching) result.getErrors().get(0)).getException().getCause() instanceof IllegalStateException
584+
((ExceptionWhileDataFetching) result.getErrors().get(0)).getException().getMessage() == "java.lang.IllegalStateException: ASYNC_TEST"
585+
586+
assertTraces(1) {
587+
trace(6) {
588+
span {
589+
operationName operation()
590+
resourceName "findBookById"
591+
spanType DDSpanTypes.GRAPHQL
592+
errored true
593+
measured true
594+
parent()
595+
tags {
596+
"$Tags.COMPONENT" "graphql-java"
597+
"graphql.source" expectedQuery
598+
"graphql.operation.name" "findBookById"
599+
"error.message" { it.contains("ASYNC_TEST") }
600+
defaultTags()
601+
}
602+
}
603+
span {
604+
operationName "graphql.field"
605+
resourceName "Book.asyncCover"
606+
childOf(span(0))
607+
spanType DDSpanTypes.GRAPHQL
608+
errored true
609+
measured true
610+
tags {
611+
"$Tags.COMPONENT" "graphql-java"
612+
"graphql.type" "String"
613+
"graphql.coordinates" "Book.asyncCover"
614+
"error.type" "java.util.concurrent.CompletionException"
615+
"error.message" "java.lang.IllegalStateException: ASYNC_TEST"
616+
"error.stack" String
617+
defaultTags()
618+
}
619+
}
620+
span {
621+
operationName "graphql.field"
622+
resourceName "Query.bookById"
623+
childOf(span(0))
624+
spanType DDSpanTypes.GRAPHQL
625+
errored false
626+
measured true
627+
tags {
628+
"$Tags.COMPONENT" "graphql-java"
629+
"graphql.type" "Book"
630+
"graphql.coordinates" "Query.bookById"
631+
defaultTags()
632+
}
633+
}
634+
span {
635+
operationName "getBookById"
636+
resourceName "book"
637+
childOf(span(2))
638+
spanType null
639+
errored false
640+
measured false
641+
tags {
642+
"$Tags.COMPONENT" "trace"
643+
defaultTags()
644+
}
645+
}
646+
span {
647+
operationName "graphql.validation"
648+
resourceName "graphql.validation"
649+
childOf(span(0))
650+
spanType DDSpanTypes.GRAPHQL
651+
errored false
652+
measured true
653+
tags {
654+
"$Tags.COMPONENT" "graphql-java"
655+
defaultTags()
656+
}
657+
}
658+
span {
659+
operationName "graphql.parsing"
660+
resourceName "graphql.parsing"
661+
childOf(span(0))
662+
spanType DDSpanTypes.GRAPHQL
663+
errored false
664+
measured true
665+
tags {
666+
"$Tags.COMPONENT" "graphql-java"
667+
defaultTags()
668+
}
669+
}
670+
}
671+
}
672+
}
673+
549674
def "fetch `year` returning a CompletedStage which is a MinimalStage with most methods throwing UnsupportedOperationException"() {
550675
setup:
551676
def query = 'query findBookById {\n' +

dd-java-agent/instrumentation/graphql-java/graphql-java-14.0/src/test/resources/schema.graphqls

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type Book {
1313
pageCount: Int
1414
author: Author
1515
cover: String
16+
asyncCover: String
1617
isbn: ID!
1718
bookHash: Int!
1819
year: Int

dd-java-agent/instrumentation/graphql-java/graphql-java-20.0/src/main/java/datadog/trace/instrumentation/graphqljava20/GraphQLJavaInstrumentation.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public String[] helperClassNames() {
3333
"datadog.trace.instrumentation.graphqljava.State",
3434
packageName + ".GraphQLInstrumentation",
3535
"datadog.trace.instrumentation.graphqljava.GraphQLQuerySanitizer",
36-
"datadog.trace.instrumentation.graphqljava.InstrumentedDataFetcher"
36+
"datadog.trace.instrumentation.graphqljava.InstrumentedDataFetcher",
37+
"datadog.trace.instrumentation.graphqljava.AsyncExceptionUnwrapper"
3738
};
3839
}
3940

dd-java-agent/instrumentation/graphql-java/graphql-java-20.0/src/test/groovy/GraphQLTest.groovy

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import datadog.trace.api.DDSpanTypes
33
import datadog.trace.api.Trace
44
import datadog.trace.bootstrap.instrumentation.api.Tags
55
import datadog.trace.test.util.Flaky
6+
import graphql.ExceptionWhileDataFetching
67
import graphql.ExecutionResult
78
import graphql.GraphQL
89
import graphql.schema.DataFetcher
@@ -15,6 +16,7 @@ import spock.lang.Shared
1516

1617
import java.nio.charset.StandardCharsets
1718
import java.util.concurrent.CompletableFuture
19+
import java.util.concurrent.CompletionException
1820
import java.util.concurrent.CompletionStage
1921
import java.util.concurrent.TimeUnit
2022

@@ -62,6 +64,14 @@ abstract class GraphQLTest extends VersionedNamingTestBase {
6264
throw new IllegalStateException("TEST")
6365
}
6466
}))
67+
.type(newTypeWiring("Book").dataFetcher("asyncCover", new DataFetcher<CompletionStage<String>>() {
68+
@Override
69+
CompletionStage<String> get(DataFetchingEnvironment environment) throws Exception {
70+
def future = new CompletableFuture<String>()
71+
future.completeExceptionally(new CompletionException(new CompletionException(new IllegalStateException("ASYNC_TEST"))))
72+
return future
73+
}
74+
}))
6575
.type(newTypeWiring("Book").dataFetcher("bookHash", new DataFetcher<CompletableFuture<Integer>>() {
6676
@Override
6777
CompletableFuture<Integer> get(DataFetchingEnvironment environment) throws Exception {
@@ -546,6 +556,118 @@ abstract class GraphQLTest extends VersionedNamingTestBase {
546556
}
547557
}
548558

559+
def "query async fetch error unwraps nested CompletionException wrappers"() {
560+
setup:
561+
def query = 'query findBookById {\n' +
562+
' bookById(id: "book-1") {\n' +
563+
' id #test\n' +
564+
' asyncCover\n' +
565+
' }\n' +
566+
'}'
567+
def expectedQuery = 'query findBookById {\n' +
568+
' bookById(id: {String}) {\n' +
569+
' id\n' +
570+
' asyncCover\n' +
571+
' }\n' +
572+
'}\n'
573+
ExecutionResult result = graphql.execute(query)
574+
575+
expect:
576+
!result.getErrors().isEmpty()
577+
result.getErrors().get(0).getMessage().contains("ASYNC_TEST")
578+
!result.getErrors().get(0).getMessage().contains("CompletionException")
579+
result.getErrors().get(0) instanceof ExceptionWhileDataFetching
580+
((ExceptionWhileDataFetching) result.getErrors().get(0)).getException() instanceof IllegalStateException
581+
((ExceptionWhileDataFetching) result.getErrors().get(0)).getException().getMessage() == "ASYNC_TEST"
582+
583+
assertTraces(1) {
584+
trace(6) {
585+
span {
586+
operationName operation()
587+
resourceName "findBookById"
588+
spanType DDSpanTypes.GRAPHQL
589+
errored true
590+
measured true
591+
parent()
592+
tags {
593+
"$Tags.COMPONENT" "graphql-java"
594+
"graphql.source" expectedQuery
595+
"graphql.operation.name" "findBookById"
596+
"error.message" { it.contains("ASYNC_TEST") }
597+
defaultTags()
598+
}
599+
}
600+
span {
601+
operationName "graphql.field"
602+
resourceName "Book.asyncCover"
603+
childOf(span(0))
604+
spanType DDSpanTypes.GRAPHQL
605+
errored true
606+
measured true
607+
tags {
608+
"$Tags.COMPONENT" "graphql-java"
609+
"graphql.type" "String"
610+
"graphql.coordinates" "Book.asyncCover"
611+
"error.type" "java.util.concurrent.CompletionException"
612+
"error.message" "java.lang.IllegalStateException: ASYNC_TEST"
613+
"error.stack" String
614+
defaultTags()
615+
}
616+
}
617+
span {
618+
operationName "graphql.field"
619+
resourceName "Query.bookById"
620+
childOf(span(0))
621+
spanType DDSpanTypes.GRAPHQL
622+
errored false
623+
measured true
624+
tags {
625+
"$Tags.COMPONENT" "graphql-java"
626+
"graphql.type" "Book"
627+
"graphql.coordinates" "Query.bookById"
628+
defaultTags()
629+
}
630+
}
631+
span {
632+
operationName "getBookById"
633+
resourceName "book"
634+
childOf(span(2))
635+
spanType null
636+
errored false
637+
measured false
638+
tags {
639+
"$Tags.COMPONENT" "trace"
640+
defaultTags()
641+
}
642+
}
643+
span {
644+
operationName "graphql.validation"
645+
resourceName "graphql.validation"
646+
childOf(span(0))
647+
spanType DDSpanTypes.GRAPHQL
648+
errored false
649+
measured true
650+
tags {
651+
"$Tags.COMPONENT" "graphql-java"
652+
defaultTags()
653+
}
654+
}
655+
span {
656+
operationName "graphql.parsing"
657+
resourceName "graphql.parsing"
658+
childOf(span(0))
659+
spanType DDSpanTypes.GRAPHQL
660+
errored false
661+
measured true
662+
tags {
663+
"$Tags.COMPONENT" "graphql-java"
664+
defaultTags()
665+
}
666+
}
667+
}
668+
}
669+
}
670+
549671
def "fetch `year` returning a CompletedStage which is a MinimalStage with most methods throwing UnsupportedOperationException"() {
550672
setup:
551673
def query = 'query findBookById {\n' +

dd-java-agent/instrumentation/graphql-java/graphql-java-20.0/src/test/resources/schema.graphqls

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type Book {
1313
pageCount: Int
1414
author: Author
1515
cover: String
16+
asyncCover: String
1617
isbn: ID!
1718
bookHash: Int!
1819
year: Int

dd-java-agent/instrumentation/graphql-java/graphql-java-common/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
muzzle {
33
pass {
4+
name = "graphql-java-common"
45
group = "com.graphql-java"
56
module = 'graphql-java'
67
versions = '[14.0,)'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package datadog.trace.instrumentation.graphqljava;
2+
3+
import java.util.concurrent.CompletionException;
4+
5+
public final class AsyncExceptionUnwrapper {
6+
7+
private AsyncExceptionUnwrapper() {}
8+
9+
// Util function to unwrap CompletionException and expose underlying exception
10+
public static Throwable unwrap(Throwable throwable) {
11+
if (throwable.getCause() != null && throwable instanceof CompletionException) {
12+
return throwable.getCause();
13+
}
14+
return throwable;
15+
}
16+
}

0 commit comments

Comments
 (0)