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> 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>> handlerEntityFlux(ClientResponse response, Flux body) { + private Mono>> 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(); + } + +}