diff --git a/spring-grpc-client-spring-boot-autoconfigure/pom.xml b/spring-grpc-client-spring-boot-autoconfigure/pom.xml index 066306a5..d04cdb53 100644 --- a/spring-grpc-client-spring-boot-autoconfigure/pom.xml +++ b/spring-grpc-client-spring-boot-autoconfigure/pom.xml @@ -135,6 +135,11 @@ micrometer-core true + + io.micrometer + micrometer-tracing + true + io.netty netty-transport-native-epoll diff --git a/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfiguration.java b/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfiguration.java new file mode 100644 index 00000000..b64346c6 --- /dev/null +++ b/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfiguration.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.List; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.GlobalClientInterceptor; + +import io.micrometer.tracing.Tracer; + +/** + * {@link org.springframework.boot.autoconfigure.EnableAutoConfiguration + * Auto-configuration} for gRPC client-side baggage propagation to metadata headers. + *

+ * This configuration automatically propagates OpenTelemetry baggage values (based on + * {@code management.tracing.baggage.remote-fields}) as gRPC metadata headers in outbound + * calls to downstream services. + * + * @author Oleksandr Shevchenko + * @since 1.2.0 + */ +@AutoConfiguration( + afterName = { "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration" }, + before = GrpcClientObservationAutoConfiguration.class) +@ConditionalOnGrpcClientEnabled +@ConditionalOnClass(Tracer.class) +@ConditionalOnBean(Tracer.class) +@ConditionalOnProperty(name = "management.tracing.baggage.enabled", havingValue = "true", matchIfMissing = false) +public final class GrpcClientHeaderAutoConfiguration { + + @Bean + @GlobalClientInterceptor + GrpcHeaderClientInterceptor grpcHeaderClientInterceptor(final Tracer tracer, final Environment environment) { + List remoteFields = Binder.get(environment) + .bind("management.tracing.baggage.remote-fields", Bindable.listOf(String.class)) + .orElse(List.of()); + return new GrpcHeaderClientInterceptor(tracer, remoteFields); + } + +} diff --git a/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptor.java b/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptor.java new file mode 100644 index 00000000..9f002427 --- /dev/null +++ b/spring-grpc-client-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptor.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.List; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.micrometer.tracing.Baggage; +import io.micrometer.tracing.Tracer; + +/** + * A gRPC {@link ClientInterceptor} that propagates OpenTelemetry baggage to outbound gRPC + * calls by adding them as metadata headers. + *

+ * This interceptor ensures that baggage values are automatically forwarded to downstream + * services in gRPC metadata headers. + *

+ * The baggage fields to propagate are configured via + * {@code management.tracing.baggage.remote-fields} in Spring Boot configuration. + * + * @author Oleksandr Shevchenko + * @since 1.2.0 + */ +public class GrpcHeaderClientInterceptor implements ClientInterceptor { + + private final Tracer tracer; + + private final List remoteFields; + + /** + * Creates a new {@code GrpcHeaderClientInterceptor}. + * @param tracer the tracer to use for accessing baggage + * @param remoteFields the list of baggage field names to propagate as gRPC metadata + * headers + */ + public GrpcHeaderClientInterceptor(final Tracer tracer, final List remoteFields) { + this.tracer = tracer; + this.remoteFields = remoteFields; + } + + @Override + public ClientCall interceptCall(final MethodDescriptor method, + final CallOptions callOptions, final Channel next) { + + return new ForwardingClientCall.SimpleForwardingClientCall<>(next.newCall(method, callOptions)) { + @Override + public void start(final Listener responseListener, final Metadata headers) { + for (String fieldName : GrpcHeaderClientInterceptor.this.remoteFields) { + Baggage baggage = GrpcHeaderClientInterceptor.this.tracer.getBaggage(fieldName); + if (baggage != null) { + String value = baggage.get(); + if (value != null) { + Metadata.Key key = Metadata.Key.of(fieldName, Metadata.ASCII_STRING_MARSHALLER); + headers.put(key, value); + } + } + } + super.start(responseListener, headers); + } + }; + } + +} diff --git a/spring-grpc-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-grpc-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 7d3249ab..17be669b 100644 --- a/spring-grpc-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-grpc-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration org.springframework.boot.grpc.client.autoconfigure.GrpcClientObservationAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientHeaderAutoConfiguration diff --git a/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfigurationTests.java b/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfigurationTests.java new file mode 100644 index 00000000..e62a418f --- /dev/null +++ b/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientHeaderAutoConfigurationTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import io.micrometer.tracing.Tracer; + +class GrpcClientHeaderAutoConfigurationTests { + + private final ApplicationContextRunner baseContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientHeaderAutoConfiguration.class)); + + private ApplicationContextRunner validContextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientHeaderAutoConfiguration.class)) + .withBean("tracer", Tracer.class, () -> mock(Tracer.class)) + .withPropertyValues("management.tracing.baggage.enabled=true", + "management.tracing.baggage.remote-fields=x-request-id,x-user-id"); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.stub.AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenTracerNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(Tracer.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenTracerNotProvidedThenAutoConfigSkipped() { + this.baseContextRunner.withPropertyValues("management.tracing.baggage.enabled=true") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyDisabledThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("management.tracing.baggage.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyNotSetThenAutoConfigIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GrpcClientHeaderAutoConfiguration.class)) + .withBean("tracer", Tracer.class, () -> mock(Tracer.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyEnabledAndClientNotDisabledThenAutoConfigNotSkipped() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcClientHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyEnabledThenInterceptorIsCreated() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcHeaderClientInterceptor.class)); + } + +} diff --git a/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptorTests.java b/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptorTests.java new file mode 100644 index 00000000..d2f657d6 --- /dev/null +++ b/spring-grpc-client-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcHeaderClientInterceptorTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.micrometer.tracing.Baggage; +import io.micrometer.tracing.Tracer; + +class GrpcHeaderClientInterceptorTests { + + private Tracer tracer; + + private Channel channel; + + private ClientCall clientCall; + + @BeforeEach + void setUp() { + this.tracer = mock(Tracer.class); + this.channel = mock(Channel.class); + this.clientCall = mock(ClientCall.class); + } + + @Test + void shouldAddBaggageToMetadataHeaders() { + List remoteFields = List.of("x-request-id", "x-user-id"); + GrpcHeaderClientInterceptor interceptor = new GrpcHeaderClientInterceptor(this.tracer, remoteFields); + + Baggage requestIdBaggage = mock(Baggage.class); + when(requestIdBaggage.get()).thenReturn("req-123"); + Baggage userIdBaggage = mock(Baggage.class); + when(userIdBaggage.get()).thenReturn("user-456"); + + when(this.tracer.getBaggage("x-request-id")).thenReturn(requestIdBaggage); + when(this.tracer.getBaggage("x-user-id")).thenReturn(userIdBaggage); + + MethodDescriptor methodDescriptor = mock(MethodDescriptor.class); + CallOptions callOptions = CallOptions.DEFAULT; + + when(this.channel.newCall(any(), any())).thenAnswer(invocation -> this.clientCall); + + ClientCall interceptedCall = interceptor.interceptCall(methodDescriptor, callOptions, + this.channel); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ClientCall.Listener listener = mock(ClientCall.Listener.class); + interceptedCall.start(listener, new Metadata()); + + verify(this.clientCall).start(any(), metadataCaptor.capture()); + + Metadata capturedMetadata = metadataCaptor.getValue(); + assertThat(capturedMetadata.get(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("req-123"); + assertThat(capturedMetadata.get(Metadata.Key.of("x-user-id", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("user-456"); + } + + @Test + void shouldNotAddHeaderWhenBaggageIsNull() { + List remoteFields = List.of("x-request-id"); + GrpcHeaderClientInterceptor interceptor = new GrpcHeaderClientInterceptor(this.tracer, remoteFields); + + when(this.tracer.getBaggage("x-request-id")).thenReturn(null); + + MethodDescriptor methodDescriptor = mock(MethodDescriptor.class); + CallOptions callOptions = CallOptions.DEFAULT; + + when(this.channel.newCall(any(), any())).thenAnswer(invocation -> this.clientCall); + + ClientCall interceptedCall = interceptor.interceptCall(methodDescriptor, callOptions, + this.channel); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ClientCall.Listener listener = mock(ClientCall.Listener.class); + interceptedCall.start(listener, new Metadata()); + + verify(this.clientCall).start(any(), metadataCaptor.capture()); + + Metadata capturedMetadata = metadataCaptor.getValue(); + assertThat(capturedMetadata.get(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER))).isNull(); + } + + @Test + void shouldNotAddHeaderWhenBaggageValueIsNull() { + List remoteFields = List.of("x-request-id"); + GrpcHeaderClientInterceptor interceptor = new GrpcHeaderClientInterceptor(this.tracer, remoteFields); + + Baggage requestIdBaggage = mock(Baggage.class); + when(requestIdBaggage.get()).thenReturn(null); + when(this.tracer.getBaggage("x-request-id")).thenReturn(requestIdBaggage); + + MethodDescriptor methodDescriptor = mock(MethodDescriptor.class); + CallOptions callOptions = CallOptions.DEFAULT; + + when(this.channel.newCall(any(), any())).thenAnswer(invocation -> this.clientCall); + + ClientCall interceptedCall = interceptor.interceptCall(methodDescriptor, callOptions, + this.channel); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ClientCall.Listener listener = mock(ClientCall.Listener.class); + interceptedCall.start(listener, new Metadata()); + + verify(this.clientCall).start(any(), metadataCaptor.capture()); + + Metadata capturedMetadata = metadataCaptor.getValue(); + assertThat(capturedMetadata.get(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER))).isNull(); + } + + @Test + void shouldHandleEmptyRemoteFields() { + List remoteFields = List.of(); + GrpcHeaderClientInterceptor interceptor = new GrpcHeaderClientInterceptor(this.tracer, remoteFields); + + MethodDescriptor methodDescriptor = mock(MethodDescriptor.class); + CallOptions callOptions = CallOptions.DEFAULT; + + when(this.channel.newCall(any(), any())).thenAnswer(invocation -> this.clientCall); + + ClientCall interceptedCall = interceptor.interceptCall(methodDescriptor, callOptions, + this.channel); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ClientCall.Listener listener = mock(ClientCall.Listener.class); + interceptedCall.start(listener, new Metadata()); + + verify(this.clientCall).start(any(), metadataCaptor.capture()); + + Metadata capturedMetadata = metadataCaptor.getValue(); + assertThat(capturedMetadata.keys()).isEmpty(); + } + +} diff --git a/spring-grpc-server-spring-boot-autoconfigure/pom.xml b/spring-grpc-server-spring-boot-autoconfigure/pom.xml index 099624cf..368c071f 100644 --- a/spring-grpc-server-spring-boot-autoconfigure/pom.xml +++ b/spring-grpc-server-spring-boot-autoconfigure/pom.xml @@ -135,6 +135,11 @@ micrometer-core true + + io.micrometer + micrometer-tracing + true + io.micrometer context-propagation diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptor.java b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptor.java new file mode 100644 index 00000000..7f2ff1ac --- /dev/null +++ b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptor.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.micrometer.tracing.BaggageInScope; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; + +/** + * A gRPC {@link ServerInterceptor} that extracts headers from gRPC metadata and adds them + * to OpenTelemetry baggage and span tags. + *

+ * This interceptor enables automatic propagation of metadata headers to downstream + * services via baggage, and makes them visible in traces as span tags. + *

+ * The headers to extract are configured via + * {@code management.tracing.baggage.remote-fields} and + * {@code management.tracing.baggage.tag-fields} in Spring Boot configuration. + * + * @author Oleksandr Shevchenko + * @since 1.2.0 + */ +public class GrpcHeaderServerInterceptor implements ServerInterceptor { + + private final Tracer tracer; + + private final List remoteFields; + + private final List tagFields; + + /** + * Creates a new {@code GrpcHeaderServerInterceptor}. + * @param tracer the tracer to use for accessing the current span and creating baggage + * @param remoteFields the list of header names to extract from gRPC metadata and add + * to baggage for propagation + * @param tagFields the list of baggage field names to add as span tags for visibility + * in traces + */ + public GrpcHeaderServerInterceptor(final Tracer tracer, final List remoteFields, + final List tagFields) { + this.tracer = tracer; + this.remoteFields = remoteFields; + this.tagFields = tagFields; + } + + @Override + public ServerCall.Listener interceptCall(final ServerCall call, + final Metadata headers, final ServerCallHandler next) { + + Span currentSpan = this.tracer.currentSpan(); + List baggageScopes = new ArrayList<>(); + + for (String headerName : this.remoteFields) { + Metadata.Key key = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + String value = headers.get(key); + + if (value != null) { + BaggageInScope baggageInScope = this.tracer.createBaggageInScope(headerName, value); + baggageScopes.add(baggageInScope); + + if (this.tagFields.contains(headerName) && currentSpan != null) { + currentSpan.tag(headerName, value); + } + } + } + + ServerCall.Listener listener = next.startCall(call, headers); + + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(listener) { + @Override + public void onComplete() { + try { + super.onComplete(); + } + finally { + closeBaggageScopes(baggageScopes); + } + } + + @Override + public void onCancel() { + try { + super.onCancel(); + } + finally { + closeBaggageScopes(baggageScopes); + } + } + }; + } + + private void closeBaggageScopes(final List scopes) { + for (BaggageInScope scope : scopes) { + scope.close(); + } + } + +} diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfiguration.java b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfiguration.java new file mode 100644 index 00000000..0f1cb7c5 --- /dev/null +++ b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure; + +import java.util.List; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.grpc.server.GlobalServerInterceptor; + +import io.micrometer.tracing.Tracer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side baggage + * propagation from metadata headers. + *

+ * This configuration automatically extracts headers from gRPC metadata (based on + * {@code management.tracing.baggage.remote-fields}) and: + *

+ * + * @author Oleksandr Shevchenko + * @since 1.2.0 + */ +@AutoConfiguration( + afterName = { "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration" }, + before = GrpcServerObservationAutoConfiguration.class) +@ConditionalOnSpringGrpc +@ConditionalOnClass(Tracer.class) +@ConditionalOnGrpcServerEnabled("baggage") +@ConditionalOnBean(Tracer.class) +@ConditionalOnProperty(name = "management.tracing.baggage.enabled", havingValue = "true", matchIfMissing = false) +public final class GrpcServerHeaderAutoConfiguration { + + @Bean + @Order(-10) + @GlobalServerInterceptor + GrpcHeaderServerInterceptor grpcHeaderServerInterceptor(final Tracer tracer, final Environment environment) { + Binder binder = Binder.get(environment); + List remoteFields = binder + .bind("management.tracing.baggage.remote-fields", Bindable.listOf(String.class)) + .orElse(List.of()); + List tagFields = binder.bind("management.tracing.baggage.tag-fields", Bindable.listOf(String.class)) + .orElse(List.of()); + return new GrpcHeaderServerInterceptor(tracer, remoteFields, tagFields); + } + +} diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index f7650906..c5dae580 100644 --- a/spring-grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,6 +1,7 @@ org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerHeaderAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.GrpcServerReflectionAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.exception.GrpcAdviceAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.exception.GrpcExceptionHandlerAutoConfiguration diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptorTests.java b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptorTests.java new file mode 100644 index 00000000..8b17782d --- /dev/null +++ b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcHeaderServerInterceptorTests.java @@ -0,0 +1,201 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.micrometer.tracing.BaggageInScope; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; + +class GrpcHeaderServerInterceptorTests { + + private Tracer tracer; + + private ServerCall serverCall; + + private ServerCallHandler serverCallHandler; + + private Metadata metadata; + + @BeforeEach + void setUp() { + this.tracer = mock(Tracer.class); + this.serverCall = mock(ServerCall.class); + this.serverCallHandler = mock(ServerCallHandler.class); + this.metadata = new Metadata(); + } + + @Test + void shouldExtractHeadersAndCreateBaggage() { + List remoteFields = List.of("x-request-id", "x-user-id"); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + this.metadata.put(Metadata.Key.of("x-user-id", Metadata.ASCII_STRING_MARSHALLER), "user-456"); + + BaggageInScope baggageInScope1 = mock(BaggageInScope.class); + BaggageInScope baggageInScope2 = mock(BaggageInScope.class); + when(this.tracer.createBaggageInScope("x-request-id", "req-123")).thenReturn(baggageInScope1); + when(this.tracer.createBaggageInScope("x-user-id", "user-456")).thenReturn(baggageInScope2); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + ServerCall.Listener listener = interceptor.interceptCall(this.serverCall, this.metadata, + this.serverCallHandler); + + verify(this.tracer).createBaggageInScope("x-request-id", "req-123"); + verify(this.tracer).createBaggageInScope("x-user-id", "user-456"); + assertThat(listener).isNotNull(); + } + + @Test + void shouldAddSpanTagsWhenConfigured() { + + List remoteFields = List.of("x-request-id"); + List tagFields = List.of("x-request-id"); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + Span currentSpan = mock(Span.class); + when(this.tracer.currentSpan()).thenReturn(currentSpan); + + BaggageInScope baggageInScope = mock(BaggageInScope.class); + when(this.tracer.createBaggageInScope("x-request-id", "req-123")).thenReturn(baggageInScope); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + interceptor.interceptCall(this.serverCall, this.metadata, this.serverCallHandler); + + verify(currentSpan).tag("x-request-id", "req-123"); + } + + @Test + void shouldNotAddSpanTagsWhenNotConfigured() { + + List remoteFields = List.of("x-request-id"); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + Span currentSpan = mock(Span.class); + when(this.tracer.currentSpan()).thenReturn(currentSpan); + + BaggageInScope baggageInScope = mock(BaggageInScope.class); + when(this.tracer.createBaggageInScope("x-request-id", "req-123")).thenReturn(baggageInScope); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + interceptor.interceptCall(this.serverCall, this.metadata, this.serverCallHandler); + + verify(currentSpan, never()).tag(eq("x-request-id"), eq("req-123")); + } + + @Test + void shouldCloseBaggageOnComplete() { + List remoteFields = List.of("x-request-id"); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + BaggageInScope baggageInScope = mock(BaggageInScope.class); + when(this.tracer.createBaggageInScope("x-request-id", "req-123")).thenReturn(baggageInScope); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + ServerCall.Listener listener = interceptor.interceptCall(this.serverCall, this.metadata, + this.serverCallHandler); + + listener.onComplete(); + verify(baggageInScope).close(); + } + + @Test + void shouldCloseBaggageOnCancel() { + List remoteFields = List.of("x-request-id"); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + BaggageInScope baggageInScope = mock(BaggageInScope.class); + when(this.tracer.createBaggageInScope("x-request-id", "req-123")).thenReturn(baggageInScope); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + ServerCall.Listener listener = interceptor.interceptCall(this.serverCall, this.metadata, + this.serverCallHandler); + + listener.onCancel(); + verify(baggageInScope).close(); + } + + @Test + void shouldNotCreateBaggageWhenHeaderNotPresent() { + + List remoteFields = List.of("x-request-id"); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + interceptor.interceptCall(this.serverCall, this.metadata, this.serverCallHandler); + + verify(this.tracer, never()).createBaggageInScope(eq("x-request-id"), eq("req-123")); + } + + @Test + void shouldHandleEmptyRemoteFields() { + + List remoteFields = List.of(); + List tagFields = List.of(); + GrpcHeaderServerInterceptor interceptor = new GrpcHeaderServerInterceptor(this.tracer, remoteFields, tagFields); + + this.metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + when(this.serverCallHandler.startCall(this.serverCall, this.metadata)).thenReturn(mockListener); + + interceptor.interceptCall(this.serverCall, this.metadata, this.serverCallHandler); + + verify(this.tracer, never()).createBaggageInScope(eq("x-request-id"), eq("req-123")); + } + +} diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfigurationTests.java b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfigurationTests.java new file mode 100644 index 00000000..ecd744f9 --- /dev/null +++ b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerHeaderAutoConfigurationTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import io.grpc.BindableService; +import io.micrometer.tracing.Tracer; + +class GrpcServerHeaderAutoConfigurationTests { + + private final ApplicationContextRunner baseContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHeaderAutoConfiguration.class)); + + private ApplicationContextRunner validContextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHeaderAutoConfiguration.class)) + .withBean("tracer", Tracer.class, () -> mock(Tracer.class)) + .withPropertyValues("management.tracing.baggage.enabled=true", + "management.tracing.baggage.remote-fields=x-request-id,x-user-id", + "management.tracing.baggage.tag-fields=x-request-id,x-user-id"); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenTracerNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(Tracer.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenTracerNotProvidedThenAutoConfigSkipped() { + this.baseContextRunner.withPropertyValues("management.tracing.baggage.enabled=true") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyDisabledThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("management.tracing.baggage.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyNotSetThenAutoConfigIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GrpcServerHeaderAutoConfiguration.class)) + .withBean("tracer", Tracer.class, () -> mock(Tracer.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyEnabledAndServerNotDisabledThenAutoConfigNotSkipped() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHeaderAutoConfiguration.class)); + } + + @Test + void whenBaggagePropertyEnabledThenInterceptorIsCreated() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcHeaderServerInterceptor.class)); + } + + @Test + void whenServerBaggageDisabledThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.baggage.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHeaderAutoConfiguration.class)); + } + +}