Skip to content

Commit 72a1eb6

Browse files
committed
Allow to manually tag request metrics with exceptions
Prior to this commit, some exceptions handled at the controller or handler function level would: * not bubble up to the Spring Boot error handling support * not be tagged as part of the request metrics This situation is inconsistent because in general, exceptions handled at the controller level can be considered as expected behavior. Also, depending on how the exception is handled, the request metrics might not be tagged with the exception. This will be reconsidered in gh-23795. This commit prepares a transition to the new situation. Developers can now opt-in and set the handled exception as a request attribute. This well-known attribute will be later read by the metrics support and used for tagging the request metrics with the exception provided. This mechanism is automatically used by the error handling support in Spring Boot. Closes gh-24028
1 parent 66e9619 commit 72a1eb6

File tree

14 files changed

+177
-12
lines changed

14 files changed

+177
-12
lines changed

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/MetricsWebFilter.java

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import reactor.core.publisher.Mono;
2525

2626
import org.springframework.boot.actuate.metrics.AutoTimer;
27+
import org.springframework.boot.web.reactive.error.ErrorAttributes;
2728
import org.springframework.core.Ordered;
2829
import org.springframework.core.annotation.Order;
2930
import org.springframework.http.server.reactive.ServerHttpResponse;
@@ -93,6 +94,9 @@ private void onTerminalSignal(ServerWebExchange exchange, Throwable cause, long
9394
}
9495

9596
private void record(ServerWebExchange exchange, Throwable cause, long start) {
97+
if (cause == null) {
98+
cause = exchange.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE);
99+
}
96100
Iterable<Tag> tags = this.tagsProvider.httpRequestTags(exchange, cause);
97101
this.autoTimer.builder(this.metricName).tags(tags).register(this.registry).record(System.nanoTime() - start,
98102
TimeUnit.NANOSECONDS);

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcMetricsFilter.java

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2020 the original author or authors.
2+
* Copyright 2012-2021 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@
3333
import io.micrometer.core.instrument.Timer.Sample;
3434

3535
import org.springframework.boot.actuate.metrics.AutoTimer;
36+
import org.springframework.boot.web.servlet.error.ErrorAttributes;
3637
import org.springframework.core.annotation.MergedAnnotationCollectors;
3738
import org.springframework.core.annotation.MergedAnnotations;
3839
import org.springframework.http.HttpStatus;
@@ -96,7 +97,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
9697
// If async was started by something further down the chain we wait
9798
// until the second filter invocation (but we'll be using the
9899
// TimingContext that was attached to the first)
99-
Throwable exception = (Throwable) request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE);
100+
Throwable exception = fetchException(request);
100101
record(timingContext, request, response, exception);
101102
}
102103
}
@@ -118,6 +119,14 @@ private TimingContext startAndAttachTimingContext(HttpServletRequest request) {
118119
return timingContext;
119120
}
120121

