Browse Source

Add HttpRequestsObservationFilter

This `Filter` can be used to instrument Servlet-based web frameworks for
Micrometer Observations.
observability
Brian Clozel 2 years ago
parent
commit
b5bf16470c
  1. 139
      spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java
  2. 123
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.java
  3. 51
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java
  4. 33
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.java
  5. 145
      spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java
  6. 9
      spring-web/src/main/java/org/springframework/web/observation/package-info.java
  7. 125
      spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java
  8. 95
      spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java
  9. 3
      spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java
  10. 3
      spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java
  11. 5
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java
  12. 8
      spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java
  13. 15
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java

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;

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

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