Skip to content

Commit

Permalink
Auto-configure observations for RestClients
Browse files Browse the repository at this point in the history
Closes gh-38500
  • Loading branch information
mhalbritter committed Nov 23, 2023
1 parent 9c68a2a commit f613ab8
Show file tree
Hide file tree
Showing 8 changed files with 425 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand All @@ -48,13 +49,15 @@
* @author Stephane Nicoll
* @author Raheela Aslam
* @author Brian Clozel
* @author Moritz Halbritter
* @since 3.0.0
*/
@AutoConfiguration(after = { ObservationAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class })
RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class, RestClientAutoConfiguration.class })
@ConditionalOnClass(Observation.class)
@ConditionalOnBean(ObservationRegistry.class)
@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class })
@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class,
RestClientObservationConfiguration.class })
@EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
public class HttpClientObservationsAutoConfiguration {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.actuate.autoconfigure.observation.web.client;

import io.micrometer.observation.ObservationRegistry;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.observation.ClientRequestObservationConvention;
import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
import org.springframework.web.client.RestClient;

/**
* Configure the instrumentation of {@link RestClient}.
*
* @author Moritz Halbritter
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RestClient.class)
@ConditionalOnBean(RestClient.Builder.class)
class RestClientObservationConfiguration {

@Bean
RestClientCustomizer observationRestClientCustomizer(ObservationRegistry observationRegistry,
ObjectProvider<ClientRequestObservationConvention> customConvention,
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getClient().getRequests().getName();
ClientRequestObservationConvention observationConvention = customConvention
.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
return new ObservationRestClientCustomizer(observationRegistry, observationConvention);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import io.micrometer.observation.ObservationRegistry;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
Expand All @@ -44,7 +43,7 @@ class RestTemplateObservationConfiguration {
@Bean
ObservationRestTemplateCustomizer observationRestTemplateCustomizer(ObservationRegistry observationRegistry,
ObjectProvider<ClientRequestObservationConvention> customConvention,
ObservationProperties observationProperties, MetricsProperties metricsProperties) {
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getClient().getRequests().getName();
ClientRequestObservationConvention observationConvention = customConvention
.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.actuate.autoconfigure.observation.web.client;

import io.micrometer.common.KeyValues;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.observation.ClientRequestObservationContext;
import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClient.Builder;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;

/**
* Tests for {@link RestClientObservationConfiguration}.
*
* @author Brian Clozel
* @author Moritz Halbritter
*/
@ExtendWith(OutputCaptureExtension.class)
class RestClientObservationConfigurationTests {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withBean(ObservationRegistry.class, TestObservationRegistry::create)
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
HttpClientObservationsAutoConfiguration.class));

@Test
void contributesCustomizerBean() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestClientCustomizer.class));
}

@Test
void restClientCreatedWithBuilderIsInstrumented() {
this.contextRunner.run((context) -> {
RestClient restClient = buildRestClient(context);
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
});
}

@Test
void restClientCreatedWithBuilderUsesCustomConventionName() {
final String observationName = "test.metric.name";
this.contextRunner.withPropertyValues("management.observations.http.client.requests.name=" + observationName)
.run((context) -> {
RestClient restClient = buildRestClient(context);
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase(observationName);
});
}

@Test
void restClientCreatedWithBuilderUsesCustomConvention() {
this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> {
RestClient restClient = buildRestClient(context);
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualTo("http.client.requests")
.that()
.hasLowCardinalityKeyValue("project", "spring-boot");
});
}

@Test
void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
this.contextRunner.with(MetricsRun.simple())
.withPropertyValues("management.metrics.web.client.max-uri-tags=2")
.run((context) -> {
RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
MockRestServiceServer server = restClientWithMockServer.mockServer();
RestClient restClient = restClientWithMockServer.restClient();
for (int i = 0; i < 3; i++) {
server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK));
}
for (int i = 0; i < 3; i++) {
restClient.get().uri("/test/" + i, String.class).retrieve().toBodilessEntity();
}
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasNumberOfObservationsWithNameEqualTo("http.client.requests", 3);
MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
assertThat(meterRegistry.find("http.client.requests").timers()).hasSize(2);
assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.")
.contains("Are you using 'uriVariables'?");
});
}

@Test
void backsOffWhenRestClientBuilderIsMissing() {
new ApplicationContextRunner().with(MetricsRun.simple())
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
HttpClientObservationsAutoConfiguration.class))
.run((context) -> assertThat(context).doesNotHaveBean(ObservationRestClientCustomizer.class));
}

private RestClient buildRestClient(AssertableApplicationContext context) {
RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
restClientWithMockServer.mockServer()
.expect(requestTo("/projects/spring-boot"))
.andRespond(withStatus(HttpStatus.OK));
return restClientWithMockServer.restClient();
}

private RestClientWithMockServer buildRestClientAndMockServer(AssertableApplicationContext context) {
Builder builder = context.getBean(Builder.class);
MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
customizer.customize(builder);
return new RestClientWithMockServer(builder.build(), customizer.getServer());
}

private record RestClientWithMockServer(RestClient restClient, MockRestServiceServer mockServer) {
}

@Configuration(proxyBeanMethods = false)
static class CustomConventionConfiguration {

@Bean
CustomConvention customConvention() {
return new CustomConvention();
}

}

static class CustomConvention extends DefaultClientRequestObservationConvention {

@Override
public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
return super.getLowCardinalityKeyValues(context).and("project", "spring-boot");
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.actuate.autoconfigure.observation.web.client;

import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClient.Builder;

import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;

/**
* Tests for {@link RestClientObservationConfiguration} without Micrometer Metrics.
*
* @author Brian Clozel
* @author Andy Wilkinson
* @author Moritz Halbritter
*/
@ExtendWith(OutputCaptureExtension.class)
@ClassPathExclusions("micrometer-core-*.jar")
class RestClientObservationConfigurationWithoutMetricsTests {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withBean(ObservationRegistry.class, TestObservationRegistry::create)
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
HttpClientObservationsAutoConfiguration.class));

@Test
void restClientCreatedWithBuilderIsInstrumented() {
this.contextRunner.run((context) -> {
RestClient restClient = buildRestClient(context);
restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
});
}

private RestClient buildRestClient(AssertableApplicationContext context) {
Builder builder = context.getBean(Builder.class);
MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
customizer.customize(builder);
customizer.getServer().expect(requestTo("/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK));
return builder.build();
}

}
Loading

0 comments on commit f613ab8

Please # to comment.