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:
Brian Clozel
2022-09-12 11:36:37 +02:00
parent ac9360b624
commit 6eded96740
13 changed files with 756 additions and 0 deletions

View File

@@ -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"));
}
}

View File

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