Instrument Servlet server apps for Observability
This commit introduces the new `HttpRequestsObservationFilter` This `Filter` can be used to instrument Servlet-based web frameworks for Micrometer Observations. While the Servlet request and responses are automatically used for extracting KeyValues for observations, web frameworks still need to provide the matching URL pattern, if supported. This can be done by fetching the observation context from the request attributes and contributing to it. This commit instruments Spring MVC (annotation and functional variants), effectively replacing Spring Boot's `WebMvcMetricsFilter`. See gh-28880
This commit is contained in:
parent
ac9360b624
commit
6eded96740
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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.
|
||||
* <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 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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<HttpRequestsObservationContext> {
|
||||
|
||||
@Override
|
||||
default boolean supportsContext(Observation.Context context) {
|
||||
return context instanceof HttpRequestsObservationContext;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = "";
|
||||
|
|
Loading…
Reference in New Issue