Compare commits

...

6 Commits

Author SHA1 Message Date
Brian Clozel 34681cdfa8 Instrument reactive apps 2 years ago
Brian Clozel b5bf16470c Add HttpRequestsObservationFilter 2 years ago
Brian Clozel 81ecb97f9d Instrument WebClient for Observability 2 years ago
Brian Clozel 48813bed25 Instrument RestTemplate for observability 2 years ago
Brian Clozel 776319386d Add Micrometer BOM 1.10 dependency management 2 years ago
Brian Clozel c34d8a7ce5 Extract Mock HTTP client requests and responses 2 years ago
  1. 7
      build.gradle
  2. 1
      framework-platform/framework-platform.gradle
  3. 2
      spring-web/spring-web.gradle
  4. 136
      spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservation.java
  5. 64
      spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationContext.java
  6. 33
      spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationConvention.java
  7. 152
      spring-web/src/main/java/org/springframework/http/client/observation/DefaultClientHttpObservationConvention.java
  8. 10
      spring-web/src/main/java/org/springframework/http/client/observation/package-info.java
  9. 108
      spring-web/src/main/java/org/springframework/web/client/RestTemplate.java
  10. 139
      spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java
  11. 123
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.java
  12. 51
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java
  13. 33
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.java
  14. 145
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java
  15. 9
      spring-web/src/main/java/org/springframework/web/observation/package-info.java
  16. 145
      spring-web/src/main/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConvention.java
  17. 123
      spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservation.java
  18. 81
      spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationContext.java
  19. 33
      spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationConvention.java
  20. 132
      spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilter.java
  21. 9
      spring-web/src/main/java/org/springframework/web/observation/reactive/package-info.java
  22. 133
      spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java
  23. 118
      spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientHttpObservationConventionTests.java
  24. 191
      spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java
  25. 125
      spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java
  26. 95
      spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java
  27. 142
      spring-web/src/test/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConventionTests.java
  28. 104
      spring-web/src/test/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilterTests.java
  29. 154
      spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java
  30. 99
      spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpResponse.java
  31. 9
      spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/package-info.java
  32. 9
      spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/package-info.java
  33. 1
      spring-webflux/spring-webflux.gradle
  34. 134
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservation.java
  35. 61
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationContext.java
  36. 33
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientObservationConvention.java
  37. 123
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientObservationConvention.java
  38. 26
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
  39. 24
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
  40. 19
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
  41. 100
      spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationConventionTests.java
  42. 105
      spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientObservationTests.java
  43. 3
      spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java
  44. 3
      spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java
  45. 5
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java
  46. 8
      spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java
  47. 15
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java

7
build.gradle

