Skip to content

Commit 96a429a

Browse files
committed
Move reactive server instrumentation out of WebFilter
Prior to this commit, the Observation instrumentation for Reactive server applications was implemented with a `WebFilter`. This allowed to record observations and set up a tracing context for the controller handlers. The limitation of this approach is that all processing happening at a lower level is not aware of any observation. Here, the `HttpWebHandlerAdapter` handles several interesting aspects: * logging of HTTP requests and responses at the TRACE level * logging of client disconnect errors * handling of unresolved errors With the current instrumentation, these logging statements will miss the tracing context information. As a result, this commit deprecates the `ServerHttpObservationFilter` in favor of a more direct instrumentation of the `HttpWebHandlerAdapter`. This enables a more precise instrumentattion and allows to set up the current observation earlier in the reactor context: log statements will now contain the relevant information. Fixes gh-30013
1 parent e5ee369 commit 96a429a

File tree

16 files changed

+419
-31
lines changed

16 files changed

+419
-31
lines changed

framework-docs/modules/ROOT/pages/integration/observability.adoc

+7-3
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,15 @@ By default, the following `KeyValues` are created:
121121
[[observability.http-server.reactive]]
122122
=== Reactive applications
123123

124-
Applications need to configure the `org.springframework.web.filter.reactive.ServerHttpObservationFilter` reactive `WebFilter` in their application.
124+
Applications need to configure the `WebHttpHandlerBuilder` with a `MeterRegistry` to enable server instrumentation.
125+
This can be done on the `WebHttpHandlerBuilder`, as follows:
126+
127+
include::code:HttpHandlerConfiguration[]
128+
125129
It is using the `org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention` by default, backed by the `ServerRequestObservationContext`.
126130

127-
This will only record an observation as an error if the `Exception` has not been handled by the web Framework and has bubbled up to the `WebFilter`.
128-
Typically, all exceptions handled by Spring WebFlux's `@ExceptionHandler` and xref:web/webflux/ann-rest-exceptions.adoc[`ProblemDetail` support] will not be recorded with the observation.
131+
This will only record an observation as an error if the `Exception` has not been handled by an application Controller.
132+
Typically, all exceptions handled by Spring WebFlux's `@ExceptionHandler` and <<web.adoc#webflux-ann-rest-exceptions,`ProblemDetail` support>> will not be recorded with the observation.
129133
You can, at any point during request processing, set the error field on the `ObservationContext` yourself:
130134

