Auto-configure Observation support for RestTemplate

Prior to this commit, Spring Boot would auto-configure a customizer that
instruments `RestTemplate` through a `RestTemplateBuilder`. This would
install a request interceptor that instrumented client exchanges for
producing metrics.

As of spring-projects/spring-framework#28341, the instrumentation is
done at the `RestTemplate` level directly using the `Observation` API.
The `Tag` (now `KeyValue`) extraction, observation name and
instrumentation behavior now lives in the Spring Framework project.

This commit updates the auto-configuration to switch from Boot-specific
Metrics instrumentation to a generic Observation instrumentation.
As a migration path, some configuration properties are deprecated in
favor of the new `management.observations.*` namespace.

Closes gh-32484
pull/32526/head
Brian Clozel 2 years ago
parent 3acd9b80e8
commit eac50a8f0c

@ -147,6 +147,7 @@ dependencies {
testImplementation(project(":spring-boot-project:spring-boot-test"))
testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
testImplementation("io.micrometer:micrometer-observation-test")
testImplementation("io.projectreactor:reactor-test")
testImplementation("io.r2dbc:r2dbc-h2")
testImplementation("com.squareup.okhttp3:mockwebserver")

@ -25,6 +25,7 @@ import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
@ -158,6 +159,7 @@ public class MetricsProperties {
return this.autotime;
}
@DeprecatedConfigurationProperty(replacement = "management.observations.http.client.requests.name")
public String getMetricName() {
return this.metricName;
}

@ -0,0 +1,71 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import io.micrometer.common.KeyValues;
import io.micrometer.core.instrument.Tag;
import io.micrometer.observation.Observation;
import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
import org.springframework.http.client.observation.ClientHttpObservationContext;
import org.springframework.http.client.observation.ClientHttpObservationConvention;
/**
* Adapter class that applies {@link RestTemplateExchangeTagsProvider} tags as a
* {@link ClientHttpObservationConvention}.
*
* @author Brian Clozel
*/
@SuppressWarnings("deprecation")
class ClientHttpObservationConventionAdapter implements ClientHttpObservationConvention {
private final String metricName;
private final RestTemplateExchangeTagsProvider tagsProvider;
ClientHttpObservationConventionAdapter(String metricName, RestTemplateExchangeTagsProvider tagsProvider) {
this.metricName = metricName;
this.tagsProvider = tagsProvider;
}
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof ClientHttpObservationContext;
}
@Override
public KeyValues getLowCardinalityKeyValues(ClientHttpObservationContext context) {
KeyValues keyValues = KeyValues.empty();
Iterable<Tag> tags = this.tagsProvider.getTags(context.getUriTemplate(), context.getCarrier(),
context.getResponse());
for (Tag tag : tags) {
keyValues = keyValues.and(tag.getKey(), tag.getValue());
}
return keyValues;
}
@Override
public KeyValues getHighCardinalityKeyValues(ClientHttpObservationContext context) {
return KeyValues.empty();
}
@Override
public String getName() {
return this.metricName;
}
}