@ -25,7 +25,12 @@ configure(allprojects) { project -> @@ -25,7 +25,12 @@ configure(allprojects) { project ->
repositories {
mavenCentral()
maven { url "https://repo.spring.io/libs-spring-framework-build" }
maven { url "https://repo.spring.io/milestone" } // temporarily for context-propagation via Reactor Netty
if (version.contains('-')) {
maven { url "https://repo.spring.io/milestone" }
}
if (version.endsWith('-SNAPSHOT')) {
maven { url "https://repo.spring.io/snapshot" }
}
}
configurations.all {
resolutionStrategy {

1
framework-platform/framework-platform.gradle

@ -8,6 +8,7 @@ javaPlatform { @@ -8,6 +8,7 @@ javaPlatform {
dependencies {
api(platform("com.fasterxml.jackson:jackson-bom:2.13.3"))
api(platform("io.micrometer:micrometer-bom:1.10.0-SNAPSHOT"))
api(platform("io.netty:netty-bom:4.1.80.Final"))
api(platform("io.netty:netty5-bom:5.0.0.Alpha4"))
api(platform("io.projectreactor:reactor-bom:2022.0.0-M5"))

2
spring-web/spring-web.gradle

@ -6,6 +6,7 @@ apply plugin: "kotlinx-serialization" @@ -6,6 +6,7 @@ apply plugin: "kotlinx-serialization"
dependencies {
api(project(":spring-beans"))
api(project(":spring-core"))
api("io.micrometer:micrometer-observation")
compileOnly("io.projectreactor.tools:blockhound")
optional(project(":spring-aop"))
optional(project(":spring-context"))
@ -70,6 +71,7 @@ dependencies { @@ -70,6 +71,7 @@ dependencies {
testImplementation("org.xmlunit:xmlunit-assertj")
testImplementation("org.xmlunit:xmlunit-matchers")
testImplementation("io.projectreactor.tools:blockhound")
testImplementation("io.micrometer:micrometer-observation-test")
testRuntimeOnly("com.sun.mail:jakarta.mail")
testRuntimeOnly("com.sun.xml.bind:jaxb-core")
testRuntimeOnly("com.sun.xml.bind:jaxb-impl")

136
spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservation.java

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
/*
* 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.http.client.observation;
import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.docs.DocumentedObservation;
import org.springframework.http.client.ClientHttpRequestFactory;
/**
* Documented {@link io.micrometer.common.KeyValue KeyValues} for {@link ClientHttpRequestFactory 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 ClientHttpObservation implements DocumentedObservation {
/**
* Observation created for a client HTTP exchange.
*/
HTTP_REQUEST {
@Override
public Class<? extends Observation.ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultClientHttpObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return LowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return 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";
}
}
}
}

64
spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationContext.java

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
/*
* 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.http.client.observation;
import io.micrometer.observation.transport.RequestReplySenderContext;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.Nullable;
/**
* Context that holds information for metadata collection
* during the {@link ClientHttpRequestFactory client HTTP} observations.
* <p>This context also extends {@link RequestReplySenderContext} for propagating tracing
* information with the HTTP client exchange.
* @author Brian Clozel
* @since 6.0
*/
public class ClientHttpObservationContext extends RequestReplySenderContext<ClientHttpRequest, ClientHttpResponse> {
@Nullable
private String uriTemplate;
public ClientHttpObservationContext() {
super(ClientHttpObservationContext::setRequestHeader);
}
private static void setRequestHeader(@Nullable ClientHttpRequest request, String name, String value) {
if (request != null) {
request.getHeaders().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-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationConvention.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.http.client.observation;
import io.micrometer.observation.Observation;
/**
* Interface for an {@link Observation.ObservationConvention} related to RestTemplate HTTP exchanges.
* @author Brian Clozel
* @since 6.0
*/
public interface ClientHttpObservationConvention extends Observation.ObservationConvention<ClientHttpObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ClientHttpObservationContext;
}
}

152
spring-web/src/main/java/org/springframework/http/client/observation/DefaultClientHttpObservationConvention.java

@ -0,0 +1,152 @@ @@ -0,0 +1,152 @@
/*
* 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.http.client.observation;
import java.io.IOException;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
/**
* Default implementation for a {@link ClientHttpObservationConvention},
* extracting information from the {@link ClientHttpObservationContext}.
*
* @author Brian Clozel
* @since 6.0
*/
public class DefaultClientHttpObservationConvention implements ClientHttpObservationConvention {
private static final String DEFAULT_NAME = "http.client.requests";
private static final KeyValue URI_NONE = KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.URI, "none");
private static final KeyValue METHOD_NONE = KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.METHOD, "none");
private static final KeyValue EXCEPTION_NONE = KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.EXCEPTION, "none");
private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.OUTCOME, "UNKNOWN");
private static final KeyValue URI_EXPANDED_NONE = KeyValue.of(ClientHttpObservation.HighCardinalityKeyNames.URI_EXPANDED, "none");
private final String name;
/**
* Create a convention with the default name {@code "http.client.requests"}.
*/
public DefaultClientHttpObservationConvention() {
this(DEFAULT_NAME);
}
/**
* Create a convention with a custom name.
* @param name the observation name
*/
public DefaultClientHttpObservationConvention(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
@Override
public KeyValues getLowCardinalityKeyValues(ClientHttpObservationContext context) {
return KeyValues.of(uri(context), method(context), status(context), exception(context), outcome(context));
}
protected KeyValue uri(ClientHttpObservationContext context) {
if (context.getUriTemplate() != null) {
return KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.URI, context.getUriTemplate());
}
return URI_NONE;
}
protected KeyValue method(ClientHttpObservationContext context) {
if (context.getCarrier() != null) {
return KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.METHOD, context.getCarrier().getMethod().name());
}
else {
return METHOD_NONE;
}
}
protected KeyValue status(ClientHttpObservationContext context) {
return KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.STATUS, getStatusMessage(context.getResponse()));
}
private String getStatusMessage(@Nullable ClientHttpResponse response) {
try {
if (response == null) {
return "CLIENT_ERROR";
}
return String.valueOf(response.getStatusCode().value());
}
catch (IOException ex) {
return "IO_ERROR";
}
}
protected KeyValue exception(ClientHttpObservationContext context) {
return context.getError().map(exception -> {
String simpleName = exception.getClass().getSimpleName();
return KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.EXCEPTION,
StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName());
}).orElse(EXCEPTION_NONE);
}
protected static KeyValue outcome(ClientHttpObservationContext context) {
try {
if (context.getResponse() != null) {
HttpStatus status = HttpStatus.resolve(context.getResponse().getStatusCode().value());
if (status != null) {
return KeyValue.of(ClientHttpObservation.LowCardinalityKeyNames.OUTCOME, status.series().name());
}
}
}
catch (IOException ex) {
// Continue
}
return OUTCOME_UNKNOWN;
}
@Override
public KeyValues getHighCardinalityKeyValues(ClientHttpObservationContext context) {
return KeyValues.of(requestUri(context), clientName(context));
}
protected KeyValue requestUri(ClientHttpObservationContext context) {
if (context.getCarrier() != null) {
return KeyValue.of(ClientHttpObservation.HighCardinalityKeyNames.URI_EXPANDED, context.getCarrier().getURI().toASCIIString());
}
return URI_EXPANDED_NONE;
}
protected KeyValue clientName(ClientHttpObservationContext context) {
String host = "none";
if (context.getCarrier() != null && context.getCarrier().getURI().getHost() != null) {
host = context.getCarrier().getURI().getHost();
}
return KeyValue.of(ClientHttpObservation.HighCardinalityKeyNames.CLIENT_NAME, host);
}
}

10
spring-web/src/main/java/org/springframework/http/client/observation/package-info.java

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
/**
* This package provides support for client HTTP
* {@link io.micrometer.observation.Observation}.
*/
@NonNullApi
@NonNullFields
package org.springframework.http.client.observation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

108
spring-web/src/main/java/org/springframework/web/client/RestTemplate.java

@ -28,6 +28,9 @@ import java.util.Set; @@ -28,6 +28,9 @@ import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.SpringProperties;
import org.springframework.http.HttpEntity;
@ -40,6 +43,10 @@ import org.springframework.http.ResponseEntity; @@ -40,6 +43,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.observation.ClientHttpObservation;
import org.springframework.http.client.observation.ClientHttpObservationContext;
import org.springframework.http.client.observation.ClientHttpObservationConvention;
import org.springframework.http.client.observation.DefaultClientHttpObservationConvention;
import org.springframework.http.client.support.InterceptingHttpAccessor;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.GenericHttpMessageConverter;
@ -119,6 +126,8 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -119,6 +126,8 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
private static final boolean kotlinSerializationJsonPresent;
private static final ClientHttpObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultClientHttpObservationConvention();
static {
ClassLoader classLoader = RestTemplate.class.getClassLoader();
romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader);
@ -142,6 +151,11 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -142,6 +151,11 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
private final ResponseExtractor<HttpHeaders> headersExtractor = new HeadersExtractor();
private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
@Nullable
private ClientHttpObservationConvention observationConvention;
/**
* Create a new instance of the {@link RestTemplate} using default settings.
@ -323,6 +337,30 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -323,6 +337,30 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
return this.uriTemplateHandler;
}
/**
* Configure an {@link ObservationRegistry} for collecting spans and metrics
* for request execution. By default, {@link Observation} are No-Ops.
* @param observationRegistry the observation registry to use
* @since 6.0
*/
public void setObservationRegistry(ObservationRegistry observationRegistry) {
Assert.notNull(observationRegistry, "observationRegistry must not be null");
this.observationRegistry = observationRegistry;
}
/**
* Configure an {@link Observation.ObservationConvention} that sets the name of the
* {@link Observation observation} as well as its {@link io.micrometer.common.KeyValues}
* extracted from the {@link ClientHttpObservationContext}.
* If none set, the {@link DefaultClientHttpObservationConvention default convention} will be used.
* @param observationConvention the observation convention to use
* @since 6.0
* @see #setObservationRegistry(ObservationRegistry)
*/
public void setObservationConvention(ClientHttpObservationConvention observationConvention) {
Assert.notNull(observationConvention, "observationConvention must not be null");
this.observationConvention = observationConvention;
}
// GET
@ -658,7 +696,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -658,7 +696,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
RequestCallback requestCallback = httpEntityCallback(entity, responseType);
ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(responseType);
return nonNull(doExecute(resolveUrl(entity), entity.getMethod(), requestCallback, responseExtractor));
return nonNull(doExecute(resolveUrl(entity), resolveUriTemplate(entity), entity.getMethod(), requestCallback, responseExtractor));
}
@Override
@ -668,7 +706,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -668,7 +706,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
Type type = responseType.getType();
RequestCallback requestCallback = httpEntityCallback(entity, type);
ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(type);
return nonNull(doExecute(resolveUrl(entity), entity.getMethod(), requestCallback, responseExtractor));
return nonNull(doExecute(resolveUrl(entity), resolveUriTemplate(entity), entity.getMethod(), requestCallback, responseExtractor));
}
private URI resolveUrl(RequestEntity<?> entity) {
@ -689,6 +727,16 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -689,6 +727,16 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
}
}
@Nullable
private String resolveUriTemplate(RequestEntity<?> entity) {
if (entity instanceof RequestEntity.UriTemplateRequestEntity<?> templated) {
return templated.getUriTemplate();
}
else {
return null;
}
}
// General execution
@ -705,11 +753,11 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -705,11 +753,11 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
*/
@Override
@Nullable
public <T> T execute(String url, HttpMethod method, @Nullable RequestCallback requestCallback,
public <T> T execute(String uriTemplate, HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor, Object... uriVariables) throws RestClientException {
URI expanded = getUriTemplateHandler().expand(url, uriVariables);
return doExecute(expanded, method, requestCallback, responseExtractor);
URI url = getUriTemplateHandler().expand(uriTemplate, uriVariables);
return doExecute(url, uriTemplate, method, requestCallback, responseExtractor);
}
/**
@ -725,12 +773,12 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -725,12 +773,12 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
*/
@Override
@Nullable
public <T> T execute(String url, HttpMethod method, @Nullable RequestCallback requestCallback,
public <T> T execute(String uriTemplate, HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor, Map<String, ?> uriVariables)
throws RestClientException {
URI expanded = getUriTemplateHandler().expand(url, uriVariables);
return doExecute(expanded, method, requestCallback, responseExtractor);
URI url = getUriTemplateHandler().expand(uriTemplate, uriVariables);
return doExecute(url, uriTemplate, method, requestCallback, responseExtractor);
}
/**
@ -749,7 +797,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -749,7 +797,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
public <T> T execute(URI url, HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
return doExecute(url, method, requestCallback, responseExtractor);
return doExecute(url, null, method, requestCallback, responseExtractor);
}
/**
@ -761,20 +809,49 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -761,20 +809,49 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
* @param requestCallback object that prepares the request (can be {@code null})
* @param responseExtractor object that extracts the return value from the response (can be {@code null})
* @return an arbitrary object, as returned by the {@link ResponseExtractor}
* @deprecated in favor of {@link #doExecute(URI, String, HttpMethod, RequestCallback, ResponseExtractor)}
*/
@Nullable
@Deprecated
protected <T> T doExecute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
Assert.notNull(url, "URI is required");
return doExecute(url, null, method, requestCallback, responseExtractor);
}
/**
* Execute the given method on the provided URI.
* <p>The {@link ClientHttpRequest} is processed using the {@link RequestCallback};
* the response with the {@link ResponseExtractor}.
* @param url the fully-expanded URL to connect to
* @param uriTemplate the URI template that was used for creating the expanded URL
* @param method the HTTP method to execute (GET, POST, etc.)
* @param requestCallback object that prepares the request (can be {@code null})
* @param responseExtractor object that extracts the return value from the response (can be {@code null})
* @return an arbitrary object, as returned by the {@link ResponseExtractor}
*/
@Nullable
@SuppressWarnings("try")
protected <T> T doExecute(URI url, @Nullable String uriTemplate, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
Assert.notNull(url, "url is required");
Assert.notNull(method, "HttpMethod is required");
ClientHttpObservationContext observationContext = new ClientHttpObservationContext();
Observation observation = ClientHttpObservation.HTTP_REQUEST.observation(this.observationConvention,
DEFAULT_OBSERVATION_CONVENTION, observationContext, this.observationRegistry).start();
observationContext.setUriTemplate(uriTemplate);
ClientHttpResponse response = null;
try {
ClientHttpRequest request = createRequest(url, method);
observationContext.setCarrier(request);
if (requestCallback != null) {
requestCallback.doWithRequest(request);
}
response = request.execute();
try (Observation.Scope scope = observation.openScope()) {
response = request.execute();
}
observationContext.setResponse(response);
handleResponse(url, method, response);
return (responseExtractor != null ? responseExtractor.extractData(response) : null);
}
@ -782,13 +859,20 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat @@ -782,13 +859,20 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
String resource = url.toString();
String query = url.getRawQuery();
resource = (query != null ? resource.substring(0, resource.indexOf('?')) : resource);
throw new ResourceAccessException("I/O error on " + method.name() +
ResourceAccessException exception = new ResourceAccessException("I/O error on " + method.name() +
" request for \"" + resource + "\": " + ex.getMessage(), ex);
observation.error(exception);
throw exception;
}
catch (RestClientException exc) {
observation.error(exc);
throw exc;
}
finally {
if (response != null) {
response.close();
}
observation.stop();
}
}

139
spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java

@ -0,0 +1,139 @@ @@ -0,0 +1,139 @@
/*
* 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.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.HttpStatus;
/**
* Default {@link HttpRequestsObservationConvention}.
* @author Brian Clozel
* @since 6.0
*/
public class DefaultHttpRequestsObservationConvention implements HttpRequestsObservationConvention {
private static final String DEFAULT_NAME = "http.server.requests";
private static final KeyValue METHOD_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, "UNKNOWN");
private static final KeyValue STATUS_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, "UNKNOWN");
private static final KeyValue URI_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "UNKNOWN");
private static final KeyValue URI_ROOT = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "root");
private static final KeyValue URI_NOT_FOUND = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "NOT_FOUND");
private static final KeyValue URI_REDIRECTION = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "REDIRECTION");
private static final KeyValue EXCEPTION_NONE = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, "none");
private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, "UNKNOWN");
private static final KeyValue URI_EXPANDED_UNKNOWN = KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, "UNKNOWN");
private final String name;
/**
* Create a convention with the default name {@code "http.server.requests"}.
*/
public DefaultHttpRequestsObservationConvention() {
this(DEFAULT_NAME);
}
/**
* Create a convention with a custom name.
* @param name the observation name
*/
public DefaultHttpRequestsObservationConvention(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
@Override
public KeyValues getLowCardinalityKeyValues(HttpRequestsObservationContext context) {
return KeyValues.of(method(context), uri(context), status(context), exception(context), outcome(context));
}
@Override
public KeyValues getHighCardinalityKeyValues(HttpRequestsObservationContext context) {
return KeyValues.of(uriExpanded(context));
}
protected KeyValue method(HttpRequestsObservationContext context) {
return (context.getCarrier() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, context.getCarrier().getMethod()) : METHOD_UNKNOWN;
}
protected KeyValue status(HttpRequestsObservationContext context) {
return (context.getResponse() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, Integer.toString(context.getResponse().getStatus())) : STATUS_UNKNOWN;
}
protected KeyValue uri(HttpRequestsObservationContext context) {
if (context.getCarrier() != null) {
String pattern = context.getPathPattern();
if (pattern != null) {
if (pattern.isEmpty()) {
return URI_ROOT;
}
return KeyValue.of("uri", pattern);
}
if (context.getResponse() != null) {
HttpStatus status = HttpStatus.resolve(context.getResponse().getStatus());
if (status != null) {
if (status.is3xxRedirection()) {
return URI_REDIRECTION;
}
if (status == HttpStatus.NOT_FOUND) {
return URI_NOT_FOUND;
}
}
}
}
return URI_UNKNOWN;
}
protected KeyValue exception(HttpRequestsObservationContext context) {
return context.getError().map(throwable ->
KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, throwable.getClass().getSimpleName()))
.orElse(EXCEPTION_NONE);
}
protected KeyValue outcome(HttpRequestsObservationContext context) {
if (context.getResponse() != null) {
HttpStatus status = HttpStatus.resolve(context.getResponse().getStatus());
if (status != null) {
return KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, status.series().name());
}
}
return OUTCOME_UNKNOWN;
}
protected KeyValue uriExpanded(HttpRequestsObservationContext context) {
if (context.getCarrier() != null) {
String uriExpanded = (context.getCarrier().getPathInfo() != null) ? context.getCarrier().getPathInfo() : "/";
return KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, uriExpanded);
}
return URI_EXPANDED_UNKNOWN;
}
}

