Skip to content

Commit f613ab8

Browse files
committed
Auto-configure observations for RestClients
Closes gh-38500
1 parent 9c68a2a commit f613ab8

File tree

8 files changed

+425
-7
lines changed

8 files changed

+425
-7
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java

+5-2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
3232
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
3333
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
34+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
3435
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
3536
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
3637
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -48,13 +49,15 @@
4849
* @author Stephane Nicoll
4950
* @author Raheela Aslam
5051
* @author Brian Clozel
52+
* @author Moritz Halbritter
5153
* @since 3.0.0
5254
*/
5355
@AutoConfiguration(after = { ObservationAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
54-
RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class })
56+
RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class, RestClientAutoConfiguration.class })
5557
@ConditionalOnClass(Observation.class)
5658
@ConditionalOnBean(ObservationRegistry.class)
57-
@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class })
59+
@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class,
60+
RestClientObservationConfiguration.class })
5861
@EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
5962
public class HttpClientObservationsAutoConfiguration {
6063

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.observation.web.client;
18+
19+
import io.micrometer.observation.ObservationRegistry;
20+
21+
import org.springframework.beans.factory.ObjectProvider;
22+
import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
23+
import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
24+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
25+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
26+
import org.springframework.boot.web.client.RestClientCustomizer;
27+
import org.springframework.context.annotation.Bean;
28+
import org.springframework.context.annotation.Configuration;
29+
import org.springframework.http.client.observation.ClientRequestObservationConvention;
30+
import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
31+
import org.springframework.web.client.RestClient;
32+
33+
/**
34+
* Configure the instrumentation of {@link RestClient}.
35+
*
36+
* @author Moritz Halbritter
37+
*/
38+
@Configuration(proxyBeanMethods = false)
39+
@ConditionalOnClass(RestClient.class)
40+
@ConditionalOnBean(RestClient.Builder.class)
41+
class RestClientObservationConfiguration {
42+
43+
@Bean
44+
RestClientCustomizer observationRestClientCustomizer(ObservationRegistry observationRegistry,
45+
ObjectProvider<ClientRequestObservationConvention> customConvention,
46+
ObservationProperties observationProperties) {
47+
String name = observationProperties.getHttp().getClient().getRequests().getName();
48+
ClientRequestObservationConvention observationConvention = customConvention
49+
.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
50+
return new ObservationRestClientCustomizer(observationRegistry, observationConvention);
51+
}
52+
53+
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java

+1-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import io.micrometer.observation.ObservationRegistry;
2020

2121
import org.springframework.beans.factory.ObjectProvider;
22-
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
2322
import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
2423
import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer;
2524
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -44,7 +43,7 @@ class RestTemplateObservationConfiguration {
4443
@Bean
4544
ObservationRestTemplateCustomizer observationRestTemplateCustomizer(ObservationRegistry observationRegistry,
4645
ObjectProvider<ClientRequestObservationConvention> customConvention,
47-
ObservationProperties observationProperties, MetricsProperties metricsProperties) {
46+
ObservationProperties observationProperties) {
4847
String name = observationProperties.getHttp().getClient().getRequests().getName();
4948
ClientRequestObservationConvention observationConvention = customConvention
5049
.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.observation.web.client;
18+
19+
import io.micrometer.common.KeyValues;
20+
import io.micrometer.core.instrument.MeterRegistry;
21+
import io.micrometer.observation.ObservationRegistry;
22+
import io.micrometer.observation.tck.TestObservationRegistry;
23+
import io.micrometer.observation.tck.TestObservationRegistryAssert;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.extension.ExtendWith;
26+
27+
import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
28+
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
29+
import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
30+
import org.springframework.boot.autoconfigure.AutoConfigurations;
31+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
32+
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
33+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
34+
import org.springframework.boot.test.system.CapturedOutput;
35+
import org.springframework.boot.test.system.OutputCaptureExtension;
36+
import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
37+
import org.springframework.context.annotation.Bean;
38+
import org.springframework.context.annotation.Configuration;
39+
import org.springframework.http.HttpStatus;
40+
import org.springframework.http.client.observation.ClientRequestObservationContext;
41+
import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
42+
import org.springframework.test.web.client.MockRestServiceServer;
43+
import org.springframework.web.client.RestClient;
44+
import org.springframework.web.client.RestClient.Builder;
45+
46+
import static org.assertj.core.api.Assertions.assertThat;
47+
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
48+
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
49+
50+
/**
51+
* Tests for {@link RestClientObservationConfiguration}.
52+
*
53+
* @author Brian Clozel
54+
* @author Moritz Halbritter
55+
*/
56+
@ExtendWith(OutputCaptureExtension.class)
57+
class RestClientObservationConfigurationTests {
58+
59+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
60+
.withBean(ObservationRegistry.class, TestObservationRegistry::create)
61+
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
62+
HttpClientObservationsAutoConfiguration.class));
63+
64+
@Test
65+
void contributesCustomizerBean() {
66+
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestClientCustomizer.class));
67+
}
68+
69+
@Test
70+
void restClientCreatedWithBuilderIsInstrumented() {
71+
this.contextRunner.run((context) -> {
72+
RestClient restClient = buildRestClient(context);
73+
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
74+
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
75+
TestObservationRegistryAssert.assertThat(registry)
76+
.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
77+
});
78+
}
79+
80+
@Test
81+
void restClientCreatedWithBuilderUsesCustomConventionName() {
82+
final String observationName = "test.metric.name";
83+
this.contextRunner.withPropertyValues("management.observations.http.client.requests.name=" + observationName)
84+
.run((context) -> {
85+
RestClient restClient = buildRestClient(context);
86+
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
87+
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
88+
TestObservationRegistryAssert.assertThat(registry)
89+
.hasObservationWithNameEqualToIgnoringCase(observationName);
90+
});
91+
}
92+
93+
@Test
94+
void restClientCreatedWithBuilderUsesCustomConvention() {
95+
this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> {
96+
RestClient restClient = buildRestClient(context);
97+
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
98+
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
99+
TestObservationRegistryAssert.assertThat(registry)
100+
.hasObservationWithNameEqualTo("http.client.requests")
101+
.that()
102+
.hasLowCardinalityKeyValue("project", "spring-boot");
103+
});
104+
}
105+
106+
@Test
107+
void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
108+
this.contextRunner.with(MetricsRun.simple())
109+
.withPropertyValues("management.metrics.web.client.max-uri-tags=2")
110+
.run((context) -> {
111+
RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
112+
MockRestServiceServer server = restClientWithMockServer.mockServer();
113+
RestClient restClient = restClientWithMockServer.restClient();
114+
for (int i = 0; i < 3; i++) {
115+
server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK));
116+
}
117+
for (int i = 0; i < 3; i++) {
118+
restClient.get().uri("/test/" + i, String.class).retrieve().toBodilessEntity();
119+
}
120+
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
121+
TestObservationRegistryAssert.assertThat(registry)
122+
.hasNumberOfObservationsWithNameEqualTo("http.client.requests", 3);
123+
MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
124+
assertThat(meterRegistry.find("http.client.requests").timers()).hasSize(2);
125+
assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.")
126+
.contains("Are you using 'uriVariables'?");
127+
});
128+
}
129+
130+
@Test
131+
void backsOffWhenRestClientBuilderIsMissing() {
132+
new ApplicationContextRunner().with(MetricsRun.simple())
133+
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
134+
HttpClientObservationsAutoConfiguration.class))
135+
.run((context) -> assertThat(context).doesNotHaveBean(ObservationRestClientCustomizer.class));
136+
}
137+
138+
private RestClient buildRestClient(AssertableApplicationContext context) {
139+
RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
140+
restClientWithMockServer.mockServer()
141+
.expect(requestTo("/projects/spring-boot"))
142+
.andRespond(withStatus(HttpStatus.OK));
143+
return restClientWithMockServer.restClient();
144+
}
145+
146+
private RestClientWithMockServer buildRestClientAndMockServer(AssertableApplicationContext context) {
147+
Builder builder = context.getBean(Builder.class);
148+
MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
149+
customizer.customize(builder);
150+
return new RestClientWithMockServer(builder.build(), customizer.getServer());
151+
}
152+
153+
private record RestClientWithMockServer(RestClient restClient, MockRestServiceServer mockServer) {
154+
}
155+
156+
@Configuration(proxyBeanMethods = false)
157+
static class CustomConventionConfiguration {
158+
159+
@Bean
160+
CustomConvention customConvention() {
161+
return new CustomConvention();
162+
}
163+
164+
}
165+
166+
static class CustomConvention extends DefaultClientRequestObservationConvention {
167+
168+
@Override
169+
public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
170+
return super.getLowCardinalityKeyValues(context).and("project", "spring-boot");
171+
}
172+
173+
}
174+
175+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.observation.web.client;
18+
19+
import io.micrometer.observation.ObservationRegistry;
20+
import io.micrometer.observation.tck.TestObservationRegistry;
21+
import io.micrometer.observation.tck.TestObservationRegistryAssert;
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.ExtendWith;
24+
25+
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
26+
import org.springframework.boot.autoconfigure.AutoConfigurations;
27+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
28+
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
29+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
30+
import org.springframework.boot.test.system.OutputCaptureExtension;
31+
import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
32+
import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
33+
import org.springframework.http.HttpStatus;
34+
import org.springframework.web.client.RestClient;
35+
import org.springframework.web.client.RestClient.Builder;
36+
37+
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
38+
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
39+
40+
/**
41+
* Tests for {@link RestClientObservationConfiguration} without Micrometer Metrics.
42+
*
43+
* @author Brian Clozel
44+
* @author Andy Wilkinson
45+
* @author Moritz Halbritter
46+
*/
47+
@ExtendWith(OutputCaptureExtension.class)
48+
@ClassPathExclusions("micrometer-core-*.jar")
49+
class RestClientObservationConfigurationWithoutMetricsTests {
50+
51+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
52+
.withBean(ObservationRegistry.class, TestObservationRegistry::create)
53+
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
54+
HttpClientObservationsAutoConfiguration.class));
55+
56+
@Test
57+
void restClientCreatedWithBuilderIsInstrumented() {
58+
this.contextRunner.run((context) -> {
59+
RestClient restClient = buildRestClient(context);
60+
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
61+
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
62+
TestObservationRegistryAssert.assertThat(registry)
63+
.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
64+
});
65+
}
66+
67+
private RestClient buildRestClient(AssertableApplicationContext context) {
68+
Builder builder = context.getBean(Builder.class);
69+
MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
70+
customizer.customize(builder);
71+
customizer.getServer().expect(requestTo("/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK));
72+
return builder.build();
73+
}
74+
75+
}

0 commit comments

Comments
 (0)