@ -20,15 +20,15 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.config.MeterFilter;
import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
import org.springframework.boot.actuate.autoconfigure.metrics.OnlyOnceLoggingDenyMeterFilter;
import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
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.RestTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
@ -42,11 +42,11 @@ import org.springframework.core.annotation.Order;
* @author Raheela Aslam
* @since 2.1.0
*/
@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
SimpleMetricsExportAutoConfiguration.class, RestTemplateAutoConfiguration.class })
@AutoConfiguration(after = { ObservationAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class })
@ConditionalOnClass(MeterRegistry.class)
@ConditionalOnBean(MeterRegistry.class)
@Import({ RestTemplateMetricsConfiguration.class, WebClientMetricsConfiguration.class })
@Import({ RestTemplateObservationConfiguration.class, WebClientMetricsConfiguration.class })
public class HttpClientMetricsAutoConfiguration {
@Bean

@ -1,60 +0,0 @@
/*
* Copyright 2012-2019 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.metrics.web.client;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties.Web.Client.ClientRequest;
import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider;
import org.springframework.boot.actuate.metrics.web.client.MetricsRestTemplateCustomizer;
import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* Configure the instrumentation of {@link RestTemplate}.
*
* @author Jon Schneider
* @author Phillip Webb
* @author Raheela Aslam
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RestTemplate.class)
@ConditionalOnBean(RestTemplateBuilder.class)
class RestTemplateMetricsConfiguration {
@Bean
@ConditionalOnMissingBean(RestTemplateExchangeTagsProvider.class)
DefaultRestTemplateExchangeTagsProvider restTemplateExchangeTagsProvider() {
return new DefaultRestTemplateExchangeTagsProvider();
}
@Bean
MetricsRestTemplateCustomizer metricsRestTemplateCustomizer(MeterRegistry meterRegistry,
RestTemplateExchangeTagsProvider restTemplateExchangeTagsProvider, MetricsProperties properties) {
ClientRequest request = properties.getWeb().getClient().getRequest();
return new MetricsRestTemplateCustomizer(meterRegistry, restTemplateExchangeTagsProvider,
request.getMetricName(), request.getAutotime());
}
}

@ -0,0 +1,61 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import io.micrometer.observation.Observation;
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.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.observation.ClientHttpObservationConvention;
import org.springframework.http.client.observation.DefaultClientHttpObservationConvention;
import org.springframework.web.client.RestTemplate;
/**
* Configure the instrumentation of {@link RestTemplate}.
*
* @author Brian Clozel
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ RestTemplate.class, Observation.class })
@ConditionalOnBean({ RestTemplateBuilder.class, ObservationRegistry.class })
@SuppressWarnings("deprecation")
class RestTemplateObservationConfiguration {
@Bean
ObservationRestTemplateCustomizer metricsRestTemplateCustomizer(ObservationRegistry observationRegistry,
ObservationProperties observationProperties, MetricsProperties metricsProperties,
ObjectProvider<RestTemplateExchangeTagsProvider> optionalTagsProvider) {
String metricName = metricsProperties.getWeb().getClient().getRequest().getMetricName();
String observationName = observationProperties.getHttp().getClient().getRequests().getName();
String name = (observationName != null) ? observationName : metricName;
RestTemplateExchangeTagsProvider tagsProvider = optionalTagsProvider.getIfAvailable();
ClientHttpObservationConvention observationConvention = (tagsProvider != null)
? new ClientHttpObservationConventionAdapter(name, tagsProvider)
: new DefaultClientHttpObservationConvention(name);
return new ObservationRestTemplateCustomizer(observationRegistry, observationConvention);
}
}

@ -33,6 +33,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -40,10 +41,12 @@ import org.springframework.context.annotation.Configuration;
* {@link EnableAutoConfiguration Auto-configuration} for the Micrometer Observation API.
*
* @author Moritz Halbritter
* @author Brian Clozel
* @since 3.0.0
*/
@AutoConfiguration(after = CompositeMeterRegistryAutoConfiguration.class)
@ConditionalOnClass(ObservationRegistry.class)
@EnableConfigurationProperties(ObservationProperties.class)
public class ObservationAutoConfiguration {
@Bean

@ -0,0 +1,75 @@
/*
* Copyright 2012-2022 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;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* {@link ConfigurationProperties @ConfigurationProperties} for configuring Micrometer
* observations.
*
* @author Brian Clozel
* @since 3.0.0
*/
@ConfigurationProperties("management.observations")
public class ObservationProperties {
private final Http http = new Http();
public Http getHttp() {
return this.http;
}
public static class Http {
private final Client client = new Client();
public Client getClient() {
return this.client;
}
public static class Client {
private final ClientRequests requests = new ClientRequests();
public ClientRequests getRequests() {
return this.requests;
}
public static class ClientRequests {
/**
* Name of the observation for client requests. If empty, will use the
* default "http.client.requests".
*/
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
}
}
}

@ -44,6 +44,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.orm.jpa.HibernateM
import org.springframework.boot.actuate.autoconfigure.metrics.web.client.HttpClientMetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.web.reactive.WebFluxMetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.web.servlet.WebMvcMetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
@ -135,12 +136,12 @@ class MetricsIntegrationTests {
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration({ MetricsAutoConfiguration.class, JvmMetricsAutoConfiguration.class,
LogbackMetricsAutoConfiguration.class, SystemMetricsAutoConfiguration.class,
RabbitMetricsAutoConfiguration.class, CacheMetricsAutoConfiguration.class,
DataSourcePoolMetricsAutoConfiguration.class, HibernateMetricsAutoConfiguration.class,
HttpClientMetricsAutoConfiguration.class, WebFluxMetricsAutoConfiguration.class,
WebMvcMetricsAutoConfiguration.class, JacksonAutoConfiguration.class,
@ImportAutoConfiguration({ MetricsAutoConfiguration.class, ObservationAutoConfiguration.class,
JvmMetricsAutoConfiguration.class, LogbackMetricsAutoConfiguration.class,
SystemMetricsAutoConfiguration.class, RabbitMetricsAutoConfiguration.class,
CacheMetricsAutoConfiguration.class, DataSourcePoolMetricsAutoConfiguration.class,
HibernateMetricsAutoConfiguration.class, HttpClientMetricsAutoConfiguration.class,
WebFluxMetricsAutoConfiguration.class, WebMvcMetricsAutoConfiguration.class, JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, RestTemplateAutoConfiguration.class,
WebMvcAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
ServletWebServerFactoryAutoConfiguration.class })

@ -0,0 +1,91 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import java.net.URI;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.observation.ClientHttpObservationContext;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ClientHttpObservationConventionAdapter}.
*
* @author Brian Clozel
*/
@SuppressWarnings("deprecation")
class ClientHttpObservationConventionAdapterTests {
private static final String TEST_METRIC_NAME = "test.metric.name";
private ClientHttpObservationConventionAdapter convention = new ClientHttpObservationConventionAdapter(
TEST_METRIC_NAME, new DefaultRestTemplateExchangeTagsProvider());
private ClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/resource/test"));
private ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.OK);
private ClientHttpObservationContext context;
@BeforeEach
void setup() {
this.context = new ClientHttpObservationContext();
this.context.setCarrier(this.request);
this.context.setResponse(this.response);
this.context.setUriTemplate("/resource/{name}");
}
@Test
void shouldUseConfiguredName() {
assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME);
}
@Test
void shouldOnlySupportClientHttpObservationContext() {
assertThat(this.convention.supportsContext(this.context)).isTrue();
assertThat(this.convention.supportsContext(new OtherContext())).isFalse();
}
@Test
void shouldPushTagsAsLowCardinalityKeyValues() {
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"),
KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
KeyValue.of("method", "GET"));
}
@Test
void shouldNotPushAnyHighCardinalityKeyValue() {
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty();
}
static class OtherContext extends Observation.Context {
}
}

@ -1,162 +0,0 @@
/*
* Copyright 2012-2021 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.metrics.web.client;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.distribution.HistogramSnapshot;
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.metrics.web.client.DefaultRestTemplateExchangeTagsProvider;
import org.springframework.boot.actuate.metrics.web.client.MetricsRestTemplateCustomizer;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
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.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
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 RestTemplateMetricsConfiguration}.
*
* @author Stephane Nicoll
* @author Jon Schneider
* @author Raheela Aslam
*/
@ExtendWith(OutputCaptureExtension.class)
class RestTemplateMetricsConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple())
.withConfiguration(AutoConfigurations.of(RestTemplateAutoConfiguration.class,
HttpClientMetricsAutoConfiguration.class));
@Test
void restTemplateCreatedWithBuilderIsInstrumented() {
this.contextRunner.run((context) -> {
MeterRegistry registry = context.getBean(MeterRegistry.class);
RestTemplateBuilder builder = context.getBean(RestTemplateBuilder.class);
validateRestTemplate(builder, registry);
});
}
@Test
void restTemplateWithRootUriIsInstrumented() {
this.contextRunner.run((context) -> {
MeterRegistry registry = context.getBean(MeterRegistry.class);
RestTemplateBuilder builder = context.getBean(RestTemplateBuilder.class);
builder = builder.rootUri("/root");
validateRestTemplate(builder, registry, "/root");
});
}
@Test
void restTemplateCanBeCustomizedManually() {
this.contextRunner.run((context) -> {
assertThat(context).hasSingleBean(MetricsRestTemplateCustomizer.class);
RestTemplateBuilder customBuilder = new RestTemplateBuilder()
.customizers(context.getBean(MetricsRestTemplateCustomizer.class));
MeterRegistry registry = context.getBean(MeterRegistry.class);
validateRestTemplate(customBuilder, registry);
});
}
@Test
void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
this.contextRunner.withPropertyValues("management.metrics.web.client.max-uri-tags=2").run((context) -> {
MeterRegistry registry = getInitializedMeterRegistry(context);
assertThat(registry.get("http.client.requests").meters()).hasSize(2);
assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.")
.contains("Are you using 'uriVariables'?");
});
}
@Test
void shouldNotDenyNorLogIfMaxUrisIsNotReached(CapturedOutput output) {
this.contextRunner.withPropertyValues("management.metrics.web.client.max-uri-tags=5").run((context) -> {
MeterRegistry registry = getInitializedMeterRegistry(context);
assertThat(registry.get("http.client.requests").meters()).hasSize(3);
assertThat(output).doesNotContain("Reached the maximum number of URI tags for 'http.client.requests'.")
.doesNotContain("Are you using 'uriVariables'?");
});
}
@Test
void autoTimeRequestsCanBeConfigured() {
this.contextRunner.withPropertyValues("management.metrics.web.client.request.autotime.enabled=true",
"management.metrics.web.client.request.autotime.percentiles=0.5,0.7",
"management.metrics.web.client.request.autotime.percentiles-histogram=true").run((context) -> {
MeterRegistry registry = getInitializedMeterRegistry(context);
Timer timer = registry.get("http.client.requests").timer();
HistogramSnapshot snapshot = timer.takeSnapshot();
assertThat(snapshot.percentileValues()).hasSize(2);
assertThat(snapshot.percentileValues()[0].percentile()).isEqualTo(0.5);
assertThat(snapshot.percentileValues()[1].percentile()).isEqualTo(0.7);
});
}
@Test
void backsOffWhenRestTemplateBuilderIsMissing() {
new ApplicationContextRunner().with(MetricsRun.simple())
.withConfiguration(AutoConfigurations.of(HttpClientMetricsAutoConfiguration.class))
.run((context) -> assertThat(context).doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class)
.doesNotHaveBean(MetricsRestTemplateCustomizer.class));
}
private MeterRegistry getInitializedMeterRegistry(AssertableApplicationContext context) {
MeterRegistry registry = context.getBean(MeterRegistry.class);
RestTemplate restTemplate = context.getBean(RestTemplateBuilder.class).build();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
for (int i = 0; i < 3; i++) {
server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK));
}
for (int i = 0; i < 3; i++) {
restTemplate.getForObject("/test/" + i, String.class);
}
return registry;
}
private void validateRestTemplate(RestTemplateBuilder builder, MeterRegistry registry) {
this.validateRestTemplate(builder, registry, "");
}
private void validateRestTemplate(RestTemplateBuilder builder, MeterRegistry registry, String rootUri) {
RestTemplate restTemplate = mockRestTemplate(builder, rootUri);
assertThat(registry.find("http.client.requests").meter()).isNull();
assertThat(restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot").getStatusCode())
.isEqualTo(HttpStatus.OK);
assertThat(registry.get("http.client.requests").tags("uri", rootUri + "/projects/{project}").meter())
.isNotNull();
}
private RestTemplate mockRestTemplate(RestTemplateBuilder builder, String rootUri) {
RestTemplate restTemplate = builder.build();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
server.expect(requestTo(rootUri + "/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK));
return restTemplate;
}
}

@ -0,0 +1,173 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
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.DefaultRestTemplateExchangeTagsProvider;
import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer;
import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
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.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
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 RestTemplateObservationConfiguration}.
*
* @author Brian Clozel
*/
@ExtendWith(OutputCaptureExtension.class)
@SuppressWarnings("deprecation")
class RestTemplateObservationConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple())
.withBean(ObservationRegistry.class, TestObservationRegistry::create)
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
RestTemplateAutoConfiguration.class, HttpClientMetricsAutoConfiguration.class));
@Test
void contributesCustomizerBean() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestTemplateCustomizer.class)
.doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class));
}
@Test
void restTemplateCreatedWithBuilderIsInstrumented() {
this.contextRunner.run((context) -> {
RestTemplate restTemplate = buildRestTemplate(context);
restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
});
}
@Test
void restTemplateCreatedWithBuilderUsesCustomConventionName() {
final String observationName = "test.metric.name";
this.contextRunner.withPropertyValues("management.observations.http.client.requests.name=" + observationName)
.run((context) -> {
RestTemplate restTemplate = buildRestTemplate(context);
restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase(observationName);
});
}
@Test
void restTemplateCreatedWithBuilderUsesCustomMetricName() {
final String metricName = "test.metric.name";
this.contextRunner.withPropertyValues("management.metrics.web.client.request.metric-name=" + metricName)
.run((context) -> {
RestTemplate restTemplate = buildRestTemplate(context);
restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualToIgnoringCase(metricName);
});
}
@Test
void restTemplateCreatedWithBuilderUsesCustomTagsProvider() {
this.contextRunner.withUserConfiguration(CustomTagsConfiguration.class).run((context) -> {
RestTemplate restTemplate = buildRestTemplate(context);
restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
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.withPropertyValues("management.metrics.web.client.max-uri-tags=2").run((context) -> {
RestTemplate restTemplate = context.getBean(RestTemplateBuilder.class).build();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
for (int i = 0; i < 3; i++) {
server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK));
}
for (int i = 0; i < 3; i++) {
restTemplate.getForObject("/test/" + i, String.class);
}
TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
TestObservationRegistryAssert.assertThat(registry);
// TODO: check observation count for name
assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.")
.contains("Are you using 'uriVariables'?");
});
}
@Test
void backsOffWhenRestTemplateBuilderIsMissing() {
new ApplicationContextRunner().with(MetricsRun.simple())
.withConfiguration(AutoConfigurations.of(HttpClientMetricsAutoConfiguration.class))
.run((context) -> assertThat(context).doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class)
.doesNotHaveBean(ObservationRestTemplateCustomizer.class));
}
private RestTemplate buildRestTemplate(AssertableApplicationContext context) {
RestTemplate restTemplate = context.getBean(RestTemplateBuilder.class).build();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
server.expect(requestTo("/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK));
return restTemplate;
}
@Configuration
static class CustomTagsConfiguration {
@Bean
CustomTagsProvider customTagsProvider() {
return new CustomTagsProvider();
}
}
static class CustomTagsProvider implements RestTemplateExchangeTagsProvider {
@Override
public Iterable<Tag> getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response) {
return Tags.of("project", "spring-boot");
}
}
}

