diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConvention.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConvention.java new file mode 100644 index 0000000000..897cf0003e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConvention.java @@ -0,0 +1,145 @@ +/* + * 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.reactive; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +import org.springframework.http.HttpStatus; +import org.springframework.web.util.pattern.PathPattern; + +/** + * Default {@link HttpRequestsObservationConvention}. + * + * @author Brian Clozel + * @since 6.0 + */ +public class DefaultHttpRequestsObservationConvention implements HttpRequestsObservationConvention { + + private static final String DEFAULT_NAME = "http.server.requests"; + + private static final KeyValue METHOD_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, "UNKNOWN"); + + private static final KeyValue STATUS_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, "UNKNOWN"); + + private static final KeyValue URI_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "UNKNOWN"); + + private static final KeyValue URI_ROOT = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "root"); + + private static final KeyValue URI_NOT_FOUND = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "NOT_FOUND"); + + private static final KeyValue URI_REDIRECTION = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.URI, "REDIRECTION"); + + private static final KeyValue EXCEPTION_NONE = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, "none"); + + private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, "UNKNOWN"); + + private static final KeyValue URI_EXPANDED_UNKNOWN = KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, "UNKNOWN"); + + private final String name; + + /** + * Create a convention with the default name {@code "http.server.requests"}. + */ + public DefaultHttpRequestsObservationConvention() { + this(DEFAULT_NAME); + } + + /** + * Create a convention with a custom name. + * + * @param name the observation name + */ + public DefaultHttpRequestsObservationConvention(String name) { + this.name = name; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public KeyValues getLowCardinalityKeyValues(HttpRequestsObservationContext context) { + return KeyValues.of(method(context), uri(context), status(context), exception(context), outcome(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(HttpRequestsObservationContext context) { + return KeyValues.of(uriExpanded(context)); + } + + protected KeyValue method(HttpRequestsObservationContext context) { + return (context.getCarrier() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.METHOD, context.getCarrier().getMethod().name()) : METHOD_UNKNOWN; + } + + protected KeyValue status(HttpRequestsObservationContext context) { + return (context.getResponse() != null) ? KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.STATUS, Integer.toString(context.getResponse().getStatusCode().value())) : STATUS_UNKNOWN; + } + + protected KeyValue uri(HttpRequestsObservationContext context) { + if (context.getCarrier() != null) { + PathPattern pattern = context.getPathPattern(); + if (pattern != null) { + if (pattern.toString().isEmpty()) { + return URI_ROOT; + } + return KeyValue.of("uri", pattern.toString()); + } + if (context.getResponse() != null) { + HttpStatus status = HttpStatus.resolve(context.getResponse().getStatusCode().value()); + if (status != null) { + if (status.is3xxRedirection()) { + return URI_REDIRECTION; + } + if (status == HttpStatus.NOT_FOUND) { + return URI_NOT_FOUND; + } + } + } + } + return URI_UNKNOWN; + } + + protected KeyValue exception(HttpRequestsObservationContext context) { + return context.getError().map(throwable -> + KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.EXCEPTION, throwable.getClass().getSimpleName())) + .orElse(EXCEPTION_NONE); + } + + protected KeyValue outcome(HttpRequestsObservationContext context) { + if (context.isConnectionAborted()) { + return OUTCOME_UNKNOWN; + } + else if (context.getResponse() != null) { + HttpStatus status = HttpStatus.resolve(context.getResponse().getStatusCode().value()); + if (status != null) { + return KeyValue.of(HttpRequestsObservation.LowCardinalityKeyNames.OUTCOME, status.series().name()); + } + } + return OUTCOME_UNKNOWN; + } + + protected KeyValue uriExpanded(HttpRequestsObservationContext context) { + if (context.getCarrier() != null) { + String uriExpanded = context.getCarrier().getPath().toString(); + return KeyValue.of(HttpRequestsObservation.HighCardinalityKeyNames.URI_EXPANDED, uriExpanded); + } + return URI_EXPANDED_UNKNOWN; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservation.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservation.java new file mode 100644 index 0000000000..1d886f09e7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservation.java @@ -0,0 +1,123 @@ +/* + * 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.reactive; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.docs.DocumentedObservation; + +/** + * Documented {@link io.micrometer.common.KeyValue KeyValues} for the HTTP server observations + * for Servlet-based web applications. + *

This class is used by automated tools to document KeyValues attached to the HTTP server observations. + * @author Brian Clozel + * @since 6.0 + */ +public enum HttpRequestsObservation implements DocumentedObservation { + + /** + * HTTP server request observations. + */ + HTTP_REQUESTS { + @Override + public Class> getDefaultConvention() { + return DefaultHttpRequestsObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * Name of HTTP request method or {@code "None"} if the request was not received properly. + */ + METHOD { + @Override + public String asString() { + return "method"; + } + + }, + + /** + * HTTP response raw status code, or {@code "STATUS_UNKNOWN"} if no response was created. + */ + STATUS { + @Override + public String asString() { + return "status"; + } + }, + + /** + * URI pattern for the matching handler if available, falling back to {@code REDIRECTION} for 3xx responses, + * {@code NOT_FOUND} for 404 responses, {@code root} for requests with no path info, + * and {@code UNKNOWN} for all other requests. + */ + URI { + @Override + public String asString() { + return "uri"; + } + }, + + /** + * Name of the exception thrown during the exchange, or {@code "None"} if no exception happened. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + + /** + * Outcome of the HTTP server exchange. + * @see org.springframework.http.HttpStatus.Series + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + } + } + + public enum HighCardinalityKeyNames implements KeyName { + + /** + * HTTP request URI. + */ + URI_EXPANDED { + @Override + public String asString() { + return "uri.expanded"; + } + } + + } +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationContext.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationContext.java new file mode 100644 index 0000000000..685e1d6360 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationContext.java @@ -0,0 +1,81 @@ +/* + * 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.reactive; + +import io.micrometer.observation.transport.RequestReplyReceiverContext; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.lang.Nullable; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.pattern.PathPattern; + +/** + * Context that holds information for metadata collection during observations for reactive web applications. + *

This context also extends {@link RequestReplyReceiverContext} for propagating + * tracing information with the HTTP server exchange. + * @author Brian Clozel + * @since 6.0 + */ +public class HttpRequestsObservationContext extends RequestReplyReceiverContext { + + @Nullable + private PathPattern pathPattern; + + private boolean connectionAborted; + + public HttpRequestsObservationContext(ServerWebExchange exchange) { + super((request, key) -> request.getHeaders().getFirst(key)); + this.setCarrier(exchange.getRequest()); + this.setResponse(exchange.getResponse()); + } + + /** + * Return the path pattern for the handler that matches the current request. + * For example, {@code "/projects/{name}"}. + *

Path patterns must have a low cardinality for the entire application. + * @return the path pattern, or {@code null} if none found + */ + @Nullable + public PathPattern getPathPattern() { + return this.pathPattern; + } + + /** + * Set the path pattern for the handler that matches the current request. + *

Path patterns must have a low cardinality for the entire application. + * @param pathPattern the path pattern, for example {@code "/projects/{name}"}. + */ + public void setPathPattern(@Nullable PathPattern pathPattern) { + this.pathPattern = pathPattern; + } + + /** + * Whether the current connection was aborted by the client, resulting + * in a {@link reactor.core.publisher.SignalType#CANCEL cancel signal} on te reactive chain, + * or an {@code AbortedException} when reading the request. + * @return if the connection has been aborted + */ + public boolean isConnectionAborted() { + return this.connectionAborted; + } + + void setConnectionAborted(boolean connectionAborted) { + this.connectionAborted = connectionAborted; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationConvention.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationConvention.java new file mode 100644 index 0000000000..5a7cec838e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationConvention.java @@ -0,0 +1,33 @@ +/* + * 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.reactive; + +import io.micrometer.observation.Observation; + +/** + * Interface for an {@link Observation.ObservationConvention} related to reactive HTTP exchanges. + * @author Brian Clozel + * @since 6.0 + */ +public interface HttpRequestsObservationConvention extends Observation.ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof HttpRequestsObservationContext; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilter.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilter.java new file mode 100644 index 0000000000..060550010b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilter.java @@ -0,0 +1,132 @@ +/* + * 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.reactive; + +import java.util.Optional; +import java.util.Set; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + + +/** + * {@link org.springframework.web.server.WebFilter} that creates {@link Observation observations} + * for HTTP exchanges. This collects information about the execution time and + * information gathered from the {@link HttpRequestsObservationContext}. + *

Web Frameworks can fetch the current {@link HttpRequestsObservationContext context} + * as a {@link #CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE request attribute} and contribute + * additional information to it. + * The configured {@link HttpRequestsObservationConvention} will use this context to collect + * {@link io.micrometer.common.KeyValue metadata} and attach it to the observation. + * + * @author Brian Clozel + * @since 6.0 + */ +public class HttpRequestsObservationWebFilter implements WebFilter { + + /** + * Name of the request attribute holding the {@link HttpRequestsObservationContext context} for the current observation. + */ + public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = HttpRequestsObservationWebFilter.class.getName() + ".context"; + + private static final HttpRequestsObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultHttpRequestsObservationConvention(); + + private static final Set DISCONNECTED_CLIENT_EXCEPTIONS = Set.of("AbortedException", + "ClientAbortException", "EOFException", "EofException"); + + private final ObservationRegistry observationRegistry; + + private final HttpRequestsObservationConvention observationConvention; + + /** + * Create a {@code HttpRequestsObservationWebFilter} that records observations + * against the given {@link ObservationRegistry}. The default + * {@link DefaultHttpRequestsObservationConvention convention} will be used. + * @param observationRegistry the registry to use for recording observations + */ + public HttpRequestsObservationWebFilter(ObservationRegistry observationRegistry) { + this(observationRegistry, new DefaultHttpRequestsObservationConvention()); + } + + /** + * Create a {@code HttpRequestsObservationWebFilter} that records observations + * against the given {@link ObservationRegistry} with a custom convention. + * @param observationRegistry the registry to use for recording observations + * @param observationConvention the convention to use for all recorded observations + */ + public HttpRequestsObservationWebFilter(ObservationRegistry observationRegistry, HttpRequestsObservationConvention observationConvention) { + this.observationRegistry = observationRegistry; + this.observationConvention = observationConvention; + } + + /** + * Get the current {@link HttpRequestsObservationContext observation context} from the given request, if available. + * @param exchange the current exchange + * @return the current observation context + */ + public static Optional findObservationContext(ServerWebExchange exchange) { + return Optional.ofNullable(exchange.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)); + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + HttpRequestsObservationContext observationContext = new HttpRequestsObservationContext(exchange); + exchange.getAttributes().put(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observationContext); + return chain.filter(exchange).transformDeferred(call -> filter(exchange, observationContext, call)); + } + + private Publisher filter(ServerWebExchange exchange, HttpRequestsObservationContext observationContext, Mono call) { + Observation observation = Observation.createNotStarted(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, + observationContext, this.observationRegistry); + observation.start(); + return call.doOnEach(signal -> { + Throwable throwable = signal.getThrowable(); + if (throwable != null) { + if (DISCONNECTED_CLIENT_EXCEPTIONS.contains(throwable.getClass().getSimpleName())) { + observationContext.setConnectionAborted(true); + } + observationContext.setError(throwable); + } + onTerminalSignal(observation, exchange); + }) + .doOnCancel(() -> { + observationContext.setConnectionAborted(true); + observation.stop(); + }); + } + + private void onTerminalSignal(Observation observation, ServerWebExchange exchange) { + ServerHttpResponse response = exchange.getResponse(); + if (response.isCommitted()) { + observation.stop(); + } + else { + response.beforeCommit(() -> { + observation.stop(); + return Mono.empty(); + }); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/observation/reactive/package-info.java b/spring-web/src/main/java/org/springframework/web/observation/reactive/package-info.java new file mode 100644 index 0000000000..75a10dd5aa --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/observation/reactive/package-info.java @@ -0,0 +1,9 @@ +/** + * Instrumentation for {@link io.micrometer.observation.Observation observing} reactive web applications. + */ +@NonNullApi +@NonNullFields +package org.springframework.web.observation.reactive; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-web/src/test/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConventionTests.java b/spring-web/src/test/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConventionTests.java new file mode 100644 index 0000000000..446dead3ca --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/observation/reactive/DefaultHttpRequestsObservationConventionTests.java @@ -0,0 +1,142 @@ +/* + * 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.reactive; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultHttpRequestsObservationConvention}. + * @author Brian Clozel + */ +class DefaultHttpRequestsObservationConventionTests { + + private final DefaultHttpRequestsObservationConvention convention = new DefaultHttpRequestsObservationConvention(); + + + @Test + void shouldHaveDefaultName() { + assertThat(convention.getName()).isEqualTo("http.server.requests"); + } + + @Test + void supportsOnlyHttpRequestsObservationContext() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource")); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + assertThat(this.convention.supportsContext(context)).isTrue(); + assertThat(this.convention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void addsKeyValuesForExchange() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource")); + exchange.getResponse().setRawStatusCode(201); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + + assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5) + .contains(KeyValue.of("method", "POST"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "201"), + KeyValue.of("exception", "none"), KeyValue.of("outcome", "SUCCESSFUL")); + assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/resource")); + } + + @Test + void addsKeyValuesForExchangeWithPathPattern() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource")); + exchange.getResponse().setRawStatusCode(200); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + PathPattern pathPattern = getPathPattern("/test/{name}"); + context.setPathPattern(pathPattern); + + assertThat(this.convention.getLowCardinalityKeyValues(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(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/resource")); + } + + + @Test + void addsKeyValuesForErrorExchange() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource")); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + context.setError(new IllegalArgumentException("custom error")); + exchange.getResponse().setRawStatusCode(500); + + assertThat(this.convention.getLowCardinalityKeyValues(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(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/resource")); + } + + @Test + void addsKeyValuesForRedirectExchange() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/redirect")); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + exchange.getResponse().setRawStatusCode(302); + exchange.getResponse().getHeaders().add("Location", "https://example.org/other"); + + assertThat(this.convention.getLowCardinalityKeyValues(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(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/redirect")); + } + + @Test + void addsKeyValuesForNotFoundExchange() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/notFound")); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + exchange.getResponse().setRawStatusCode(404); + + assertThat(this.convention.getLowCardinalityKeyValues(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(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/notFound")); + } + + @Test + void addsKeyValuesForCancelledExchange() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource")); + HttpRequestsObservationContext context = new HttpRequestsObservationContext(exchange); + context.setConnectionAborted(true); + exchange.getResponse().setRawStatusCode(200); + + assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(5) + .contains(KeyValue.of("method", "GET"), KeyValue.of("uri", "UNKNOWN"), KeyValue.of("status", "200"), + KeyValue.of("exception", "none"), KeyValue.of("outcome", "UNKNOWN")); + assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(1) + .contains(KeyValue.of("uri.expanded", "/test/resource")); + } + + private static PathPattern getPathPattern(String pattern) { + PathPatternParser pathPatternParser = new PathPatternParser(); + return pathPatternParser.parse(pattern); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilterTests.java b/spring-web/src/test/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilterTests.java new file mode 100644 index 0000000000..b7a7a4964d --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/observation/reactive/HttpRequestsObservationWebFilterTests.java @@ -0,0 +1,104 @@ +/* + * 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.reactive; + + +import java.util.Optional; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpRequestsObservationWebFilter}. + * + * @author Brian Clozel + */ +class HttpRequestsObservationWebFilterTests { + + private final TestObservationRegistry observationRegistry = TestObservationRegistry.create(); + + private final HttpRequestsObservationWebFilter filter = new HttpRequestsObservationWebFilter(this.observationRegistry); + + @Test + void filterShouldFillObservationContext() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource")); + exchange.getResponse().setRawStatusCode(200); + WebFilterChain filterChain = createFilterChain(filterExchange -> { + Optional observationContext = HttpRequestsObservationWebFilter.findObservationContext(filterExchange); + assertThat(observationContext).isPresent(); + assertThat(observationContext.get().getCarrier()).isEqualTo(exchange.getRequest()); + assertThat(observationContext.get().getResponse()).isEqualTo(exchange.getResponse()); + }); + this.filter.filter(exchange, filterChain).block(); + assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESSFUL"); + } + + @Test + void filterShouldUseThrownException() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource")); + exchange.getResponse().setRawStatusCode(500); + WebFilterChain filterChain = createFilterChain(filterExchange -> { + throw new IllegalArgumentException("server error"); + }); + StepVerifier.create(this.filter.filter(exchange, filterChain)) + .expectError(IllegalArgumentException.class) + .verify(); + Optional observationContext = HttpRequestsObservationWebFilter.findObservationContext(exchange); + assertThat(observationContext.get().getError()).get().isInstanceOf(IllegalArgumentException.class); + assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SERVER_ERROR"); + } + + @Test + void filterShouldRecordObservationWhenCancelled() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/test/resource")); + exchange.getResponse().setRawStatusCode(200); + WebFilterChain filterChain = createFilterChain(filterExchange -> { + }); + StepVerifier.create(this.filter.filter(exchange, filterChain)) + .thenCancel() + .verify(); + assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN"); + } + + WebFilterChain createFilterChain(ThrowingConsumer exchangeConsumer) { + return filterExchange -> { + try { + exchangeConsumer.accept(filterExchange); + } + catch (Throwable ex) { + return Mono.error(ex); + } + return Mono.empty(); + }; + } + + private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { + return TestObservationRegistryAssert.assertThat(this.observationRegistry) + .hasObservationWithNameEqualTo("http.server.requests").that(); + } +}