Skip to content

Commit 20f4036

Browse files
authored
Spring boot 4 support for RestClient (#15684)
1 parent dcee1b7 commit 20f4036

File tree

14 files changed

+331
-89
lines changed

14 files changed

+331
-89
lines changed

instrumentation/spring/spring-boot-autoconfigure/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ dependencies {
134134
add("javaSpring4CompileOnly", project(":instrumentation:spring:spring-kafka-2.7:library"))
135135
add("javaSpring4CompileOnly", project(":instrumentation:mongo:mongo-3.1:library"))
136136
add("javaSpring4CompileOnly", project(":instrumentation:micrometer:micrometer-1.5:library"))
137+
add("javaSpring4CompileOnly", project(":instrumentation:spring:spring-web:spring-web-3.1:library"))
137138
}
138139

139140
val latestDepTest = findProperty("testLatestDeps") as Boolean
@@ -221,6 +222,7 @@ testing {
221222
implementation(project(":instrumentation:spring:spring-webmvc:spring-webmvc-6.0:library"))
222223
implementation("jakarta.servlet:jakarta.servlet-api:5.0.0")
223224
implementation("org.springframework.boot:spring-boot-starter-test:$version")
225+
implementation(project(":instrumentation:spring:spring-boot-autoconfigure:testing"))
224226
}
225227
}
226228

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web;
7+
8+
import io.opentelemetry.api.OpenTelemetry;
9+
import io.opentelemetry.instrumentation.api.incubator.config.internal.InstrumentationConfig;
10+
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.properties.InstrumentationConfigUtil;
11+
import io.opentelemetry.instrumentation.spring.web.v3_1.SpringWebTelemetry;
12+
import io.opentelemetry.instrumentation.spring.web.v3_1.internal.WebTelemetryUtil;
13+
import java.util.List;
14+
import java.util.concurrent.atomic.AtomicBoolean;
15+
import org.springframework.beans.factory.ObjectProvider;
16+
import org.springframework.beans.factory.config.BeanPostProcessor;
17+
import org.springframework.http.client.ClientHttpRequestInterceptor;
18+
import org.springframework.web.client.RestClient;
19+
20+
final class RestClientBeanPostProcessorSpring4 implements BeanPostProcessor {
21+
22+
private final ObjectProvider<OpenTelemetry> openTelemetryProvider;
23+
private final ObjectProvider<InstrumentationConfig> configProvider;
24+
25+
public RestClientBeanPostProcessorSpring4(
26+
ObjectProvider<OpenTelemetry> openTelemetryProvider,
27+
ObjectProvider<InstrumentationConfig> configProvider) {
28+
this.openTelemetryProvider = openTelemetryProvider;
29+
this.configProvider = configProvider;
30+
}
31+
32+
@Override
33+
public Object postProcessAfterInitialization(Object bean, String beanName) {
34+
if (bean instanceof RestClient restClient) {
35+
return addRestClientInterceptorIfNotPresent(
36+
restClient, openTelemetryProvider.getObject(), configProvider.getObject());
37+
}
38+
return bean;
39+
}
40+
41+
private static RestClient addRestClientInterceptorIfNotPresent(
42+
RestClient restClient, OpenTelemetry openTelemetry, InstrumentationConfig config) {
43+
ClientHttpRequestInterceptor instrumentationInterceptor = getInterceptor(openTelemetry, config);
44+
45+
AtomicBoolean interceptorAdded = new AtomicBoolean(false);
46+
RestClient.Builder result =
47+
restClient
48+
.mutate()
49+
.requestInterceptors(
50+
interceptors -> {
51+
if (isInterceptorNotPresent(interceptors, instrumentationInterceptor)) {
52+
interceptors.add(0, instrumentationInterceptor);
53+
interceptorAdded.set(true);
54+
}
55+
});
56+
57+
return interceptorAdded.get() ? result.build() : restClient;
58+
}
59+
60+
private static boolean isInterceptorNotPresent(
61+
List<ClientHttpRequestInterceptor> interceptors,
62+
ClientHttpRequestInterceptor instrumentationInterceptor) {
63+
return interceptors.stream()
64+
.noneMatch(interceptor -> interceptor.getClass() == instrumentationInterceptor.getClass());
65+
}
66+
67+
static ClientHttpRequestInterceptor getInterceptor(
68+
OpenTelemetry openTelemetry, InstrumentationConfig config) {
69+
return InstrumentationConfigUtil.configureClientBuilder(
70+
config,
71+
SpringWebTelemetry.builder(openTelemetry),
72+
WebTelemetryUtil.getBuilderExtractor())
73+
.build()
74+
.newInterceptor();
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web;
7+
8+
import io.opentelemetry.api.OpenTelemetry;
9+
import io.opentelemetry.instrumentation.api.incubator.config.internal.InstrumentationConfig;
10+
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.ConditionalOnEnabledInstrumentation;
11+
import org.springframework.beans.factory.ObjectProvider;
12+
import org.springframework.boot.autoconfigure.AutoConfiguration;
13+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
14+
import org.springframework.boot.restclient.RestClientCustomizer;
15+
import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration;
16+
import org.springframework.context.annotation.Bean;
17+
import org.springframework.context.annotation.Configuration;
18+
import org.springframework.web.client.RestClient;
19+
20+
/**
21+
* Configures {@link RestClient} for tracing.
22+
*
23+
* <p>Adds OpenTelemetry instrumentation to {@link RestClient} beans after initialization.
24+
*
25+
* <p>This class is internal and is hence not for public use. Its APIs are unstable and can change
26+
* at any time.
27+
*/
28+
@ConditionalOnEnabledInstrumentation(module = "spring-web")
29+
@ConditionalOnClass({RestClient.class, RestClientCustomizer.class})
30+
@AutoConfiguration(after = RestClientAutoConfiguration.class)
31+
@Configuration
32+
public class RestClientInstrumentationSpringBoot4AutoConfiguration {
33+
34+
@Bean
35+
static RestClientBeanPostProcessorSpring4 otelRestClientBeanPostProcessor(
36+
ObjectProvider<OpenTelemetry> openTelemetryProvider,
37+
ObjectProvider<InstrumentationConfig> configProvider) {
38+
return new RestClientBeanPostProcessorSpring4(openTelemetryProvider, configProvider);
39+
}
40+
41+
@Bean
42+
RestClientCustomizer otelRestClientCustomizer(
43+
ObjectProvider<OpenTelemetry> openTelemetryProvider,
44+
ObjectProvider<InstrumentationConfig> configProvider) {
45+
return builder ->
46+
builder.requestInterceptor(
47+
RestClientBeanPostProcessorSpring4.getInterceptor(
48+
openTelemetryProvider.getObject(), configProvider.getObject()));
49+
}
50+
}

instrumentation/spring/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.w
1515
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web.SpringWebInstrumentationSpringBoot4AutoConfiguration
1616
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.webflux.SpringWebfluxInstrumentationAutoConfiguration
1717
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web.RestClientInstrumentationAutoConfiguration
18+
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web.RestClientInstrumentationSpringBoot4AutoConfiguration
1819
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.webmvc.SpringWebMvc6InstrumentationAutoConfiguration
1920
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.scheduling.SpringSchedulingInstrumentationAutoConfiguration
2021
io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.runtimemetrics.RuntimeMetricsAutoConfiguration

instrumentation/spring/spring-boot-autoconfigure/src/testSpring3/java/io/opentelemetry/instrumentation/spring/autoconfigure/internal/instrumentation/web/RestClientInstrumentationAutoConfigurationTest.java

Lines changed: 9 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,86 +5,19 @@
55

66
package io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web;
77

8-
import static org.assertj.core.api.Assertions.assertThat;
9-
10-
import io.opentelemetry.api.OpenTelemetry;
11-
import io.opentelemetry.instrumentation.api.incubator.config.internal.InstrumentationConfig;
12-
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.properties.ConfigPropertiesBridge;
13-
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
14-
import java.util.Collections;
15-
import org.junit.jupiter.api.Test;
8+
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.AbstractRestClientInstrumentationAutoConfigurationTest;
169
import org.springframework.boot.autoconfigure.AutoConfigurations;
17-
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
18-
import org.springframework.web.client.RestClient;
19-
20-
class RestClientInstrumentationAutoConfigurationTest {
21-
22-
private final ApplicationContextRunner contextRunner =
23-
new ApplicationContextRunner()
24-
.withBean(OpenTelemetry.class, OpenTelemetry::noop)
25-
.withBean(
26-
InstrumentationConfig.class,
27-
() ->
28-
new ConfigPropertiesBridge(
29-
DefaultConfigProperties.createFromMap(Collections.emptyMap())))
30-
.withBean(RestClient.class, RestClient::create)
31-
.withConfiguration(
32-
AutoConfigurations.of(RestClientInstrumentationAutoConfiguration.class));
3310

34-
/**
35-
* Tests the case that users create a {@link RestClient} bean themselves.
36-
*
37-
* <pre>{@code
38-
* @Bean public RestClient restClient() {
39-
* return new RestClient();
40-
* }
41-
* }</pre>
42-
*/
43-
@Test
44-
void instrumentationEnabled() {
45-
contextRunner
46-
.withPropertyValues("otel.instrumentation.spring-web.enabled=true")
47-
.run(
48-
context -> {
49-
assertThat(
50-
context.getBean(
51-
"otelRestClientBeanPostProcessor", RestClientBeanPostProcessor.class))
52-
.isNotNull();
53-
54-
context
55-
.getBean(RestClient.class)
56-
.mutate()
57-
.requestInterceptors(
58-
interceptors -> {
59-
long count =
60-
interceptors.stream()
61-
.filter(
62-
rti ->
63-
rti.getClass()
64-
.getName()
65-
.startsWith("io.opentelemetry.instrumentation"))
66-
.count();
67-
assertThat(count).isEqualTo(1);
68-
});
69-
});
70-
}
11+
class RestClientInstrumentationAutoConfigurationTest
12+
extends AbstractRestClientInstrumentationAutoConfigurationTest {
7113

72-
@Test
73-
void instrumentationDisabled() {
74-
contextRunner
75-
.withPropertyValues("otel.instrumentation.spring-web.enabled=false")
76-
.run(
77-
context ->
78-
assertThat(context.containsBean("otelRestClientBeanPostProcessor")).isFalse());
14+
@Override
15+
protected AutoConfigurations autoConfigurations() {
16+
return AutoConfigurations.of(RestClientInstrumentationAutoConfiguration.class);
7917
}
8018

81-
@Test
82-
void defaultConfiguration() {
83-
contextRunner.run(
84-
context ->
85-
assertThat(
86-
context.getBean(
87-
"otelRestClientBeanPostProcessor", RestClientBeanPostProcessor.class))
88-
.isNotNull());
19+
@Override
20+
protected Class<?> postProcessorClass() {
21+
return RestClientBeanPostProcessor.class;
8922
}
9023
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.spring.autoconfigure.internal.instrumentation.web;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
import io.opentelemetry.api.OpenTelemetry;
11+
import io.opentelemetry.instrumentation.api.incubator.config.internal.InstrumentationConfig;
12+
import io.opentelemetry.instrumentation.spring.autoconfigure.internal.AbstractRestClientInstrumentationAutoConfigurationTest;
13+
import org.junit.jupiter.api.Test;
14+
import org.springframework.boot.autoconfigure.AutoConfigurations;
15+
import org.springframework.web.client.RestClient;
16+
17+
class RestClientInstrumentationSpringBoot4AutoConfigurationTest
18+
extends AbstractRestClientInstrumentationAutoConfigurationTest {
19+
20+
@Override
21+
protected AutoConfigurations autoConfigurations() {
22+
return AutoConfigurations.of(RestClientInstrumentationSpringBoot4AutoConfiguration.class);
23+
}
24+
25+
@Override
26+
protected Class<?> postProcessorClass() {
27+
return RestClientBeanPostProcessorSpring4.class;
28+
}
29+
30+
@Test
31+
void shouldNotCreateNewBeanWhenInterceptorAlreadyPresent() {
32+
contextRunner
33+
.withPropertyValues("otel.instrumentation.spring-web.enabled=true")
34+
.run(
35+
context -> {
36+
RestClientBeanPostProcessorSpring4 beanPostProcessor =
37+
context.getBean(
38+
"otelRestClientBeanPostProcessor", RestClientBeanPostProcessorSpring4.class);
39+
40+
RestClient restClientWithInterceptor =
41+
RestClient.builder()
42+
.requestInterceptor(
43+
RestClientBeanPostProcessor.getInterceptor(
44+
context.getBean(OpenTelemetry.class),
45+
context.getBean(InstrumentationConfig.class)))
46+
.build();
47+
48+
RestClient processed =
49+
(RestClient)
50+
beanPostProcessor.postProcessAfterInitialization(
51+
restClientWithInterceptor, "testBean");
52+
53+
// Should return the same instance when interceptor is already present
54+
assertThat(processed).isSameAs(restClientWithInterceptor);
55+
56+
// Verify only one interceptor exists
57+
processed
58+
.mutate()
59+
.requestInterceptors(
60+
interceptors -> {
61+
long count =
62+
interceptors.stream()
63+
.filter(
64+
rti ->
65+
rti.getClass()
66+
.getName()
67+
.startsWith("io.opentelemetry.instrumentation"))
68+
.count();
69+
assertThat(count).isEqualTo(1);
70+
});
71+
});
72+
}
73+
}

0 commit comments

Comments
 (0)