Revisit GraphQL metrics

Prior to this commit, we introduced a basic implementation for GraphQL
metrics with Spring GraphQL. The main problem was that we were tracking
the GraphQL queries themselves and those are good candidates for
tags cardinality explosion.

This commit revisits the metrics arrangement and introduces:

* `graphql.request` as the main metric; this tracks the execution of a
  single request (i.e. multiple queries)
* `graphql.datafetcher` for tracking data fetching operations.
* `graphql.error` for counting errors.

Closes gh-19
This commit is contained in:
Brian Clozel
2021-05-10 10:41:57 +02:00
parent f93c46eee3
commit ee51f26755
10 changed files with 366 additions and 44 deletions

View File

@@ -153,14 +153,39 @@ A custom `WebInterceptor` can, for example, change the HTTP request/response hea
### Metrics
If the `spring-boot-starter-actuator` dependency is on the classpath, metrics will be collected for GraphQL queries.
If the `spring-boot-starter-actuator` dependency is on the classpath, metrics will be collected for GraphQL requests.
You can see those metrics by exposing the metrics endpoint with `application.properties`:
```properties
management.endpoints.web.exposure.include=health,metrics,info
```
#### GraphQL Request (timer)
You can then check those metrics at `http://localhost:8080/actuator/metrics/graphql.query`.
A Request metric timer is available at `/actuator/metrics/graphql.request`.
| Tag | Description | Sample values |
|---------|-----------------|--------------------|
| outcome | Request outcome | "SUCCESS", "ERROR" |
#### GraphQL Data Fetcher (timer)
A Data Fetcher metric timer is available at `/actuator/metrics/graphql.datafetcher`.
| Tag | Description | Sample values |
|---------|-----------------------|--------------------|
| path | data fetcher path | "Query.project" |
| outcome | data fetching outcome | "SUCCESS", "ERROR" |
#### GraphQL Error (counter)
A counter metric counter is available at `/actuator/metrics/graphql.error`.
| Tag | Description | Sample values |
|-----------|-----------------|-------------------------|
| errorType | error type | "DataFetchingException" |
| errorPath | error JSON Path | "$.project" |
## Sample applications

View File

