From a7839bc9b96a9a6f0b8afff83cd98aee78dba388 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:24 +0100 Subject: [PATCH] Add Metrics support for Spring GraphQL This commit adds the required infrastructure for instrumenting the GraphQL engine and datafetchers in order to collect metrics. With this infrastructure, we can collect metrics such as: * "graphql.request", a timer for GraphQL query * "graphql.datafetcher", a timer for GraphQL datafetcher calls * "graphql.request.datafetch.count", a distribution summary of datafetcher count per query * "graphql.error", an error counter See gh-29140 --- .../build.gradle | 1 + .../SpringApplicationHierarchyTests.java | 5 +- ...intsAutoConfigurationIntegrationTests.java | 13 +- .../spring-boot-actuator/build.gradle | 1 + .../graphql/DefaultGraphQlTagsProvider.java | 77 ++++++++ .../GraphQlMetricsInstrumentation.java | 160 ++++++++++++++++ .../actuate/metrics/graphql/GraphQlTags.java | 102 ++++++++++ .../graphql/GraphQlTagsContributor.java | 45 +++++ .../metrics/graphql/GraphQlTagsProvider.java | 44 +++++ .../actuate/metrics/graphql/package-info.java | 20 ++ .../GraphQlMetricsInstrumentationTests.java | 176 ++++++++++++++++++ .../metrics/graphql/GraphQlTagsTests.java | 95 ++++++++++ 12 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle index 5b4e527779..1652458515 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -146,6 +146,7 @@ dependencies { optional("org.springframework.data:spring-data-elasticsearch") { exclude group: "commons-logging", module: "commons-logging" } + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.integration:spring-integration-core") optional("org.springframework.kafka:spring-kafka") optional("org.springframework.security:spring-security-config") diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java index 5de0389b31..7675208da0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoCo import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.util.ApplicationContextTestUtils; @@ -69,7 +70,7 @@ class SpringApplicationHierarchyTests { @Configuration @EnableAutoConfiguration(exclude = { ElasticsearchDataAutoConfiguration.class, ElasticsearchRepositoriesAutoConfiguration.class, CassandraAutoConfiguration.class, - CassandraDataAutoConfiguration.class, MongoDataAutoConfiguration.class, + CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, MongoDataAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, Neo4jAutoConfiguration.class, Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, FlywayAutoConfiguration.class, MetricsAutoConfiguration.class }) @@ -80,7 +81,7 @@ class SpringApplicationHierarchyTests { @Configuration @EnableAutoConfiguration(exclude = { ElasticsearchDataAutoConfiguration.class, ElasticsearchRepositoriesAutoConfiguration.class, CassandraAutoConfiguration.class, - CassandraDataAutoConfiguration.class, MongoDataAutoConfiguration.class, + CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, MongoDataAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, Neo4jAutoConfiguration.class, Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, FlywayAutoConfiguration.class, MetricsAutoConfiguration.class }) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java index c25193341f..1c95dff9de 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java @@ -34,6 +34,7 @@ import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration; import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration; @@ -75,12 +76,12 @@ class WebEndpointsAutoConfigurationIntegrationTests { } @EnableAutoConfiguration(exclude = { FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class, - CassandraAutoConfiguration.class, CassandraDataAutoConfiguration.class, Neo4jDataAutoConfiguration.class, - Neo4jRepositoriesAutoConfiguration.class, MongoAutoConfiguration.class, MongoDataAutoConfiguration.class, - MongoReactiveAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, - RepositoryRestMvcAutoConfiguration.class, HazelcastAutoConfiguration.class, - ElasticsearchDataAutoConfiguration.class, SolrAutoConfiguration.class, RedisAutoConfiguration.class, - RedisRepositoriesAutoConfiguration.class, MetricsAutoConfiguration.class }) + CassandraAutoConfiguration.class, CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, + Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, MongoAutoConfiguration.class, + MongoDataAutoConfiguration.class, MongoReactiveAutoConfiguration.class, + MongoReactiveDataAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class, + HazelcastAutoConfiguration.class, ElasticsearchDataAutoConfiguration.class, SolrAutoConfiguration.class, + RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, MetricsAutoConfiguration.class }) @SpringBootConfiguration static class WebEndpointTestApplication { diff --git a/spring-boot-project/spring-boot-actuator/build.gradle b/spring-boot-project/spring-boot-actuator/build.gradle index a9a6a5c9bd..3ef631eb02 100644 --- a/spring-boot-project/spring-boot-actuator/build.gradle +++ b/spring-boot-project/spring-boot-actuator/build.gradle @@ -78,6 +78,7 @@ dependencies { optional("org.springframework.data:spring-data-mongodb") optional("org.springframework.data:spring-data-redis") optional("org.springframework.data:spring-data-rest-webmvc") + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.integration:spring-integration-core") optional("org.springframework.security:spring-security-core") optional("org.springframework.security:spring-security-web") diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java new file mode 100644 index 0000000000..656a841c99 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java @@ -0,0 +1,77 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import java.util.Collections; +import java.util.List; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; + +/** + * Default implementation for {@link GraphQlTagsProvider}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public class DefaultGraphQlTagsProvider implements GraphQlTagsProvider { + + private final List contributors; + + public DefaultGraphQlTagsProvider(List contributors) { + this.contributors = contributors; + } + + public DefaultGraphQlTagsProvider() { + this(Collections.emptyList()); + } + + @Override + public Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + Throwable exception) { + Tags tags = Tags.of(GraphQlTags.executionOutcome(result, exception)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getExecutionTags(parameters, result, exception)); + } + return tags; + } + + @Override + public Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error) { + Tags tags = Tags.of(GraphQlTags.errorType(error), GraphQlTags.errorPath(error)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getErrorTags(parameters, error)); + } + return tags; + } + + @Override + public Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + Throwable exception) { + Tags tags = Tags.of(GraphQlTags.dataFetchingOutcome(exception), GraphQlTags.dataFetchingPath(parameters)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getDataFetchingTags(dataFetcher, parameters, exception)); + } + return tags; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java new file mode 100644 index 0000000000..893cc2622b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java @@ -0,0 +1,160 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicLong; + +import graphql.ExecutionResult; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.SimpleInstrumentation; +import graphql.execution.instrumentation.SimpleInstrumentationContext; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; + +import org.springframework.boot.actuate.metrics.AutoTimer; +import org.springframework.lang.Nullable; + +public class GraphQlMetricsInstrumentation extends SimpleInstrumentation { + + private final MeterRegistry registry; + + private final GraphQlTagsProvider tagsProvider; + + private final AutoTimer autoTimer; + + private final DistributionSummary dataFetchingSummary; + + public GraphQlMetricsInstrumentation(MeterRegistry registry, GraphQlTagsProvider tagsProvider, + AutoTimer autoTimer) { + this.registry = registry; + this.tagsProvider = tagsProvider; + this.autoTimer = autoTimer; + this.dataFetchingSummary = DistributionSummary.builder("graphql.request.datafetch.count").baseUnit("calls") + .description("Count of DataFetcher calls per request.").register(this.registry); + } + + @Override + public InstrumentationState createState() { + return new RequestMetricsInstrumentationState(this.autoTimer, this.registry); + } + + @Override + public InstrumentationContext beginExecution(InstrumentationExecutionParameters parameters) { + if (this.autoTimer.isEnabled()) { + RequestMetricsInstrumentationState state = parameters.getInstrumentationState(); + state.startTimer(); + return new SimpleInstrumentationContext() { + @Override + public void onCompleted(ExecutionResult result, Throwable exc) { + Iterable tags = GraphQlMetricsInstrumentation.this.tagsProvider.getExecutionTags(parameters, + result, exc); + state.tags(tags).stopTimer(); + if (!result.getErrors().isEmpty()) { + result.getErrors() + .forEach((error) -> GraphQlMetricsInstrumentation.this.registry.counter("graphql.error", + GraphQlMetricsInstrumentation.this.tagsProvider.getErrorTags(parameters, error)) + .increment()); + } + GraphQlMetricsInstrumentation.this.dataFetchingSummary.record(state.getDataFetchingCount()); + } + }; + } + return super.beginExecution(parameters); + } + + @Override + public DataFetcher instrumentDataFetcher(DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters) { + if (this.autoTimer.isEnabled() && !parameters.isTrivialDataFetcher()) { + return (environment) -> { + Timer.Sample sample = Timer.start(this.registry); + try { + Object value = dataFetcher.get(environment); + if (value instanceof CompletionStage) { + CompletionStage completion = (CompletionStage) value; + return completion.whenComplete( + (result, error) -> recordDataFetcherMetric(sample, dataFetcher, parameters, error)); + } + else { + recordDataFetcherMetric(sample, dataFetcher, parameters, null); + return value; + } + } + catch (Throwable throwable) { + recordDataFetcherMetric(sample, dataFetcher, parameters, throwable); + throw throwable; + } + }; + } + return super.instrumentDataFetcher(dataFetcher, parameters); + } + + private void recordDataFetcherMetric(Timer.Sample sample, DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters, @Nullable Throwable throwable) { + Timer.Builder timer = this.autoTimer.builder("graphql.datafetcher"); + timer.tags(this.tagsProvider.getDataFetchingTags(dataFetcher, parameters, throwable)); + sample.stop(timer.register(this.registry)); + RequestMetricsInstrumentationState state = parameters.getInstrumentationState(); + state.incrementDataFetchingCount(); + } + + static class RequestMetricsInstrumentationState implements InstrumentationState { + + private final MeterRegistry registry; + + private final Timer.Builder timer; + + private Timer.Sample sample; + + private AtomicLong dataFetchingCount = new AtomicLong(0L); + + RequestMetricsInstrumentationState(AutoTimer autoTimer, MeterRegistry registry) { + this.timer = autoTimer.builder("graphql.request"); + this.registry = registry; + } + + RequestMetricsInstrumentationState tags(Iterable tags) { + this.timer.tags(tags); + return this; + } + + void startTimer() { + this.sample = Timer.start(this.registry); + } + + void stopTimer() { + this.sample.stop(this.timer.register(this.registry)); + } + + void incrementDataFetchingCount() { + this.dataFetchingCount.incrementAndGet(); + } + + long getDataFetchingCount() { + return this.dataFetchingCount.get(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java new file mode 100644 index 0000000000..ded7572502 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import java.util.List; + +import graphql.ErrorClassification; +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.ExecutionStepInfo; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.GraphQLObjectType; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +/** + * Factory methods for Tags associated with a GraphQL request. + * + * @author Brian Clozel + * @since 1.0.0 + */ +public final class GraphQlTags { + + private static final Tag OUTCOME_SUCCESS = Tag.of("outcome", "SUCCESS"); + + private static final Tag OUTCOME_ERROR = Tag.of("outcome", "ERROR"); + + private static final Tag UNKNOWN_ERRORTYPE = Tag.of("errorType", "UNKNOWN"); + + private GraphQlTags() { + + } + + public static Tag executionOutcome(ExecutionResult result, @Nullable Throwable exception) { + if (exception == null && result.getErrors().isEmpty()) { + return OUTCOME_SUCCESS; + } + else { + return OUTCOME_ERROR; + } + } + + public static Tag errorType(GraphQLError error) { + ErrorClassification errorType = error.getErrorType(); + if (errorType instanceof ErrorType) { + return Tag.of("errorType", ((ErrorType) errorType).name()); + } + return UNKNOWN_ERRORTYPE; + } + + public static Tag errorPath(GraphQLError error) { + StringBuilder builder = new StringBuilder(); + List pathSegments = error.getPath(); + if (!CollectionUtils.isEmpty(pathSegments)) { + builder.append('$'); + for (Object segment : pathSegments) { + try { + int index = Integer.parseUnsignedInt(segment.toString()); + builder.append("[*]"); + } + catch (NumberFormatException exc) { + builder.append('.'); + builder.append(segment); + } + } + } + return Tag.of("errorPath", builder.toString()); + } + + public static Tag dataFetchingOutcome(@Nullable Throwable exception) { + return (exception != null) ? OUTCOME_ERROR : OUTCOME_SUCCESS; + } + + public static Tag dataFetchingPath(InstrumentationFieldFetchParameters parameters) { + ExecutionStepInfo executionStepInfo = parameters.getExecutionStepInfo(); + StringBuilder dataFetchingType = new StringBuilder(); + if (executionStepInfo.hasParent() && executionStepInfo.getParent().getType() instanceof GraphQLObjectType) { + dataFetchingType.append(((GraphQLObjectType) executionStepInfo.getParent().getType()).getName()); + dataFetchingType.append('.'); + } + dataFetchingType.append(executionStepInfo.getPath().getSegmentName()); + return Tag.of("path", dataFetchingType.toString()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java new file mode 100644 index 0000000000..99806736d8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; + +/** + * A contributor of {@link Tag Tags} for Spring GraphQL-based request handling. Typically, + * used by a {@link GraphQlTagsProvider} to provide tags in addition to its defaults. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public interface GraphQlTagsContributor { + + Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + @Nullable Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + @Nullable Throwable exception); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java new file mode 100644 index 0000000000..13ba9555b6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; + +/** + * Provides {@link Tag Tags} for Spring GraphQL-based request handling. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public interface GraphQlTagsProvider { + + Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + @Nullable Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + @Nullable Throwable exception); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java new file mode 100644 index 0000000000..03a0361170 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides instrumentation support for Spring GraphQL. + */ +package org.springframework.boot.actuate.metrics.graphql; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java new file mode 100644 index 0000000000..7d5f6e63e3 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.DataFetchingEnvironmentImpl; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaGenerator; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.metrics.AutoTimer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlMetricsInstrumentation}. + * + * @author Brian Clozel + */ +class GraphQlMetricsInstrumentationTests { + + private final ExecutionInput input = ExecutionInput.newExecutionInput("{greeting}").build(); + + private final GraphQLSchema schema = SchemaGenerator.createdMockedSchema("type Query { greeting: String }"); + + private MeterRegistry registry; + + private GraphQlMetricsInstrumentation instrumentation; + + private InstrumentationState state; + + private InstrumentationExecutionParameters parameters; + + @BeforeEach + void setup() { + this.registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock()); + this.instrumentation = new GraphQlMetricsInstrumentation(this.registry, mock(GraphQlTagsProvider.class), + AutoTimer.ENABLED); + this.state = this.instrumentation.createState(); + this.parameters = new InstrumentationExecutionParameters(this.input, this.schema, this.state); + } + + @Test + void shouldRecordTimerWhenResult() { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters); + ExecutionResult result = new ExecutionResultImpl("Hello", null); + execution.onCompleted(result, null); + + Timer timer = this.registry.find("graphql.request").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingCount() throws Exception { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters); + ExecutionResult result = new ExecutionResultImpl("Hello", null); + + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + execution.onCompleted(result, null); + + DistributionSummary summary = this.registry.find("graphql.request.datafetch.count").summary(); + assertThat(summary).isNotNull(); + assertThat(summary.count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenSuccess() throws Exception { + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenSuccessCompletionStage() throws Exception { + DataFetcher> dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn(CompletableFuture.completedFuture("Hello")); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenError() throws Exception { + DataFetcher> dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willThrow(new IllegalStateException("test")); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + assertThatThrownBy(() -> instrumented.get(environment)).isInstanceOf(IllegalStateException.class); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldNotRecordDataFetchingMetricWhenTrivial() throws Exception { + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(true); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNull(); + } + + private InstrumentationFieldFetchParameters mockFieldFetchParameters(boolean isTrivial) { + InstrumentationFieldFetchParameters fieldFetchParameters = mock(InstrumentationFieldFetchParameters.class); + given(fieldFetchParameters.isTrivialDataFetcher()).willReturn(isTrivial); + given(fieldFetchParameters.getInstrumentationState()).willReturn(this.state); + return fieldFetchParameters; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java new file mode 100644 index 0000000000..84f1283e38 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.graphql; + +import java.util.Arrays; + +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import io.micrometer.core.instrument.Tag; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlTags}. + * + * @author Brian Clozel + */ +class GraphQlTagsTests { + + @Test + void executionOutcomeShouldSucceed() { + ExecutionResult result = ExecutionResultImpl.newExecutionResult().build(); + Tag outcomeTag = GraphQlTags.executionOutcome(result, null); + assertThat(outcomeTag.getValue()).isEqualTo("SUCCESS"); + } + + @Test + void executionOutcomeShouldErrorWhenExceptionThrown() { + ExecutionResult result = ExecutionResultImpl.newExecutionResult().build(); + Tag tag = GraphQlTags.executionOutcome(result, new IllegalArgumentException("test error")); + assertThat(tag.getValue()).isEqualTo("ERROR"); + } + + @Test + void executionOutcomeShouldErrorWhenResponseErrors() { + GraphQLError error = GraphqlErrorBuilder.newError().message("Invalid query").build(); + Tag tag = GraphQlTags.executionOutcome(ExecutionResultImpl.newExecutionResult().addError(error).build(), null); + assertThat(tag.getValue()).isEqualTo("ERROR"); + } + + @Test + void errorTypeShouldBeDefinedIfPresent() { + GraphQLError error = GraphqlErrorBuilder.newError().errorType(ErrorType.DataFetchingException) + .message("test error").build(); + Tag errorTypeTag = GraphQlTags.errorType(error); + assertThat(errorTypeTag.getValue()).isEqualTo("DataFetchingException"); + } + + @Test + void errorPathShouldUseJsonPathFormat() { + GraphQLError error = GraphqlErrorBuilder.newError().path(Arrays.asList("project", "name")).message("test error") + .build(); + Tag errorPathTag = GraphQlTags.errorPath(error); + assertThat(errorPathTag.getValue()).isEqualTo("$.project.name"); + } + + @Test + void errorPathShouldUseJsonPathFormatForIndices() { + GraphQLError error = GraphqlErrorBuilder.newError().path(Arrays.asList("issues", "42", "title")) + .message("test error").build(); + Tag errorPathTag = GraphQlTags.errorPath(error); + assertThat(errorPathTag.getValue()).isEqualTo("$.issues[*].title"); + } + + @Test + void dataFetchingOutcomeShouldBeSuccessfulIfNoException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(null); + assertThat(fetchingOutcomeTag.getValue()).isEqualTo("SUCCESS"); + } + + @Test + void dataFetchingOutcomeShouldBeErrorIfException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(new IllegalStateException("error state")); + assertThat(fetchingOutcomeTag.getValue()).isEqualTo("ERROR"); + } + +}