Write TraceId in HTTP Response headers

Prior to this commit, the Micrometer instrumentation support would
auto-configure a `ServerHttpObservationFilter` for creating observations
in Spring MVC applications.

As of Spring Framework 6.2, applications can extend this filter class to
get notified of the observation scope being opened.
This commit contributes a new `TraceHeaderObservationFilter`
implementation that writes the current Trace Id (if present) to the
`X-Trace-Id` HTTP response header.

This feature is disabled by default, applications will need to enable
`management.observations.http.server.requests.write-trace-header`.
`
Closes gh-40857
This commit is contained in:
Brian Clozel 2025-02-10 17:51:00 +01:00
parent f5f888dbdd
commit afcc780e61
8 changed files with 323 additions and 27 deletions

View File

@ -136,6 +136,7 @@ dependencies {
testImplementation(project(":spring-boot-project:spring-boot-test")) testImplementation(project(":spring-boot-project:spring-boot-test"))
testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
testImplementation("io.micrometer:micrometer-observation-test") testImplementation("io.micrometer:micrometer-observation-test")
testImplementation("io.micrometer:micrometer-tracing-test")
testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor:reactor-test")
testImplementation("io.prometheus:prometheus-metrics-exposition-formats") testImplementation("io.prometheus:prometheus-metrics-exposition-formats")
testImplementation("io.r2dbc:r2dbc-h2") testImplementation("io.r2dbc:r2dbc-h2")

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2024 the original author or authors. * Copyright 2012-2025 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -127,6 +127,11 @@ public class ObservationProperties {
*/ */
private String name = "http.server.requests"; private String name = "http.server.requests";
/**
* Whether to write the "X-Trace-Id" HTTP response header.
*/
private boolean writeTraceHeader = false;
public String getName() { public String getName() {
return this.name; return this.name;
} }
@ -135,6 +140,14 @@ public class ObservationProperties {
this.name = name; this.name = name;
} }
public boolean isWriteTraceHeader() {
return this.writeTraceHeader;
}
public void setWriteTraceHeader(boolean writeTraceHeader) {
this.writeTraceHeader = writeTraceHeader;
}
} }
} }

View File

@ -0,0 +1,89 @@
/*
* Copyright 2012-2025 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.boot.actuate.autoconfigure.observation.web.servlet;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Tracer;
import jakarta.servlet.DispatcherType;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.servlet.ConditionalOnMissingFilterBean;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
import org.springframework.http.server.observation.ServerRequestObservationConvention;
import org.springframework.web.filter.ServerHttpObservationFilter;
/**
* Observation filter configurations imported by
* {@link WebMvcObservationAutoConfiguration}.
*
* @author Brian Clozel
*/
abstract class ObservationFilterConfigurations {
static <T extends ServerHttpObservationFilter> FilterRegistrationBean<T> filterRegistration(T filter) {
FilterRegistrationBean<T> registration = new FilterRegistrationBean<>(filter);
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC);
return registration;
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Tracer.class)
static class TracingHeaderObservation {
@Bean
@ConditionalOnProperty(prefix = "management.observations.http.server.requests", name = "write-trace-header")
@ConditionalOnBean(Tracer.class)
@ConditionalOnMissingFilterBean({ ServerHttpObservationFilter.class, TraceHeaderObservationFilter.class })
FilterRegistrationBean<TraceHeaderObservationFilter> webMvcObservationFilter(ObservationRegistry registry,
Tracer tracer, ObjectProvider<ServerRequestObservationConvention> customConvention,
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getServer().getRequests().getName();
ServerRequestObservationConvention convention = customConvention
.getIfAvailable(() -> new DefaultServerRequestObservationConvention(name));
TraceHeaderObservationFilter filter = new TraceHeaderObservationFilter(tracer, registry, convention);
return filterRegistration(filter);
}
}
@Configuration(proxyBeanMethods = false)
static class DefaultObservation {
@Bean
@ConditionalOnMissingFilterBean({ ServerHttpObservationFilter.class, TraceHeaderObservationFilter.class })
FilterRegistrationBean<ServerHttpObservationFilter> webMvcObservationFilter(ObservationRegistry registry,
ObjectProvider<ServerRequestObservationConvention> customConvention,
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getServer().getRequests().getName();
ServerRequestObservationConvention convention = customConvention
.getIfAvailable(() -> new DefaultServerRequestObservationConvention(name));
ServerHttpObservationFilter filter = new ServerHttpObservationFilter(registry, convention);
return filterRegistration(filter);
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2012-2025 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.boot.actuate.autoconfigure.observation.web.servlet;
import io.micrometer.observation.Observation.Scope;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.server.observation.ServerRequestObservationConvention;
import org.springframework.util.Assert;
import org.springframework.web.filter.ServerHttpObservationFilter;
/**
* {@link ServerHttpObservationFilter} that writes the current {@link Span} in an HTTP
* response header. By default, the {@code "X-Trace-Id"} header is used.
*
* @author Brian Clozel
* @since 3.5.0
*/
public class TraceHeaderObservationFilter extends ServerHttpObservationFilter {
private static final String TRACE_ID_HEADER_NAME = "X-Trace-Id";
private final Tracer tracer;
/**
* Create a {@link TraceHeaderObservationFilter} that will write the
* {@code "X-Trace-Id"} HTTP response header.
* @param tracer the current tracer
* @param observationRegistry the current observation registry
*/
public TraceHeaderObservationFilter(Tracer tracer, ObservationRegistry observationRegistry) {
super(observationRegistry);
Assert.notNull(tracer, "Tracer must not be null");
this.tracer = tracer;
}
/**
* Create a {@link TraceHeaderObservationFilter} that will write the
* {@code "X-Trace-Id"} HTTP response header.
* @param tracer the current tracer
* @param observationRegistry the current observation registry
* @param observationConvention the custom observation convention to use.
*/
public TraceHeaderObservationFilter(Tracer tracer, ObservationRegistry observationRegistry,
ServerRequestObservationConvention observationConvention) {
super(observationRegistry, observationConvention);
Assert.notNull(tracer, "Tracer must not be null");
this.tracer = tracer;
}
@Override
protected void onScopeOpened(Scope scope, HttpServletRequest request, HttpServletResponse response) {
Span currentSpan = this.tracer.currentSpan();
if (currentSpan != null && !currentSpan.isNoop()) {
response.setHeader(TRACE_ID_HEADER_NAME, currentSpan.context().traceId());
}
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2023 the original author or authors. * Copyright 2012-2025 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -20,9 +20,7 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.observation.Observation; import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.ObservationRegistry;
import jakarta.servlet.DispatcherType;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
@ -35,16 +33,11 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.web.servlet.ConditionalOnMissingFilterBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
import org.springframework.http.server.observation.ServerRequestObservationConvention;
import org.springframework.web.filter.ServerHttpObservationFilter;
import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.DispatcherServlet;
/** /**
@ -62,23 +55,10 @@ import org.springframework.web.servlet.DispatcherServlet;
@ConditionalOnClass({ DispatcherServlet.class, Observation.class }) @ConditionalOnClass({ DispatcherServlet.class, Observation.class })
@ConditionalOnBean(ObservationRegistry.class) @ConditionalOnBean(ObservationRegistry.class)
@EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class }) @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
@Import({ ObservationFilterConfigurations.TracingHeaderObservation.class,
ObservationFilterConfigurations.DefaultObservation.class })
public class WebMvcObservationAutoConfiguration { public class WebMvcObservationAutoConfiguration {
@Bean
@ConditionalOnMissingFilterBean
public FilterRegistrationBean<ServerHttpObservationFilter> webMvcObservationFilter(ObservationRegistry registry,
ObjectProvider<ServerRequestObservationConvention> customConvention,
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getServer().getRequests().getName();
ServerRequestObservationConvention convention = customConvention
.getIfAvailable(() -> new DefaultServerRequestObservationConvention(name));
ServerHttpObservationFilter filter = new ServerHttpObservationFilter(registry, convention);
FilterRegistrationBean<ServerHttpObservationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC);
return registration;
}
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@ConditionalOnClass(MeterRegistry.class) @ConditionalOnClass(MeterRegistry.class)
@ConditionalOnBean(MeterRegistry.class) @ConditionalOnBean(MeterRegistry.class)

View File

@ -0,0 +1,66 @@
/*
* Copyright 2012-2025 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.boot.actuate.autoconfigure.observation.web.servlet;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
import io.micrometer.tracing.test.simple.SimpleTracer;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link TraceHeaderObservationFilter}.
*/
class TraceHeaderObservationFilterTests {
TestObservationRegistry observationRegistry = TestObservationRegistry.create();
@Test
void shouldWriteTraceHeaderWhenCurrentTrace() throws Exception {
TraceHeaderObservationFilter filter = createFilter(new SimpleTracer());
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(new MockHttpServletRequest(), response, getFilterChain());
assertThat(response.getHeader("X-Trace-Id")).isNotEmpty();
}
@Test
void shouldNotWriteTraceHeaderWhenNoCurrentTrace() throws Exception {
TraceHeaderObservationFilter filter = createFilter(Tracer.NOOP);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(new MockHttpServletRequest(), response, getFilterChain());
assertThat(response.getHeaderNames()).doesNotContain("X-Trace-Id");
}
private TraceHeaderObservationFilter createFilter(Tracer tracer) {
this.observationRegistry.observationConfig().observationHandler(new DefaultTracingObservationHandler(tracer));
return new TraceHeaderObservationFilter(tracer, this.observationRegistry);
}
private static FilterChain getFilterChain() {
return (servletRequest, servletResponse) -> servletResponse.getWriter().print("Hello");
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2024 the original author or authors. * Copyright 2012-2025 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -20,6 +20,7 @@ import java.util.EnumSet;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.tracing.Tracer;
import jakarta.servlet.DispatcherType; import jakarta.servlet.DispatcherType;
import jakarta.servlet.Filter; import jakarta.servlet.Filter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -29,6 +30,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfigu
import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController; import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController;
import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.tracing.NoopTracerAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
@ -75,7 +77,8 @@ class WebMvcObservationAutoConfigurationTests {
this.contextRunner.run((context) -> { this.contextRunner.run((context) -> {
assertThat(context).hasSingleBean(FilterRegistrationBean.class); assertThat(context).hasSingleBean(FilterRegistrationBean.class);
assertThat(context.getBean(FilterRegistrationBean.class).getFilter()) assertThat(context.getBean(FilterRegistrationBean.class).getFilter())
.isInstanceOf(ServerHttpObservationFilter.class); .isInstanceOf(ServerHttpObservationFilter.class)
.isNotInstanceOf(TraceHeaderObservationFilter.class);
}); });
} }
@ -126,6 +129,50 @@ class WebMvcObservationAutoConfigurationTests {
.run((context) -> assertThat(context).hasBean("testFilter").hasBean("webMvcObservationFilter")); .run((context) -> assertThat(context).hasBean("testFilter").hasBean("webMvcObservationFilter"));
} }
@Test
void usesTracingFilterWhenTracingIsPresentAndEnabled() {
this.contextRunner.withConfiguration(AutoConfigurations.of(NoopTracerAutoConfiguration.class))
.withPropertyValues("management.observations.http.server.requests.write-trace-header=true")
.run((context) -> {
assertThat(context).hasSingleBean(FilterRegistrationBean.class);
assertThat(context.getBean(FilterRegistrationBean.class).getFilter())
.isInstanceOf(TraceHeaderObservationFilter.class);
});
}
@Test
void tracingFilterRegistrationHasExpectedDispatcherTypesAndOrder() {
this.contextRunner.withConfiguration(AutoConfigurations.of(NoopTracerAutoConfiguration.class))
.withPropertyValues("management.observations.http.server.requests.write-trace-header=true")
.run((context) -> {
FilterRegistrationBean<?> registration = context.getBean(FilterRegistrationBean.class);
assertThat(registration).hasFieldOrPropertyWithValue("dispatcherTypes",
EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
assertThat(registration.getOrder()).isEqualTo(Ordered.HIGHEST_PRECEDENCE + 1);
});
}
@Test
void filterRegistrationBacksOffWithAnotherTraceHeaderObservationFilterRegistration() {
this.contextRunner.withConfiguration(AutoConfigurations.of(NoopTracerAutoConfiguration.class))
.withPropertyValues("management.observations.http.server.requests.write-trace-header=true")
.withUserConfiguration(TestTraceHeaderObservationFilterRegistrationConfiguration.class)
.run((context) -> {
assertThat(context).hasSingleBean(FilterRegistrationBean.class);
assertThat(context.getBean(FilterRegistrationBean.class))
.isSameAs(context.getBean("testTraceHeaderObservationFilter"));
});
}
@Test
void filterRegistrationBacksOffWithAnothertestTraceHeaderObservationFilter() {
this.contextRunner.withConfiguration(AutoConfigurations.of(NoopTracerAutoConfiguration.class))
.withPropertyValues("management.observations.http.server.requests.write-trace-header=true")
.withUserConfiguration(TestTraceHeaderObservationFilterConfiguration.class)
.run((context) -> assertThat(context).doesNotHaveBean(FilterRegistrationBean.class)
.hasSingleBean(TraceHeaderObservationFilter.class));
}
@Test @Test
void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) { void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
this.contextRunner.withUserConfiguration(TestController.class) this.contextRunner.withUserConfiguration(TestController.class)
@ -202,6 +249,27 @@ class WebMvcObservationAutoConfigurationTests {
} }
@Configuration(proxyBeanMethods = false)
static class TestTraceHeaderObservationFilterRegistrationConfiguration {
@Bean
@SuppressWarnings("unchecked")
FilterRegistrationBean<TraceHeaderObservationFilter> testTraceHeaderObservationFilter() {
return mock(FilterRegistrationBean.class);
}
}
@Configuration(proxyBeanMethods = false)
static class TestTraceHeaderObservationFilterConfiguration {
@Bean
TraceHeaderObservationFilter testTraceHeaderObservationFilter() {
return new TraceHeaderObservationFilter(Tracer.NOOP, TestObservationRegistry.create());
}
}
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
static class TestFilterRegistrationConfiguration { static class TestFilterRegistrationConfiguration {

View File

@ -775,6 +775,8 @@ See the {url-spring-framework-docs}/integration/observability.html#observability
To add to the default tags, provide a javadoc:org.springframework.context.annotation.Bean[format=annotation] that extends javadoc:org.springframework.http.server.observation.DefaultServerRequestObservationConvention[] from the `org.springframework.http.server.observation` package. To add to the default tags, provide a javadoc:org.springframework.context.annotation.Bean[format=annotation] that extends javadoc:org.springframework.http.server.observation.DefaultServerRequestObservationConvention[] from the `org.springframework.http.server.observation` package.
To replace the default tags, provide a javadoc:org.springframework.context.annotation.Bean[format=annotation] that implements javadoc:org.springframework.http.server.observation.ServerRequestObservationConvention[]. To replace the default tags, provide a javadoc:org.springframework.context.annotation.Bean[format=annotation] that implements javadoc:org.springframework.http.server.observation.ServerRequestObservationConvention[].
If the application is using xref:actuator/tracing.adoc#actuator.micrometer-tracing[Tracing], you can configure the HTTP server observations to print an `X-Trace-Id`
HTTP response header containing the current trace Id. For that, you will need to enable the following configuration property: configprop:management.observations.http.server.requests.write-trace-header[].
TIP: In some cases, exceptions handled in web controllers are not recorded as request metrics tags. TIP: In some cases, exceptions handled in web controllers are not recorded as request metrics tags.
Applications can opt in and record exceptions by xref:web/servlet.adoc#web.servlet.spring-mvc.error-handling[setting handled exceptions as request attributes]. Applications can opt in and record exceptions by xref:web/servlet.adoc#web.servlet.spring-mvc.error-handling[setting handled exceptions as request attributes].