diff --git a/platform/build.gradle b/platform/build.gradle index 275aa4da..2eebb753 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -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")) diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index f29bdc90..1320e0ce 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -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' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationContext.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationContext.java new file mode 100644 index 00000000..d32b5aff --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationContext.java @@ -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; + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationConvention.java new file mode 100644 index 00000000..a7731e11 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataFetcherObservationConvention.java @@ -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 { + + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof DataFetcherObservationContext; + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConvention.java new file mode 100644 index 00000000..2b4ea8fa --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConvention.java @@ -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()); + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConvention.java new file mode 100644 index 00000000..2b81c17e --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConvention.java @@ -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()); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java new file mode 100644 index 00000000..c05b069c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java @@ -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}. + *

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 { + + public ExecutionRequestObservationContext(ExecutionInput executionInput) { + super((input, key) -> executionInput.getExtensions().get(key).toString()); + setCarrier(executionInput); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationConvention.java new file mode 100644 index 00000000..f0f9b661 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationConvention.java @@ -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 { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof ExecutionRequestObservationContext; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java new file mode 100644 index 00000000..c475acf9 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java @@ -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}. + *

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> 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> 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"; + } + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java new file mode 100644 index 00000000..e7869107 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java @@ -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. + *

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. + *

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 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; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/package-info.java new file mode 100644 index 00000000..1901f993 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/package-info.java @@ -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; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConventionTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConventionTests.java new file mode 100644 index 00000000..7dddc575 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultDataFetcherObservationConventionTests.java @@ -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 consumer) { + GraphQLContext graphQLContext = new GraphQLContext.Builder().build(); + DataFetchingEnvironmentImpl.Builder builder = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .graphQLContext(graphQLContext); + consumer.accept(builder); + return builder.build(); + } +} \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConventionTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConventionTests.java new file mode 100644 index 00000000..2b3f1322 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/DefaultExecutionRequestObservationConventionTests.java @@ -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 resultConsumer) { + ExecutionRequestObservationContext context = new ExecutionRequestObservationContext(executionInput); + ExecutionResultImpl.Builder builder = ExecutionResultImpl.newExecutionResult(); + resultConsumer.accept(builder); + context.setResponse(builder.build()); + return context; + } + +} \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java new file mode 100644 index 00000000..21f8403c --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java @@ -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 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 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 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 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 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); + } + + + +} \ No newline at end of file diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java index 7f30b8de..84182c53 100644 --- a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java +++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java @@ -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;