Use Observation infrastructure for instrumenting WebClient

As of spring-projects/spring-framework#28341, `WebClient` is
instrumented directly for `Observation`.
This commit removes the custom `ExchangeFilterFunction` that previously
instrumented the client for metrics.

As a result, the relevant tag providers are now deprecated and adapted
as `ObservationConvention` for the time being.

Closes gh-32518
This commit is contained in:
Brian Clozel
2022-09-30 11:18:07 +02:00
parent f0e40bb00d
commit 8b4a20d6cd
21 changed files with 399 additions and 526 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 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.
@@ -29,7 +29,10 @@ import org.springframework.web.reactive.function.client.ClientResponse;
* @author Brian Clozel
* @author Nishant Raut
* @since 2.1.0
* @deprecated since 3.0.0 for removal in 3.2.0 in favor of
* {@link org.springframework.web.reactive.function.client.DefaultClientObservationConvention}
*/
@Deprecated
public class DefaultWebClientExchangeTagsProvider implements WebClientExchangeTagsProvider {
@Override

View File

@@ -1,60 +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.reactive.client;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.web.reactive.function.client.WebClient;
/**
* {@link WebClientCustomizer} that configures the {@link WebClient} to record request
* metrics.
*
* @author Brian Clozel
* @since 2.1.0
*/
public class MetricsWebClientCustomizer implements WebClientCustomizer {
private final MetricsWebClientFilterFunction filterFunction;
/**
* Create a new {@code MetricsWebClientFilterFunction} that will record metrics using
* the given {@code meterRegistry} with tags provided by the given
* {@code tagProvider}.
* @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 MetricsWebClientCustomizer(MeterRegistry meterRegistry, WebClientExchangeTagsProvider tagProvider,
String metricName, AutoTimer autoTimer) {
this.filterFunction = new MetricsWebClientFilterFunction(meterRegistry, tagProvider, metricName, autoTimer);
}
@Override
public void customize(WebClient.Builder webClientBuilder) {
webClientBuilder.filters((filterFunctions) -> {
if (!filterFunctions.contains(this.filterFunction)) {
filterFunctions.add(0, this.filterFunction);
}
});
}
}

View File

@@ -1,120 +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.reactive.client;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SignalType;
import reactor.util.context.Context;
import reactor.util.context.ContextView;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
/**
* {@link ExchangeFilterFunction} applied via a {@link MetricsWebClientCustomizer} to
* record metrics.
*
* @author Brian Clozel
* @author Tadaya Tsuyukubo
* @author Scott Frederick
* @since 2.1.0
*/
public class MetricsWebClientFilterFunction implements ExchangeFilterFunction {
private static final String METRICS_WEBCLIENT_START_TIME = MetricsWebClientFilterFunction.class.getName()
+ ".START_TIME";
private static final Log logger = LogFactory.getLog(MetricsWebClientFilterFunction.class);
private final MeterRegistry meterRegistry;
private final WebClientExchangeTagsProvider tagProvider;
private final String metricName;
private final AutoTimer autoTimer;
/**
* Create a new {@code MetricsWebClientFilterFunction}.
* @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-timer configuration or {@code null} to disable
* @since 2.2.0
*/
public MetricsWebClientFilterFunction(MeterRegistry meterRegistry, WebClientExchangeTagsProvider tagProvider,
String metricName, AutoTimer autoTimer) {
this.meterRegistry = meterRegistry;
this.tagProvider = tagProvider;
this.metricName = metricName;
this.autoTimer = (autoTimer != null) ? autoTimer : AutoTimer.DISABLED;
}
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
if (!this.autoTimer.isEnabled()) {
return next.exchange(request);
}
return next.exchange(request).as((responseMono) -> instrumentResponse(request, responseMono))
.contextWrite(this::putStartTime);
}
private Mono<ClientResponse> instrumentResponse(ClientRequest request, Mono<ClientResponse> responseMono) {
final AtomicBoolean responseReceived = new AtomicBoolean();
return Mono.deferContextual((ctx) -> responseMono.doOnEach((signal) -> {
if (signal.isOnNext() || signal.isOnError()) {
responseReceived.set(true);
recordTimer(request, signal.get(), signal.getThrowable(), getStartTime(ctx));
}
}).doFinally((signalType) -> {
if (!responseReceived.get() && SignalType.CANCEL.equals(signalType)) {
recordTimer(request, null, null, getStartTime(ctx));
}
}));
}
private void recordTimer(ClientRequest request, ClientResponse response, Throwable error, Long startTime) {
try {
Iterable<Tag> tags = this.tagProvider.tags(request, response, error);
this.autoTimer.builder(this.metricName).tags(tags).description("Timer of WebClient operation")
.register(this.meterRegistry).record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS);
}
catch (Exception ex) {
logger.warn("Failed to record timer metrics", ex);
// Allow request-response exchange to continue, unaffected by metrics problem
}
}
private Long getStartTime(ContextView context) {
return context.get(METRICS_WEBCLIENT_START_TIME);
}
private Context putStartTime(Context context) {
return context.put(METRICS_WEBCLIENT_START_TIME, System.nanoTime());
}
}

