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")