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:
@@ -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"))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user