Browse Source

Instrument WebClient for Observability

This commit introduces Micrometer as an API dependency to the
spring-webflux module. Micrometer is used here to instrument `WebClient`
and record `Observation` for HTTP client exchanges.
observability
Brian Clozel 2 years ago
parent
commit
81ecb97f9d
  1. 1
      spring-webflux/spring-webflux.gradle
  2. 134
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java
  3. 61
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java
  4. 33
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java
  5. 123
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java
  6. 26
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
  7. 24
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
  8. 19
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
  9. 100
      spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java
  10. 105
      spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java

1
spring-webflux/spring-webflux.gradle

@ -41,6 +41,7 @@ dependencies { @@ -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")

134
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java

@ -0,0 +1,134 @@ @@ -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.
* <p>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";
}
}
}
}

61
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java

@ -0,0 +1,61 @@ @@ -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<ClientRequest, ClientResponse> {
@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;
}
}

33
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java

@ -0,0 +1,33 @@ @@ -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<ClientObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ClientObservationContext;
}
}

123
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java

@ -0,0 +1,123 @@ @@ -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);
}
}

26
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java

@ -32,6 +32,8 @@ import java.util.function.Predicate; @@ -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 { @@ -72,6 +74,7 @@ class DefaultWebClient implements WebClient {
private static final Mono<ClientResponse> 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 { @@ -88,6 +91,10 @@ class DefaultWebClient implements WebClient {
private final List<DefaultResponseSpec.StatusHandler> defaultStatusHandlers;
private final ObservationRegistry observationRegistry;
private final ClientObservationConvention observationConvention;
private final DefaultWebClientBuilder builder;
@ -95,12 +102,15 @@ class DefaultWebClient implements WebClient { @@ -95,12 +102,15 @@ class DefaultWebClient implements WebClient {
@Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap<String, String> defaultCookies,
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
@Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> 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 { @@ -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 { @@ -442,17 +456,25 @@ class DefaultWebClient implements WebClient {
@Override
@SuppressWarnings("deprecation")
public Mono<ClientResponse> 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<ClientResponse> 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 { @@ -652,7 +674,7 @@ class DefaultWebClient implements WebClient {
return (result != null ? result.flux().switchIfEmpty(body) : body);
}
private <T> Mono<? extends ResponseEntity<Flux<T>>> handlerEntityFlux(ClientResponse response, Flux<T> body) {
private <T> Mono<? extends ResponseEntity<Flux<T>>> handlerEntityFlux(ClientResponse response, Flux<T> body) {
ResponseEntity<Flux<T>> entity = new ResponseEntity<>(
body.onErrorResume(WebClientUtils.WRAP_EXCEPTION_PREDICATE, exceptionWrappingFunction(response)),
response.headers().asHttpHeaders(),

24
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java

@ -25,6 +25,7 @@ import java.util.function.Consumer; @@ -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 { @@ -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 { @@ -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 { @@ -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<WebClient.Builder> builderConsumer) {
builderConsumer.accept(this);
@ -302,6 +324,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder { @@ -302,6 +324,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
defaultCookies,
this.defaultRequest,
this.statusHandlers,
this.observationRegistry,
this.observationConvention,
new DefaultWebClientBuilder(this));
}

19
spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java

@ -26,6 +26,8 @@ import java.util.function.Function; @@ -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 { @@ -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.
* <p>This can be useful for applying pre-packaged customizations.

100
spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java

@ -0,0 +1,100 @@ @@ -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;
}
}

105
spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java

@ -0,0 +1,105 @@ @@ -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<ClientRequest> 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();
}
}
Loading…
Cancel
Save