122+
private Throwable fetchException(HttpServletRequest request) {
123+
Throwable exception = (Throwable) request.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE);
124+
if (exception == null) {
125+
exception = (Throwable) request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE);
126+
}
127+
return exception;
128+
}
129+
121130
private void record(TimingContext timingContext, HttpServletRequest request, HttpServletResponse response,
122131
Throwable exception) {
123132
Object handler = getHandler(request);

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/MetricsWebFilterTests.java

+13
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import reactor.test.StepVerifier;
2929

3030
import org.springframework.boot.actuate.metrics.AutoTimer;
31+
import org.springframework.boot.web.reactive.error.ErrorAttributes;
3132
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
3233
import org.springframework.mock.web.server.MockServerWebExchange;
3334
import org.springframework.web.reactive.HandlerMapping;
@@ -94,6 +95,18 @@ void filterAddsNonEmptyTagsToRegistryForAnonymousExceptions() {
9495
assertMetricsContainsTag("exception", anonymous.getClass().getName());
9596
}
9697

98+
@Test
99+
void filterAddsTagsToRegistryForHandledExceptions() {
100+
MockServerWebExchange exchange = createExchange("/projects/spring-boot", "/projects/{project}");
101+
this.webFilter.filter(exchange, (serverWebExchange) -> {
102+
exchange.getAttributes().put(ErrorAttributes.ERROR_ATTRIBUTE, new IllegalStateException("test error"));
103+
return exchange.getResponse().setComplete();
104+
}).block(Duration.ofSeconds(30));
105+
assertMetricsContainsTag("uri", "/projects/{project}");
106+
assertMetricsContainsTag("status", "200");
107+
assertMetricsContainsTag("exception", "IllegalStateException");
108+
}
109+
97110
@Test
98111
void filterAddsTagsToRegistryForExceptionsAndCommittedResponse() {
99112
MockServerWebExchange exchange = createExchange("/projects/spring-boot", "/projects/{project}");

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcMetricsFilterTests.java

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2020 the original author or authors.
2+
* Copyright 2012-2021 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -57,6 +57,7 @@
5757
import org.springframework.beans.factory.annotation.Autowired;
5858
import org.springframework.beans.factory.annotation.Qualifier;
5959
import org.springframework.boot.actuate.metrics.AutoTimer;
60+
import org.springframework.boot.web.servlet.error.ErrorAttributes;
6061
import org.springframework.context.annotation.Bean;
6162
import org.springframework.context.annotation.Configuration;
6263
import org.springframework.context.annotation.Import;
@@ -264,7 +265,8 @@ void asyncCompletableFutureRequest() throws Exception {
264265
@Test
265266
void endpointThrowsError() throws Exception {
266267
this.mvc.perform(get("/api/c1/error/10")).andExpect(status().is4xxClientError());
267-
assertThat(this.registry.get("http.server.requests").tags("status", "422").timer().count()).isEqualTo(1L);
268+
assertThat(this.registry.get("http.server.requests").tags("status", "422", "exception", "IllegalStateException")
269+
.timer().count()).isEqualTo(1L);
268270
}
269271

270272
@Test
@@ -491,6 +493,8 @@ String meta(@PathVariable String id) {
491493
@ExceptionHandler(IllegalStateException.class)
492494
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
493495
ModelAndView defaultErrorHandler(HttpServletRequest request, Exception e) {
496+
// this is done by ErrorAttributes implementations
497+
request.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, e);
494498
return new ModelAndView("myerror");
495499
}
496500

spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc

+4-1
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,8 @@ By default, Spring MVC-related metrics are tagged with the following information
21882188
To add to the default tags, provide one or more ``@Bean``s that implement `WebMvcTagsContributor`.
21892189
To replace the default tags, provide a `@Bean` that implements `WebMvcTagsProvider`.
21902190
2191+
TIP: In some cases, exceptions handled in Web controllers are not recorded as request metrics tags.
2192+
Applications can opt-in and record exceptions by <<spring-boot-features.adoc#boot-features-error-handling, setting handled exceptions as request parameters>>.
21912193
21922194
21932195
[[production-ready-metrics-web-flux]]
@@ -2222,7 +2224,8 @@ By default, WebFlux-related metrics are tagged with the following information:
22222224
To add to the default tags, provide one or more ``@Bean``s that implement `WebFluxTagsContributor`.
22232225
To replace the default tags, provide a `@Bean` that implements `WebFluxTagsProvider`.
22242226
2225-
2227+
TIP: In some cases, exceptions handled in controllers and handler functions are not recorded as request metrics tags.
2228+
Applications can opt-in and record exceptions by <<spring-boot-features.adoc#boot-features-webflux-error-handling, setting handled exceptions as request parameters>>.
22262229
22272230
[[production-ready-metrics-jersey-server]]
22282231
==== Jersey Server Metrics

spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc

+14
Original file line numberDiff line numberDiff line change
@@ -2566,7 +2566,13 @@ include::{include-springbootfeatures}/webapplications/servlet/MyControllerAdvice
25662566

25672567
In the preceding example, if `YourException` is thrown by a controller defined in the same package as `AcmeController`, a JSON representation of the `CustomErrorType` POJO is used instead of the `ErrorAttributes` representation.
25682568

2569+
In some cases, errors handled at the controller level are not recorded by the <<production-ready-features.adoc#production-ready-metrics-spring-mvc, metrics infrastructure>>.
2570+
Applications can ensure that such exceptions are recorded with the request metrics by setting the handled exception as a request attribute:
25692571

2572+
[source,java,indent=0,subs="verbatim,quotes,attributes"]
2573+
----
2574+
include::{include-springbootfeatures}/webapplications/servlet/MyController.java[]
2575+
----
25702576

25712577
[[boot-features-error-handling-custom-error-pages]]
25722578
===== Custom Error Pages
@@ -2823,6 +2829,14 @@ include::{include-springbootfeatures}/webapplications/webflux/CustomErrorWebExce
28232829

28242830
For a more complete picture, you can also subclass `DefaultErrorWebExceptionHandler` directly and override specific methods.
28252831

2832+
In some cases, errors handled at the controller or handler function level are not recorded by the <<production-ready-features.adoc#production-ready-metrics-web-flux, metrics infrastructure>>.
2833+
Applications can ensure that such exceptions are recorded with the request metrics by setting the handled exception as a request attribute:
2834+
2835+
[source,java,indent=0,subs="verbatim,quotes,attributes"]
2836+
----
2837+
include::{include-springbootfeatures}/webapplications/webflux/ExceptionHandlingController.java[]
2838+
----
2839+
28262840

28272841

28282842
[[boot-features-webflux-error-handling-custom-error-pages]]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.docs.springbootfeatures.webapplications.servlet;
18+
19+
import javax.servlet.http.HttpServletRequest;
20+
21+
import org.springframework.boot.web.servlet.error.ErrorAttributes;
22+
import org.springframework.stereotype.Controller;
23+
import org.springframework.web.bind.annotation.ExceptionHandler;
24+
25+
@Controller
26+
public class MyController {
27+
28+
@ExceptionHandler(CustomException.class)
29+
String handleCustomException(HttpServletRequest request, CustomException ex) {
30+
request.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, ex);
31+
return "errorView";
32+
}
33+
34+
}
35+
// @chomp:file
36+
37+
class CustomException extends RuntimeException {
38+
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.docs.springbootfeatures.webapplications.webflux;
18+
19+
import org.springframework.boot.web.reactive.error.ErrorAttributes;
20+
import org.springframework.stereotype.Controller;
21+
import org.springframework.web.bind.annotation.ExceptionHandler;
22+
import org.springframework.web.bind.annotation.GetMapping;
23+
import org.springframework.web.reactive.result.view.Rendering;
24+
import org.springframework.web.server.ServerWebExchange;
25+
26+
@Controller
27+
public class ExceptionHandlingController {
28+
29+
@GetMapping("/profile")
30+
public Rendering userProfile() {
31+
// ..
32+
throw new IllegalStateException();
33+
// ...
34+
}
35+
36+
@ExceptionHandler(IllegalStateException.class)
37+
Rendering handleIllegalState(ServerWebExchange exchange, IllegalStateException exc) {
38+
exchange.getAttributes().putIfAbsent(ErrorAttributes.ERROR_ATTRIBUTE, exc);
39+
return Rendering.view("errorView").modelAttribute("message", exc.getMessage()).build();
40+
}
41+
42+
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java

+6-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Date;
2222
import java.util.LinkedHashMap;
2323
import java.util.Map;
24+
import java.util.Optional;
2425

2526
import org.springframework.boot.web.error.ErrorAttributeOptions;
2627
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
@@ -61,7 +62,7 @@
6162
*/
6263
public class DefaultErrorAttributes implements ErrorAttributes {
6364

64-
private static final String ERROR_ATTRIBUTE = DefaultErrorAttributes.class.getName() + ".ERROR";
65+
private static final String ERROR_INTERNAL_ATTRIBUTE = DefaultErrorAttributes.class.getName() + ".ERROR";
6566

6667
@Override
6768
public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
@@ -147,13 +148,15 @@ private void handleException(Map<String, Object> errorAttributes, Throwable erro
147148

148149
@Override
149150
public Throwable getError(ServerRequest request) {
150-
return (Throwable) request.attribute(ERROR_ATTRIBUTE)
151+
Optional<Object> error = request.attribute(ERROR_INTERNAL_ATTRIBUTE);
152+
error.ifPresent((value) -> request.attributes().putIfAbsent(ErrorAttributes.ERROR_ATTRIBUTE, value));
153+
return (Throwable) error
151154
.orElseThrow(() -> new IllegalStateException("Missing exception attribute in ServerWebExchange"));
152155
}
153156

154157
@Override
155158
public void storeErrorInformation(Throwable error, ServerWebExchange exchange) {
156-
exchange.getAttributes().putIfAbsent(ERROR_ATTRIBUTE, error);
159+
exchange.getAttributes().putIfAbsent(ERROR_INTERNAL_ATTRIBUTE, error);
157160
}
158161

159162
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/ErrorAttributes.java

+7
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
*/
3535
public interface ErrorAttributes {
3636

37+
/**
38+
* Name of the {@link ServerRequest#attribute(String)} Request attribute} holding the
39+
* error resolved by the {@code ErrorAttributes} implementation.
40+
* @since 2.5.0
41+
*/
42+
String ERROR_ATTRIBUTE = ErrorAttributes.class.getName() + ".error";
43+
3744
/**
3845
* Return a {@link Map} of the error attributes. The map can be used as the model of
3946
* an error page, or returned as a {@link ServerResponse} body.

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java

+10-4
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
@Order(Ordered.HIGHEST_PRECEDENCE)
6969
public class DefaultErrorAttributes implements ErrorAttributes, HandlerExceptionResolver, Ordered {
7070

71-
private static final String ERROR_ATTRIBUTE = DefaultErrorAttributes.class.getName() + ".ERROR";
71+
private static final String ERROR_INTERNAL_ATTRIBUTE = DefaultErrorAttributes.class.getName() + ".ERROR";
7272

7373
@Override
7474
public int getOrder() {
@@ -83,7 +83,7 @@ public ModelAndView resolveException(HttpServletRequest request, HttpServletResp
8383
}
8484

8585
private void storeErrorAttributes(HttpServletRequest request, Exception ex) {
86-
request.setAttribute(ERROR_ATTRIBUTE, ex);
86+
request.setAttribute(ERROR_INTERNAL_ATTRIBUTE, ex);
8787
}
8888

8989
@Override
@@ -216,8 +216,14 @@ private void addPath(Map<String, Object> errorAttributes, RequestAttributes requ
216216

217217
@Override
218218
public Throwable getError(WebRequest webRequest) {
219-
Throwable exception = getAttribute(webRequest, ERROR_ATTRIBUTE);
220-
return (exception != null) ? exception : getAttribute(webRequest, RequestDispatcher.ERROR_EXCEPTION);
219+
Throwable exception = getAttribute(webRequest, ERROR_INTERNAL_ATTRIBUTE);
220+
if (exception == null) {
221+
exception = getAttribute(webRequest, RequestDispatcher.ERROR_EXCEPTION);
222+
}
223+
// store the exception in a well-known attribute to make it available to metrics
224+
// instrumentation.
225+
webRequest.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, exception, WebRequest.SCOPE_REQUEST);
226+
return exception;
221227
}
222228

223229
@SuppressWarnings("unchecked")

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/ErrorAttributes.java

+8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434
*/
3535
public interface ErrorAttributes {
3636

37+
/**
38+
* Name of the {@link javax.servlet.http.HttpServletRequest#getAttribute(String)
39+
* Request attribute} holding the error resolved by the {@code ErrorAttributes}
40+
* implementation.
41+
* @since 2.5.0
42+
*/
43+
String ERROR_ATTRIBUTE = ErrorAttributes.class.getName() + ".error";
44+
3745
/**
3846
* Returns a {@link Map} of the error attributes. The map can be used as the model of
3947
* an error page {@link ModelAndView}, or returned as a

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java

+3
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ void includeException() {
160160
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(serverRequest,
161161
ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE));
162162
assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error);
163+
assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error);
163164
assertThat(attributes.get("exception")).isEqualTo(RuntimeException.class.getName());
164165
assertThat(attributes.get("message")).isEqualTo("Test");
165166
}
@@ -177,6 +178,7 @@ void processResponseStatusException() {
177178
assertThat(attributes.get("message")).isEqualTo("invalid request");
178179
assertThat(attributes.get("exception")).isEqualTo(RuntimeException.class.getName());
179180
assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error);
181+
assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error);
180182
}
181183

182184
@Test
@@ -192,6 +194,7 @@ void processResponseStatusExceptionWithNoNestedCause() {
192194
assertThat(attributes.get("message")).isEqualTo("could not process request");
193195
assertThat(attributes.get("exception")).isEqualTo(ResponseStatusException.class.getName());
194196
assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error);
197+
assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error);
195198
}
196199

197200
@Test

0 commit comments

Comments
 (0)