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
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:
+ *
+ * - Creates OpenTelemetry baggage for propagation to downstream services
+ * - Adds them as span tags (based on {@code management.tracing.baggage.tag-fields}) for
+ * visibility in traces
+ *
+ *
+ * @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));
+ }
+
+}