View File

@@ -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.reactive.client;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.web.reactive.function.client.ClientObservationConvention;
import org.springframework.web.reactive.function.client.WebClient;
/**
* {@link WebClientCustomizer} that configures the {@link WebClient} to record request
* observations.
*
* @author Brian Clozel
* @since 3.0.0
*/
public class ObservationWebClientCustomizer implements WebClientCustomizer {
private final ObservationRegistry observationRegistry;
private final ClientObservationConvention observationConvention;
/**
* Create a new {@code ObservationWebClientCustomizer} that will configure the
* {@code Observation} setup on the client.
* @param observationRegistry the registry to publish observations to
* @param observationConvention the convention to use to populate observations
*/
public ObservationWebClientCustomizer(ObservationRegistry observationRegistry,
ClientObservationConvention observationConvention) {
this.observationRegistry = observationRegistry;
this.observationConvention = observationConvention;
}
@Override
public void customize(WebClient.Builder webClientBuilder) {
webClientBuilder.observationRegistry(this.observationRegistry)
.observationConvention(this.observationConvention);
}
}

View File

@@ -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.
@@ -27,8 +27,11 @@ import org.springframework.web.reactive.function.client.ClientResponse;
*
* @author Brian Clozel
* @since 2.1.0
* @deprecated since 3.0.0 for removal in 3.2.0 in favor of
* {@link org.springframework.web.reactive.function.client.ClientObservationConvention}
*/
@FunctionalInterface
@Deprecated
public interface WebClientExchangeTagsProvider {
/**

View File

@@ -39,6 +39,7 @@ import static org.mockito.Mockito.mock;
* @author Brian Clozel
* @author Nishant Raut
*/
@SuppressWarnings("deprecation")
class DefaultWebClientExchangeTagsProviderTests {
private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate";

View File

@@ -1,64 +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.reactive.client;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link MetricsWebClientCustomizer}
*
* @author Brian Clozel
*/
class MetricsWebClientCustomizerTests {
private MetricsWebClientCustomizer customizer;
private WebClient.Builder clientBuilder;
@BeforeEach
void setup() {
this.customizer = new MetricsWebClientCustomizer(mock(MeterRegistry.class),
mock(WebClientExchangeTagsProvider.class), "test", null);
this.clientBuilder = WebClient.builder();
}
@Test
void customizeShouldAddFilterFunction() {
this.clientBuilder.filter(mock(ExchangeFilterFunction.class));
this.customizer.customize(this.clientBuilder);
this.clientBuilder.filters(
(filters) -> assertThat(filters).hasSize(2).first().isInstanceOf(MetricsWebClientFilterFunction.class));
}
@Test
void customizeShouldNotAddDuplicateFilterFunction() {
this.customizer.customize(this.clientBuilder);
this.clientBuilder.filters((filters) -> assertThat(filters).hasSize(1));
this.customizer.customize(this.clientBuilder);
this.clientBuilder.filters(
(filters) -> assertThat(filters).singleElement().isInstanceOf(MetricsWebClientFilterFunction.class));
}
}

View File

@@ -1,194 +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.reactive.client;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
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;
import io.micrometer.core.instrument.search.MeterNotFoundException;
import io.micrometer.core.instrument.simple.SimpleConfig;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.boot.actuate.metrics.AutoTimer;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link MetricsWebClientFilterFunction}
*
* @author Brian Clozel
* @author Scott Frederick
*/
class MetricsWebClientFilterFunctionTests {
private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate";
private MeterRegistry registry;
private MetricsWebClientFilterFunction filterFunction;
private final FaultyTagsProvider tagsProvider = new FaultyTagsProvider();
private ClientResponse response;
private ExchangeFunction exchange;
@BeforeEach
void setup() {
this.registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock());
this.filterFunction = new MetricsWebClientFilterFunction(this.registry, this.tagsProvider,
"http.client.requests", AutoTimer.ENABLED);
this.response = mock(ClientResponse.class);
this.exchange = (r) -> Mono.just(this.response);
}
@Test
void filterShouldRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
given(this.response.statusCode()).willReturn(HttpStatus.OK);
this.filterFunction.filter(request, this.exchange).block(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "200").timer().count()).isEqualTo(1);
}
@Test
void filterWhenUriTemplatePresentShouldRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot"))
.attribute(URI_TEMPLATE_ATTRIBUTE, "/projects/{project}").build();
given(this.response.statusCode()).willReturn(HttpStatus.OK);
this.filterFunction.filter(request, this.exchange).block(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/{project}", "status", "200").timer().count()).isEqualTo(1);
}
@Test
void filterWhenIoExceptionThrownShouldRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
ExchangeFunction errorExchange = (r) -> Mono.error(new IOException());
this.filterFunction.filter(request, errorExchange).onErrorResume(IOException.class, (t) -> Mono.empty())
.block(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "IO_ERROR").timer().count())
.isEqualTo(1);
}
@Test
void filterWhenExceptionThrownShouldRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
ExchangeFunction exchange = (r) -> Mono.error(new IllegalArgumentException());
this.filterFunction.filter(request, exchange).onErrorResume(IllegalArgumentException.class, (t) -> Mono.empty())
.block(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "CLIENT_ERROR").timer().count())
.isEqualTo(1);
}
@Test
void filterWhenCancelThrownShouldRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
given(this.response.statusCode()).willReturn(HttpStatus.OK);
Mono<ClientResponse> filter = this.filterFunction.filter(request, this.exchange);
StepVerifier.create(filter).thenCancel().verify(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "CLIENT_ERROR").timer().count())
.isEqualTo(1);
assertThatThrownBy(() -> this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "200").timer())
.isInstanceOf(MeterNotFoundException.class);
}
@Test
void filterWhenCancelAfterResponseThrownShouldNotRecordTimer() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
given(this.response.statusCode()).willReturn(HttpStatus.OK);
Mono<ClientResponse> filter = this.filterFunction.filter(request, this.exchange);
StepVerifier.create(filter).expectNextCount(1).thenCancel().verify(Duration.ofSeconds(5));
assertThat(this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "200").timer().count()).isEqualTo(1);
assertThatThrownBy(() -> this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "CLIENT_ERROR").timer())
.isInstanceOf(MeterNotFoundException.class);
}
@Test
void filterWhenExceptionAndRetryShouldNotAccumulateRecordTime() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
ExchangeFunction exchange = (r) -> Mono.error(new IllegalArgumentException())
.delaySubscription(Duration.ofMillis(1000)).cast(ClientResponse.class);
this.filterFunction.filter(request, exchange).retry(1)
.onErrorResume(IllegalArgumentException.class, (t) -> Mono.empty()).block(Duration.ofSeconds(5));
Timer timer = this.registry.get("http.client.requests")
.tags("method", "GET", "uri", "/projects/spring-boot", "status", "CLIENT_ERROR").timer();
assertThat(timer.count()).isEqualTo(2);
assertThat(timer.max(TimeUnit.MILLISECONDS)).isLessThan(2000);
}
@Test
void whenMetricsRecordingFailsThenFilteringSucceeds() {
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("https://example.com/projects/spring-boot")).build();
given(this.response.statusCode()).willReturn(HttpStatus.OK);
this.tagsProvider.failOnce();
this.filterFunction.filter(request, this.exchange).block(Duration.ofSeconds(5));
}
static class FaultyTagsProvider extends DefaultWebClientExchangeTagsProvider {
private final AtomicBoolean fail = new AtomicBoolean(false);
@Override
public Iterable<Tag> tags(ClientRequest request, ClientResponse response, Throwable throwable) {
if (this.fail.compareAndSet(true, false)) {
throw new RuntimeException();
}
return super.tags(request, response, throwable);
}
void failOnce() {
this.fail.set(true);
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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.reactive.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.test.util.ReflectionTestUtils;
import org.springframework.web.reactive.function.client.ClientObservationConvention;
import org.springframework.web.reactive.function.client.DefaultClientObservationConvention;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ObservationWebClientCustomizer}
*
* @author Brian Clozel
*/
@SuppressWarnings("deprecation")
class ObservationWebClientCustomizerTests {
private static final String TEST_METRIC_NAME = "http.test.metric.name";
private TestObservationRegistry observationRegistry = TestObservationRegistry.create();
private ClientObservationConvention observationConvention = new DefaultClientObservationConvention(
TEST_METRIC_NAME);
private ObservationWebClientCustomizer customizer = new ObservationWebClientCustomizer(this.observationRegistry,
this.observationConvention);
private WebClient.Builder clientBuilder = WebClient.builder();
@Test
void shouldCustomizeObservationConfiguration() {
this.customizer.customize(this.clientBuilder);
assertThat((ObservationRegistry) ReflectionTestUtils.getField(this.clientBuilder, "observationRegistry"))
.isEqualTo(this.observationRegistry);
assertThat((ObservationConvention<?>) ReflectionTestUtils.getField(this.clientBuilder, "observationConvention"))
.isInstanceOf(DefaultClientObservationConvention.class).extracting("name").isEqualTo(TEST_METRIC_NAME);
}
}