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"); + } + +}