123
spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.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.observation;
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 server observations
* for Servlet-based web applications.
* <p>This class is used by automated tools to document KeyValues attached to the HTTP server observations.
* @author Brian Clozel
* @since 6.0
*/
public enum HttpRequestsObservation implements DocumentedObservation {
/**
* HTTP server request observations.
*/
HTTP_REQUESTS {
@Override
public Class<? extends Observation.ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultHttpRequestsObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return LowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return HighCardinalityKeyNames.values();
}
};
public enum LowCardinalityKeyNames implements KeyName {
/**
* Name of HTTP request method or {@code "None"} if the request was not received properly.
*/
METHOD {
@Override
public String asString() {
return "method";
}
},
/**
* HTTP response raw status code, or {@code "STATUS_UNKNOWN"} if no response was created.
*/
STATUS {
@Override
public String asString() {
return "status";
}
},
/**
* URI pattern for the matching handler if available, falling back to {@code REDIRECTION} for 3xx responses,
* {@code NOT_FOUND} for 404 responses, {@code root} for requests with no path info,
* and {@code UNKNOWN} for all other requests.
*/
URI {
@Override
public String asString() {
return "uri";
}
},
/**
* 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 server 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";
}
}
}
}

51
spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
/*
* 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.observation;
import io.micrometer.observation.transport.RequestReplyReceiverContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
/**
* Context that holds information for metadata collection during observations for Servlet web application.
* <p>This context also extends {@link RequestReplyReceiverContext} for propagating
* tracing information with the HTTP server exchange.
* @author Brian Clozel
* @since 6.0
*/
public class HttpRequestsObservationContext extends RequestReplyReceiverContext<HttpServletRequest, HttpServletResponse> {
@Nullable
private String pathPattern;
public HttpRequestsObservationContext(HttpServletRequest request, HttpServletResponse response) {
super(HttpServletRequest::getHeader);
this.setCarrier(request);
this.setResponse(response);
}
@Nullable
public String getPathPattern() {
return this.pathPattern;
}
public void setPathPattern(@Nullable String pathPattern) {
this.pathPattern = pathPattern;
}
}

33
spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.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.observation;
import io.micrometer.observation.Observation;
/**
* Interface for an {@link Observation.ObservationConvention} related to Servlet HTTP exchanges.
* @author Brian Clozel
* @since 6.0
*/
public interface HttpRequestsObservationConvention extends Observation.ObservationConvention<HttpRequestsObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof HttpRequestsObservationContext;
}
}

145
spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java

@ -0,0 +1,145 @@ @@ -0,0 +1,145 @@
/*
* 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.observation;
import java.io.IOException;
import java.util.Optional;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import jakarta.servlet.FilterChain;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* {@link jakarta.servlet.Filter} that creates {@link Observation observations}
* for HTTP exchanges. This collects information about the execution time and
* information gathered from the {@link HttpRequestsObservationContext}.
* <p>Web Frameworks can fetch the current {@link HttpRequestsObservationContext context}
* as a {@link #CURRENT_OBSERVATION_ATTRIBUTE request attribute} and contribute
* additional information to it.
* The configured {@link HttpRequestsObservationConvention} will use this context to collect
* {@link io.micrometer.common.KeyValue metadata} and attach it to the observation.
* @author Brian Clozel
* @since 6.0
*/
public class HttpRequestsObservationFilter extends OncePerRequestFilter {
/**
* Name of the request attribute holding the {@link HttpRequestsObservationContext context} for the current observation.
*/
public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = HttpRequestsObservationFilter.class.getName() + ".context";
private static final HttpRequestsObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultHttpRequestsObservationConvention();
private static final String CURRENT_OBSERVATION_ATTRIBUTE = HttpRequestsObservationFilter.class.getName() + ".observation";
private final ObservationRegistry observationRegistry;
private final HttpRequestsObservationConvention observationConvention;
/**
* Create a {@code HttpRequestsObservationFilter} that records observations
* against the given {@link ObservationRegistry}. The default
* {@link DefaultHttpRequestsObservationConvention convention} will be used.
* @param observationRegistry the registry to use for recording observations
*/
public HttpRequestsObservationFilter(ObservationRegistry observationRegistry) {
this(observationRegistry, new DefaultHttpRequestsObservationConvention());
}
/**
* Create a {@code HttpRequestsObservationFilter} that records observations
* against the given {@link ObservationRegistry} with a custom convention.
* @param observationRegistry the registry to use for recording observations
* @param observationConvention the convention to use for all recorded observations
*/
public HttpRequestsObservationFilter(ObservationRegistry observationRegistry, HttpRequestsObservationConvention observationConvention) {
this.observationRegistry = observationRegistry;
this.observationConvention = observationConvention;
}
/**
* Get the current {@link HttpRequestsObservationContext observation context} from the given request, if available.
* @param request the current request
* @return the current observation context
*/
public static Optional<HttpRequestsObservationContext> findObservationContext(HttpServletRequest request) {
return Optional.ofNullable((HttpRequestsObservationContext) request.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE));
}
@Override
protected boolean shouldNotFilterAsyncDispatch() {
return false;
}
@Override
@SuppressWarnings("try")
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
Observation observation = createOrFetchObservation(request, response);
try (Observation.Scope scope = observation.openScope()) {
filterChain.doFilter(request, response);
}
catch (Exception ex) {
observation.error(unwrapServletException(ex)).stop();
throw ex;
}
finally {
// Only stop Observation if async processing is done or has never been started.
if (!request.isAsyncStarted()) {
Throwable error = fetchException(request);
if (error != null) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
observation.error(error);
}
observation.stop();
}
}
}
private Observation createOrFetchObservation(HttpServletRequest request, HttpServletResponse response) {
Observation observation = (Observation) request.getAttribute(CURRENT_OBSERVATION_ATTRIBUTE);
if (observation == null) {
HttpRequestsObservationContext context = new HttpRequestsObservationContext(request, response);
observation = HttpRequestsObservation.HTTP_REQUESTS.observation(this.observationConvention,
DEFAULT_OBSERVATION_CONVENTION, context, this.observationRegistry).start();
request.setAttribute(CURRENT_OBSERVATION_ATTRIBUTE, observation);
request.setAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observation.getContext());
}
return observation;
}
private Throwable unwrapServletException(Throwable ex) {
return (ex instanceof ServletException) ? ex.getCause() : ex;
}
@Nullable
private Throwable fetchException(HttpServletRequest request) {
return (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
}
}

