diff --git a/spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java b/spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java new file mode 100644 index 0000000000..d9b2fad575 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/DefaultHttpRequestsObservationConvention.java @@ -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; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.java b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.java new file mode 100644 index 0000000000..2e6a8c976a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservation.java @@ -0,0 +1,124 @@ +/* + * 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.ObservationConvention; +import io.micrometer.observation.docs.DocumentedObservation; + +/** + * Documented {@link io.micrometer.common.KeyValue KeyValues} for the HTTP server observations + * for Servlet-based web applications. + *

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> 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"; + } + } + + } +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java new file mode 100644 index 0000000000..b62280696a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationContext.java @@ -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. + *

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 { + + @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; + } +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.java b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.java new file mode 100644 index 0000000000..bac16c6dd4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationConvention.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} related to Servlet HTTP exchanges. + * @author Brian Clozel + * @since 6.0 + */ +public interface HttpRequestsObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof HttpRequestsObservationContext; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java new file mode 100644 index 0000000000..8d42cd6a4e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/HttpRequestsObservationFilter.java @@ -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}. + *

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 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); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/package-info.java b/spring-web/src/main/java/org/springframework/web/observation/package-info.java new file mode 100644 index 0000000000..4cc30c56be --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/package-info.java @@ -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; diff --git a/spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java b/spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java new file mode 100644 index 0000000000..53323c31a0 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/observation/DefaultHttpRequestsObservationConventionTests.java @@ -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")); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java b/spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java new file mode 100644 index 0000000000..a093317500 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/observation/HttpRequestsObservationFilterTests.java @@ -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(); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java index 8a1c44bdb3..5ffda8c829 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java @@ -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 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); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java index fc5dfbb208..4f4c790aa2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java @@ -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 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); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java index e52bbe5b01..727382096d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java @@ -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 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 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); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java index 8d28f5e666..28b8f91d76 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingTests.java @@ -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; 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 { 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; } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java index bc8f0da478..a3cf9695ec 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java @@ -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 { 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 = "";