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 extends 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";
+ }
+ }
+
+ }
+}
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 = "";