9
spring-web/src/main/java/org/springframework/web/observation/package-info.java

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
/**
* Instrumentation for {@link io.micrometer.observation.Observation observing} web applications.
*/
@NonNullApi
@NonNullFields
package org.springframework.web.observation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

145
spring-web/src/main/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConvention.java

@ -0,0 +1,145 @@ @@ -0,0 +1,145 @@
/*
* 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.observation.reactive;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.HttpStatus;
import org.springframework.web.util.pattern.PathPattern;
/**
* Default {@link HttpRequestsObservationConvention}.
*
* @author Brian Clozel
* @since 6.0
*/
public class DefaultHttpRequestsObservationConvention implements HttpRequestsObservationConvention {
private static final String DEFAULT_NAME = "http.server.requests";
private static final KeyValue METHOD_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, "UNKNOWN");
private static final KeyValue STATUS_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, "UNKNOWN");
private static final KeyValue URI_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "UNKNOWN");
private static final KeyValue URI_ROOT = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "root");
private static final KeyValue URI_NOT_FOUND = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "NOT_FOUND");
private static final KeyValue URI_REDIRECTION = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "REDIRECTION");
private static final KeyValue EXCEPTION_NONE = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, "none");
private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, "UNKNOWN");
private static final KeyValue URI_EXPANDED_UNKNOWN = KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, "UNKNOWN");
private final String name;
/**
* Create a convention with the default name {@code "http.server.requests"}.
*/
public DefaultHttpRequestsObservationConvention() {
this(DEFAULT_NAME);
}
/**
* Create a convention with a custom name.
*
* @param name the observation name
*/
public DefaultHttpRequestsObservationConvention(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
@Override
public KeyValues getLowCardinalityKeyValues(HttpRequestsObservationContext context) {
return KeyValues.of(method(context), uri(context), status(context), exception(context), outcome(context));
}
@Override
public KeyValues getHighCardinalityKeyValues(HttpRequestsObservationContext context) {
return KeyValues.of(uriExpanded(context));
}
protected KeyValue method(HttpRequestsObservationContext context) {
return (context.getCarrier() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, context.getCarrier().getMethod().name()) : METHOD_UNKNOWN;
}
protected KeyValue status(HttpRequestsObservationContext context) {
return (context.getResponse() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, Integer.toString(context.getResponse().getStatusCode().value())) : STATUS_UNKNOWN;
}
protected KeyValue uri(HttpRequestsObservationContext context) {
if (context.getCarrier() != null) {
PathPattern pattern = context.getPathPattern();
if (pattern != null) {
if (pattern.toString().isEmpty()) {
return URI_ROOT;
}
return KeyValue.of("uri", pattern.toString());
}
if (context.getResponse() != null) {
HttpStatus status = HttpStatus.resolve(context.getResponse().getStatusCode().value());
if (status != null) {
if (status.is3xxRedirection()) {
return URI_REDIRECTION;
}
if (status == HttpStatus.NOT_FOUND) {
return URI_NOT_FOUND;
}
}
}
}
return URI_UNKNOWN;
}
protected KeyValue exception(HttpRequestsObservationContext context) {
return context.getError().map(throwable ->
KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, throwable.getClass().getSimpleName()))
.orElse(EXCEPTION_NONE);
}
protected KeyValue outcome(HttpRequestsObservationContext context) {
if (context.isConnectionAborted()) {
return OUTCOME_UNKNOWN;
}
else if (context.getResponse() != null) {
HttpStatus status = HttpStatus.resolve(context.getResponse().getStatusCode().value());
if (status != null) {
return KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, status.series().name());
}
}
return OUTCOME_UNKNOWN;
}
protected KeyValue uriExpanded(HttpRequestsObservationContext context) {
if (context.getCarrier() != null) {
String uriExpanded = context.getCarrier().getPath().toString();
return KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, uriExpanded);
}
return URI_EXPANDED_UNKNOWN;
}
}

123
spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservation.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.observation.reactive;
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 server observations
* for Servlet-based web applications.
* <p>This class is used by automated tools to document KeyValues attached to the HTTP server observations.
* @author Brian Clozel
* @since 6.0
*/
public enum HttpRequestsObservation implements DocumentedObservation {
/**
* HTTP server request observations.
*/
HTTP_REQUESTS {
@Override
public Class<? extends Observation.ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultHttpRequestsObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return LowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return HighCardinalityKeyNames.values();
}
};
public enum LowCardinalityKeyNames implements KeyName {
/**
* Name of HTTP request method or {@code "None"} if the request was not received properly.
*/
METHOD {
@Override
public String asString() {
return "method";
}
},
/**
* HTTP response raw status code, or {@code "STATUS_UNKNOWN"} if no response was created.
*/
STATUS {
@Override
public String asString() {
return "status";
}
},
/**
* URI pattern for the matching handler if available, falling back to {@code REDIRECTION} for 3xx responses,
* {@code NOT_FOUND} for 404 responses, {@code root} for requests with no path info,
* and {@code UNKNOWN} for all other requests.
*/
URI {
@Override
public String asString() {
return "uri";
}
},
/**
* 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 server 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";
}
}
}
}

81
spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationContext.java

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
/*
* 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.observation.reactive;
import io.micrometer.observation.transport.RequestReplyReceiverContext;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.pattern.PathPattern;
/**
* Context that holds information for metadata collection during observations for reactive web applications.
* <p>This context also extends {@link RequestReplyReceiverContext} for propagating
* tracing information with the HTTP server exchange.
* @author Brian Clozel
* @since 6.0
*/
public class HttpRequestsObservationContext extends RequestReplyReceiverContext<ServerHttpRequest, ServerHttpResponse> {
@Nullable
private PathPattern pathPattern;
private boolean connectionAborted;
public HttpRequestsObservationContext(ServerWebExchange exchange) {
super((request, key) -> request.getHeaders().getFirst(key));
this.setCarrier(exchange.getRequest());
this.setResponse(exchange.getResponse());
}
/**
* Return the path pattern for the handler that matches the current request.
* For example, {@code "/projects/{name}"}.
* <p>Path patterns must have a low cardinality for the entire application.
* @return the path pattern, or {@code null} if none found
*/
@Nullable
public PathPattern getPathPattern() {
return this.pathPattern;
}
/**
* Set the path pattern for the handler that matches the current request.
* <p>Path patterns must have a low cardinality for the entire application.
* @param pathPattern the path pattern, for example {@code "/projects/{name}"}.
*/
public void setPathPattern(@Nullable PathPattern pathPattern) {
this.pathPattern = pathPattern;
}
/**
* Whether the current connection was aborted by the client, resulting
* in a {@link reactor.core.publisher.SignalType#CANCEL cancel signal} on te reactive chain,
* or an {@code AbortedException} when reading the request.
* @return if the connection has been aborted
*/
public boolean isConnectionAborted() {
return this.connectionAborted;
}
void setConnectionAborted(boolean connectionAborted) {
this.connectionAborted = connectionAborted;
}
}

33
spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationConvention.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.observation.reactive;
import io.micrometer.observation.Observation;
/**
* Interface for an {@link Observation.ObservationConvention} related to reactive HTTP exchanges.
* @author Brian Clozel
* @since 6.0
*/
public interface HttpRequestsObservationConvention extends Observation.ObservationConvention<HttpRequestsObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof HttpRequestsObservationContext;
}
}