@ -26,6 +26,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
@ -55,8 +56,8 @@ import static org.mockito.Mockito.mock;
class WebClientMetricsConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple())
.withConfiguration(
AutoConfigurations.of(WebClientAutoConfiguration.class, HttpClientMetricsAutoConfiguration.class));
.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
WebClientAutoConfiguration.class, HttpClientMetricsAutoConfiguration.class));
@Test
void webClientCreatedWithBuilderIsInstrumented() {

@ -86,6 +86,7 @@ dependencies {
testImplementation(project(":spring-boot-project:spring-boot-autoconfigure"))
testImplementation("org.assertj:assertj-core")
testImplementation("com.jayway.jsonpath:json-path")
testImplementation("io.micrometer:micrometer-observation-test")
testImplementation("io.projectreactor:reactor-test")
testImplementation("io.r2dbc:r2dbc-h2")
testImplementation("org.apache.logging.log4j:log4j-to-slf4j")

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2022 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.
@ -22,6 +22,7 @@ import io.micrometer.core.instrument.Tag;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.observation.DefaultClientHttpObservationConvention;
import org.springframework.util.StringUtils;
/**
@ -30,7 +31,10 @@ import org.springframework.util.StringUtils;
* @author Jon Schneider
* @author Nishant Raut
* @since 2.0.0
* @deprecated since 3.0.0 for removal in 3.2.0 in favor of
* {@link DefaultClientHttpObservationConvention}
*/
@Deprecated
public class DefaultRestTemplateExchangeTagsProvider implements RestTemplateExchangeTagsProvider {
@Override

@ -1,159 +0,0 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import java.io.IOException;
import java.net.URI;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.boot.web.client.RootUriTemplateHandler;
import org.springframework.core.NamedThreadLocal;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.util.UriTemplateHandler;
/**
* {@link ClientHttpRequestInterceptor} applied via a
* {@link MetricsRestTemplateCustomizer} to record metrics.
*
* @author Jon Schneider
* @author Phillip Webb
*/
class MetricsClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
private static final Log logger = LogFactory.getLog(MetricsClientHttpRequestInterceptor.class);
private static final ThreadLocal<Deque<String>> urlTemplate = new UrlTemplateThreadLocal();
private final MeterRegistry meterRegistry;
private final RestTemplateExchangeTagsProvider tagProvider;
private final String metricName;
private final AutoTimer autoTimer;
/**
* Create a new {@code MetricsClientHttpRequestInterceptor}.
* @param meterRegistry the registry to which metrics are recorded
* @param tagProvider provider for metrics tags
* @param metricName name of the metric to record
* @param autoTimer the auto-timers to apply or {@code null} to disable auto-timing
* @since 2.2.0
*/
MetricsClientHttpRequestInterceptor(MeterRegistry meterRegistry, RestTemplateExchangeTagsProvider tagProvider,
String metricName, AutoTimer autoTimer) {
this.tagProvider = tagProvider;
this.meterRegistry = meterRegistry;
this.metricName = metricName;
this.autoTimer = (autoTimer != null) ? autoTimer : AutoTimer.DISABLED;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
if (!enabled()) {
return execution.execute(request, body);
}
long startTime = System.nanoTime();
ClientHttpResponse response = null;
try {
response = execution.execute(request, body);
return response;
}
finally {
try {
getTimeBuilder(request, response).register(this.meterRegistry).record(System.nanoTime() - startTime,
TimeUnit.NANOSECONDS);
}
catch (Exception ex) {
logger.info("Failed to record metrics.", ex);
}
if (urlTemplate.get().isEmpty()) {
urlTemplate.remove();
}
}
}
private boolean enabled() {
return this.autoTimer.isEnabled();
}
UriTemplateHandler createUriTemplateHandler(UriTemplateHandler delegate) {
if (delegate instanceof RootUriTemplateHandler rootHandler) {
return rootHandler.withHandlerWrapper(CapturingUriTemplateHandler::new);
}
return new CapturingUriTemplateHandler(delegate);
}
private Timer.Builder getTimeBuilder(HttpRequest request, ClientHttpResponse response) {
return this.autoTimer.builder(this.metricName)
.tags(this.tagProvider.getTags(urlTemplate.get().poll(), request, response))
.description("Timer of RestTemplate operation");
}
private final class CapturingUriTemplateHandler implements UriTemplateHandler {
private final UriTemplateHandler delegate;
private CapturingUriTemplateHandler(UriTemplateHandler delegate) {
this.delegate = delegate;
}
@Override
public URI expand(String url, Map<String, ?> arguments) {
if (enabled()) {
urlTemplate.get().push(url);
}
return this.delegate.expand(url, arguments);
}
@Override
public URI expand(String url, Object... arguments) {
if (enabled()) {
urlTemplate.get().push(url);
}
return this.delegate.expand(url, arguments);
}
}
private static final class UrlTemplateThreadLocal extends NamedThreadLocal<Deque<String>> {
private UrlTemplateThreadLocal() {
super("Rest Template URL Template");
}
@Override
protected Deque<String> initialValue() {
return new LinkedList<>();
}
}
}

@ -1,72 +0,0 @@
/*
* Copyright 2012-2020 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.metrics.web.client;
import java.util.ArrayList;
import java.util.List;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriTemplateHandler;
/**
* {@link RestTemplateCustomizer} that configures the {@link RestTemplate} to record
* request metrics.
*
* @author Andy Wilkinson
* @author Phillip Webb
* @since 2.0.0
*/
public class MetricsRestTemplateCustomizer implements RestTemplateCustomizer {
private final MetricsClientHttpRequestInterceptor interceptor;
/**
* Creates a new {@code MetricsRestTemplateInterceptor}. When {@code autoTimeRequests}
* is set to {@code true}, the interceptor records metrics using the given
* {@code meterRegistry} with tags provided by the given {@code tagProvider} and with
* {@link AutoTimer auto-timed configuration}.
* @param meterRegistry the meter registry
* @param tagProvider the tag provider
* @param metricName the name of the recorded metric
* @param autoTimer the auto-timers to apply or {@code null} to disable auto-timing
* @since 2.2.0
*/
public MetricsRestTemplateCustomizer(MeterRegistry meterRegistry, RestTemplateExchangeTagsProvider tagProvider,
String metricName, AutoTimer autoTimer) {
this.interceptor = new MetricsClientHttpRequestInterceptor(meterRegistry, tagProvider, metricName, autoTimer);
}
@Override
public void customize(RestTemplate restTemplate) {
UriTemplateHandler templateHandler = restTemplate.getUriTemplateHandler();
templateHandler = this.interceptor.createUriTemplateHandler(templateHandler);
restTemplate.setUriTemplateHandler(templateHandler);
List<ClientHttpRequestInterceptor> existingInterceptors = restTemplate.getInterceptors();
if (!existingInterceptors.contains(this.interceptor)) {
List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
interceptors.add(this.interceptor);
interceptors.addAll(existingInterceptors);
restTemplate.setInterceptors(interceptors);
}
}
}

@ -0,0 +1,55 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.http.client.observation.ClientHttpObservationConvention;
import org.springframework.web.client.RestTemplate;
/**
* {@link RestTemplateCustomizer} that configures the {@link RestTemplate} to record
* request observations.
*
* @author Brian Clozel
* @since 3.0.0
*/
public class ObservationRestTemplateCustomizer implements RestTemplateCustomizer {
private final ObservationRegistry observationRegistry;
private final ClientHttpObservationConvention observationConvention;
/**
* Create a new {@code ObservationRestTemplateCustomizer}.
* @param observationConvention the observation convention
* @param observationRegistry the observation registry
*/
public ObservationRestTemplateCustomizer(ObservationRegistry observationRegistry,
ClientHttpObservationConvention observationConvention) {
this.observationConvention = observationConvention;
this.observationRegistry = observationRegistry;
}
@Override
public void customize(RestTemplate restTemplate) {
restTemplate.setObservationConvention(this.observationConvention);
restTemplate.setObservationRegistry(this.observationRegistry);
}
}

@ -25,6 +25,7 @@ import io.micrometer.core.instrument.Tag;
import org.springframework.boot.actuate.metrics.http.Outcome;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.observation.DefaultClientHttpObservationConvention;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
@ -37,7 +38,10 @@ import org.springframework.web.client.RestTemplate;
* @author Nishant Raut
* @author Brian Clozel
* @since 2.0.0
* @deprecated since 3.0.0 for removal in 3.2.0 in favor of
* {@link DefaultClientHttpObservationConvention}
*/
@Deprecated
public final class RestTemplateExchangeTags {
private static final Pattern STRIP_URI_PATTERN = Pattern.compile("^https?://[^/]+/");

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2022 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.
@ -20,6 +20,7 @@ import io.micrometer.core.instrument.Tag;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.observation.ClientHttpObservationConvention;
import org.springframework.web.client.RestTemplate;
/**
@ -28,8 +29,11 @@ import org.springframework.web.client.RestTemplate;
* @author Jon Schneider
* @author Andy Wilkinson
* @since 2.0.0
* @deprecated since 3.0.0 for removal in 3.2.0 in favor of
* {@link ClientHttpObservationConvention}
*/
@FunctionalInterface
@Deprecated
public interface RestTemplateExchangeTagsProvider {
/**

@ -1,207 +0,0 @@
/*
* Copyright 2012-2021 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.metrics.web.client;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.concurrent.atomic.AtomicBoolean;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.MockClock;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Timer.Builder;
import io.micrometer.core.instrument.simple.SimpleConfig;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.boot.test.web.client.LocalHostUriTemplateHandler;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.client.RootUriTemplateHandler;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.match.MockRestRequestMatchers;
import org.springframework.test.web.client.response.MockRestResponseCreators;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link MetricsRestTemplateCustomizer}.
*
* @author Jon Schneider
* @author Brian Clozel
*/
class MetricsRestTemplateCustomizerTests {
private MeterRegistry registry;
private RestTemplate restTemplate;
private MockRestServiceServer mockServer;
private MetricsRestTemplateCustomizer customizer;
@BeforeEach
void setup() {
this.registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock());
this.restTemplate = new RestTemplate();
this.mockServer = MockRestServiceServer.createServer(this.restTemplate);
this.customizer = new MetricsRestTemplateCustomizer(this.registry,
new DefaultRestTemplateExchangeTagsProvider(), "http.client.requests", AutoTimer.ENABLED);
this.customizer.customize(this.restTemplate);
}
@Test
void interceptRestTemplate() {
this.mockServer.expect(MockRestRequestMatchers.requestTo("/test/123"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
String result = this.restTemplate.getForObject("/test/{id}", String.class, 123);
assertThat(this.registry.find("http.client.requests").meters())
.anySatisfy((m) -> assertThat(m.getId().getTags().stream().map(Tag::getKey)).doesNotContain("bucket"));
assertThat(this.registry.get("http.client.requests").tags("method", "GET", "uri", "/test/{id}", "status", "200")
.timer().count()).isEqualTo(1);
assertThat(result).isEqualTo("OK");
this.mockServer.verify();
}
@Test
void avoidDuplicateRegistration() {
this.customizer.customize(this.restTemplate);
assertThat(this.restTemplate.getInterceptors()).hasSize(1);
this.customizer.customize(this.restTemplate);
assertThat(this.restTemplate.getInterceptors()).hasSize(1);
}
@Test
void normalizeUriToContainLeadingSlash() {
this.mockServer.expect(MockRestRequestMatchers.requestTo("/test/123"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
String result = this.restTemplate.getForObject("test/{id}", String.class, 123);
this.registry.get("http.client.requests").tags("uri", "/test/{id}").timer();
assertThat(result).isEqualTo("OK");
this.mockServer.verify();
}
@Test
void interceptRestTemplateWithUri() throws URISyntaxException {
this.mockServer.expect(MockRestRequestMatchers.requestTo("http://localhost/test/123"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
String result = this.restTemplate.getForObject(new URI("http://localhost/test/123"), String.class);
assertThat(result).isEqualTo("OK");
this.registry.get("http.client.requests").tags("uri", "/test/123").timer();
this.mockServer.verify();
}
@Test
void interceptNestedRequest() {
this.mockServer.expect(MockRestRequestMatchers.requestTo("/test/123"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
RestTemplate nestedRestTemplate = new RestTemplate();
MockRestServiceServer nestedMockServer = MockRestServiceServer.createServer(nestedRestTemplate);
nestedMockServer.expect(MockRestRequestMatchers.requestTo("/nestedTest/124"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
this.customizer.customize(nestedRestTemplate);
TestInterceptor testInterceptor = new TestInterceptor(nestedRestTemplate);
this.restTemplate.getInterceptors().add(testInterceptor);
this.restTemplate.getForObject("/test/{id}", String.class, 123);
this.registry.get("http.client.requests").tags("uri", "/test/{id}").timer();
this.registry.get("http.client.requests").tags("uri", "/nestedTest/{nestedId}").timer();
this.mockServer.verify();
nestedMockServer.verify();
}
@Test
void whenCustomizerAndLocalHostUriTemplateHandlerAreUsedTogetherThenRestTemplateBuilderCanBuild() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty("local.server.port", "8443");
LocalHostUriTemplateHandler uriTemplateHandler = new LocalHostUriTemplateHandler(environment, "https");
RestTemplate restTemplate = new RestTemplateBuilder(this.customizer).uriTemplateHandler(uriTemplateHandler)
.build();
assertThat(restTemplate.getUriTemplateHandler())
.asInstanceOf(InstanceOfAssertFactories.type(RootUriTemplateHandler.class))
.extracting(RootUriTemplateHandler::getRootUri).isEqualTo("https://localhost:8443");
}
@Test
void whenAutoTimingIsDisabledUriTemplateHandlerDoesNotCaptureUris() {
AtomicBoolean enabled = new AtomicBoolean();
AutoTimer autoTimer = new AutoTimer() {
@Override
public boolean isEnabled() {
return enabled.get();
}
@Override
public void apply(Builder builder) {
}
};
RestTemplate restTemplate = new RestTemplateBuilder(new MetricsRestTemplateCustomizer(this.registry,
new DefaultRestTemplateExchangeTagsProvider(), "http.client.requests", autoTimer)).build();
MockRestServiceServer mockServer = MockRestServiceServer.createServer(restTemplate);
mockServer.expect(MockRestRequestMatchers.requestTo("/first/123"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
mockServer.expect(MockRestRequestMatchers.requestTo("/second/456"))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(MockRestResponseCreators.withSuccess("OK", MediaType.APPLICATION_JSON));
assertThat(restTemplate.getForObject("/first/{id}", String.class, 123)).isEqualTo("OK");
assertThat(this.registry.find("http.client.requests").timer()).isNull();
enabled.set(true);
assertThat(restTemplate.getForObject(URI.create("/second/456"), String.class)).isEqualTo("OK");
this.registry.get("http.client.requests").tags("uri", "/second/456").timer();
this.mockServer.verify();
}
private static final class TestInterceptor implements ClientHttpRequestInterceptor {
private final RestTemplate restTemplate;
private TestInterceptor(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
this.restTemplate.getForObject("/nestedTest/{nestedId}", String.class, 124);
return execution.execute(request, body);
}
}
}

@ -0,0 +1,56 @@
/*
* Copyright 2012-2022 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.metrics.web.client;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.observation.DefaultClientHttpObservationConvention;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ObservationRestTemplateCustomizer}.
*
* @author Brian Clozel
*/
class ObservationRestTemplateCustomizerTests {
private static final String TEST_METRIC_NAME = "http.test.metric.name";
private ObservationRegistry observationRegistry = TestObservationRegistry.create();
private RestTemplate restTemplate = new RestTemplate();
private ObservationRestTemplateCustomizer customizer = new ObservationRestTemplateCustomizer(
this.observationRegistry, new DefaultClientHttpObservationConvention(TEST_METRIC_NAME));
@Test
void shouldCustomizeObservationConfiguration() {
this.customizer.customize(this.restTemplate);
assertThat((ObservationRegistry) ReflectionTestUtils.getField(this.restTemplate, "observationRegistry"))
.isEqualTo(this.observationRegistry);
assertThat((ObservationConvention<?>) ReflectionTestUtils.getField(this.restTemplate, "observationConvention"))
.isInstanceOf(DefaultClientHttpObservationConvention.class).extracting("name")
.isEqualTo(TEST_METRIC_NAME);
}
}

@ -38,6 +38,7 @@ import static org.mockito.Mockito.mock;
* @author Nishant Raut
* @author Brian Clozel
*/
@SuppressWarnings("deprecation")
class RestTemplateExchangeTagsTests {
@Test

@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2022 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.
@ -60,7 +60,7 @@ class AutoConfigureMockRestServiceServerWithRootUriIntegrationTests {
this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
ResponseEntity<String> entity = this.restTemplate.getForEntity("/test", String.class);
assertThat(entity.getBody()).isEqualTo("hello");
assertThat(this.meterRegistry.find("http.client.requests").tag("uri", "/rest/test").timer()).isNotNull();
assertThat(this.meterRegistry.find("http.client.requests").tag("uri", "/test").timer()).isNotNull();
}
@EnableAutoConfiguration(exclude = CassandraAutoConfiguration.class)

Loading…
Cancel
Save