diff --git a/platform/build.gradle b/platform/build.gradle index 6dbbc008..e864be82 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -11,6 +11,7 @@ dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.14.1")) api(platform("io.projectreactor:reactor-bom:2022.0.0")) api(platform("io.micrometer:micrometer-bom:1.10.2")) + api(platform("io.micrometer:micrometer-tracing-bom:1.0.0")) api(platform("org.springframework.data:spring-data-bom:2022.0.0")) api(platform("org.springframework.security:spring-security-bom:6.0.0")) api(platform("com.querydsl:querydsl-bom:5.0.0")) diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index e49ea7b3..8f45ef75 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation 'io.micrometer:context-propagation' compileOnly 'io.micrometer:micrometer-observation' + compileOnly 'io.micrometer:micrometer-tracing' compileOnly 'jakarta.annotation:jakarta.annotation-api' compileOnly 'org.springframework:spring-webflux' compileOnly 'org.springframework:spring-webmvc' @@ -45,6 +46,7 @@ dependencies { testImplementation 'org.springframework.data:spring-data-keyvalue' testImplementation 'org.springframework.data:spring-data-jpa' testImplementation 'io.micrometer:micrometer-observation-test' + testImplementation 'io.micrometer:micrometer-tracing-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/ExecutionRequestObservationContext.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java index c05b069c..fff84209 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/ExecutionRequestObservationContext.java @@ -16,6 +16,8 @@ package org.springframework.graphql.observation; +import java.util.Map; + import graphql.ExecutionInput; import graphql.ExecutionResult; import io.micrometer.observation.transport.RequestReplyReceiverContext; @@ -24,7 +26,8 @@ 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. + * tracing information from the {@link graphql.GraphQLContext} + * or the {@link ExecutionInput#getExtensions() input extensions}. * * @author Brian Clozel * @since 1.1.0 @@ -32,8 +35,23 @@ import io.micrometer.observation.transport.RequestReplyReceiverContext; public class ExecutionRequestObservationContext extends RequestReplyReceiverContext { public ExecutionRequestObservationContext(ExecutionInput executionInput) { - super((input, key) -> executionInput.getExtensions().get(key).toString()); + super(ExecutionRequestObservationContext::getContextValue); setCarrier(executionInput); } + /** + * Read propagation field from the {@link graphql.GraphQLContext}, + * or the {@link ExecutionInput#getExtensions() input extensions} as a fallback. + */ + private static String getContextValue(ExecutionInput executionInput, String key) { + String value = executionInput.getGraphQLContext().get(key); + if (value == null) { + Map extensions = executionInput.getExtensions(); + if (extensions != null) { + value = (String) extensions.get(key); + } + } + return value; + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptor.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptor.java new file mode 100644 index 00000000..c883d0d7 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptor.java @@ -0,0 +1,65 @@ +/* + * 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.tracing.propagation.Propagator; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; + +/** + * {@link WebGraphQlInterceptor} that copies {@link Propagator propagation} headers + * from the HTTP request to the {@link graphql.GraphQLContext}. + * This makes it possible to propagate tracing information sent by HTTP clients. + * + * @author Brian Clozel + * @since 1.1.1 + */ +public class PropagationWebGraphQlInterceptor implements WebGraphQlInterceptor { + + private final Propagator propagator; + + /** + * Create an interceptor that leverages the field names used by the given + * {@link Propagator} instance. + * + * @param propagator the propagator that will be used for tracing support + */ + public PropagationWebGraphQlInterceptor(Propagator propagator) { + Assert.notNull(propagator, "propagator should not be null"); + this.propagator = propagator; + } + + @Override + public Mono intercept(WebGraphQlRequest request, Chain chain) { + request.configureExecutionInput((input, inputBuilder) -> { + HttpHeaders headers = request.getHeaders(); + for (String field : this.propagator.fields()) { + if (headers.containsKey(field)) { + inputBuilder.graphQLContext(contextBuilder -> contextBuilder.of(field, headers.getFirst(field))); + } + } + return inputBuilder.build(); + }); + return chain.next(request); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/ExecutionRequestObservationContextTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/ExecutionRequestObservationContextTests.java new file mode 100644 index 00000000..6e5df645 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/ExecutionRequestObservationContextTests.java @@ -0,0 +1,62 @@ +/* + * 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.Map; + +import graphql.ExecutionInput; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExecutionRequestObservationContext}. + * + * @author Brian Clozel + */ +class ExecutionRequestObservationContextTests { + + @Test + void readPropagationFieldFromGraphQlContext() { + ExecutionInput executionInput = ExecutionInput + .newExecutionInput("{ notUsed }") + .graphQLContext(builder -> builder.of("X-Tracing-Test", "traceId")) + .build(); + ExecutionRequestObservationContext context = new ExecutionRequestObservationContext(executionInput); + assertThat(context.getGetter().get(executionInput, "X-Tracing-Test")).isEqualTo("traceId"); + } + + @Test + void readPropagationFieldFromExtensions() { + ExecutionInput executionInput = ExecutionInput + .newExecutionInput("{ notUsed }") + .extensions(Map.of("X-Tracing-Test", "traceId")) + .build(); + ExecutionRequestObservationContext context = new ExecutionRequestObservationContext(executionInput); + assertThat(context.getGetter().get(executionInput, "X-Tracing-Test")).isEqualTo("traceId"); + } + + @Test + void doesNotFailIsMissingPropagationField() { + ExecutionInput executionInput = ExecutionInput + .newExecutionInput("{ notUsed }") + .build(); + ExecutionRequestObservationContext context = new ExecutionRequestObservationContext(executionInput); + assertThat(context.getGetter().get(executionInput, "X-Tracing-Test")).isNull(); + } +} \ 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 index 7f4edce2..cf6e6cd4 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java @@ -16,15 +16,26 @@ package org.springframework.graphql.observation; +import java.util.List; import java.util.concurrent.CompletableFuture; import graphql.GraphqlErrorBuilder; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; +import io.micrometer.observation.transport.ReceiverContext; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler; +import io.micrometer.tracing.handler.TracingObservationHandler; +import io.micrometer.tracing.propagation.Propagator; +import io.micrometer.tracing.test.simple.SimpleSpanBuilder; +import io.micrometer.tracing.test.simple.SimpleTracer; +import io.micrometer.tracing.test.simple.TracerAssert; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import org.springframework.graphql.BookSource; +import org.springframework.graphql.ExecutionGraphQlRequest; import org.springframework.graphql.ExecutionGraphQlResponse; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; @@ -32,6 +43,8 @@ import org.springframework.graphql.TestExecutionRequest; import org.springframework.graphql.execution.DataFetcherExceptionResolver; import org.springframework.graphql.execution.ErrorType; +import static org.assertj.core.api.Assertions.assertThat; + /** * Tests for {@link GraphQlObservationInstrumentation}. * @@ -186,6 +199,65 @@ class GraphQlObservationInstrumentationTests { .hasParentObservationContextMatching(context -> context instanceof ExecutionRequestObservationContext); } + @Test + void inboundTracingInformationIsPropagated() { + SimpleTracer simpleTracer = new SimpleTracer(); + String traceId = "traceId"; + TracingObservationHandler tracingHandler = new PropagatingReceiverTracingObservationHandler<>(simpleTracer, new TestPropagator(simpleTracer, traceId)); + this.observationRegistry.observationConfig().observationHandler(tracingHandler); + String document = """ + { + bookById(id: 1) { + name + } + } + """; + ExecutionGraphQlRequest executionRequest = TestExecutionRequest.forDocument(document); + executionRequest.configureExecutionInput((input, builder) -> + builder.graphQLContext(context -> context.of(TestPropagator.TRACING_HEADER_NAME, traceId)).build()); + Mono responseMono = graphQlSetup + .queryFetcher("bookById", env -> BookSource.getBookWithoutAuthor(1L)) + .toGraphQlService() + .execute(executionRequest); + ResponseHelper response = ResponseHelper.forResponse(responseMono); + TracerAssert.assertThat(simpleTracer) + .onlySpan() + .hasNameEqualTo("graphql query") + .hasKindEqualTo(Span.Kind.SERVER) + .hasTag("graphql.operation", "query") + .hasTag("graphql.outcome", "SUCCESS") + .hasTagWithKey("graphql.execution.id"); + } -} \ No newline at end of file + static class TestPropagator implements Propagator { + + public static String TRACING_HEADER_NAME = "X-Test-Tracing"; + + private final SimpleTracer tracer; + + private final String traceId; + + TestPropagator(SimpleTracer tracer, String traceId) { + this.tracer = tracer; + this.traceId = traceId; + } + + @Override + public List fields() { + return List.of(TRACING_HEADER_NAME); + } + + @Override + public void inject(TraceContext context, C carrier, Setter setter) { + setter.set(carrier, TRACING_HEADER_NAME, "traceId"); + } + + @Override + public Span.Builder extract(C carrier, Getter getter) { + String foo = getter.get(carrier, TRACING_HEADER_NAME); + assertThat(foo).isEqualTo(this.traceId); + return new SimpleSpanBuilder(this.tracer); + } + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptorTests.java new file mode 100644 index 00000000..95364356 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/PropagationWebGraphQlInterceptorTests.java @@ -0,0 +1,113 @@ +/* + * 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.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import graphql.ExecutionInput; +import graphql.ExecutionResultImpl; +import graphql.GraphQLContext; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.propagation.Propagator; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.ExecutionGraphQlRequest; +import org.springframework.graphql.ExecutionGraphQlResponse; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.support.DefaultExecutionGraphQlResponse; +import org.springframework.http.HttpHeaders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link PropagationWebGraphQlInterceptor}. + * + * @author Brian Clozel + */ +class PropagationWebGraphQlInterceptorTests { + + PropagationWebGraphQlInterceptor interceptor = new PropagationWebGraphQlInterceptor(new TestPropagator()); + + @Test + void rejectsNullPropagator() { + assertThatThrownBy(() -> new PropagationWebGraphQlInterceptor(null)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("propagator should not be null"); + } + + @Test + void copiesPropagationHeadersWhenPresent() { + Map tracingHeaders = Map.of("X-Test-TraceId", "traceId", "baggage", "project=spring"); + Map httpHeaders = new HashMap<>(); + httpHeaders.put("Accept", "application/graphql+json"); + httpHeaders.putAll(tracingHeaders); + WebGraphQlRequest webRequest = createRequest(httpHeaders); + + WebGraphQlHandler handler = WebGraphQlHandler.builder(request -> { + GraphQLContext context = request.toExecutionInput().getGraphQLContext(); + assertThatContextContains(context, tracingHeaders); + return emptyExecutionResult(request); + }).interceptor(this.interceptor).build(); + handler.handleRequest(webRequest).block(); + } + + private void assertThatContextContains(GraphQLContext context, Map tracingHeaders) { + tracingHeaders.forEach((key, value) -> { + String actual = context.get(key); + assertThat(actual).isEqualTo(value); + }); + } + + WebGraphQlRequest createRequest(Map headers) { + HttpHeaders httpHeaders = new HttpHeaders(); + headers.forEach(httpHeaders::set); + return new WebGraphQlRequest(URI.create("https://example.org/graphql"), httpHeaders, Map.of("query", "{ notUsed }"), "1", null); + } + + private Mono emptyExecutionResult(ExecutionGraphQlRequest request) { + return Mono.just(new DefaultExecutionGraphQlResponse( + ExecutionInput.newExecutionInput("{}").build(), + ExecutionResultImpl.newExecutionResult().build())); + } + + + static class TestPropagator implements Propagator { + + @Override + public List fields() { + return List.of("X-Test-TraceId", "baggage"); + } + + @Override + public void inject(TraceContext context, C carrier, Setter setter) { + + } + + @Override + public Span.Builder extract(C carrier, Getter getter) { + return null; + } + } + +} \ No newline at end of file