132
spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilter.java

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
/*
* 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.observation.reactive;
import java.util.Optional;
import java.util.Set;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
/**
* {@link org.springframework.web.server.WebFilter} that creates {@link Observation observations}
* for HTTP exchanges. This collects information about the execution time and
* information gathered from the {@link HttpRequestsObservationContext}.
* <p>Web Frameworks can fetch the current {@link HttpRequestsObservationContext context}
* as a {@link #CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE request attribute} and contribute
* additional information to it.
* The configured {@link HttpRequestsObservationConvention} will use this context to collect
* {@link io.micrometer.common.KeyValue metadata} and attach it to the observation.
*
* @author Brian Clozel
* @since 6.0
*/
public class HttpRequestsObservationWebFilter implements WebFilter {
/**
* Name of the request attribute holding the {@link HttpRequestsObservationContext context} for the current observation.
*/
public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = HttpRequestsObservationWebFilter.class.getName() + ".context";
private static final HttpRequestsObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultHttpRequestsObservationConvention();
private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS = Set.of("AbortedException",
"ClientAbortException", "EOFException", "EofException");
private final ObservationRegistry observationRegistry;
private final HttpRequestsObservationConvention observationConvention;
/**
* Create a {@code HttpRequestsObservationWebFilter} that records observations
* against the given {@link ObservationRegistry}. The default
* {@link DefaultHttpRequestsObservationConvention convention} will be used.
* @param observationRegistry the registry to use for recording observations
*/
public HttpRequestsObservationWebFilter(ObservationRegistry observationRegistry) {
this(observationRegistry, new DefaultHttpRequestsObservationConvention());
}
/**
* Create a {@code HttpRequestsObservationWebFilter} that records observations
* against the given {@link ObservationRegistry} with a custom convention.
* @param observationRegistry the registry to use for recording observations
* @param observationConvention the convention to use for all recorded observations
*/
public HttpRequestsObservationWebFilter(ObservationRegistry observationRegistry, HttpRequestsObservationConvention observationConvention) {
this.observationRegistry = observationRegistry;
this.observationConvention = observationConvention;
}
/**
* Get the current {@link HttpRequestsObservationContext observation context} from the given request, if available.
* @param exchange the current exchange
* @return the current observation context
*/
public static Optional<HttpRequestsObservationContext> findObservationContext(ServerWebExchange exchange) {
return Optional.ofNullable(exchange.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE));
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
HttpRequestsObservationContext observationContext = new HttpRequestsObservationContext(exchange);
exchange.getAttributes().put(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observationContext);
return chain.filter(exchange).transformDeferred(call -> filter(exchange, observationContext, call));
}
private Publisher<Void> filter(ServerWebExchange exchange, HttpRequestsObservationContext observationContext, Mono<Void> call) {
Observation observation = Observation.createNotStarted(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION,
observationContext, this.observationRegistry);
observation.start();
return call.doOnEach(signal -> {
Throwable throwable = signal.getThrowable();
if (throwable != null) {
if (DISCONNECTED_CLIENT_EXCEPTIONS.contains(throwable.getClass().getSimpleName())) {
observationContext.setConnectionAborted(true);
}
observationContext.setError(throwable);
}
onTerminalSignal(observation, exchange);
})
.doOnCancel(() -> {
observationContext.setConnectionAborted(true);
observation.stop();
});
}
private void onTerminalSignal(Observation observation, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
if (response.isCommitted()) {
observation.stop();
}
else {
response.beforeCommit(() -> {
observation.stop();
return Mono.empty();
});
}
}
}

9
spring-web/src/main/java/org/springframework/web/observation/reactive/package-info.java

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
/**
* Instrumentation for {@link io.micrometer.observation.Observation observing} reactive web applications.
*/
@NonNullApi
@NonNullFields
package org.springframework.web.observation.reactive;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

133
spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java