@@ -43,6 +43,7 @@ dependencies {
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.springframework.boot:spring-boot-autoconfigure-processor'
testImplementation project(':spring-graphql-test')
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'org.springframework:spring-webflux'
testImplementation 'org.springframework:spring-webmvc'
@@ -52,6 +53,7 @@ dependencies {
testImplementation 'org.apache.tomcat.embed:tomcat-embed-core'
testImplementation 'org.apache.tomcat.embed:tomcat-embed-websocket'
testImplementation 'org.springframework.boot:spring-boot-actuator-autoconfigure'
testImplementation 'io.micrometer:micrometer-core'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-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.
@@ -16,34 +16,48 @@
package org.springframework.graphql.boot.actuate.metrics;
import graphql.ErrorClassification;
import graphql.ErrorType;
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;
public class DefaultGraphQLTagsProvider implements GraphQLTagsProvider {
private static final Tag OUTCOME_SUCCESS = Tag.of("outcome", "SUCCESS");
private final List<GraphQLTagsContributor> contributors;
private static final Tag OUTCOME_ERROR = Tag.of("outcome", "ERROR");
public DefaultGraphQLTagsProvider(List<GraphQLTagsContributor> contributors) {
this.contributors = contributors;
}
@Override
public Iterable<Tag> getTags(InstrumentationExecutionParameters parameters, ExecutionResult result, Throwable exception) {
Tags tags = Tags.of(Tag.of("query", parameters.getQuery()));
if (result.isDataPresent()) {
tags = tags.and(OUTCOME_SUCCESS);
public Iterable<Tag> 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));
}
else {
tags = tags.and(OUTCOME_ERROR);
if (!result.getErrors().isEmpty()) {
ErrorClassification errorClassification = result.getErrors().get(0).getErrorType();
if (errorClassification instanceof ErrorType) {
tags = tags.and(Tag.of("errorType", ((ErrorType) errorClassification).name()));
}
}
return tags;
}
@Override
public Iterable<Tag> 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<Tag> 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;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-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.
@@ -16,8 +16,11 @@
package org.springframework.graphql.boot.actuate.metrics;
import java.util.stream.Collectors;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
@@ -42,8 +45,8 @@ public class GraphQLMetricsAutoConfiguration {
@Bean
@ConditionalOnMissingBean(GraphQLTagsProvider.class)
public DefaultGraphQLTagsProvider graphQLTagsProvider() {
return new DefaultGraphQLTagsProvider();
public DefaultGraphQLTagsProvider graphQLTagsProvider(ObjectProvider<GraphQLTagsContributor> contributors) {
return new DefaultGraphQLTagsProvider(contributors.orderedStream().collect(Collectors.toList()));
}
@Bean

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-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.
@@ -16,12 +16,16 @@
package org.springframework.graphql.boot.actuate.metrics;
import java.util.concurrent.CompletionStage;
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.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Timer;
@@ -44,40 +48,89 @@ public class GraphQLMetricsInstrumentation extends SimpleInstrumentation {
@Override
public InstrumentationState createState() {
return new MetricsInstrumentationState(this.registry);
return new RequestMetricsInstrumentationState(this.autoTimer, this.registry);
}
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
MetricsInstrumentationState state = parameters.getInstrumentationState();
state.startTimer();
if (this.autoTimer.isEnabled()) {
RequestMetricsInstrumentationState state = parameters.getInstrumentationState();
state.startTimer();
return new SimpleInstrumentationContext<ExecutionResult>() {
@Override
public void onCompleted(ExecutionResult result, Throwable exc) {
Iterable<Tag> tags = tagsProvider.getExecutionTags(parameters, result, exc);
state.tags(tags).stopTimer();
if (!result.getErrors().isEmpty()) {
result.getErrors().forEach(error -> {
registry.counter("graphql.error", tagsProvider.getErrorTags(parameters, error)).increment();
});
}
}
};
}
return super.beginExecution(parameters);
}
return new SimpleInstrumentationContext<ExecutionResult>() {
@Override
public void onCompleted(ExecutionResult result, Throwable exc) {
Iterable<Tag> tags = tagsProvider.getTags(parameters, result, exc);
state.stopTimer(autoTimer.builder("graphql.query").tags(tags));
}
};
@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, Throwable throwable) {
Timer.Builder timer = this.autoTimer.builder("graphql.datafetcher");
timer.tags(this.tagsProvider.getDataFetchingTags(dataFetcher, parameters, throwable));
sample.stop(timer.register(this.registry));
}
static class MetricsInstrumentationState implements InstrumentationState {
static class RequestMetricsInstrumentationState implements InstrumentationState {
private final MeterRegistry registry;
private Timer.Sample timerSample;
private final Timer.Builder timer;
MetricsInstrumentationState(MeterRegistry registry) {
private Timer.Sample sample;
RequestMetricsInstrumentationState(AutoTimer autoTimer, MeterRegistry registry) {
this.timer = autoTimer.builder("graphql.request");
this.registry = registry;
}
public void startTimer() {
this.timerSample = Timer.start(this.registry);
public RequestMetricsInstrumentationState tags(Iterable<Tag> tags) {
this.timer.tags(tags);
return this;
}
public void stopTimer(Timer.Builder timer) {
this.timerSample.stop(timer.register(this.registry));
public void startTimer() {
this.sample = Timer.start(this.registry);
}
public void stopTimer() {
this.sample.stop(this.timer.register(this.registry));
}
}

View File

@@ -0,0 +1,94 @@
/*
* 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.graphql.boot.actuate.metrics;
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;
/**
* Factory methods for Tags associated with a GraphQL requests.
*
* @author Brian Clozel
*/
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");
public static Tag executionOutcome(ExecutionResult result, 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<Object> pathSegments = error.getPath();
if (!pathSegments.isEmpty()) {
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(Throwable exception) {
return (exception == null) ? OUTCOME_SUCCESS : OUTCOME_ERROR;
}
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());
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2002-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.graphql.boot.actuate.metrics;
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;
public interface GraphQLTagsContributor {
Iterable<Tag> getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, Throwable exception);
Iterable<Tag> getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error);
Iterable<Tag> getDataFetchingTags(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters, Throwable exception);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-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.
@@ -17,10 +17,18 @@
package org.springframework.graphql.boot.actuate.metrics;
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;
public interface GraphQLTagsProvider {
Iterable<Tag> getTags(InstrumentationExecutionParameters parameters, ExecutionResult result, Throwable exception);
Iterable<Tag> getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, Throwable exception);
Iterable<Tag> getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error);
Iterable<Tag> getDataFetchingTags(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters, Throwable exception);
}

View File

@@ -0,0 +1,92 @@
/*
* 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.graphql.boot.actuate.metrics;
import java.util.Arrays;
import graphql.ErrorType;
import graphql.ExecutionResultImpl;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import io.micrometer.core.instrument.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.graphql.test.tester.TestExecutionResult;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link GraphQLTags}
*
* @author Brian Clozel
*/
class GraphQLTagsTests {
@Test
void executionOutcomeShouldSucceed() {
Tag outcomeTag = GraphQLTags.executionOutcome(new TestExecutionResult(), null);
assertThat(outcomeTag.getValue()).isEqualTo("SUCCESS");
}
@Test
void executionOutcomeShouldErrorWhenExceptionThrown() {
Tag outcomeTag = GraphQLTags.executionOutcome(new TestExecutionResult(), new IllegalArgumentException("test error"));
assertThat(outcomeTag.getValue()).isEqualTo("ERROR");
}
@Test
void executionOutcomeShouldErrorWhenResponseErrors() {
ExecutionResultImpl.Builder builder = new ExecutionResultImpl.Builder();
builder.addError(GraphqlErrorBuilder.newError().message("Invalid query").build());
Tag outcomeTag = GraphQLTags.executionOutcome(builder.build(), null);
assertThat(outcomeTag.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");
}
}

View File

@@ -123,8 +123,6 @@ class ReactorDataFetcherAdapter implements DataFetcher<Object> {
Method method = ClassUtils.getMethod(dataFetcher.getClass(), "get", DataFetchingEnvironment.class);
method = ClassUtils.getMostSpecificMethod(method, dataFetcher.getClass());
Class<?> returnType = method.getReturnType();
System.out.println(returnType.getName());
dataFetcher = new ReactorDataFetcherAdapter(dataFetcher, parent.getName().equals("Subscription"));
codeRegistry.dataFetcher(parent, fieldDefinition, dataFetcher);