Add Observability support

Prior to this commit, metrics instrumentation was provided by Spring
Boot, using Micrometer metrics. The `Instrumentation` would publish two
timers and a counter, but would not support tracing.

This commit replaces the former with a dedicated support in Spring for
GraphQL directly. This uses the new `Observation` API from Micrometer
and publishes two observations:

* a request execution observation, with timing and tracing included
* a data fetching observation with the request execution as a parent
  observation

Closes gh-501
This commit is contained in:
Brian Clozel
2022-10-18 15:30:14 +02:00
parent 30bc70be5f
commit 1b19189500
15 changed files with 1134 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ dependencies {
api(platform("org.springframework:spring-framework-bom:${springFrameworkVersion}"))
api(platform("com.fasterxml.jackson:jackson-bom:2.13.4"))
api(platform("io.projectreactor:reactor-bom:2022.0.0-SNAPSHOT"))
api(platform("io.micrometer:micrometer-bom:1.10.0-SNAPSHOT"))
api(platform("org.springframework.data:spring-data-bom:2022.0.0-M6"))
api(platform("org.springframework.security:spring-security-bom:6.0.0-M7"))
api(platform("com.querydsl:querydsl-bom:5.0.0"))

View File

@@ -8,6 +8,7 @@ dependencies {
api 'org.springframework:spring-context'
implementation 'io.micrometer:context-propagation'
compileOnly 'io.micrometer:micrometer-observation'
compileOnly 'jakarta.annotation:jakarta.annotation-api'
compileOnly 'org.springframework:spring-webflux'
compileOnly 'org.springframework:spring-webmvc'
@@ -42,7 +43,7 @@ dependencies {
testImplementation 'org.springframework.data:spring-data-commons'
testImplementation 'org.springframework.data:spring-data-keyvalue'
testImplementation 'org.springframework.data:spring-data-jpa'
testImplementation 'io.micrometer:context-propagation'
testImplementation 'io.micrometer:micrometer-observation-test'
testImplementation 'com.h2database:h2'
testImplementation 'org.hibernate:hibernate-core'
testImplementation 'org.hibernate.validator:hibernate-validator'

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2020-2022 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.observation;
import graphql.schema.DataFetchingEnvironment;
import io.micrometer.observation.Observation;
/**
* Context that holds information for metadata collection during observations
* for {@link GraphQlObservationDocumentation#DATA_FETCHER data fetching operations}.
*
* @author Brian Clozel
* @since 1.1.0
*/
public class DataFetcherObservationContext extends Observation.Context {
private final DataFetchingEnvironment environment;
private Object value;
DataFetcherObservationContext(DataFetchingEnvironment environment) {
this.environment = environment;
}
/**
* Return the data fetching environment provided as an input.
*/
public DataFetchingEnvironment getEnvironment() {
return this.environment;
}
/**
* Return the value returned by the {@link graphql.schema.DataFetcher}, if any.
* @see #getError() for the exception thrown by the data fetcher.
*/
public Object getValue() {
return this.value;
}
void setValue(Object value) {
this.value = value;
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2020-2022 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.observation;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* Interface for an {@link ObservationConvention}
* for {@link GraphQlObservationDocumentation#DATA_FETCHER data fetching observations}.
*
* @author Brian Clozel
* @since 1.1.0
*/
public interface DataFetcherObservationConvention extends ObservationConvention<DataFetcherObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof DataFetcherObservationContext;
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2020-2022 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.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.graphql.observation.GraphQlObservationDocumentation.DataFetcherLowCardinalityKeyNames;
/**
* Default implementation for a {@link DataFetcherObservationConvention}
* extracting information from a {@link DataFetcherObservationContext}.
*
* @author Brian Clozel
* @since 1.1.0
*/
public class DefaultDataFetcherObservationConvention implements DataFetcherObservationConvention {
private static final String DEFAULT_NAME = "graphql.datafetcher";
private static final KeyValue OUTCOME_SUCCESS = KeyValue.of(DataFetcherLowCardinalityKeyNames.OUTCOME, "SUCCESS");
private static final KeyValue OUTCOME_ERROR = KeyValue.of(DataFetcherLowCardinalityKeyNames.OUTCOME, "ERROR");
private static final KeyValue ERROR_TYPE_NONE = KeyValue.of(DataFetcherLowCardinalityKeyNames.ERROR_TYPE, "NONE");
private final String name;
public DefaultDataFetcherObservationConvention() {
this(DEFAULT_NAME);
}
public DefaultDataFetcherObservationConvention(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
@Override
public String getContextualName(DataFetcherObservationContext context) {
return "graphQL field " + context.getEnvironment().getField().getName();
}
@Override
public KeyValues getLowCardinalityKeyValues(DataFetcherObservationContext context) {
return KeyValues.of(outcome(context), fieldName(context), errorType(context));
}
protected KeyValue outcome(DataFetcherObservationContext context) {
if (context.getError() != null) {
return OUTCOME_ERROR;
} return OUTCOME_SUCCESS;
}
protected KeyValue fieldName(DataFetcherObservationContext context) {
return KeyValue.of(DataFetcherLowCardinalityKeyNames.FIELD_NAME, context.getEnvironment().getField().getName());
}
protected KeyValue errorType(DataFetcherObservationContext context) {
if (context.getError() != null) {
return KeyValue.of(DataFetcherLowCardinalityKeyNames.ERROR_TYPE, context.getError().getClass().getSimpleName());
} return ERROR_TYPE_NONE;
}
@Override
public KeyValues getHighCardinalityKeyValues(DataFetcherObservationContext context) {
return KeyValues.of(fieldPath(context));
}
protected KeyValue fieldPath(DataFetcherObservationContext context) {
return KeyValue.of(GraphQlObservationDocumentation.DataFetcherHighCardinalityKeyNames.FIELD_PATH,
context.getEnvironment().getExecutionStepInfo().getPath().toString());
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2020-2022 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.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.graphql.observation.GraphQlObservationDocumentation.ExecutionRequestHighCardinalityKeyNames;
import org.springframework.graphql.observation.GraphQlObservationDocumentation.ExecutionRequestLowCardinalityKeyNames;
/**
* Default implementation for a {@link ExecutionRequestObservationConvention}
* extracting information from a {@link ExecutionRequestObservationContext}.
*
* @author Brian Clozel
* @since 1.1.0
*/
public class DefaultExecutionRequestObservationConvention implements ExecutionRequestObservationConvention {
private static final String DEFAULT_NAME = "graphql.request";
private static final String BASE_CONTEXTUAL_NAME = "graphQL ";
private static final KeyValue OUTCOME_SUCCESS = KeyValue.of(ExecutionRequestLowCardinalityKeyNames.OUTCOME, "SUCCESS");
private static final KeyValue OUTCOME_REQUEST_ERROR = KeyValue.of(ExecutionRequestLowCardinalityKeyNames.OUTCOME, "REQUEST_ERROR");
private static final KeyValue OUTCOME_INTERNAL_ERROR = KeyValue.of(ExecutionRequestLowCardinalityKeyNames.OUTCOME, "INTERNAL_ERROR");
private static final KeyValue OPERATION_QUERY = KeyValue.of(ExecutionRequestLowCardinalityKeyNames.OPERATION, "query");
private final String name;
public DefaultExecutionRequestObservationConvention() {
this(DEFAULT_NAME);
}
public DefaultExecutionRequestObservationConvention(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
@Override
public String getContextualName(ExecutionRequestObservationContext context) {
return BASE_CONTEXTUAL_NAME + context.getCarrier().getOperationName();
}
@Override
public KeyValues getLowCardinalityKeyValues(ExecutionRequestObservationContext context) {
return KeyValues.of(outcome(context), operation(context));
}
protected KeyValue outcome(ExecutionRequestObservationContext context) {
if (context.getError() != null || context.getResponse() == null) {
return OUTCOME_INTERNAL_ERROR;
}
else if (context.getResponse().getErrors().size() > 0) {
return OUTCOME_REQUEST_ERROR;
}
return OUTCOME_SUCCESS;
}
protected KeyValue operation(ExecutionRequestObservationContext context) {
String operationName = context.getCarrier().getOperationName();
if (operationName != null) {
return KeyValue.of(ExecutionRequestLowCardinalityKeyNames.OPERATION, operationName);
}
return OPERATION_QUERY;
}
@Override
public KeyValues getHighCardinalityKeyValues(ExecutionRequestObservationContext context) {
return KeyValues.of(executionId(context));
}
protected KeyValue executionId(ExecutionRequestObservationContext context) {
return KeyValue.of(ExecutionRequestHighCardinalityKeyNames.EXECUTION_ID, context.getCarrier().getExecutionId().toString());
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2020-2022 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.observation;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import io.micrometer.observation.transport.RequestReplyReceiverContext;
/**
* Context that holds information for metadata collection during observations
* for {@link GraphQlObservationDocumentation#EXECUTION_REQUEST GraphQL requests}.
* <p>This context also extends {@link RequestReplyReceiverContext} for propagating
* tracing information with the HTTP server exchange.
*
* @author Brian Clozel
* @since 1.1.0
*/
public class ExecutionRequestObservationContext extends RequestReplyReceiverContext<ExecutionInput, ExecutionResult> {
public ExecutionRequestObservationContext(ExecutionInput executionInput) {
super((input, key) -> executionInput.getExtensions().get(key).toString());
setCarrier(executionInput);
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2020-2022 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.observation;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* Interface for an {@link ObservationConvention} for {@link GraphQlObservationDocumentation#EXECUTION_REQUEST GraphQL requests}.
*
* @author Brian Clozel
* @since 1.1.0
*/
public interface ExecutionRequestObservationConvention extends ObservationConvention<ExecutionRequestObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ExecutionRequestObservationContext;
}
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2020-2022 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.observation;
import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters;
import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
/**
* Documented {@link io.micrometer.common.KeyValue KeyValues} for {@link graphql.GraphQL GraphQL server observations}.
* <p>This class is used by automated tools to document KeyValues attached to the GraphQL execution request
* and data fetcher observations.
*
* @author Brian Clozel
* @since 1.1.0
*/
public enum GraphQlObservationDocumentation implements ObservationDocumentation {
/**
* Observation created for GraphQL execution requests.
*/
EXECUTION_REQUEST {
@Override
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultExecutionRequestObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return ExecutionRequestLowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return ExecutionRequestHighCardinalityKeyNames.values();
}
},
/**
* Observation created for {@link InstrumentationFieldFetchParameters#isTrivialDataFetcher() non-trivial}
* data fetching operations.
*/
DATA_FETCHER {
@Override
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultDataFetcherObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return DataFetcherLowCardinalityKeyNames.values();
}
};
public enum ExecutionRequestLowCardinalityKeyNames implements KeyName {
/**
* Outcome of the GraphQL request.
*/
OUTCOME {
@Override
public String asString() {
return "outcome";
}
},
/**
* GraphQL Operation name.
*/
OPERATION {
@Override
public String asString() {
return "operation";
}
}
}
public enum ExecutionRequestHighCardinalityKeyNames implements KeyName {
/**
* {@link graphql.execution.ExecutionId} of the GraphQL request.
*/
EXECUTION_ID {
@Override
public String asString() {
return "execution.id";
}
}
}
public enum DataFetcherLowCardinalityKeyNames implements KeyName {
/**
* Outcome of the GraphQL data fetching operation.
*/
OUTCOME {
@Override
public String asString() {
return "outcome";
}
},
/**
* Name of the field being fetched.
*/
FIELD_NAME {
@Override
public String asString() {
return "field.name";
}
},
/**
* Class name of the data fetching error
*/
ERROR_TYPE {
@Override
public String asString() {
return "error.type";
}
}
}
public enum DataFetcherHighCardinalityKeyNames implements KeyName {
/**
* Path to the field being fetched.
*/
FIELD_PATH {
@Override
public String asString() {
return "field.path";
}
}
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright 2020-2022 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.observation;
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.InstrumentationCreateStateParameters;
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters;
import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters;
import graphql.schema.DataFetcher;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
/**
* {@link SimpleInstrumentation} that creates {@link Observation observations}
* for GraphQL requests and data fetcher operations.
* <p>GraphQL request instrumentation measures the execution time of requests
* and collects information from the {@link ExecutionRequestObservationContext}.
* A request can perform many data fetching operations.
* The configured {@link ExecutionRequestObservationConvention} will be used,
* or the {@link DefaultExecutionRequestObservationConvention} if none was provided.
* <p>GraphQL data fetcher instrumentation measures the execution time of
* a data fetching operation in the context of the current request.
* Information is collected from the {@link DataFetcherObservationContext}.
* The configured {@link DataFetcherObservationConvention} will be used,
* or the {@link DefaultDataFetcherObservationConvention} if none was provided.
*
* @author Brian Clozel
* @since 1.1.0
*/
public class GraphQlObservationInstrumentation extends SimpleInstrumentation {
private static final String OBSERVATION_KEY = "micrometer.observation";
private static final ExecutionRequestObservationConvention DEFAULT_REQUEST_CONVENTION =
new DefaultExecutionRequestObservationConvention();
private static final DataFetcherObservationConvention DEFAULT_DATA_FETCHER_CONVENTION =
new DefaultDataFetcherObservationConvention();
private final ObservationRegistry observationRegistry;
private final ExecutionRequestObservationConvention requestObservationConvention;
private final DataFetcherObservationConvention dataFetcherObservationConvention;
/**
* Create an {@code GraphQlObservationInstrumentation} that records observations
* against the given {@link ObservationRegistry}. The default observation
* conventions will be used.
* @param observationRegistry the registry to use for recording observations
*/
public GraphQlObservationInstrumentation(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
this.requestObservationConvention = new DefaultExecutionRequestObservationConvention();
this.dataFetcherObservationConvention = new DefaultDataFetcherObservationConvention();
}
/**
* Create an {@code GraphQlObservationInstrumentation} that records observations
* against the given {@link ObservationRegistry} with a custom convention.
* @param observationRegistry the registry to use for recording observations
* @param requestObservationConvention the convention to use for request observations
* @param dateFetcherObservationConvention the convention to use for data fetcher observations
*/
public GraphQlObservationInstrumentation(ObservationRegistry observationRegistry,
ExecutionRequestObservationConvention requestObservationConvention,
DataFetcherObservationConvention dateFetcherObservationConvention) {
this.observationRegistry = observationRegistry;
this.requestObservationConvention = requestObservationConvention;
this.dataFetcherObservationConvention = dateFetcherObservationConvention;
}
@Override
public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
return new RequestObservationInstrumentationState();
}
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters,
InstrumentationState state) {
if (state instanceof RequestObservationInstrumentationState instrumentationState) {
ExecutionRequestObservationContext observationContext = new ExecutionRequestObservationContext(parameters.getExecutionInput());
Observation requestObservation = instrumentationState.createRequestObservation(this.requestObservationConvention,
observationContext, this.observationRegistry);
requestObservation.start();
return new SimpleInstrumentationContext<>() {
@Override
public void onCompleted(ExecutionResult result, Throwable exc) {
observationContext.setResponse(result);
if (exc != null) {
observationContext.setError(exc);
requestObservation.error(exc);
}
else {
requestObservation.stop();
}
}
};
}
return super.beginExecution(parameters, state);
}
@Override
public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher,
InstrumentationFieldFetchParameters parameters, InstrumentationState state) {
if (!parameters.isTrivialDataFetcher()
&& state instanceof RequestObservationInstrumentationState instrumentationState) {
return (environment) -> {
DataFetcherObservationContext observationContext = new DataFetcherObservationContext(parameters.getEnvironment());
Observation dataFetcherObservation = instrumentationState.createDataFetcherObservation(
this.dataFetcherObservationConvention, observationContext, this.observationRegistry);
parameters.getExecutionContext().getGraphQLContext().put(OBSERVATION_KEY, dataFetcherObservation);
dataFetcherObservation.start();
try {
Object value = dataFetcher.get(environment);
if (value instanceof CompletionStage<?> completion) {
return completion.whenComplete((result, error) -> {
if (error != null) {
dataFetcherObservation.error(error);
}
observationContext.setValue(result);
dataFetcherObservation.stop();
});
}
else {
observationContext.setValue(value);
dataFetcherObservation.stop();
return value;
}
}
catch (Throwable throwable) {
dataFetcherObservation.error(throwable);
dataFetcherObservation.stop();
throw throwable;
}
};
}
return super.instrumentDataFetcher(dataFetcher, parameters, state);
}
static class RequestObservationInstrumentationState implements InstrumentationState {
private Observation requestObservation;
Observation createRequestObservation(ExecutionRequestObservationConvention convention,
ExecutionRequestObservationContext context, ObservationRegistry registry) {
Observation observation = GraphQlObservationDocumentation.EXECUTION_REQUEST.observation(convention,
DEFAULT_REQUEST_CONVENTION, () -> context, registry);
this.requestObservation = observation;
return observation;
}
Observation createDataFetcherObservation(DataFetcherObservationConvention convention,
DataFetcherObservationContext context, ObservationRegistry registry) {
Observation dataFetcherObservation = GraphQlObservationDocumentation.DATA_FETCHER.observation(convention,
DEFAULT_DATA_FETCHER_CONVENTION, () -> context, registry);
dataFetcherObservation.parentObservation(requestObservation);
return dataFetcherObservation;
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2020-2022 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.
*/
/**
* Support for GraphQL {@link io.micrometer.observation.Observation observability}.
*/
@NonNullApi
@NonNullFields
package org.springframework.graphql.observation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2020-2022 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.observation;
import java.util.function.Consumer;
import graphql.GraphQLContext;
import graphql.execution.ExecutionStepInfo;
import graphql.execution.MergedField;
import graphql.execution.ResultPath;
import graphql.language.Field;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DataFetchingEnvironmentImpl;
import graphql.schema.GraphQLObjectType;
import io.micrometer.common.KeyValue;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DefaultDataFetcherObservationConvention}
*
* @author Brian Clozel
*/
class DefaultDataFetcherObservationConventionTests {
DataFetcherObservationConvention convention = new DefaultDataFetcherObservationConvention();
@Test
void nameHasDefault() {
assertThat(this.convention.getName()).isEqualTo("graphql.datafetcher");
}
@Test
void nameCanBeCustomized() {
String customName = "graphql.custom";
DefaultDataFetcherObservationConvention customConvention = new DefaultDataFetcherObservationConvention(customName);
assertThat(customConvention.getName()).isEqualTo(customName);
}
@Test
void contextualNameContainsFieldName() {
DataFetchingEnvironment environment = createDataFetchingEnvironment(builder -> {
builder.mergedField(MergedField.newMergedField(Field.newField("project").build()).build());
});
DataFetcherObservationContext context = new DataFetcherObservationContext(environment);
assertThat(this.convention.getContextualName(context)).isEqualTo("graphQL field project");
}
@Test
void fieldNameKeyValueIsPresent() {
DataFetchingEnvironment environment = createDataFetchingEnvironment(builder -> {
builder.mergedField(MergedField.newMergedField(Field.newField("project").build()).build());
});
DataFetcherObservationContext context = new DataFetcherObservationContext(environment);
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("field.name", "project"));
}
@Test
void errorTypeKeyValueIsPresent() {
DataFetchingEnvironment environment = createDataFetchingEnvironment(builder -> {
builder.mergedField(MergedField.newMergedField(Field.newField("project").build()).build());
});
DataFetcherObservationContext context = new DataFetcherObservationContext(environment);
context.setError(new IllegalStateException("custom data fetching failure"));
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("error.type", "IllegalStateException"));
}
@Test
void fieldPathKeyValueIsPresent() {
DataFetchingEnvironment environment = createDataFetchingEnvironment(builder -> {
builder.mergedField(MergedField.newMergedField(Field.newField("project").build()).build())
.executionStepInfo(ExecutionStepInfo.newExecutionStepInfo().type(new GraphQLObjectType.Builder().name("project").build())
.path(ResultPath.parse("/projectBySlug/releases")).build());
});
DataFetcherObservationContext context = new DataFetcherObservationContext(environment);
context.setError(new IllegalStateException("custom data fetching failure"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("field.path", "/projectBySlug/releases"));
}
private DataFetchingEnvironment createDataFetchingEnvironment(Consumer<DataFetchingEnvironmentImpl.Builder> consumer) {
GraphQLContext graphQLContext = new GraphQLContext.Builder().build();
DataFetchingEnvironmentImpl.Builder builder = DataFetchingEnvironmentImpl.newDataFetchingEnvironment()
.graphQLContext(graphQLContext);
consumer.accept(builder);
return builder.build();
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2020-2022 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.observation;
import java.util.function.Consumer;
import graphql.ExecutionInput;
import graphql.ExecutionResultImpl;
import graphql.execution.ExecutionId;
import graphql.schema.idl.errors.QueryOperationMissingError;
import io.micrometer.common.KeyValue;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DefaultExecutionRequestObservationConvention}.
*
* @author Brian Clozel
*/
class DefaultExecutionRequestObservationConventionTests {
DefaultExecutionRequestObservationConvention convention = new DefaultExecutionRequestObservationConvention();
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }")
.executionId(ExecutionId.from("42"))
.operationName("query").build();
@Test
void nameHasDefault() {
assertThat(this.convention.getName()).isEqualTo("graphql.request");
}
@Test
void nameCanBeCustomized() {
String customName = "graphql.custom";
DefaultExecutionRequestObservationConvention customConvention = new DefaultExecutionRequestObservationConvention(customName);
assertThat(customConvention.getName()).isEqualTo(customName);
}
@Test
void hasContextualName() {
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }")
.operationName("mutation").build();
ExecutionRequestObservationContext context = createObservationContext(input, builder -> {});
assertThat(this.convention.getContextualName(context)).isEqualTo("graphQL mutation");
}
@Test
void hasOperationKeyValueWhenSuccessfulOutput() {
ExecutionRequestObservationContext context = createObservationContext(this.input, builder -> {
});
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("operation", "query"));
}
@Test
void hasOutcomeKeyValueWhenSuccessfulOutput() {
ExecutionRequestObservationContext context = createObservationContext(this.input, builder -> {
});
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "SUCCESS"));
}
@Test
void hasOutcomeKeyValueWhenErrorOutput() {
ExecutionRequestObservationContext context = createObservationContext(this.input,
builder -> builder.addError(new QueryOperationMissingError()));
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "REQUEST_ERROR"));
}
@Test
void hasOutcomeKeyValueWhenInternalError() {
ExecutionRequestObservationContext context = createObservationContext(this.input, builder -> {
});
context.setError(new IllegalStateException("custom internal error"));
assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "INTERNAL_ERROR"));
}
@Test
void hasExecutionIdKeyValue() {
ExecutionRequestObservationContext context = createObservationContext(this.input, builder -> {
});
assertThat(this.convention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("execution.id", "42"));
}
private ExecutionRequestObservationContext createObservationContext(ExecutionInput executionInput, Consumer<ExecutionResultImpl.Builder> resultConsumer) {
ExecutionRequestObservationContext context = new ExecutionRequestObservationContext(executionInput);
ExecutionResultImpl.Builder builder = ExecutionResultImpl.newExecutionResult();
resultConsumer.accept(builder);
context.setResponse(builder.build());
return context;
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright 2020-2022 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.observation;
import java.util.concurrent.CompletableFuture;
import graphql.GraphqlErrorBuilder;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.graphql.BookSource;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.graphql.ResponseHelper;
import org.springframework.graphql.TestExecutionRequest;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.ErrorType;
/**
* Tests for {@link GraphQlObservationInstrumentation}.
*
* @author Brian Clozel
*/
class GraphQlObservationInstrumentationTests {
private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
private final GraphQlObservationInstrumentation instrumentation = new GraphQlObservationInstrumentation(this.observationRegistry);
private final GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema).instrumentation(this.instrumentation);
@Test
void instrumentGraphQlRequestWhenSuccess() {
String document = """
{
bookById(id: 1) {
name
}
}
""";
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.queryFetcher("bookById", env -> BookSource.getBookWithoutAuthor(1L))
.toGraphQlService()
.execute(TestExecutionRequest.forDocument(document));
ResponseHelper response = ResponseHelper.forResponse(responseMono);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("outcome", "SUCCESS")
.hasHighCardinalityKeyValueWithKey("execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.datafetcher", 1)
.hasObservationWithNameEqualTo("graphql.datafetcher")
.that()
.hasLowCardinalityKeyValue("outcome", "SUCCESS")
.hasLowCardinalityKeyValue("field.name", "bookById")
.hasHighCardinalityKeyValue("field.path", "/bookById");
}
@Test
void instrumentGraphQlRequestWhenInvalidRequest() {
String document = "invalid";
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.queryFetcher("bookById", env -> BookSource.getBookWithoutAuthor(1L))
.toGraphQlService()
.execute(TestExecutionRequest.forDocument(document));
ResponseHelper response = ResponseHelper.forResponse(responseMono);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("outcome", "REQUEST_ERROR")
.hasHighCardinalityKeyValueWithKey("execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.datafetcher", 0);
}
@Test
void instrumentMultipleDataFetcherOperations() {
String document = """
{
bookById(id: 1) {
author {
firstName,
lastName
}
}
}
""";
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.queryFetcher("bookById", env -> BookSource.getBookWithoutAuthor(1L))
.dataFetcher("Book", "author", env -> BookSource.getAuthor(101L))
.toGraphQlService()
.execute(TestExecutionRequest.forDocument(document));
ResponseHelper response = ResponseHelper.forResponse(responseMono);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("outcome", "SUCCESS")
.hasHighCardinalityKeyValueWithKey("execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.datafetcher", 2);
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasObservationWithNameEqualTo("graphql.datafetcher")
.that()
.hasLowCardinalityKeyValue("outcome", "SUCCESS")
.hasLowCardinalityKeyValue("field.name", "bookById")
.hasHighCardinalityKeyValue("field.path", "/bookById");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasAnObservationWithAKeyValue("field.name", "author")
.hasAnObservationWithAKeyValue("field.path", "/bookById/author");
}
@Test
void instrumentGraphQlRequestWhenDataFetchingFailure() {
String document = """
{
bookById(id: 1) {
name
}
}
""";
DataFetcherExceptionResolver resolver =
DataFetcherExceptionResolver.forSingleError((ex, env) ->
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage())
.errorType(ErrorType.BAD_REQUEST).build());
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.exceptionResolver(resolver)
.queryFetcher("bookById", env ->
CompletableFuture.failedStage(new IllegalStateException("book fetching failure")))
.toGraphQlService()
.execute(TestExecutionRequest.forDocument(document));
ResponseHelper response = ResponseHelper.forResponse(responseMono);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("outcome", "REQUEST_ERROR")
.hasHighCardinalityKeyValueWithKey("execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.datafetcher", 1)
.hasObservationWithNameEqualTo("graphql.datafetcher")
.that()
.hasLowCardinalityKeyValue("outcome", "ERROR")
.hasLowCardinalityKeyValue("error.type", "IllegalStateException")
.hasLowCardinalityKeyValue("field.name", "bookById")
.hasHighCardinalityKeyValue("field.path", "/bookById");
}
@Test
void propagatesContextBetweenObservations() {
String document = """
{
bookById(id: 1) {
name
}
}
""";
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.queryFetcher("bookById", env -> BookSource.getBookWithoutAuthor(1L))
.toGraphQlService()
.execute(TestExecutionRequest.forDocument(document));
ResponseHelper response = ResponseHelper.forResponse(responseMono);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("outcome", "SUCCESS")
.hasHighCardinalityKeyValueWithKey("execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasObservationWithNameEqualTo("graphql.datafetcher")
.that()
.hasParentObservationContextMatching(context -> context instanceof ExecutionRequestObservationContext);
}
}

View File

@@ -21,6 +21,7 @@ import java.util.Arrays;
import java.util.List;
import graphql.GraphQL;
import graphql.execution.instrumentation.Instrumentation;
import graphql.schema.DataFetcher;
import graphql.schema.GraphQLTypeVisitor;
import graphql.schema.TypeResolver;
@@ -96,6 +97,11 @@ public class GraphQlSetup implements GraphQlServiceSetup {
return this;
}
public GraphQlSetup instrumentation(Instrumentation... instrumentations) {
this.graphQlSourceBuilder.instrumentation(Arrays.asList(instrumentations));
return this;
}
public GraphQlSetup subscriptionExceptionResolvers(SubscriptionExceptionResolver... resolvers) {
this.graphQlSourceBuilder.subscriptionExceptionResolvers(Arrays.asList(resolvers));
return this;