@ -16,23 +16,21 @@ @@ -16,23 +16,21 @@
package org.springframework.http.client;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.support.HttpRequestWrapper;
import org.springframework.web.testfixture.http.client.MockClientHttpRequest;
import org.springframework.web.testfixture.http.client.MockClientHttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
@ -44,12 +42,16 @@ class InterceptingClientHttpRequestFactoryTests { @@ -44,12 +42,16 @@ class InterceptingClientHttpRequestFactoryTests {
private RequestFactoryMock requestFactoryMock = new RequestFactoryMock();
private RequestMock requestMock = new RequestMock();
private MockClientHttpRequest requestMock = new MockClientHttpRequest();
private ResponseMock responseMock = new ResponseMock();
private MockClientHttpResponse responseMock = new MockClientHttpResponse();
private InterceptingClientHttpRequestFactory requestFactory;
@BeforeEach
void beforeEach() {
this.requestMock.setResponse(this.responseMock);
}
@Test
void basic() throws Exception {
@ -65,7 +67,7 @@ class InterceptingClientHttpRequestFactoryTests { @@ -65,7 +67,7 @@ class InterceptingClientHttpRequestFactoryTests {
assertThat(((NoOpInterceptor) interceptors.get(0)).invoked).isTrue();
assertThat(((NoOpInterceptor) interceptors.get(1)).invoked).isTrue();
assertThat(((NoOpInterceptor) interceptors.get(2)).invoked).isTrue();
assertThat(requestMock.executed).isTrue();
assertThat(requestMock.isExecuted()).isTrue();
assertThat(response).isSameAs(responseMock);
}
@ -81,7 +83,7 @@ class InterceptingClientHttpRequestFactoryTests { @@ -81,7 +83,7 @@ class InterceptingClientHttpRequestFactoryTests {
ClientHttpResponse response = request.execute();
assertThat(((NoOpInterceptor) interceptors.get(1)).invoked).isFalse();
assertThat(requestMock.executed).isFalse();
assertThat(requestMock.isExecuted()).isFalse();
assertThat(response).isSameAs(responseMock);
}
@ -92,19 +94,19 @@ class InterceptingClientHttpRequestFactoryTests { @@ -92,19 +94,19 @@ class InterceptingClientHttpRequestFactoryTests {
final String otherValue = "Baz";
ClientHttpRequestInterceptor interceptor = (request, body, execution) -> {
HttpRequestWrapper wrapper = new HttpRequestWrapper(request);
wrapper.getHeaders().add(headerName, otherValue);
return execution.execute(wrapper, body);
};
HttpRequestWrapper wrapper = new HttpRequestWrapper(request);
wrapper.getHeaders().add(headerName, otherValue);
return execution.execute(wrapper, body);
};
requestMock = new RequestMock() {
requestMock = new MockClientHttpRequest() {
@Override
public ClientHttpResponse execute() throws IOException {
protected ClientHttpResponse executeInternal() throws IOException {
List<String> headerValues = getHeaders().get(headerName);
assertThat(headerValues.size()).isEqualTo(2);
assertThat(headerValues.get(0)).isEqualTo(headerValue);
assertThat(headerValues.get(1)).isEqualTo(otherValue);
return super.execute();
return responseMock;
}
};
requestMock.getHeaders().add(headerName, headerValue);
@ -177,7 +179,7 @@ class InterceptingClientHttpRequestFactoryTests { @@ -177,7 +179,7 @@ class InterceptingClientHttpRequestFactoryTests {
ClientHttpRequest request = requestFactory.createRequest(new URI("https://example.com"), HttpMethod.GET);
request.execute();
assertThat(Arrays.equals(changedBody, requestMock.body.toByteArray())).isTrue();
assertThat(Arrays.equals(changedBody, requestMock.getBodyAsBytes())).isTrue();
}
@ -205,101 +207,4 @@ class InterceptingClientHttpRequestFactoryTests { @@ -205,101 +207,4 @@ class InterceptingClientHttpRequestFactoryTests {
}
private class RequestMock implements ClientHttpRequest {
private URI uri;
private HttpMethod method;
private HttpHeaders headers = new HttpHeaders();
private ByteArrayOutputStream body = new ByteArrayOutputStream();
private boolean executed = false;
private RequestMock() {
}
@Override
public URI getURI() {
return uri;
}
public void setURI(URI uri) {
this.uri = uri;
}
@Override
public HttpMethod getMethod() {
return method;
}
@Override
@Deprecated
public String getMethodValue() {
return method.name();
}
public void setMethod(HttpMethod method) {
this.method = method;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
@Override
public OutputStream getBody() throws IOException {
return body;
}
@Override
public ClientHttpResponse execute() throws IOException {
executed = true;
return responseMock;
}
}
private static class ResponseMock implements ClientHttpResponse {
private HttpStatus statusCode = HttpStatus.OK;
private String statusText = "";
private HttpHeaders headers = new HttpHeaders();
@Override
public HttpStatus getStatusCode() throws IOException {
return statusCode;
}
@Override
@SuppressWarnings("deprecation")
public int getRawStatusCode() throws IOException {
return statusCode.value();
}
@Override
public String getStatusText() throws IOException {
return statusText;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
@Override
public InputStream getBody() throws IOException {
return null;
}
@Override
public void close() {
}
}
}

118
spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientHttpObservationConventionTests.java

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
/*
* 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.http.client.observation;
import java.io.IOException;
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.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.testfixture.http.client.MockClientHttpRequest;
import org.springframework.web.testfixture.http.client.MockClientHttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link DefaultClientHttpObservationConvention}.
*
* @author Brian Clozel
*/
class DefaultClientHttpObservationConventionTests {
private final DefaultClientHttpObservationConvention observationConvention = new DefaultClientHttpObservationConvention();
@Test
void supportsOnlyClientHttpObservationContext() {
assertThat(this.observationConvention.supportsContext(new ClientHttpObservationContext())).isTrue();
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void addsKeyValuesForNullExchange() {
ClientHttpObservationContext context = new ClientHttpObservationContext();
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 addsKeyValuesForExchangeWithException() {
ClientHttpObservationContext context = new ClientHttpObservationContext();
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 addsKeyValuesForRequestWithUriTemplate() {
ClientHttpObservationContext context = createContext(
new MockClientHttpRequest(HttpMethod.GET, "/resource/{id}", 42), new MockClientHttpResponse());
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 addsKeyValuesForRequestWithoutUriTemplate() {
ClientHttpObservationContext context = createContext(
new MockClientHttpRequest(HttpMethod.GET, "/resource/42"), new MockClientHttpResponse());
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 addsClientNameForRequestWithHost() {
ClientHttpObservationContext context = createContext(
new MockClientHttpRequest(HttpMethod.GET, "https://localhost:8080/resource/42"),
new MockClientHttpResponse());
assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("client.name", "localhost"));
}
@Test
void addsKeyValueForNonResolvableStatus() throws Exception {
ClientHttpObservationContext context = new ClientHttpObservationContext();
ClientHttpResponse response = mock(ClientHttpResponse.class);
context.setResponse(response);
given(response.getStatusCode()).willThrow(new IOException("test error"));
assertThat(this.observationConvention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("status", "IO_ERROR"));
}
private ClientHttpObservationContext createContext(ClientHttpRequest request, ClientHttpResponse response) {
ClientHttpObservationContext context = new ClientHttpObservationContext();
context.setCarrier(request);
context.setResponse(response);
return context;
}
}

191
spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java

@ -0,0 +1,191 @@ @@ -0,0 +1,191 @@
/*
* 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.client;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Map;
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.BDDMockito;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.converter.HttpMessageConverter;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.http.HttpMethod.GET;
/**
* Tests for the client HTTP observations with {@link RestTemplate}.
* @author Brian Clozel
*/
public class RestTemplateObservationTests {
private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
private final ClientHttpRequestFactory requestFactory = mock(ClientHttpRequestFactory.class);
private final ClientHttpRequest request = mock(ClientHttpRequest.class);
private final ClientHttpResponse response = mock(ClientHttpResponse.class);
private final ResponseErrorHandler errorHandler = mock(ResponseErrorHandler.class);
@SuppressWarnings("unchecked")
private final HttpMessageConverter<String> converter = mock(HttpMessageConverter.class);
private final RestTemplate template = new RestTemplate(List.of(converter));
@BeforeEach
void setupEach() {
this.template.setRequestFactory(this.requestFactory);
this.template.setErrorHandler(this.errorHandler);
this.template.setObservationRegistry(this.observationRegistry);
}
@Test
void executeVarArgsAddsUriTemplateAsKeyValue() throws Exception {
mockSentRequest(GET, "https://example.com/hotels/42/bookings/21");
mockResponseStatus(HttpStatus.OK);
template.execute("https://example.com/hotels/{hotel}/bookings/{booking}", GET,
null, null, "42", "21");
assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "https://example.com/hotels/{hotel}/bookings/{booking}");
}
@Test
void executeArgsMapAddsUriTemplateAsKeyValue() throws Exception {
mockSentRequest(GET, "https://example.com/hotels/42/bookings/21");
mockResponseStatus(HttpStatus.OK);
Map<String, String> vars = Map.of("hotel", "42", "booking", "21");
template.execute("https://example.com/hotels/{hotel}/bookings/{booking}", GET,
null, null, vars);
assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "https://example.com/hotels/{hotel}/bookings/{booking}");
}
@Test
void executeAddsSucessAsOutcome() throws Exception {
mockSentRequest(GET, "https://example.org");
mockResponseStatus(HttpStatus.OK);
mockResponseBody("Hello World", MediaType.TEXT_PLAIN);
template.execute("https://example.org", GET, null, null);
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL");
}
@Test
void executeAddsServerErrorAsOutcome() throws Exception {
String url = "https://example.org";
mockSentRequest(GET, url);
mockResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR);
BDDMockito.willThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR))
.given(errorHandler).handleError(new URI(url), GET, response);
assertThatExceptionOfType(HttpServerErrorException.class).isThrownBy(() ->
template.execute(url, GET, null, null));
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SERVER_ERROR");
}
@Test
void executeAddsExceptionAsKeyValue() throws Exception {
mockSentRequest(GET, "https://example.org/resource");
mockResponseStatus(HttpStatus.OK);
given(converter.canRead(String.class, null)).willReturn(true);
MediaType supportedMediaType = new MediaType("test", "supported");
given(converter.getSupportedMediaTypes()).willReturn(Collections.singletonList(supportedMediaType));
MediaType other = new MediaType("test", "other");
mockResponseBody("Test Body", other);
given(converter.canRead(String.class, other)).willReturn(false);
assertThatExceptionOfType(RestClientException.class).isThrownBy(() ->
template.getForObject("https://example.org/{p}", String.class, "resource"));
assertThatHttpObservation().hasLowCardinalityKeyValue("exception", "UnknownContentTypeException");
}
@Test
void executeWithIoExceptionAddsUnknownOutcome() throws Exception {
String url = "https://example.org/resource";
mockSentRequest(GET, url);
given(request.execute()).willThrow(new IOException("Socket failure"));
assertThatExceptionOfType(ResourceAccessException.class).isThrownBy(() ->
template.getForObject(url, String.class));
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN");
}
private void mockSentRequest(HttpMethod method, String uri) throws Exception {
mockSentRequest(method, uri, new HttpHeaders());
}
private void mockSentRequest(HttpMethod method, String uri, HttpHeaders requestHeaders) throws Exception {
given(requestFactory.createRequest(new URI(uri), method)).willReturn(request);
given(request.getHeaders()).willReturn(requestHeaders);
given(request.getMethod()).willReturn(method);
given(request.getURI()).willReturn(URI.create(uri));
}
private void mockResponseStatus(HttpStatus responseStatus) throws Exception {
given(request.execute()).willReturn(response);
given(errorHandler.hasError(response)).willReturn(responseStatus.isError());
given(response.getStatusCode()).willReturn(responseStatus);
given(response.getStatusText()).willReturn(responseStatus.getReasonPhrase());
}
private void mockResponseBody(String expectedBody, MediaType mediaType) throws Exception {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(mediaType);
responseHeaders.setContentLength(expectedBody.length());
given(response.getHeaders()).willReturn(responseHeaders);
given(response.getBody()).willReturn(new ByteArrayInputStream(expectedBody.getBytes()));
given(converter.read(eq(String.class), any(HttpInputMessage.class))).willReturn(expectedBody);
}
private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() {
return TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasObservationWithNameEqualTo("http.client.requests").that();
}
}

125
spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
/*
* 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.observation;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DefaultHttpRequestsObservationConvention}.
* @author Brian Clozel
*/
class DefaultHttpRequestsObservationConventionTests {
private final DefaultHttpRequestsObservationConvention convention = new DefaultHttpRequestsObservationConvention();
private final MockHttpServletRequest request = new MockHttpServletRequest();
private final MockHttpServletResponse response = new MockHttpServletResponse();
private final HttpRequestsObservationContext context = new HttpRequestsObservationContext(this.request, this.response);
@Test
void shouldHaveDefaultName() {
assertThat(convention.getName()).isEqualTo("http.server.requests");
}
@Test
void supportsOnlyHttpRequestsObservationContext() {
assertThat(this.convention.supportsContext(this.context)).isTrue();
assertThat(this.convention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void addsKeyValuesForExchange() {
this.request.setMethod("POST");
this.request.setRequestURI("/test/resource");
this.request.setPathInfo("/test/resource");
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(5)
.contains(KeyValue.of("method", "POST"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "200"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "SUCCESSFUL"));
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForExchangeWithPathPattern() {
this.request.setMethod("GET");
this.request.setRequestURI("/test/resource");
this.request.setPathInfo("/test/resource");
this.context.setPathPattern("/test/{name}");
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "/test/{name}"), KeyValue.of("status", "200"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "SUCCESSFUL"));
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForErrorExchange() {
this.request.setMethod("GET");
this.request.setRequestURI("/test/resource");
this.request.setPathInfo("/test/resource");
this.context.setError(new IllegalArgumentException("custom error"));
this.response.setStatus(500);
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "500"),
KeyValue.of("exception", "IllegalArgumentException"), KeyValue.of("outcome", "SERVER_ERROR"));
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForRedirectExchange() {
this.request.setMethod("GET");
this.request.setRequestURI("/test/redirect");
this.request.setPathInfo("/test/redirect");
this.response.setStatus(302);
this.response.addHeader("Location", "https://example.org/other");
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "REDIRECTION"), KeyValue.of("status", "302"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "REDIRECTION"));
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/redirect"));
}
@Test
void addsKeyValuesForNotFoundExchange() {
this.request.setMethod("GET");
this.request.setRequestURI("/test/notFound");
this.request.setPathInfo("/test/notFound");
this.response.setStatus(404);
assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "NOT_FOUND"), KeyValue.of("status", "404"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "CLIENT_ERROR"));
assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/notFound"));
}
}

95
spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java

@ -0,0 +1,95 @@ @@ -0,0 +1,95 @@
/*
* 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.observation;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.web.testfixture.servlet.MockFilterChain;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@link HttpRequestsObservationFilter}.
* @author Brian Clozel
*/
public class HttpRequestsObservationFilterTests {
private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
private final HttpRequestsObservationFilter filter = new HttpRequestsObservationFilter(this.observationRegistry);
private final MockFilterChain mockFilterChain = new MockFilterChain();
private final MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/resource/test");
private final MockHttpServletResponse response = new MockHttpServletResponse();
@Test
void filterShouldFillObservationContext() throws Exception {
this.filter.doFilter(this.request, this.response, this.mockFilterChain);
HttpRequestsObservationContext context = (HttpRequestsObservationContext) this.request
.getAttribute(HttpRequestsObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE);
assertThat(context).isNotNull();
assertThat(context.getCarrier()).isEqualTo(this.request);
assertThat(context.getResponse()).isEqualTo(this.response);
assertThat(context.getPathPattern()).isNull();
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL");
}
@Test
void filterShouldUseThrownException() throws Exception {
IllegalArgumentException customError = new IllegalArgumentException("custom error");
this.request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, customError);
this.filter.doFilter(this.request, this.response, this.mockFilterChain);
HttpRequestsObservationContext context = (HttpRequestsObservationContext) this.request
.getAttribute(HttpRequestsObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE);
assertThat(context.getError()).get().isEqualTo(customError);
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SERVER_ERROR");
}
@Test
void filterShouldUnwrapServletException() {
IllegalArgumentException customError = new IllegalArgumentException("custom error");
assertThatThrownBy(() -> {
this.filter.doFilter(this.request, this.response, (request, response) -> {
throw new ServletException(customError);
});
}).isInstanceOf(ServletException.class);
HttpRequestsObservationContext context = (HttpRequestsObservationContext) this.request
.getAttribute(HttpRequestsObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE);
assertThat(context.getError()).get().isEqualTo(customError);
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL");
}
private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() {
return TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasObservationWithNameEqualTo("http.server.requests").that();
}
}

142
spring-web/src/test/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConventionTests.java

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
/*
* 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.observation.reactive;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DefaultHttpRequestsObservationConvention}.
* @author Brian Clozel
*/
class DefaultHttpRequestsObservationConventionTests {
private final DefaultHttpRequestsObservationConvention convention = new DefaultHttpRequestsObservationConvention();
@Test
void shouldHaveDefaultName() {
assertThat(convention.getName()).isEqualTo("http.server.requests");
}
@Test
void supportsOnlyHttpRequestsObservationContext() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource"));
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
assertThat(this.convention.supportsContext(context)).isTrue();
assertThat(this.convention.supportsContext(new Observation.Context())).isFalse();
}
@Test
void addsKeyValuesForExchange() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource"));
exchange.getResponse().setRawStatusCode(201);
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "POST"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "201"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "SUCCESSFUL"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForExchangeWithPathPattern() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource"));
exchange.getResponse().setRawStatusCode(200);
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
PathPattern pathPattern = getPathPattern("/test/{name}");
context.setPathPattern(pathPattern);
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "/test/{name}"), KeyValue.of("status", "200"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "SUCCESSFUL"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForErrorExchange() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource"));
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
context.setError(new IllegalArgumentException("custom error"));
exchange.getResponse().setRawStatusCode(500);
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "500"),
KeyValue.of("exception", "IllegalArgumentException"), KeyValue.of("outcome", "SERVER_ERROR"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
@Test
void addsKeyValuesForRedirectExchange() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/redirect"));
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
exchange.getResponse().setRawStatusCode(302);
exchange.getResponse().getHeaders().add("Location", "https://example.org/other");
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "REDIRECTION"), KeyValue.of("status", "302"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "REDIRECTION"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/redirect"));
}
@Test
void addsKeyValuesForNotFoundExchange() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/notFound"));
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
exchange.getResponse().setRawStatusCode(404);
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "NOT_FOUND"), KeyValue.of("status", "404"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "CLIENT_ERROR"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/notFound"));
}
@Test
void addsKeyValuesForCancelledExchange() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource"));
HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange);
context.setConnectionAborted(true);
exchange.getResponse().setRawStatusCode(200);
assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5)
.contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "200"),
KeyValue.of("exception", "none"), KeyValue.of("outcome", "UNKNOWN"));
assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1)
.contains(KeyValue.of("uri.expanded", "/test/resource"));
}
private static PathPattern getPathPattern(String pattern) {
PathPatternParser pathPatternParser = new PathPatternParser();
return pathPatternParser.parse(pattern);
}
}

