diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle
index dad4a490b6..770ab6965e 100644
--- a/spring-webflux/spring-webflux.gradle
+++ b/spring-webflux/spring-webflux.gradle
@@ -41,6 +41,7 @@ dependencies {
testImplementation("jakarta.validation:jakarta.validation-api")
testImplementation("io.reactivex.rxjava3:rxjava")
testImplementation("io.projectreactor:reactor-test")
+ testImplementation("io.micrometer:micrometer-observation-test")
testImplementation("io.undertow:undertow-core")
testImplementation("org.apache.tomcat.embed:tomcat-embed-core")
testImplementation("org.apache.tomcat:tomcat-util")
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java
new file mode 100644
index 0000000000..f6ed602edd
--- /dev/null
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import io.micrometer.common.docs.KeyName;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.docs.DocumentedObservation;
+
+/**
+ * Documented {@link io.micrometer.common.KeyValue KeyValues} for the HTTP client observations.
+ *
This class is used by automated tools to document KeyValues attached to the HTTP client observations.
+ * @author Brian Clozel
+ * @since 6.0
+ */
+public enum ClientObservation implements DocumentedObservation {
+
+ /**
+ * Observation created for an client HTTP exchange.
+ */
+ HTTP_REQUEST {
+ @Override
+ public Class extends Observation.ObservationConvention extends Observation.Context>> getDefaultConvention() {
+ return DefaultClientObservationConvention.class;
+ }
+
+ @Override
+ public KeyName[] getLowCardinalityKeyNames() {
+ return ClientObservation.LowCardinalityKeyNames.values();
+ }
+
+ @Override
+ public KeyName[] getHighCardinalityKeyNames() {
+ return ClientObservation.HighCardinalityKeyNames.values();
+ }
+
+ };
+
+ public enum LowCardinalityKeyNames implements KeyName {
+
+ /**
+ * Name of HTTP request method or {@code "None"} if the request could not be created.
+ */
+ METHOD {
+ @Override
+ public String asString() {
+ return "method";
+ }
+
+ },
+
+ /**
+ * URI template used for HTTP request, or {@code ""} if none was provided.
+ */
+ URI {
+ @Override
+ public String asString() {
+ return "uri";
+ }
+ },
+
+ /**
+ * HTTP response raw status code, or {@code "IO_ERROR"} in case of {@code IOException},
+ * or {@code "CLIENT_ERROR"} if no response was received.
+ */
+ STATUS {
+ @Override
+ public String asString() {
+ return "status";
+ }
+ },
+
+ /**
+ * Name of the exception thrown during the exchange, or {@code "None"} if no exception happened.
+ */
+ EXCEPTION {
+ @Override
+ public String asString() {
+ return "exception";
+ }
+ },
+
+ /**
+ * Outcome of the HTTP client exchange.
+ *
+ * @see org.springframework.http.HttpStatus.Series
+ */
+ OUTCOME {
+ @Override
+ public String asString() {
+ return "outcome";
+ }
+ }
+
+ }
+
+ public enum HighCardinalityKeyNames implements KeyName {
+
+ /**
+ * HTTP request URI.
+ */
+ URI_EXPANDED {
+ @Override
+ public String asString() {
+ return "uri.expanded";
+ }
+ },
+
+ /**
+ * Client name derived from the request URI host.
+ */
+ CLIENT_NAME {
+ @Override
+ public String asString() {
+ return "client.name";
+ }
+ }
+
+ }
+
+}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java
new file mode 100644
index 0000000000..20a7c2cf99
--- /dev/null
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import io.micrometer.observation.transport.RequestReplySenderContext;
+
+import org.springframework.lang.Nullable;
+
+/**
+ * Context that holds information for metadata collection
+ * during the HTTP client observations.
+ *
+ * @author Brian Clozel
+ * @since 6.0
+ */
+public class ClientObservationContext extends RequestReplySenderContext {
+
+ @Nullable
+ private String uriTemplate;
+
+
+ public ClientObservationContext() {
+ super(ClientObservationContext::setRequestHeader);
+ }
+
+ private static void setRequestHeader(@Nullable ClientRequest request, String name, String value) {
+ if (request != null) {
+ request.headers().set(name, value);
+ }
+ }
+
+ /**
+ * Return the URI template used for the current client exchange, {@code null} if none was used.
+ */
+ @Nullable
+ public String getUriTemplate() {
+ return this.uriTemplate;
+ }
+
+ /**
+ * Set the URI template used for the current client exchange.
+ */
+ public void setUriTemplate(@Nullable String uriTemplate) {
+ this.uriTemplate = uriTemplate;
+ }
+
+}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java
new file mode 100644
index 0000000000..259b9e8b6b
--- /dev/null
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import io.micrometer.observation.Observation;
+
+/**
+ * Interface for an {@link Observation.ObservationConvention} related to client HTTP exchanges.
+ * @author Brian Clozel
+ * @since 6.0
+ */
+public interface ClientObservationConvention extends Observation.ObservationConvention {
+
+ @Override
+ default boolean supportsContext(Observation.Context context) {
+ return context instanceof ClientObservationContext;
+ }
+
+}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java
new file mode 100644
index 0000000000..fa70378780
--- /dev/null
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import java.io.IOException;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import io.micrometer.observation.Observation;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.util.StringUtils;
+
+/**
+ * Default implementation for a {@code WebClient} {@link Observation.ObservationConvention},
+ * extracting information from the {@link ClientObservationContext}.
+ *
+ * @author Brian Clozel
+ * @since 6.0
+ */
+public class DefaultClientObservationConvention implements ClientObservationConvention {
+
+ private static final KeyValue URI_NONE = KeyValue.of(ClientObservation.LowCardinalityKeyNames.URI, "none");
+
+ private static final KeyValue METHOD_NONE = KeyValue.of(ClientObservation.LowCardinalityKeyNames.METHOD, "none");
+
+ private static final KeyValue EXCEPTION_NONE = KeyValue.of(ClientObservation.LowCardinalityKeyNames.EXCEPTION, "none");
+
+ private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(ClientObservation.LowCardinalityKeyNames.OUTCOME, "UNKNOWN");
+
+ @Override
+ public String getName() {
+ return "http.client.requests";
+ }
+
+ @Override
+ public KeyValues getLowCardinalityKeyValues(ClientObservationContext context) {
+ return KeyValues.of(uri(context), method(context), status(context), exception(context), outcome(context));
+ }
+
+ protected KeyValue uri(ClientObservationContext context) {
+ if (context.getUriTemplate() != null) {
+ return KeyValue.of(ClientObservation.LowCardinalityKeyNames.URI, context.getUriTemplate());
+ }
+ return URI_NONE;
+ }
+
+ protected KeyValue method(ClientObservationContext context) {
+ if (context.getCarrier() != null) {
+ return KeyValue.of(ClientObservation.LowCardinalityKeyNames.METHOD, context.getCarrier().method().name());
+ }
+ else {
+ return METHOD_NONE;
+ }
+ }
+
+ protected KeyValue status(ClientObservationContext context) {
+ return KeyValue.of(ClientObservation.LowCardinalityKeyNames.STATUS, getStatusMessage(context));
+ }
+
+ private String getStatusMessage(ClientObservationContext context) {
+ if (context.getResponse() != null) {
+ return String.valueOf(context.getResponse().statusCode().value());
+ }
+ if (context.getError().isPresent()) {
+ return (context.getError().get() instanceof IOException) ? "IO_ERROR" : "CLIENT_ERROR";
+ }
+ return "CLIENT_ERROR";
+ }
+
+ protected KeyValue exception(ClientObservationContext context) {
+ return context.getError().map(exception -> {
+ String simpleName = exception.getClass().getSimpleName();
+ return KeyValue.of(ClientObservation.LowCardinalityKeyNames.EXCEPTION,
+ StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName());
+ }).orElse(EXCEPTION_NONE);
+ }
+
+ protected static KeyValue outcome(ClientObservationContext context) {
+ if (context.getResponse() != null) {
+ HttpStatus status = HttpStatus.resolve(context.getResponse().statusCode().value());
+ if (status != null) {
+ return KeyValue.of(ClientObservation.LowCardinalityKeyNames.OUTCOME, status.series().name());
+ }
+ }
+ return OUTCOME_UNKNOWN;
+ }
+
+ @Override
+ public KeyValues getHighCardinalityKeyValues(ClientObservationContext context) {
+ return KeyValues.of(uriExpanded(context), clientName(context));
+ }
+
+ protected KeyValue uriExpanded(ClientObservationContext context) {
+ if (context.getCarrier() != null) {
+ return KeyValue.of(ClientObservation.HighCardinalityKeyNames.URI_EXPANDED, context.getCarrier().url().toASCIIString());
+ }
+ return KeyValue.of(ClientObservation.HighCardinalityKeyNames.URI_EXPANDED, "none");
+ }
+
+ protected KeyValue clientName(ClientObservationContext context) {
+ String host = "none";
+ if (context.getCarrier() != null && context.getCarrier().url().getHost() != null) {
+ host = context.getCarrier().url().getHost();
+ }
+ return KeyValue.of(ClientObservation.HighCardinalityKeyNames.CLIENT_NAME, host);
+ }
+
+}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
index 5f1e0fb66d..f0e3b593a7 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
@@ -32,6 +32,8 @@ import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -72,6 +74,7 @@ class DefaultWebClient implements WebClient {
private static final Mono NO_HTTP_CLIENT_RESPONSE_ERROR = Mono.error(
() -> new IllegalStateException("The underlying HTTP client completed without emitting a response."));
+ private static final DefaultClientObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultClientObservationConvention();
private final ExchangeFunction exchangeFunction;
@@ -88,6 +91,10 @@ class DefaultWebClient implements WebClient {
private final List defaultStatusHandlers;
+ private final ObservationRegistry observationRegistry;
+
+ private final ClientObservationConvention observationConvention;
+
private final DefaultWebClientBuilder builder;
@@ -95,12 +102,15 @@ class DefaultWebClient implements WebClient {
@Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap defaultCookies,
@Nullable Consumer> defaultRequest,
@Nullable Map, Function>> statusHandlerMap,
+ ObservationRegistry observationRegistry, ClientObservationConvention observationConvention,
DefaultWebClientBuilder builder) {
this.exchangeFunction = exchangeFunction;
this.uriBuilderFactory = uriBuilderFactory;
this.defaultHeaders = defaultHeaders;
this.defaultCookies = defaultCookies;
+ this.observationRegistry = observationRegistry;
+ this.observationConvention = observationConvention;
this.defaultRequest = defaultRequest;
this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap);
this.builder = builder;
@@ -388,21 +398,25 @@ class DefaultWebClient implements WebClient {
private HttpRequest createRequest() {
return new HttpRequest() {
private final URI uri = initUri();
+
private final HttpHeaders headers = initHeaders();
@Override
public HttpMethod getMethod() {
return httpMethod;
}
+
@Override
@Deprecated
public String getMethodValue() {
return httpMethod.name();
}
+
@Override
public URI getURI() {
return this.uri;
}
+
@Override
public HttpHeaders getHeaders() {
return this.headers;
@@ -442,17 +456,25 @@ class DefaultWebClient implements WebClient {
@Override
@SuppressWarnings("deprecation")
public Mono exchange() {
+ ClientObservationContext observationContext = new ClientObservationContext();
ClientRequest request = (this.inserter != null ?
initRequestBuilder().body(this.inserter).build() :
initRequestBuilder().build());
return Mono.defer(() -> {
+ Observation observation = ClientObservation.HTTP_REQUEST.observation(observationConvention,
+ DEFAULT_OBSERVATION_CONVENTION, observationContext, observationRegistry).start();
+ observationContext.setCarrier(request);
+ observationContext.setUriTemplate((String) request.attribute(URI_TEMPLATE_ATTRIBUTE).orElse(null));
Mono responseMono = exchangeFunction.exchange(request)
.checkpoint("Request to " + this.httpMethod.name() + " " + this.uri + " [DefaultWebClient]")
.switchIfEmpty(NO_HTTP_CLIENT_RESPONSE_ERROR);
if (this.contextModifier != null) {
responseMono = responseMono.contextWrite(this.contextModifier);
}
- return responseMono;
+ return responseMono.doOnNext(observationContext::setResponse)
+ .doOnError(observationContext::setError)
+ .doOnCancel(observation::stop)
+ .doOnTerminate(observation::stop);
});
}
@@ -652,7 +674,7 @@ class DefaultWebClient implements WebClient {
return (result != null ? result.flux().switchIfEmpty(body) : body);
}
- private Mono extends ResponseEntity>> handlerEntityFlux(ClientResponse response, Flux body) {
+ private Mono extends ResponseEntity>> handlerEntityFlux(ClientResponse response, Flux body) {
ResponseEntity> entity = new ResponseEntity<>(
body.onErrorResume(WebClientUtils.WRAP_EXCEPTION_PREDICATE, exceptionWrappingFunction(response)),
response.headers().asHttpHeaders(),
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
index cd673871f6..2804243a5a 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
@@ -25,6 +25,7 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
+import io.micrometer.observation.ObservationRegistry;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpHeaders;
@@ -105,6 +106,11 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
@Nullable
private ExchangeFunction exchangeFunction;
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+
+ @Nullable
+ private ClientObservationConvention observationConvention;
+
public DefaultWebClientBuilder() {
}
@@ -136,6 +142,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
this.strategiesConfigurers = (other.strategiesConfigurers != null ?
new ArrayList<>(other.strategiesConfigurers) : null);
this.exchangeFunction = other.exchangeFunction;
+ this.observationRegistry = other.observationRegistry;
+ this.observationConvention = other.observationConvention;
}
@@ -268,6 +276,20 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
return this;
}
+ @Override
+ public WebClient.Builder observationRegistry(ObservationRegistry observationRegistry) {
+ Assert.notNull(observationRegistry, "observationRegistry must not be null");
+ this.observationRegistry = observationRegistry;
+ return this;
+ }
+
+ @Override
+ public WebClient.Builder observationConvention(ClientObservationConvention observationConvention) {
+ Assert.notNull(observationConvention, "observationConvention must not be null");
+ this.observationConvention = observationConvention;
+ return this;
+ }
+
@Override
public WebClient.Builder apply(Consumer builderConsumer) {
builderConsumer.accept(this);
@@ -302,6 +324,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
defaultCookies,
this.defaultRequest,
this.statusHandlers,
+ this.observationRegistry,
+ this.observationConvention,
new DefaultWebClientBuilder(this));
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
index 1d71505963..443f3ce9b8 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
@@ -26,6 +26,8 @@ import java.util.function.Function;
import java.util.function.IntPredicate;
import java.util.function.Predicate;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -335,6 +337,23 @@ public interface WebClient {
*/
Builder exchangeFunction(ExchangeFunction exchangeFunction);
+ /**
+ * Provide an {@link ObservationRegistry} to use for recording
+ * observations for HTTP client calls.
+ * @param observationRegistry the observation registry to use
+ * @since 6.0
+ */
+ Builder observationRegistry(ObservationRegistry observationRegistry);
+
+ /**
+ * Provide a {@link Observation.ObservationConvention} to use for collecting
+ * metadata for the current observation. Will use {@link DefaultClientObservationConvention}
+ * if none provided.
+ * @param observationConvention the observation convention to use
+ * @since 6.0
+ */
+ Builder observationConvention(ClientObservationConvention observationConvention);
+
/**
* Apply the given {@code Consumer} to this builder instance.
* This can be useful for applying pre-packaged customizations.
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java
new file mode 100644
index 0000000000..a76b30c23a
--- /dev/null
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import java.net.URI;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.observation.Observation;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link DefaultClientObservationConvention}.
+ *
+ * @author Brian Clozel
+ */
+class DefaultClientObservationConventionTests {
+
+ private DefaultClientObservationConvention observationConvention = new DefaultClientObservationConvention();
+
+ @Test
+ void shouldOnlySupportWebClientObservationContext() {
+ assertThat(this.observationConvention.supportsContext(new ClientObservationContext())).isTrue();
+ assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
+ }
+
+ @Test
+ void shouldAddKeyValuesForNullExchange() {
+ ClientObservationContext context = new ClientObservationContext();
+ assertThat(this.observationConvention.getLowCardinalityKeyValues(context)).hasSize(5)
+ .contains(KeyValue.of("method", "none"), KeyValue.of("uri", "none"), KeyValue.of("status", "CLIENT_ERROR"),
+ KeyValue.of("exception", "none"), KeyValue.of("outcome", "UNKNOWN"));
+ assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("client.name", "none"), KeyValue.of("uri.expanded", "none"));
+ }
+
+ @Test
+ void shouldAddKeyValuesForExchangeWithException() {
+ ClientObservationContext context = new ClientObservationContext();
+ context.setError(new IllegalStateException("Could not create client request"));
+ assertThat(this.observationConvention.getLowCardinalityKeyValues(context)).hasSize(5)
+ .contains(KeyValue.of("method", "none"), KeyValue.of("uri", "none"), KeyValue.of("status", "CLIENT_ERROR"),
+ KeyValue.of("exception", "IllegalStateException"), KeyValue.of("outcome", "UNKNOWN"));
+ assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("client.name", "none"), KeyValue.of("uri.expanded", "none"));
+ }
+
+ @Test
+ void shouldAddKeyValuesForRequestWithUriTemplate() {
+ ClientRequest request = ClientRequest.create(HttpMethod.GET, URI.create("/resource/42"))
+ .attribute(WebClient.class.getName() + ".uriTemplate", "/resource/{id}").build();
+ ClientObservationContext context = createContext(request);
+ context.setUriTemplate("/resource/{id}");
+ assertThat(this.observationConvention.getLowCardinalityKeyValues(context))
+ .contains(KeyValue.of("exception", "none"), KeyValue.of("method", "GET"), KeyValue.of("uri", "/resource/{id}"),
+ KeyValue.of("status", "200"), KeyValue.of("outcome", "SUCCESSFUL"));
+ assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("client.name", "none"), KeyValue.of("uri.expanded", "/resource/42"));
+ }
+
+ @Test
+ void shouldAddKeyValuesForRequestWithoutUriTemplate() {
+ ClientObservationContext context = createContext(ClientRequest.create(HttpMethod.GET, URI.create("/resource/42")).build());
+ assertThat(this.observationConvention.getLowCardinalityKeyValues(context))
+ .contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "none"));
+ assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).hasSize(2).contains(KeyValue.of("uri.expanded", "/resource/42"));
+ }
+
+ @Test
+ void shouldAddClientNameKeyValueForRequestWithHost() {
+ ClientObservationContext context = createContext(ClientRequest.create(HttpMethod.GET, URI.create("https://localhost:8080/resource/42")).build());
+ assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("client.name", "localhost"));
+ }
+
+ private ClientObservationContext createContext(ClientRequest request) {
+ ClientObservationContext context = new ClientObservationContext();
+ context.setCarrier(request);
+ context.setResponse(ClientResponse.create(HttpStatus.OK).build());
+ return context;
+ }
+
+}
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java
new file mode 100644
index 0000000000..a59f7e9d36
--- /dev/null
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2002-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.web.reactive.function.client;
+
+import java.time.Duration;
+
+import io.micrometer.observation.tck.TestObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistryAssert;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import org.springframework.http.HttpStatus;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.when;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for the {@link WebClient} {@link io.micrometer.observation.Observation observations}.
+ * @author Brian Clozel
+ */
+public class DefaultClientObservationTests {
+
+
+ private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
+
+ private ExchangeFunction exchangeFunction = mock(ExchangeFunction.class);
+
+ private ArgumentCaptor request = ArgumentCaptor.forClass(ClientRequest.class);
+
+ private WebClient.Builder builder;
+
+ @BeforeEach
+ public void setup() {
+ ClientResponse mockResponse = mock(ClientResponse.class);
+ when(mockResponse.statusCode()).thenReturn(HttpStatus.OK);
+ when(mockResponse.bodyToMono(Void.class)).thenReturn(Mono.empty());
+ given(this.exchangeFunction.exchange(this.request.capture())).willReturn(Mono.just(mockResponse));
+ this.builder = WebClient.builder().baseUrl("/base").exchangeFunction(this.exchangeFunction).observationRegistry(this.observationRegistry);
+ }
+
+
+ @Test
+ void recordsObservationForSuccessfulExchange() {
+ this.builder.build().get().uri("/resource/{id}", 42)
+ .retrieve().bodyToMono(Void.class).block(Duration.ofSeconds(10));
+ verifyAndGetRequest();
+
+ assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL")
+ .hasLowCardinalityKeyValue("uri", "/resource/{id}");
+ }
+
+ @Test
+ void recordsObservationForErrorExchange() {
+ ExchangeFunction exchangeFunction = mock(ExchangeFunction.class);
+ given(exchangeFunction.exchange(any())).willReturn(Mono.error(new IllegalStateException()));
+ WebClient client = WebClient.builder().observationRegistry(observationRegistry).exchangeFunction(exchangeFunction).build();
+ StepVerifier.create(client.get().uri("/path").retrieve().bodyToMono(Void.class))
+ .expectError(IllegalStateException.class)
+ .verify(Duration.ofSeconds(5));
+ assertThatHttpObservation().hasLowCardinalityKeyValue("exception", "IllegalStateException")
+ .hasLowCardinalityKeyValue("status", "CLIENT_ERROR");
+ }
+
+ @Test
+ void recordsObservationForCancelledExchange() {
+ StepVerifier.create(this.builder.build().get().uri("/path").retrieve().bodyToMono(Void.class))
+ .thenCancel()
+ .verify(Duration.ofSeconds(5));
+ assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN")
+ .hasLowCardinalityKeyValue("status", "CLIENT_ERROR");
+ }
+
+ private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() {
+ return TestObservationRegistryAssert.assertThat(this.observationRegistry)
+ .hasObservationWithNameEqualTo("http.client.requests").that();
+ }
+
+ private ClientRequest verifyAndGetRequest() {
+ verify(exchangeFunction).exchange(request.getValue());
+ verifyNoMoreInteractions(exchangeFunction);
+ return request.getValue();
+ }
+
+}