diff --git a/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/DgsGraphQLMetricsInstrumentation.kt b/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/DgsGraphQLMetricsInstrumentation.kt index 19fb0c334..a96f86df5 100644 --- a/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/DgsGraphQLMetricsInstrumentation.kt +++ b/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/DgsGraphQLMetricsInstrumentation.kt @@ -10,9 +10,11 @@ import com.netflix.graphql.types.errors.ErrorType import graphql.ExecutionInput import graphql.ExecutionResult import graphql.GraphQLError +import graphql.GraphQLException import graphql.InvalidSyntaxError import graphql.analysis.FieldComplexityCalculator import graphql.analysis.QueryComplexityCalculator +import graphql.execution.DataFetcherResult import graphql.execution.ExecutionContext import graphql.execution.instrumentation.InstrumentationContext import graphql.execution.instrumentation.InstrumentationState @@ -172,18 +174,18 @@ class DgsGraphQLMetricsInstrumentation( try { val result = dataFetcher.get(environment) if (result is CompletionStage<*>) { - result.whenComplete { _, error -> + result.whenComplete { value, error -> recordDataFetcherMetrics( registry, sampler, state, parameters, - error, + checkResponseForErrors(value, error), baseTags, ) } } else { - recordDataFetcherMetrics(registry, sampler, state, parameters, null, baseTags) + recordDataFetcherMetrics(registry, sampler, state, parameters, checkResponseForErrors(result, null), baseTags) } result } catch (exc: Exception) { @@ -193,6 +195,15 @@ class DgsGraphQLMetricsInstrumentation( } } + private fun checkResponseForErrors( + value: Any?, + error: Throwable?, + ): Throwable? = + error + ?: (value as? DataFetcherResult<*>) + ?.takeIf { it.hasErrors() } + ?.let { GraphQLException("GraphQL errors in response: ${it.errors}") } + /** * Port the implementation from MaxQueryComplexityInstrumentation in graphql-java and store the computed complexity * in the MetricsInstrumentationState for access to add tags to metrics. diff --git a/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/MicrometerServletSmokeTest.kt b/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/MicrometerServletSmokeTest.kt index 9c9312a7e..491894312 100644 --- a/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/MicrometerServletSmokeTest.kt +++ b/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/MicrometerServletSmokeTest.kt @@ -39,6 +39,7 @@ import graphql.GraphQLError import graphql.execution.DataFetcherExceptionHandler import graphql.execution.DataFetcherExceptionHandlerParameters import graphql.execution.DataFetcherExceptionHandlerResult +import graphql.execution.DataFetcherResult import graphql.schema.DataFetchingEnvironment import graphql.schema.idl.SchemaParser import graphql.schema.idl.TypeDefinitionRegistry @@ -678,6 +679,106 @@ class MicrometerServletSmokeTest { ) } + @Test + fun `Assert metrics for a successful async response with errors`() { + mvc + .perform( + MockMvcRequestBuilders + .post("/graphql") + .contentType(MediaType.APPLICATION_JSON) + .content("""{ "query": "{triggerSuccessfulRequestWithErrorAsync}" }"""), + ).andExpect(status().isOk) + .andExpect( + content().json( + """ + |{ + | "errors":[ + | {"message":"Exception triggered."} + | ], + | "data":{"triggerSuccessfulRequestWithErrorAsync":"Some data..."} + |} + """.trimMargin(), + false, + ), + ) + + val meters = fetchMeters("gql.") + + assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver") + + assertThat(meters["gql.error"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0) + assertThat(meters["gql.error"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + + assertThat(meters["gql.query"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat(meters["gql.query"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + + assertThat(meters["gql.resolver"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat(meters["gql.resolver"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + } + + @Test + fun `Assert metrics for a successful sync response with errors`() { + mvc + .perform( + MockMvcRequestBuilders + .post("/graphql") + .contentType(MediaType.APPLICATION_JSON) + .content("""{ "query": "{triggerSuccessfulRequestWithErrorSync}" }"""), + ).andExpect(status().isOk) + .andExpect( + content().json( + """ + |{ + | "errors":[ + | {"message":"Exception triggered."} + | ], + | "data":{"triggerSuccessfulRequestWithErrorSync":"Some data..."} + |} + """.trimMargin(), + false, + ), + ) + + val meters = fetchMeters("gql.") + + assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver") + + assertThat(meters["gql.error"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0) + assertThat(meters["gql.error"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + + assertThat(meters["gql.query"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat(meters["gql.query"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + + assertThat(meters["gql.resolver"]).isNotNull.hasSizeGreaterThanOrEqualTo(1) + assertThat(meters["gql.resolver"]?.first()?.id?.tags) + .containsAll( + Tags + .of("outcome", "failure"), + ) + } + @Test fun `Assert metrics for custom error`() { mvc @@ -914,6 +1015,8 @@ class MicrometerServletSmokeTest { | triggerInternalFailure: String | triggerBadRequestFailure:String | triggerCustomFailure: String + | triggerSuccessfulRequestWithErrorAsync:String + | triggerSuccessfulRequestWithErrorSync:String |} | |type Mutation{ @@ -956,6 +1059,24 @@ class MicrometerServletSmokeTest { @DgsQuery fun triggerBadRequestFailure(): String = throw DgsBadRequestException("Exception triggered.") + @DgsQuery + fun triggerSuccessfulRequestWithErrorAsync(): CompletableFuture> = + CompletableFuture.supplyAsync { + DataFetcherResult + .newResult() + .data("Some data...") + .error(TypedGraphQLError("Exception triggered.", null, null, null, null)) + .build() + } + + @DgsQuery + fun triggerSuccessfulRequestWithErrorSync(): DataFetcherResult = + DataFetcherResult + .newResult() + .data("Some data...") + .error(TypedGraphQLError("Exception triggered.", null, null, null, null)) + .build() + @DgsQuery fun triggerCustomFailure(): String = throw CustomException("Exception triggered.")