104
spring-web/src/test/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilterTests.java

@ -0,0 +1,104 @@ @@ -0,0 +1,104 @@
/*
* 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.observation.reactive;
import java.util.Optional;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link HttpRequestsObservationWebFilter}.
*
* @author Brian Clozel
*/
class HttpRequestsObservationWebFilterTests {
private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
private final HttpRequestsObservationWebFilter filter = new HttpRequestsObservationWebFilter(this.observationRegistry);
@Test
void filterShouldFillObservationContext() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource"));
exchange.getResponse().setRawStatusCode(200);
WebFilterChain filterChain = createFilterChain(filterExchange -> {
Optional<HttpRequestsObservationContext> observationContext = HttpRequestsObservationWebFilter.findObservationContext(filterExchange);
assertThat(observationContext).isPresent();
assertThat(observationContext.get().getCarrier()).isEqualTo(exchange.getRequest());
assertThat(observationContext.get().getResponse()).isEqualTo(exchange.getResponse());
});
this.filter.filter(exchange, filterChain).block();
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL");
}
@Test
void filterShouldUseThrownException() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource"));
exchange.getResponse().setRawStatusCode(500);
WebFilterChain filterChain = createFilterChain(filterExchange -> {
throw new IllegalArgumentException("server error");
});
StepVerifier.create(this.filter.filter(exchange, filterChain))
.expectError(IllegalArgumentException.class)
.verify();
Optional<HttpRequestsObservationContext> observationContext = HttpRequestsObservationWebFilter.findObservationContext(exchange);
assertThat(observationContext.get().getError()).get().isInstanceOf(IllegalArgumentException.class);
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SERVER_ERROR");
}
@Test
void filterShouldRecordObservationWhenCancelled() {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource"));
exchange.getResponse().setRawStatusCode(200);
WebFilterChain filterChain = createFilterChain(filterExchange -> {
});
StepVerifier.create(this.filter.filter(exchange, filterChain))
.thenCancel()
.verify();
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN");
}
WebFilterChain createFilterChain(ThrowingConsumer<ServerWebExchange> exchangeConsumer) {
return filterExchange -> {
try {
exchangeConsumer.accept(filterExchange);
}
catch (Throwable ex) {
return Mono.error(ex);
}
return Mono.empty();
};
}
private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() {
return TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasObservationWithNameEqualTo("http.server.requests").that();
}
}

