@ -16,26 +16,39 @@
@@ -16,26 +16,39 @@
package org.springframework.boot.actuate.metrics.web.servlet ;
import java.io.IOException ;
import java.lang.annotation.ElementType ;
import java.lang.annotation.Retention ;
import java.lang.annotation.RetentionPolicy ;
import java.lang.annotation.Target ;
import java.util.concurrent.Callable ;
import java.util.concurrent.CountDownLatch ;
import java.util.stream.StreamSupport ;
import javax.servlet.FilterChain ;
import javax.servlet.ServletException ;
import javax.servlet.http.HttpServletRequest ;
import javax.servlet.http.HttpServletResponse ;
import io.micrometer.core.annotation.Timed ;
import io.micrometer.core.instrument.MeterRegistry ;
import io.micrometer.core.instrument.Statistic ;
import io.micrometer.core.instrument.Tag ;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry ;
import io.micrometer.prometheus.PrometheusConfig ;
import io.micrometer.prometheus.PrometheusMeterRegistry ;
import org.junit.Before ;
import org.junit.Test ;
import org.junit.runner.RunWith ;
import org.springframework.beans.factory.annotation.Autowired ;
import org.springframework.boot.autoconfigure.SpringBootApplication ;
import org.springframework.boot.test.context.SpringBootTest ;
import org.springframework.context.annotation.Bean ;
import org.springframework.context.annotation.Configuration ;
import org.springframework.context.annotation.Import ;
import org.springframework.http.HttpStatus ;
import org.springframework.test.context.TestPropertySource ;
import org.springframework.test.context.junit4.SpringRunner ;
import org.springframework.test.context.web.WebAppConfiguration ;
import org.springframework.test.web.servlet.MockMvc ;
@ -48,10 +61,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
@@ -48,10 +61,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus ;
import org.springframework.web.bind.annotation.RestController ;
import org.springframework.web.context.WebApplicationContext ;
import org.springframework.web.filter.OncePerRequestFilter ;
import org.springframework.web.servlet.ModelAndView ;
import org.springframework.web.servlet.config.annotation.EnableWebMvc ;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry ;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer ;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector ;
import static org.assertj.core.api.Assertions.assertThat ;
import static org.assertj.core.api.Assertions.assertThatCode ;
@ -61,40 +76,57 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@@ -61,40 +76,57 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status ;
/ * *
* Tests for { @link MetricsHandlerInterceptor } .
* Tests for { @link MetricsFilter }
*
* @author Jon Schneider
* /
@RunWith ( SpringRunner . class )
@WebAppConfiguration
public class MetricsHandlerInterceptorTests {
private static final CountDownLatch longRequestCountDown = new CountDownLatch ( 1 ) ;
public class MetricsFilterTests {
@Autowired
private MeterRegistry registry ;
private PrometheusMeterRegistry registry ;
@Autowired
private WebApplicationContext context ;
@Autowired
private MetricsFilter filter ;
private MockMvc mvc ;
@Autowired
private CountDownLatch asyncLatch ;
@Before
public void setupMockMvc ( ) {
this . mvc = MockMvcBuilders . webAppContextSetup ( this . context ) . build ( ) ;
this . mvc = MockMvcBuilders
. webAppContextSetup ( this . context )
. addFilters ( filter , new RedirectAndNotFoundFilter ( ) )
. build ( ) ;
}
@Test
public void timedMethod ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "status" , "200" , "uri" , "/api/c1/{id}" , "public" , "true" )
. value ( Statistic . Count , 1 . 0 ) . timer ( ) ) . isPresent ( ) ;
}
@Test
public void subclassedTimedMethod ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/metaTimed/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "status" , "200" , "uri" , "/api/c1/metaTimed/{id}" )
. value ( Statistic . Count , 1 . 0 ) . timer ( ) ) . isPresent ( ) ;
}
@Test
public void untimedMethod ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/untimed/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "uri" , "/api/c1/untimed/10" ) . timer ( ) ) . isEmpty ( ) ;
}
@ -102,23 +134,48 @@ public class MetricsHandlerInterceptorTests {
@@ -102,23 +134,48 @@ public class MetricsHandlerInterceptorTests {
@Test
public void timedControllerClass ( ) throws Exception {
this . mvc . perform ( get ( "/api/c2/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat (
this . registry . find ( "http.server.requests" ) . tags ( "status" , "200" ) . timer ( ) )
. hasValueSatisfying ( ( t ) - > assertThat ( t . count ( ) ) . isEqualTo ( 1 ) ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . tags ( "status" , "200" )
. value ( Statistic . Count , 1 . 0 )
. timer ( ) ) . isPresent ( ) ;
}
@Test
public void badClientRequest ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/oops" ) ) . andExpect ( status ( ) . is4xxClientError ( ) ) ;
assertThat (
this . registry . find ( "http.server.requests" ) . tags ( "status" , "400" ) . timer ( ) )
. hasValueSatisfying ( ( t ) - > assertThat ( t . count ( ) ) . isEqualTo ( 1 ) ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . tags ( "status" , "400" )
. value ( Statistic . Count , 1 . 0 )
. timer ( ) ) . isPresent ( ) ;
}
@Test
public void redirectRequest ( ) throws Exception {
this . mvc . perform ( get ( "/api/redirect" )
. header ( RedirectAndNotFoundFilter . TEST_MISBEHAVE_HEADER , "302" ) ) . andExpect ( status ( ) . is3xxRedirection ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "uri" , "REDIRECTION" )
. tags ( "status" , "302" ) . timer ( ) ) . isPresent ( ) ;
}
@Test
public void notFoundRequest ( ) throws Exception {
this . mvc . perform ( get ( "/api/not/found" )
. header ( RedirectAndNotFoundFilter . TEST_MISBEHAVE_HEADER , "404" ) ) . andExpect ( status ( ) . is4xxClientError ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "uri" , "NOT_FOUND" )
. tags ( "status" , "404" ) . timer ( ) ) . isPresent ( ) ;
}
@Test
public void unhandledError ( ) throws Exception {
assertThatCode ( ( ) - > this . mvc . perform ( get ( "/api/c1/unhandledError/10" ) )
. andExpect ( status ( ) . isOk ( ) ) ) . hasCauseInstanceOf ( RuntimeException . class ) ;
. andExpect ( status ( ) . isOk ( ) ) )
. hasRootCauseInstanceOf ( RuntimeException . class ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "exception" , "RuntimeException" ) . value ( Statistic . Count , 1 . 0 )
. timer ( ) ) . isPresent ( ) ;
@ -127,14 +184,19 @@ public class MetricsHandlerInterceptorTests {
@@ -127,14 +184,19 @@ public class MetricsHandlerInterceptorTests {
@Test
public void longRunningRequest ( ) throws Exception {
MvcResult result = this . mvc . perform ( get ( "/api/c1/long/10" ) )
. andExpect ( request ( ) . asyncStarted ( ) ) . andReturn ( ) ;
. andExpect ( request ( ) . asyncStarted ( ) )
. andReturn ( ) ;
// the request is not prematurely recorded as complete
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "uri" , "/api/c1/async" ) . timer ( ) ) . isNotPresent ( ) ;
// while the mapping is running, it contributes to the activeTasks count
assertThat ( this . registry . find ( "my.long.request" ) . tags ( "region" , "test" )
. value ( Statistic . Count , 1 . 0 ) . longTaskTimer ( ) ) . isPresent ( ) ;
// once the mapping completes, we can gather information about status, etc.
longRequestCountDown . countDown ( ) ;
asyncLatch . countDown ( ) ;
this . mvc . perform ( asyncDispatch ( result ) ) . andExpect ( status ( ) . isOk ( ) ) ;
@ -145,6 +207,7 @@ public class MetricsHandlerInterceptorTests {
@@ -145,6 +207,7 @@ public class MetricsHandlerInterceptorTests {
@Test
public void endpointThrowsError ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/error/10" ) ) . andExpect ( status ( ) . is4xxClientError ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . tags ( "status" , "422" )
. value ( Statistic . Count , 1 . 0 ) . timer ( ) ) . isPresent ( ) ;
}
@ -152,6 +215,7 @@ public class MetricsHandlerInterceptorTests {
@@ -152,6 +215,7 @@ public class MetricsHandlerInterceptorTests {
@Test
public void regexBasedRequestMapping ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/regex/.abc" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" )
. tags ( "uri" , "/api/c1/regex/{id:\\.[a-z]+}" ) . value ( Statistic . Count , 1 . 0 )
. timer ( ) ) . isPresent ( ) ;
@ -159,80 +223,76 @@ public class MetricsHandlerInterceptorTests {
@@ -159,80 +223,76 @@ public class MetricsHandlerInterceptorTests {
@Test
public void recordQuantiles ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/qua ntiles/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
this . mvc . perform ( get ( "/api/c1/perce ntiles/10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . tags ( "quantile" , "0.5" )
. gauge ( ) ) . isNotEmpty ( ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . tags ( "quantile" , "0.95" )
. gauge ( ) ) . isNotEmpty ( ) ;
assertThat ( this . registry . scrape ( ) ) . contains ( "quantile=\"0.5\"" ) ;
assertThat ( this . registry . scrape ( ) ) . contains ( "quantile=\"0.95\"" ) ;
}
@Test
public void recordPercentiles ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/percentiles /10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
public void recordHistogram ( ) throws Exception {
this . mvc . perform ( get ( "/api/c1/histogram /10" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
assertThat ( this . registry . find ( "http.server.requests" ) . meters ( )
. stream ( ) . flatMap ( ( m ) - > StreamSupport
. stream ( m . getId ( ) . getTags ( ) . spliterator ( ) , false ) )
. map ( Tag : : getKey ) ) . contains ( "bucket" ) ;
assertThat ( this . registry . scrape ( ) ) . contains ( "le=\"0.001\"" ) ;
assertThat ( this . registry . scrape ( ) ) . contains ( "le=\"30.0\"" ) ;
}
@Target ( { ElementType . METHOD } )
@Retention ( RetentionPolicy . RUNTIME )
@Timed ( percentiles = 0 . 95 )
public @interface Timed95 {
}
@Configuration
@EnableWebMvc
@Import ( { Controller1 . class , Controller2 . class } )
static class TestConfiguration {
@Import ( { Controller1 . class , Controller2 . class } )
static class MetricsFilterApp {
@Bean
MeterRegistry meterRegistry ( ) {
return new SimpleMeterRegistry ( ) ;
// one of the few registries that support aggregable percentiles
return new PrometheusMeterRegistry ( PrometheusConfig . DEFAULT ) ;
}
@Bean
WebMvcMetrics webMvcMetrics ( MeterRegistry meterRegistry ) {
return new WebMvcMetrics ( meterRegistry , new DefaultWebMvcTagsProvider ( ) ,
"http.server.requests" , true , true ) ;
CountDownLatch asyncLatch ( ) {
return new CountDownLatch ( 1 ) ;
}
@Configuration
static class HandlerInterceptorConfiguration implements WebMvcConfigurer {
private final WebMvcMetrics webMvcMetrics ;
HandlerInterceptorConfiguration ( WebMvcMetrics webMvcMetrics ) {
this . webMvcMetrics = webMvcMetrics ;
}
@Override
public void addInterceptors ( InterceptorRegistry registry ) {
registry . addInterceptor (
new MetricsHandlerInterceptor ( this . webMvcMetrics ) ) ;
}
@Bean
public WebMvcMetrics controllerMetrics ( MeterRegistry registry ) {
return new WebMvcMetrics ( registry , new DefaultWebMvcTagsProvider ( ) , "http.server.requests" , true ,
false ) ;
}
@Bean
public MetricsFilter webMetricsFilter ( WebMvcMetrics controllerMetrics , HandlerMappingIntrospector introspector ) {
return new MetricsFilter ( controllerMetrics , introspector ) ;
}
}
@RestController
@RequestMapping ( "/api/c1" )
static class Controller1 {
private final CountDownLatch asyncLatch ;
public Controller1 ( CountDownLatch asyncLatch ) {
this . asyncLatch = asyncLatch ;
}
@Timed ( extraTags = { "public" , "true" } )
@Timed ( extraTags = { "public" , "true" } )
@GetMapping ( "/{id}" )
public String successfulWithExtraTags ( @PathVariable Long id ) {
return id . toString ( ) ;
}
@Timed // contains dimensions for status, etc. that can't be known until after the
// response is sent
@Timed ( value = "my.long.request" , extraTags = { "region" ,
"test" } , longTask = true ) // in progress metric
@Timed
@Timed ( value = "my.long.request" , extraTags = { "region" , "test" } , longTask = true )
@GetMapping ( "/long/{id}" )
public Callable < String > takesLongTimeToSatisfy ( @PathVariable Long id ) {
return ( ) - > {
try {
longRequestCountDown . await ( ) ;
}
catch ( InterruptedException e ) {
asyncLatch . await ( ) ;
} catch ( InterruptedException e ) {
throw new RuntimeException ( e ) ;
}
return id . toString ( ) ;
@ -247,13 +307,13 @@ public class MetricsHandlerInterceptorTests {
@@ -247,13 +307,13 @@ public class MetricsHandlerInterceptorTests {
@Timed
@GetMapping ( "/error/{id}" )
public String alwaysThrowsException ( @PathVariable Long id ) {
throw new IllegalStateException ( "Boom on $id !" ) ;
throw new IllegalStateException ( "Boom on " + id + " !" ) ;
}
@Timed
@GetMapping ( "/unhandledError/{id}" )
public String alwaysThrowsUnhandledException ( @PathVariable Long id ) {
throw new RuntimeException ( "Boom on $id !" ) ;
throw new RuntimeException ( "Boom on " + id + " !" ) ;
}
@Timed
@ -262,15 +322,21 @@ public class MetricsHandlerInterceptorTests {
@@ -262,15 +322,21 @@ public class MetricsHandlerInterceptorTests {
return id ;
}
@Timed ( qua ntiles = { 0 . 5 , 0 . 95 } )
@GetMapping ( "/qua ntiles/{id}" )
public String qua ntiles( @PathVariable String id ) {
@Timed ( perce ntiles = { 0 . 50 , 0 . 95 } )
@GetMapping ( "/perce ntiles/{id}" )
public String perce ntiles( @PathVariable String id ) {
return id ;
}
@Timed ( percentiles = true )
@GetMapping ( "/percentiles/{id}" )
public String percentiles ( @PathVariable String id ) {
@Timed ( histogram = true )
@GetMapping ( "/histogram/{id}" )
public String histogram ( @PathVariable String id ) {
return id ;
}
@Timed95
@GetMapping ( "/metaTimed/{id}" )
public String meta ( @PathVariable String id ) {
return id ;
}
@ -279,19 +345,31 @@ public class MetricsHandlerInterceptorTests {
@@ -279,19 +345,31 @@ public class MetricsHandlerInterceptorTests {
ModelAndView defaultErrorHandler ( HttpServletRequest request , Exception e ) {
return new ModelAndView ( "myerror" ) ;
}
}
@RestController
@Timed
@RequestMapping ( "/api/c2" )
static class Controller2 {
@GetMapping ( "/{id}" )
public String successful ( @PathVariable Long id ) {
return id . toString ( ) ;
}
}
static class RedirectAndNotFoundFilter extends OncePerRequestFilter {
static final String TEST_MISBEHAVE_HEADER = "x-test-misbehave-status" ;
@Override
protected void doFilterInternal ( HttpServletRequest request , HttpServletResponse response , FilterChain filterChain ) throws ServletException , IOException {
String misbehave = request . getHeader ( TEST_MISBEHAVE_HEADER ) ;
if ( misbehave ! = null ) {
response . setStatus ( Integer . parseInt ( misbehave ) ) ;
} else {
filterChain . doFilter ( request , response ) ;
}
}
}
}