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. This replaces Spring Boot's `MetricsWebClientFilterFunction` which instruments `WebClient` via an `ExchangeFilterFunction`. Here, a direct instrumentation is more efficient and less prone to metrics errors. See gh-28341
This commit is contained in:
parent
a0ddcd07c8
commit
ac9360b624
|
|
@ -42,6 +42,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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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.ObservationConvention;
|
||||
import io.micrometer.observation.docs.DocumentedObservation;
|
||||
|
||||
/**
|
||||
* Documented {@link io.micrometer.common.KeyValue KeyValues} for the {@link WebClient} 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 HTTP client exchange.
|
||||
*/
|
||||
HTTP_REQUEST {
|
||||
@Override
|
||||
public Class<? extends 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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
private boolean aborted;
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client aborted the current HTTP exchange before receiving any response.
|
||||
* @return whether the exchange has been aborted
|
||||
*/
|
||||
public boolean isAborted() {
|
||||
return this.aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the client aborted the current HTTP exchange.
|
||||
* @param aborted whether the exchange has been aborted
|
||||
*/
|
||||
void setAborted(boolean aborted) {
|
||||
this.aborted = aborted;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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;
|
||||
import io.micrometer.observation.ObservationConvention;
|
||||
|
||||
/**
|
||||
* Interface for an {@link ObservationConvention} related to client HTTP exchanges.
|
||||
* @author Brian Clozel
|
||||
* @since 6.0
|
||||
*/
|
||||
public interface ClientObservationConvention extends ObservationConvention<ClientObservationContext> {
|
||||
|
||||
@Override
|
||||
default boolean supportsContext(Observation.Context context) {
|
||||
return context instanceof ClientObservationContext;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 String DEFAULT_NAME = "http.client.requests";
|
||||
|
||||
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");
|
||||
|
||||
private final String name;
|
||||
|
||||
|
||||
/**
|
||||
* Create a convention with the default name {@code "http.client.requests"}.
|
||||
*/
|
||||
public DefaultClientObservationConvention() {
|
||||
this(DEFAULT_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a convention with a custom name.
|
||||
* @param name the observation name
|
||||
*/
|
||||
public DefaultClientObservationConvention(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@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.isAborted()) {
|
||||
return OUTCOME_UNKNOWN;
|
||||
}
|
||||
else 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<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 {
|
|||
|
||||
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 {
|
|||
@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 {
|
|||
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,28 @@ 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(() -> {
|
||||
observationContext.setAborted(true);
|
||||
observation.stop();
|
||||
})
|
||||
.doOnTerminate(observation::stop);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -652,7 +677,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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -109,6 +110,11 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
|
|||
@Nullable
|
||||
private ExchangeFunction exchangeFunction;
|
||||
|
||||
private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
|
||||
|
||||
@Nullable
|
||||
private ClientObservationConvention observationConvention;
|
||||
|
||||
|
||||
public DefaultWebClientBuilder() {
|
||||
}
|
||||
|
|
@ -140,6 +146,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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -272,6 +280,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);
|
||||
|
|
@ -306,6 +328,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
|
|||
defaultCookies,
|
||||
this.defaultRequest,
|
||||
this.statusHandlers,
|
||||
this.observationRegistry,
|
||||
this.observationConvention,
|
||||
new DefaultWebClientBuilder(this));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* <p>This can be useful for applying pre-packaged customizations.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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…
Reference in New Issue