154
spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
/*
* 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.testfixture.http.client;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Mock implementation of {@link ClientHttpRequest}.
*
* @author Brian Clozel
* @author Rossen Stoyanchev
*/
public class MockClientHttpRequest implements ClientHttpRequest {
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private final HttpHeaders headers = new HttpHeaders();
private HttpMethod httpMethod;
private URI uri;
private final ByteArrayOutputStream body = new ByteArrayOutputStream(1024);
@Nullable
private ClientHttpResponse clientHttpResponse;
private boolean executed = false;
public MockClientHttpRequest() {
this.httpMethod = HttpMethod.GET;
try {
this.uri = new URI("/");
}
catch (URISyntaxException ex) {
throw new IllegalStateException(ex);
}
}
public MockClientHttpRequest(HttpMethod httpMethod, String urlTemplate, Object... vars) {
this.httpMethod = httpMethod;
this.uri = UriComponentsBuilder.fromUriString(urlTemplate).buildAndExpand(vars).encode().toUri();
}
@Override
public HttpHeaders getHeaders() {
return this.headers;
}
@Override
public OutputStream getBody() throws IOException {
return this.body;
}
public byte[] getBodyAsBytes() {
return this.body.toByteArray();
}
public String getBodyAsString() {
return getBodyAsString(DEFAULT_CHARSET);
}
public String getBodyAsString(Charset charset) {
return StreamUtils.copyToString(this.body, charset);
}
public void setMethod(HttpMethod httpMethod) {
this.httpMethod = httpMethod;
}
@Override
public HttpMethod getMethod() {
return this.httpMethod;
}
@Override
@Deprecated
public String getMethodValue() {
return this.httpMethod.name();
}
public void setURI(URI uri) {
this.uri = uri;
}
@Override
public URI getURI() {
return this.uri;
}
public void setResponse(ClientHttpResponse clientHttpResponse) {
this.clientHttpResponse = clientHttpResponse;
}
public boolean isExecuted() {
return this.executed;
}
@Override
public final ClientHttpResponse execute() throws IOException {
this.executed = true;
return executeInternal();
}
protected ClientHttpResponse executeInternal() throws IOException {
Assert.state(this.clientHttpResponse != null, "No ClientHttpResponse");
return this.clientHttpResponse;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(this.httpMethod);
sb.append(' ').append(this.uri);
if (!getHeaders().isEmpty()) {
sb.append(", headers: ").append(getHeaders());
}
if (sb.length() == 0) {
sb.append("Not yet initialized");
}
return sb.toString();
}
}

99
spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpResponse.java

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
/*
* 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.testfixture.http.client;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
/**
* Mock implementation of {@link ClientHttpResponse}.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
*/
public class MockClientHttpResponse implements ClientHttpResponse {
private final HttpHeaders headers = new HttpHeaders();
private final HttpStatus status;
private InputStream body;
public MockClientHttpResponse() {
this.status = HttpStatus.OK;
}
public MockClientHttpResponse(HttpStatus statusCode) {
Assert.notNull(statusCode, "HttpStatus is required");
this.status = statusCode;
}
@Override
public HttpStatus getStatusCode() throws IOException {
return this.status;
}
@Override
@SuppressWarnings("deprecation")
public int getRawStatusCode() throws IOException {
return this.status.value();
}
@Override
public String getStatusText() throws IOException {
return this.status.getReasonPhrase();
}
@Override
public HttpHeaders getHeaders() {
return this.headers;
}
@Override
public InputStream getBody() throws IOException {
return this.body;
}
public void setBody(byte[] body) {
Assert.notNull(body, "body is required");
this.body = new ByteArrayInputStream(body);
}
public void setBody(String body) {
Assert.notNull(body, "body is required");
this.body = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
}
@Override
public void close() {
try {
getBody().close();
}
catch (IOException ex) {
// ignore
}
}
}

9
spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/package-info.java

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
/**
* Contains mock request and response types for the imperative HTTP client
*/
@NonNullApi
@NonNullFields
package org.springframework.web.testfixture.http.client;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

9
spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/package-info.java

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
/**
* Contains mock request and response types for the reactive HTTP client
*/
@NonNullApi
@NonNullFields
package org.springframework.web.testfixture.http.client.reactive;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

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();
}
}

3
spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java

@ -33,6 +33,7 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessage @@ -33,6 +33,7 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessage
import org.springframework.http.converter.xml.SourceHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.web.observation.HttpRequestsObservationFilter;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
@ -234,6 +235,8 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini @@ -234,6 +235,8 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini
if (matchingPattern != null) {
servletRequest.removeAttribute(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE);
servletRequest.setAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE, matchingPattern.getPatternString());
HttpRequestsObservationFilter.findObservationContext(request.servletRequest())
.ifPresent(context -> context.setPathPattern(matchingPattern.getPatternString()));
}
servletRequest.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, handlerFunction);
servletRequest.setAttribute(RouterFunctions.REQUEST_ATTRIBUTE, request);

3
spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java

@ -34,6 +34,7 @@ import org.springframework.util.AntPathMatcher; @@ -34,6 +34,7 @@ import org.springframework.util.AntPathMatcher;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.observation.HttpRequestsObservationFilter;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
@ -355,6 +356,8 @@ public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping i @@ -355,6 +356,8 @@ public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping i
HttpServletRequest request) {
request.setAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE, bestMatchingPattern);
HttpRequestsObservationFilter.findObservationContext(request)
.ifPresent(context -> context.setPathPattern(bestMatchingPattern));
request.setAttribute(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping);
}

5
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java

@ -45,6 +45,7 @@ import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -45,6 +45,7 @@ import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.UnsatisfiedServletRequestParameterException;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.observation.HttpRequestsObservationFilter;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.handler.AbstractHandlerMethodMapping;
import org.springframework.web.servlet.mvc.condition.NameValueExpression;
@ -172,6 +173,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe @@ -172,6 +173,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
request.setAttribute(MATRIX_VARIABLES_ATTRIBUTE, result.getMatrixVariables());
}
request.setAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern.getPatternString());
HttpRequestsObservationFilter.findObservationContext(request)
.ifPresent(context -> context.setPathPattern(bestPattern.getPatternString()));
request.setAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriVariables);
}
@ -193,6 +196,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe @@ -193,6 +196,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
uriVariables = getUrlPathHelper().decodePathVariables(request, uriVariables);
}
request.setAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern);
HttpRequestsObservationFilter.findObservationContext(request)
.ifPresent(context -> context.setPathPattern(bestPattern));
request.setAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriVariables);
}

8
spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java

@ -26,6 +26,8 @@ import org.junit.jupiter.params.provider.ValueSource; @@ -26,6 +26,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.observation.HttpRequestsObservationContext;
import org.springframework.web.observation.HttpRequestsObservationFilter;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.function.HandlerFunction;
@ -33,12 +35,14 @@ import org.springframework.web.servlet.function.RouterFunction; @@ -33,12 +35,14 @@ import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.util.ServletRequestPathUtils;
import org.springframework.web.util.pattern.PathPatternParser;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link RouterFunctionMapping}.
* @author Arjen Poutsma
* @author Brian Clozel
*/
@ -170,11 +174,15 @@ class RouterFunctionMappingTests { @@ -170,11 +174,15 @@ class RouterFunctionMappingTests {
assertThat(result).isNotNull();
assertThat(request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)).isEqualTo("/match");
assertThat(HttpRequestsObservationFilter.findObservationContext(request))
.hasValueSatisfying(context -> assertThat(context.getPathPattern()).isEqualTo("/match"));
assertThat(request.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE)).isEqualTo(handlerFunction);
}
private MockHttpServletRequest createTestRequest(String path) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
request.setAttribute(HttpRequestsObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE,
new HttpRequestsObservationContext(request, new MockHttpServletResponse()));
ServletRequestPathUtils.parseAndCache(request);
return request;
}

15
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java

@ -49,12 +49,15 @@ import org.springframework.web.context.support.StaticWebApplicationContext; @@ -49,12 +49,15 @@ import org.springframework.web.context.support.StaticWebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.support.InvocableHandlerMethod;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.observation.HttpRequestsObservationContext;
import org.springframework.web.observation.HttpRequestsObservationFilter;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.handler.MappedInterceptor;
import org.springframework.web.servlet.handler.PathPatternsParameterizedTest;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.util.ServletRequestPathUtils;
import org.springframework.web.util.UrlPathHelper;
@ -288,6 +291,18 @@ class RequestMappingInfoHandlerMappingTests { @@ -288,6 +291,18 @@ class RequestMappingInfoHandlerMappingTests {
assertThat(request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)).isEqualTo("/{path1}/2");
}
@PathPatternsParameterizedTest
void handleMatchBestMatchingPatternAttributeInObservationContext(TestRequestMappingInfoHandlerMapping mapping) {
RequestMappingInfo key = RequestMappingInfo.paths("/{path1}/2", "/**").build();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/1/2");
HttpRequestsObservationContext observationContext = new HttpRequestsObservationContext(request, new MockHttpServletResponse());
request.setAttribute(HttpRequestsObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observationContext);
mapping.handleMatch(key, "/1/2", request);
assertThat(request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)).isEqualTo("/{path1}/2");
assertThat(observationContext.getPathPattern()).isEqualTo("/{path1}/2");
}
@PathPatternsParameterizedTest // gh-22543
void handleMatchBestMatchingPatternAttributeNoPatternsDefined(TestRequestMappingInfoHandlerMapping mapping) {
String path = "";

Loading…
Cancel
Save