131135
include-code::./UserController[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2002-2023 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.docs.integration.observability.httpserver.reactive;
18+
19+
import io.micrometer.observation.ObservationRegistry;
20+
21+
import org.springframework.context.ApplicationContext;
22+
import org.springframework.context.annotation.Bean;
23+
import org.springframework.context.annotation.Configuration;
24+
import org.springframework.http.server.reactive.HttpHandler;
25+
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
26+
27+
@Configuration(proxyBeanMethods = false)
28+
public class HttpHandlerConfiguration {
29+
30+
private final ApplicationContext applicationContext;
31+
32+
public HttpHandlerConfiguration(ApplicationContext applicationContext) {
33+
this.applicationContext = applicationContext;
34+
}
35+
36+
@Bean
37+
public HttpHandler httpHandler(ObservationRegistry registry) {
38+
return WebHttpHandlerBuilder.applicationContext(this.applicationContext)
39+
.observationRegistry(registry)
40+
.build();
41+
}
42+
}

framework-docs/src/main/java/org/springframework/docs/integration/observability/httpserver/reactive/UserController.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
package org.springframework.docs.integration.observability.httpserver.reactive;
1818

1919
import org.springframework.http.ResponseEntity;
20+
import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
2021
import org.springframework.stereotype.Controller;
2122
import org.springframework.web.bind.annotation.ExceptionHandler;
22-
import org.springframework.web.filter.reactive.ServerHttpObservationFilter;
2323
import org.springframework.web.server.ServerWebExchange;
2424

2525
@Controller
@@ -28,7 +28,7 @@ public class UserController {
2828
@ExceptionHandler(MissingUserException.class)
2929
ResponseEntity<Void> handleMissingUser(ServerWebExchange exchange, MissingUserException exception) {
3030
// We want to record this exception with the observation
31-
ServerHttpObservationFilter.findObservationContext(exchange)
31+
ServerRequestObservationContext.findCurrent(exchange)
3232
.ifPresent(context -> context.setError(exception));
3333
return ResponseEntity.notFound().build();
3434
}

spring-web/src/main/java/org/springframework/http/server/reactive/observation/ServerRequestObservationContext.java

+18
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818

1919
import java.util.Collections;
2020
import java.util.Map;
21+
import java.util.Optional;
2122

2223
import io.micrometer.observation.transport.RequestReplyReceiverContext;
2324

2425
import org.springframework.http.server.reactive.ServerHttpRequest;
2526
import org.springframework.http.server.reactive.ServerHttpResponse;
2627
import org.springframework.lang.Nullable;
28+
import org.springframework.web.server.ServerWebExchange;
2729

2830
/**
2931
* Context that holds information for metadata collection regarding
@@ -36,6 +38,12 @@
3638
*/
3739
public class ServerRequestObservationContext extends RequestReplyReceiverContext<ServerHttpRequest, ServerHttpResponse> {
3840

41+
/**
42+
* Name of the request attribute holding the {@link ServerRequestObservationContext context} for the current observation.
43+
* @since 6.1.0
44+
*/
45+
public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = ServerRequestObservationContext.class.getName() + ".context";
46+
3947
private final Map<String, Object> attributes;
4048

4149
@Nullable
@@ -50,6 +58,16 @@ public ServerRequestObservationContext(ServerHttpRequest request, ServerHttpResp
5058
this.attributes = Collections.unmodifiableMap(attributes);
5159
}
5260

61+
/**
62+
* Get the current {@link ServerRequestObservationContext observation context} from the given exchange, if available.
63+
* @param exchange the current exchange
64+
* @return the current observation context
65+
* @since 6.1.0
66+
*/
67+
public static Optional<ServerRequestObservationContext> findCurrent(ServerWebExchange exchange) {
68+
return Optional.ofNullable(exchange.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE));
69+
}
70+
5371
/**
5472
* Return an immutable map of the current request attributes.
5573
*/

spring-web/src/main/java/org/springframework/web/filter/reactive/ServerHttpObservationFilter.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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 org.springframework.web.server.ServerWebExchange;
3434
import org.springframework.web.server.WebFilter;
3535
import org.springframework.web.server.WebFilterChain;
36+
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
3637

3738

3839
/**
@@ -47,7 +48,9 @@
4748
*
4849
* @author Brian Clozel
4950
* @since 6.0
51+
* @deprecated since 6.1.0 in favor of {@link WebHttpHandlerBuilder}.
5052
*/
53+
@Deprecated(since = "6.1.0", forRemoval = true)
5154
public class ServerHttpObservationFilter implements WebFilter {
5255

5356
/**

spring-web/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java

+100-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -20,8 +20,12 @@
2020
import java.util.Set;
2121
import java.util.function.Function;
2222

23+
import io.micrometer.observation.Observation;
24+
import io.micrometer.observation.ObservationRegistry;
25+
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
2326
import org.apache.commons.logging.Log;
2427
import org.apache.commons.logging.LogFactory;
28+
import org.reactivestreams.Publisher;
2529
import reactor.core.publisher.Mono;
2630

2731
import org.springframework.context.ApplicationContext;
@@ -36,11 +40,16 @@
3640
import org.springframework.http.server.reactive.HttpHandler;
3741
import org.springframework.http.server.reactive.ServerHttpRequest;
3842
import org.springframework.http.server.reactive.ServerHttpResponse;
43+
import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention;
44+
import org.springframework.http.server.reactive.observation.ServerHttpObservationDocumentation;
45+
import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
46+
import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention;
3947
import org.springframework.lang.Nullable;
4048
import org.springframework.util.Assert;
4149
import org.springframework.util.StringUtils;
4250
import org.springframework.web.server.ServerWebExchange;
4351
import org.springframework.web.server.WebHandler;
52+
import org.springframework.web.server.handler.ExceptionHandlingWebHandler;
4453
import org.springframework.web.server.handler.WebHandlerDecorator;
4554
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
4655
import org.springframework.web.server.i18n.LocaleContextResolver;
@@ -55,6 +64,7 @@
5564
*
5665
* @author Rossen Stoyanchev
5766
* @author Sebastien Deleuze
67+
* @author Brian Clozel
5868
* @since 5.0
5969
*/
6070
public class HttpWebHandlerAdapter extends WebHandlerDecorator implements HttpHandler {
@@ -75,6 +85,8 @@ public class HttpWebHandlerAdapter extends WebHandlerDecorator implements HttpHa
7585
private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS =
7686
Set.of("AbortedException", "ClientAbortException", "EOFException", "EofException");
7787

88+
private static final ServerRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultServerRequestObservationConvention();
89+
7890

7991
private static final Log logger = LogFactory.getLog(HttpWebHandlerAdapter.class);
8092

@@ -91,6 +103,12 @@ public class HttpWebHandlerAdapter extends WebHandlerDecorator implements HttpHa
91103
@Nullable
92104
private ForwardedHeaderTransformer forwardedHeaderTransformer;
93105

106+
@Nullable
107+
private ObservationRegistry observationRegistry;
108+
109+
@Nullable
110+
private ServerRequestObservationConvention observationConvention;
111+
94112
@Nullable
95113
private ApplicationContext applicationContext;
96114

@@ -192,6 +210,44 @@ public ForwardedHeaderTransformer getForwardedHeaderTransformer() {
192210
return this.forwardedHeaderTransformer;
193211
}
194212

213+
/**
214+
* Configure a {@link ObservationRegistry} for recording server exchange observations.
215+
* By default, a {@link ObservationRegistry#NOOP no-op} instance will be used.
216+
* @param observationRegistry the observation registry to use
217+
* @since 6.1.0
218+
*/
219+
public void setObservationRegistry(ObservationRegistry observationRegistry) {
220+
this.observationRegistry = observationRegistry;
221+
}
222+
223+
/**
224+
* Return the configured {@link ObservationRegistry}.
225+
* @since 6.1.0
226+
*/
227+
@Nullable
228+
public ObservationRegistry getObservationRegistry() {
229+
return this.observationRegistry;
230+
}
231+
232+
/**
233+
* Configure a {@link ServerRequestObservationConvention} for server exchanges observations.
234+
* By default, a {@link DefaultServerRequestObservationConvention} instance will be used.
235+
* @param observationConvention the observation convention to use
236+
* @since 6.1.0
237+
*/
238+
public void setObservationConvention(ServerRequestObservationConvention observationConvention) {
239+
this.observationConvention = observationConvention;
240+
}
241+
242+
/**
243+
* Return the Observation convention configured for server exchanges observations.
244+
* @since 6.1.0
245+
*/
246+
@Nullable
247+
public ServerRequestObservationConvention getObservationConvention() {
248+
return this.observationConvention;
249+
}
250+
195251
/**
196252
* Configure the {@code ApplicationContext} associated with the web application,
197253
* if it was initialized with one via
@@ -247,9 +303,12 @@ public Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response)
247303
exchange.getLogPrefix() + formatRequest(exchange.getRequest()) +
248304
(traceOn ? ", headers=" + formatHeaders(exchange.getRequest().getHeaders()) : ""));
249305

306+
ServerRequestObservationContext observationContext = new ServerRequestObservationContext(exchange.getRequest(),
307+
exchange.getResponse(), exchange.getAttributes());
308+
exchange.getAttributes().put(ServerRequestObservationContext.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observationContext);
309+
250310
return getDelegate().handle(exchange)
251-
.doOnSuccess(aVoid -> logResponse(exchange))
252-
.onErrorResume(ex -> handleUnresolvedError(exchange, ex))
311+
.transformDeferred(call -> transform(exchange, observationContext, call))
253312
.then(cleanupMultipart(exchange))
254313
.then(Mono.defer(response::setComplete));
255314
}
@@ -271,6 +330,42 @@ protected String formatRequest(ServerHttpRequest request) {
271330
return "HTTP " + request.getMethod() + " \"" + request.getPath() + query + "\"";
272331
}
273332

333+
private Publisher<Void> transform(ServerWebExchange exchange, ServerRequestObservationContext observationContext, Mono<Void> call) {
334+
Observation observation = ServerHttpObservationDocumentation.HTTP_REACTIVE_SERVER_REQUESTS.observation(this.observationConvention,
335+
DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry);
336+
observation.start();
337+
return call
338+
.doOnSuccess(aVoid -> {
339+
logResponse(exchange);
340+
stopObservation(observation, exchange);
341+
})
342+
.onErrorResume(ex -> handleUnresolvedError(exchange, observationContext, ex))
343+
.doOnCancel(() -> cancelObservation(observationContext, observation))
344+
.contextWrite(context -> context.put(ObservationThreadLocalAccessor.KEY, observation));
345+
}
346+
347+
private void stopObservation(Observation observation, ServerWebExchange exchange) {
348+
Throwable throwable = exchange.getAttribute(ExceptionHandlingWebHandler.HANDLED_WEB_EXCEPTION);
349+
if (throwable != null) {
350+
observation.error(throwable);
351+
}
352+
ServerHttpResponse response = exchange.getResponse();
353+
if (response.isCommitted()) {
354+
observation.stop();
355+
}
356+
else {
357+
response.beforeCommit(() -> {
358+
observation.stop();
359+
return Mono.empty();
360+
});
361+
}
362+
}
363+
364+
private void cancelObservation(ServerRequestObservationContext observationContext, Observation observation) {
365+
observationContext.setConnectionAborted(true);
366+
observation.stop();
367+
}
368+
274369
private void logResponse(ServerWebExchange exchange) {
275370
LogFormatUtils.traceDebug(logger, traceOn -> {
276371
HttpStatusCode status = exchange.getResponse().getStatusCode();
@@ -284,7 +379,7 @@ private String formatHeaders(HttpHeaders responseHeaders) {
284379
responseHeaders.toString() : responseHeaders.isEmpty() ? "{}" : "{masked}";
285380
}
286381

287-
private Mono<Void> handleUnresolvedError(ServerWebExchange exchange, Throwable ex) {
382+
private Mono<Void> handleUnresolvedError(ServerWebExchange exchange, ServerRequestObservationContext observationContext, Throwable ex) {
288383
ServerHttpRequest request = exchange.getRequest();
289384
ServerHttpResponse response = exchange.getResponse();
290385
String logPrefix = exchange.getLogPrefix();
@@ -304,6 +399,7 @@ else if (lostClientLogger.isDebugEnabled()) {
304399
lostClientLogger.debug(logPrefix + "Client went away: " + ex +
305400
" (stacktrace at TRACE level for '" + DISCONNECTED_CLIENT_LOG_CATEGORY + "')");
306401
}
402+
observationContext.setConnectionAborted(true);
307403
return Mono.empty();
308404
}
309405
else {

0 commit comments

Comments
 (0)