Skip to content

Commit f71537a

Browse files
authored
Spring rest client (#11038)
1 parent 985c0f6 commit f71537a

File tree

11 files changed

+241
-13
lines changed

11 files changed

+241
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Why do we need a separate module for Spring Boot 3 auto-configuration?
2+
3+
`RestClientInstrumentationAutoConfiguration` imports `RestClientCustomizer`,
4+
which is part of Spring Boot 3 (rather than Spring framework).
5+
6+
If we were to include this in the `spring-boot-autoconfigure` module, we would have to
7+
bump the Spring Boot version to 3, which would break compatibility with Spring Boot 2.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
plugins {
2+
id("otel.library-instrumentation")
3+
}
4+
5+
// Name the Spring Boot modules in accordance with https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.developing-auto-configuration.custom-starter
6+
base.archivesName.set("opentelemetry-spring-boot-3")
7+
group = "io.opentelemetry.instrumentation"
8+
9+
otelJava {
10+
minJavaVersionSupported.set(JavaVersion.VERSION_17)
11+
}
12+
13+
dependencies {
14+
val springBootVersion = "3.2.4"
15+
library("org.springframework.boot:spring-boot-starter-web:$springBootVersion")
16+
compileOnly(project(":instrumentation:spring:spring-boot-autoconfigure"))
17+
implementation(project(":instrumentation:spring:spring-web:spring-web-3.1:library"))
18+
19+
testLibrary("org.springframework.boot:spring-boot-starter-test:$springBootVersion") {
20+
exclude("org.junit.vintage", "junit-vintage-engine")
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.instrumentation.web;
7+
8+
import io.opentelemetry.api.OpenTelemetry;
9+
import io.opentelemetry.instrumentation.spring.web.v3_1.SpringWebTelemetry;
10+
import org.springframework.beans.factory.ObjectProvider;
11+
import org.springframework.beans.factory.config.BeanPostProcessor;
12+
import org.springframework.http.client.ClientHttpRequestInterceptor;
13+
import org.springframework.web.client.RestClient;
14+
15+
public final class RestClientBeanPostProcessor implements BeanPostProcessor {
16+
17+
private final ObjectProvider<OpenTelemetry> openTelemetryProvider;
18+
19+
public RestClientBeanPostProcessor(ObjectProvider<OpenTelemetry> openTelemetryProvider) {
20+
this.openTelemetryProvider = openTelemetryProvider;
21+
}
22+
23+
@Override
24+
public Object postProcessAfterInitialization(Object bean, String beanName) {
25+
if (bean instanceof RestClient restClient) {
26+
return addRestClientInterceptorIfNotPresent(restClient, openTelemetryProvider.getObject());
27+
}
28+
return bean;
29+
}
30+
31+
private static RestClient addRestClientInterceptorIfNotPresent(
32+
RestClient restClient, OpenTelemetry openTelemetry) {
33+
ClientHttpRequestInterceptor instrumentationInterceptor =
34+
SpringWebTelemetry.create(openTelemetry).newInterceptor();
35+
36+
return restClient
37+
.mutate()
38+
.requestInterceptors(
39+
interceptors -> {
40+
if (interceptors.stream()
41+
.noneMatch(
42+
interceptor ->
43+
interceptor.getClass() == instrumentationInterceptor.getClass())) {
44+
interceptors.add(0, instrumentationInterceptor);
45+
}
46+
})
47+
.build();
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.instrumentation.web;
7+
8+
import io.opentelemetry.api.OpenTelemetry;
9+
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.SdkEnabled;
10+
import io.opentelemetry.instrumentation.spring.web.v3_1.SpringWebTelemetry;
11+
import org.springframework.beans.factory.ObjectProvider;
12+
import org.springframework.boot.autoconfigure.AutoConfiguration;
13+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
14+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
15+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
16+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
17+
import org.springframework.boot.web.client.RestClientCustomizer;
18+
import org.springframework.context.annotation.Bean;
19+
import org.springframework.context.annotation.Conditional;
20+
import org.springframework.context.annotation.Configuration;
21+
import org.springframework.web.client.RestClient;
22+
23+
/**
24+
* Configures {@link RestClient} for tracing.
25+
*
26+
* <p>Adds Open Telemetry instrumentation to {@link RestClient} beans after initialization
27+
*/
28+
@ConditionalOnBean(OpenTelemetry.class)
29+
@ConditionalOnProperty(name = "otel.instrumentation.spring-web.enabled", matchIfMissing = true)
30+
@ConditionalOnClass(RestClient.class)
31+
@Conditional(SdkEnabled.class)
32+
@AutoConfiguration(after = RestClientAutoConfiguration.class)
33+
@Configuration
34+
public class RestClientInstrumentationAutoConfiguration {
35+
36+
@Bean
37+
RestClientBeanPostProcessor otelRestClientBeanPostProcessor(
38+
ObjectProvider<OpenTelemetry> openTelemetryProvider) {
39+
return new RestClientBeanPostProcessor(openTelemetryProvider);
40+
}
41+
42+
@Bean
43+
RestClientCustomizer otelRestClientCustomizer(
44+
ObjectProvider<OpenTelemetry> openTelemetryProvider) {
45+
return builder ->
46+
builder.requestInterceptor(
47+
SpringWebTelemetry.create(openTelemetryProvider.getObject()).newInterceptor());
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
2+
io.opentelemetry.instrumentation.spring.autoconfigure.instrumentation.web.RestClientInstrumentationAutoConfiguration
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.opentelemetry.instrumentation.spring.autoconfigure.instrumentation.web.RestClientInstrumentationAutoConfiguration
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.instrumentation.web;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
import io.opentelemetry.api.OpenTelemetry;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.boot.autoconfigure.AutoConfigurations;
13+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
14+
import org.springframework.web.client.RestClient;
15+
16+
class RestClientInstrumentationAutoConfigurationTest {
17+
18+
private final ApplicationContextRunner contextRunner =
19+
new ApplicationContextRunner()
20+
.withBean(OpenTelemetry.class, OpenTelemetry::noop)
21+
.withBean(RestClient.class, RestClient::create)
22+
.withConfiguration(
23+
AutoConfigurations.of(RestClientInstrumentationAutoConfiguration.class));
24+
25+
/**
26+
* Tests the case that users create a {@link RestClient} bean themselves.
27+
*
28+
* <pre>{@code
29+
* @Bean public RestClient restClient() {
30+
* return new RestClient();
31+
* }
32+
* }</pre>
33+
*/
34+
@Test
35+
void instrumentationEnabled() {
36+
contextRunner
37+
.withPropertyValues("otel.instrumentation.spring-web.enabled=true")
38+
.run(
39+
context -> {
40+
assertThat(
41+
context.getBean(
42+
"otelRestClientBeanPostProcessor", RestClientBeanPostProcessor.class))
43+
.isNotNull();
44+
45+
context
46+
.getBean(RestClient.class)
47+
.mutate()
48+
.requestInterceptors(
49+
interceptors -> {
50+
long count =
51+
interceptors.stream()
52+
.filter(
53+
rti ->
54+
rti.getClass()
55+
.getName()
56+
.startsWith("io.opentelemetry.instrumentation"))
57+
.count();
58+
assertThat(count).isEqualTo(1);
59+
});
60+
});
61+
}
62+
63+
@Test
64+
void instrumentationDisabled() {
65+
contextRunner
66+
.withPropertyValues("otel.instrumentation.spring-web.enabled=false")
67+
.run(
68+
context ->
69+
assertThat(context.containsBean("otelRestClientBeanPostProcessor")).isFalse());
70+
}
71+
72+
@Test
73+
void defaultConfiguration() {
74+
contextRunner.run(
75+
context ->
76+
assertThat(
77+
context.getBean(
78+
"otelRestClientBeanPostProcessor", RestClientBeanPostProcessor.class))
79+
.isNotNull());
80+
}
81+
}

instrumentation/spring/starters/spring-boot-starter/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
api("org.springframework.boot:spring-boot-starter:$springBootVersion")
1313
api("org.springframework.boot:spring-boot-starter-aop:$springBootVersion")
1414
api(project(":instrumentation:spring:spring-boot-autoconfigure"))
15+
api(project(":instrumentation:spring:spring-boot-autoconfigure-3"))
1516
api(project(":instrumentation-annotations"))
1617
implementation(project(":instrumentation:resources:library"))
1718
implementation("io.opentelemetry:opentelemetry-sdk-extension-incubator")

settings.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ include(":instrumentation:spark-2.3:javaagent")
513513
include(":instrumentation:spring:spring-batch-3.0:javaagent")
514514
include(":instrumentation:spring:spring-boot-actuator-autoconfigure-2.0:javaagent")
515515
include(":instrumentation:spring:spring-boot-autoconfigure")
516+
include(":instrumentation:spring:spring-boot-autoconfigure-3")
516517
include(":instrumentation:spring:spring-boot-resources:javaagent")
517518
include(":instrumentation:spring:spring-boot-resources:javaagent-unit-tests")
518519
include(":instrumentation:spring:spring-cloud-gateway:spring-cloud-gateway-2.0:javaagent")

smoke-tests-otel-starter/src/main/java/io/opentelemetry/spring/smoketest/OtelSpringStarterSmokeTestController.java

+15-7
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,30 @@
1313
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
1414
import org.springframework.web.bind.annotation.GetMapping;
1515
import org.springframework.web.bind.annotation.RestController;
16+
import org.springframework.web.client.RestClient;
1617
import org.springframework.web.client.RestTemplate;
1718

1819
@RestController
1920
public class OtelSpringStarterSmokeTestController {
2021

2122
public static final String PING = "/ping";
23+
public static final String REST_CLIENT = "/rest-client";
2224
public static final String REST_TEMPLATE = "/rest-template";
2325
public static final String TEST_HISTOGRAM = "histogram-test-otel-spring-starter";
2426
private final LongHistogram histogram;
2527
private final Optional<RestTemplate> restTemplate;
28+
private final Optional<RestClient> restClient;
2629

2730
public OtelSpringStarterSmokeTestController(
2831
OpenTelemetry openTelemetry,
32+
RestClient.Builder restClientBuilder,
2933
RestTemplateBuilder restTemplateBuilder,
3034
Optional<ServletWebServerApplicationContext> server) {
3135
Meter meter = openTelemetry.getMeter(OtelSpringStarterSmokeTestApplication.class.getName());
3236
histogram = meter.histogramBuilder(TEST_HISTOGRAM).ofLongs().build();
33-
restTemplate =
34-
server.map(
35-
s ->
36-
restTemplateBuilder
37-
.rootUri("http://localhost:" + s.getWebServer().getPort())
38-
.build());
37+
Optional<String> rootUri = server.map(s -> "http://localhost:" + s.getWebServer().getPort());
38+
restClient = rootUri.map(uri -> restClientBuilder.baseUrl(uri).build());
39+
restTemplate = rootUri.map(uri -> restTemplateBuilder.rootUri(uri).build());
3940
}
4041

4142
@GetMapping(PING)
@@ -44,10 +45,17 @@ public String ping() {
4445
return "pong";
4546
}
4647

48+
@GetMapping(REST_CLIENT)
49+
public String restClient() {
50+
return restClient
51+
.map(c -> c.get().uri(PING).retrieve().body(String.class))
52+
.orElseThrow(() -> new IllegalStateException("RestClient not available"));
53+
}
54+
4755
@GetMapping(REST_TEMPLATE)
4856
public String restTemplate() {
4957
return restTemplate
50-
.map(t -> t.getForObject("/ping", String.class))
58+
.map(t -> t.getForObject(PING, String.class))
5159
.orElseThrow(() -> new IllegalStateException("RestTemplate not available"));
5260
}
5361
}

smoke-tests-otel-starter/src/test/java/io/opentelemetry/smoketest/OtelSpringStarterSmokeTest.java

+13-6
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,19 @@ void shouldSendTelemetry() {
259259

260260
@Test
261261
@org.junit.jupiter.api.Order(2)
262-
void restTemplateClient() {
262+
void restTemplate() {
263+
assertClient(OtelSpringStarterSmokeTestController.REST_TEMPLATE);
264+
}
265+
266+
@Test
267+
void restClient() {
268+
assertClient(OtelSpringStarterSmokeTestController.REST_CLIENT);
269+
}
270+
271+
private void assertClient(String url) {
263272
resetExporters(); // ignore the telemetry from application startup
264273

265-
testRestTemplate.getForObject(OtelSpringStarterSmokeTestController.REST_TEMPLATE, String.class);
274+
testRestTemplate.getForObject(url, String.class);
266275

267276
TracesAssert.assertThat(expectSpans(4))
268277
.hasTracesSatisfyingExactly(
@@ -272,13 +281,11 @@ void restTemplateClient() {
272281
clientSpan
273282
.hasKind(SpanKind.CLIENT)
274283
.hasAttributesSatisfying(
275-
a ->
276-
assertThat(a.get(UrlAttributes.URL_FULL))
277-
.endsWith("/rest-template")),
284+
a -> assertThat(a.get(UrlAttributes.URL_FULL)).endsWith(url)),
278285
serverSpan ->
279286
serverSpan
280287
.hasKind(SpanKind.SERVER)
281-
.hasAttribute(HttpAttributes.HTTP_ROUTE, "/rest-template"),
288+
.hasAttribute(HttpAttributes.HTTP_ROUTE, url),
282289
nestedClientSpan ->
283290
nestedClientSpan
284291
.hasKind(SpanKind.CLIENT)

0 commit comments

Comments
 (0)