diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
index 3b4e685872b0..533adfbb10d6 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
@@ -161,6 +161,7 @@ dependencies {
testImplementation("org.awaitility:awaitility")
testImplementation("org.cache2k:cache2k-api")
testImplementation("org.eclipse.jetty.ee10:jetty-ee10-webapp")
+ testImplementation("org.eclipse.jetty.http2:jetty-http2-server")
testImplementation("org.glassfish.jersey.ext:jersey-spring6")
testImplementation("org.glassfish.jersey.media:jersey-media-json-jackson")
testImplementation("org.hamcrest:hamcrest")
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
index abb3253f2f99..9d797721fb88 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
@@ -36,8 +36,10 @@
* the future, see: opentelemetry-java#3651.
* Because this class configures components from the OTel SDK, it can't support HTTP/JSON.
- * To keep things simple, we only auto-configure HTTP/protobuf. If you want to use gRPC,
- * define an {@link OtlpGrpcSpanExporter} and this auto-configuration will back off.
+ * By default, we auto-configure HTTP/protobuf. If you want to use gRPC, you need to set
+ * {@code management.otlp.tracing.transport=grpc}. If you define a
+ * {@link OtlpHttpSpanExporter} or {@link OtlpGrpcSpanExporter}, this auto-configuration
+ * will back off.
*
* @author Jonatan Ivanov
* @author Moritz Halbritter
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
index 371de8491146..b6bd517a5bd7 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
@@ -44,6 +44,11 @@ public class OtlpProperties {
*/
private Duration timeout = Duration.ofSeconds(10);
+ /**
+ * Transport used to send the spans. Defaults to HTTP.
+ */
+ private Transport transport = Transport.HTTP;
+
/**
* Method used to compress the payload.
*/
@@ -70,6 +75,14 @@ public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
+ public Transport getTransport() {
+ return this.transport;
+ }
+
+ public void setTransport(Transport transport) {
+ this.transport = transport;
+ }
+
public Compression getCompression() {
return this.compression;
}
@@ -86,6 +99,20 @@ public void setHeaders(Map headers) {
this.headers = headers;
}
+ enum Transport {
+
+ /**
+ * HTTP exporter.
+ */
+ HTTP,
+
+ /**
+ * gRPC exporter.
+ */
+ GRPC
+
+ }
+
enum Compression {
/**
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java
index 142696717172..95f0ec4a4afe 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java
@@ -20,6 +20,8 @@
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
+import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
+import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder;
import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -69,10 +71,11 @@ public String getUrl() {
static class Exporters {
@Bean
- @ConditionalOnMissingBean(value = OtlpHttpSpanExporter.class,
- type = "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter")
+ @ConditionalOnMissingBean({ OtlpGrpcSpanExporter.class, OtlpHttpSpanExporter.class })
@ConditionalOnBean(OtlpTracingConnectionDetails.class)
@ConditionalOnEnabledTracing("otlp")
+ @ConditionalOnProperty(prefix = "management.otlp.tracing", name = "transport", havingValue = "http",
+ matchIfMissing = true)
OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties,
OtlpTracingConnectionDetails connectionDetails) {
OtlpHttpSpanExporterBuilder builder = OtlpHttpSpanExporter.builder()
@@ -85,6 +88,23 @@ OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties,
return builder.build();
}
+ @Bean
+ @ConditionalOnMissingBean({ OtlpGrpcSpanExporter.class, OtlpHttpSpanExporter.class })
+ @ConditionalOnBean(OtlpTracingConnectionDetails.class)
+ @ConditionalOnEnabledTracing("otlp")
+ @ConditionalOnProperty(prefix = "management.otlp.tracing", name = "transport", havingValue = "grpc")
+ OtlpGrpcSpanExporter otlpGrpcSpanExporter(OtlpProperties properties,
+ OtlpTracingConnectionDetails connectionDetails) {
+ OtlpGrpcSpanExporterBuilder builder = OtlpGrpcSpanExporter.builder()
+ .setEndpoint(connectionDetails.getUrl())
+ .setTimeout(properties.getTimeout())
+ .setCompression(properties.getCompression().name().toLowerCase());
+ for (Entry header : properties.getHeaders().entrySet()) {
+ builder.addHeader(header.getKey(), header.getValue());
+ }
+ return builder.build();
+ }
+
}
}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
index 0fcc77811f95..4f001a499380 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
@@ -17,11 +17,15 @@
package org.springframework.boot.actuate.autoconfigure.tracing.otlp;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import io.micrometer.tracing.Tracer;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
+import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
import io.opentelemetry.sdk.common.CompletableResultCode;
import io.opentelemetry.sdk.trace.export.SpanExporter;
import okhttp3.mockwebserver.MockResponse;
@@ -29,6 +33,16 @@
import okhttp3.mockwebserver.RecordedRequest;
import okio.Buffer;
import okio.GzipSource;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
+import org.eclipse.jetty.io.Content;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.Callback;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -36,8 +50,10 @@
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfigurationIntegrationTests.MockGrpcServer.RecordedGrpcRequest;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -57,16 +73,28 @@ class OtlpAutoConfigurationIntegrationTests {
private final MockWebServer mockWebServer = new MockWebServer();
+ private final MockGrpcServer mockGrpcServer = new MockGrpcServer();
+
@BeforeEach
- void setUp() throws IOException {
+ void startMockWebServer() throws IOException {
this.mockWebServer.start();
}
+ @BeforeEach
+ void startMockGrpcServer() throws Exception {
+ this.mockGrpcServer.start();
+ }
+
@AfterEach
- void tearDown() throws IOException {
+ void stopMockWebServer() throws IOException {
this.mockWebServer.close();
}
+ @AfterEach
+ void stopMockGrpcServer() throws Exception {
+ this.mockGrpcServer.stop();
+ }
+
@Test
void httpSpanExporterShouldUseProtobufAndNoCompressionByDefault() {
this.mockWebServer.enqueue(new MockResponse());
@@ -113,4 +141,88 @@ void httpSpanExporterCanBeConfiguredToUseGzipCompression() {
});
}
+ @Test
+ void grpcSpanExporter() {
+ this.contextRunner
+ .withPropertyValues(
+ "management.otlp.tracing.endpoint=http://localhost:%d".formatted(this.mockGrpcServer.getPort()),
+ "management.otlp.tracing.headers.custom=42", "management.otlp.tracing.transport=grpc")
+ .run((context) -> {
+ context.getBean(Tracer.class).nextSpan().name("test").end();
+ assertThat(context.getBean(OtlpGrpcSpanExporter.class).flush())
+ .isSameAs(CompletableResultCode.ofSuccess());
+ RecordedGrpcRequest request = this.mockGrpcServer.takeRequest(10, TimeUnit.SECONDS);
+ assertThat(request).isNotNull();
+ assertThat(request.headers().get("Content-Type")).isEqualTo("application/grpc");
+ assertThat(request.headers().get("custom")).isEqualTo("42");
+ assertThat(request.body()).contains("org.springframework.boot");
+ });
+ }
+
+ static class MockGrpcServer {
+
+ private final Server server = createServer();
+
+ private final BlockingQueue recordedRequests = new LinkedBlockingQueue<>();
+
+ void start() throws Exception {
+ this.server.start();
+ }
+
+ void stop() throws Exception {
+ this.server.stop();
+ }
+
+ int getPort() {
+ return this.server.getURI().getPort();
+ }
+
+ RecordedGrpcRequest takeRequest(int timeout, TimeUnit unit) throws InterruptedException {
+ return this.recordedRequests.poll(timeout, unit);
+ }
+
+ void recordRequest(RecordedGrpcRequest request) {
+ this.recordedRequests.add(request);
+ }
+
+ private Server createServer() {
+ Server server = new Server();
+ server.addConnector(createConnector(server));
+ server.setHandler(new GrpcHandler());
+
+ return server;
+ }
+
+ private ServerConnector createConnector(Server server) {
+ ServerConnector connector = new ServerConnector(server,
+ new HTTP2CServerConnectionFactory(new HttpConfiguration()));
+ connector.setPort(0);
+
+ return connector;
+ }
+
+ class GrpcHandler extends Handler.Abstract {
+
+ @Override
+ public boolean handle(Request request, Response response, Callback callback) throws Exception {
+ try (InputStream in = Content.Source.asInputStream(request)) {
+ recordRequest(new RecordedGrpcRequest(request.getHeaders(),
+ StreamUtils.copyToString(in, StandardCharsets.UTF_8)));
+ }
+
+ response.getHeaders().add("Content-Type", "application/grpc");
+ response.getHeaders().add("Grpc-Status", "0");
+
+ callback.succeeded();
+
+ return true;
+ }
+
+ }
+
+ record RecordedGrpcRequest(HttpFields headers, String body) {
+ }
+
+ }
+
}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
index eba8fe11251a..2f9259c6db71 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
@@ -51,6 +51,12 @@ void shouldNotSupplyBeansIfPropertyIsNotSet() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class));
}
+ @Test
+ void shouldNotSupplyBeansIfGrpcTransportIsEnabledButPropertyIsNotSet() {
+ this.contextRunner.withPropertyValues("management.otlp.tracing.transport=grpc")
+ .run((context) -> assertThat(context).doesNotHaveBean(OtlpGrpcSpanExporter.class));
+ }
+
@Test
void shouldSupplyBeans() {
this.contextRunner.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces")
@@ -58,6 +64,15 @@ void shouldSupplyBeans() {
.hasSingleBean(SpanExporter.class));
}
+ @Test
+ void shouldSupplyBeansIfGrpcTransportIsEnabled() {
+ this.contextRunner
+ .withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4317/v1/traces",
+ "management.otlp.tracing.transport=grpc")
+ .run((context) -> assertThat(context).hasSingleBean(OtlpGrpcSpanExporter.class)
+ .hasSingleBean(SpanExporter.class));
+ }
+
@Test
void shouldNotSupplyBeansIfGlobalTracingIsDisabled() {
this.contextRunner.withPropertyValues("management.tracing.enabled=false")