diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerHttpObservationDocumentation.java b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerHttpObservationDocumentation.java new file mode 100644 index 000000000000..1b97868a2475 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerHttpObservationDocumentation.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-present 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.http.server.reactive.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * Documented {@link KeyValue KeyValues} for the HTTP server + * observations for reactive web applications, following the stable OpenTelemetry semantic conventions. + * + *
This class is used by automated tools to document KeyValues attached to the
+ * HTTP server observations.
+ *
+ * @author Brian Clozel
+ * @author Tommy Ludwig
+ * @since 7.1
+ * @see OpenTelemetry Semantic Conventions for HTTP Metrics (v1.36.0)
+ * @see OpenTelemetry Semantic Conventions for HTTP Spans (v1.36.0)
+ */
+public enum OpenTelemetryServerHttpObservationDocumentation implements ObservationDocumentation {
+
+ /**
+ * HTTP request observations for reactive servers.
+ */
+ HTTP_REACTIVE_SERVER_REQUESTS {
+ @Override
+ public Class extends ObservationConvention extends Observation.Context>> getDefaultConvention() {
+ return OpenTelemetryServerRequestObservationConvention.class;
+ }
+
+ @Override
+ public KeyName[] getLowCardinalityKeyNames() {
+ return LowCardinalityKeyNames.values();
+ }
+
+ @Override
+ public KeyName[] getHighCardinalityKeyNames() {
+ return HighCardinalityKeyNames.values();
+ }
+
+ };
+
+ public enum LowCardinalityKeyNames implements KeyName {
+
+ /**
+ * Name of the HTTP request method or {@code _OTHER} when the method is not a
+ * known HTTP method. Normalized to known methods defined in internet standards.
+ */
+ METHOD {
+ @Override
+ public String asString() {
+ return "http.request.method";
+ }
+ },
+
+ /**
+ * HTTP response raw status code, or {@code "UNKNOWN"} if no response was
+ * created.
+ */
+ STATUS {
+ @Override
+ public String asString() {
+ return "http.response.status_code";
+ }
+ },
+
+ /**
+ * 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.
+ */
+ ROUTE {
+ @Override
+ public String asString() {
+ return "http.route";
+ }
+ },
+
+ /**
+ * Fully qualified name of the exception thrown during the exchange, or
+ * {@value KeyValue#NONE_VALUE} if no exception was thrown.
+ */
+ EXCEPTION {
+ @Override
+ public String asString() {
+ return "error.type";
+ }
+ },
+
+ /**
+ * The scheme of the original client request, if known (e.g. from Forwarded#proto, X-Forwarded-Proto,
+ * or a similar header). Otherwise, the scheme of the immediate peer request.
+ */
+ SCHEME {
+ @Override
+ public String asString() {
+ return "url.scheme";
+ }
+ },
+
+ /**
+ * 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 URL.
+ */
+ URL_PATH {
+ @Override
+ public String asString() {
+ return "url.path";
+ }
+ },
+
+ /**
+ * Original HTTP method sent by the client in the request line.
+ */
+ METHOD_ORIGINAL {
+ @Override
+ public String asString() {
+ return "http.request.method_original";
+ }
+ }
+
+ }
+
+}
diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConvention.java
new file mode 100644
index 000000000000..dedf47847ea9
--- /dev/null
+++ b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConvention.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2002-present 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.http.server.reactive.observation;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.http.server.reactive.observation.OpenTelemetryServerHttpObservationDocumentation.HighCardinalityKeyNames;
+import org.springframework.http.server.reactive.observation.OpenTelemetryServerHttpObservationDocumentation.LowCardinalityKeyNames;
+
+/**
+ * A {@link ServerRequestObservationConvention} based on the stable OpenTelemetry semantic conventions.
+ *
+ * @author Brian Clozel
+ * @author Tommy Ludwig
+ * @since 7.1
+ * @see OpenTelemetryServerHttpObservationDocumentation
+ */
+public class OpenTelemetryServerRequestObservationConvention implements ServerRequestObservationConvention {
+
+ private static final String NAME = "http.server.request.duration";
+
+ private static final KeyValue METHOD_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.METHOD, "_OTHER");
+
+ private static final KeyValue SCHEME_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.SCHEME, "UNKNOWN");
+
+ private static final KeyValue STATUS_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.STATUS, "UNKNOWN");
+
+ private static final KeyValue HTTP_OUTCOME_SUCCESS = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "SUCCESS");
+
+ private static final KeyValue HTTP_OUTCOME_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "UNKNOWN");
+
+ private static final KeyValue ROUTE_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.ROUTE, "UNKNOWN");
+
+ private static final KeyValue ROUTE_ROOT = KeyValue.of(LowCardinalityKeyNames.ROUTE, "root");
+
+ private static final KeyValue ROUTE_NOT_FOUND = KeyValue.of(LowCardinalityKeyNames.ROUTE, "NOT_FOUND");
+
+ private static final KeyValue ROUTE_REDIRECTION = KeyValue.of(LowCardinalityKeyNames.ROUTE, "REDIRECTION");
+
+ private static final KeyValue EXCEPTION_NONE = KeyValue.of(LowCardinalityKeyNames.EXCEPTION, KeyValue.NONE_VALUE);
+
+ private static final KeyValue HTTP_URL_UNKNOWN = KeyValue.of(HighCardinalityKeyNames.URL_PATH, "UNKNOWN");
+
+ private static final KeyValue ORIGINAL_METHOD_UNKNOWN = KeyValue.of(HighCardinalityKeyNames.METHOD_ORIGINAL, "UNKNOWN");
+
+ private static final Set
+ * The {@code {method}} MUST be {@code {http.request.method}} if the method represents the original
+ * method known to the instrumentation. In other cases (when {@code {http.request.method}} is
+ * set to {@code _OTHER}), {@code {method}} MUST be HTTP.
+ *
+ * The {@code target} SHOULD be the {@code {http.route}}.
+ * @param context context
+ * @return contextual name
+ * @see OpenTelemetry Semantic Convention HTTP Span Name (v1.36.0)
+ */
+ @Override
+ public String getContextualName(ServerRequestObservationContext context) {
+ if (context.getCarrier() == null) {
+ return "HTTP";
+ }
+ String maybeMethod = getMethodValue(context);
+ String method = maybeMethod == null ? "HTTP" : maybeMethod;
+ String target = context.getPathPattern();
+ if (target != null) {
+ return method + " " + target;
+ }
+ return method;
+ }
+
+ @Override
+ public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
+ // Make sure that KeyValues entries are already sorted by name for better performance
+ return KeyValues.of(exception(context), method(context), status(context), pathTemplate(context), outcome(context), scheme(context));
+ }
+
+ @Override
+ public KeyValues getHighCardinalityKeyValues(ServerRequestObservationContext context) {
+ // Make sure that KeyValues entries are already sorted by name for better performance
+ return KeyValues.of(methodOriginal(context), httpUrl(context));
+ }
+
+ protected KeyValue method(ServerRequestObservationContext context) {
+ String method = getMethodValue(context);
+ if (method != null) {
+ return KeyValue.of(LowCardinalityKeyNames.METHOD, method);
+ }
+ return METHOD_UNKNOWN;
+ }
+
+ protected @Nullable String getMethodValue(ServerRequestObservationContext context) {
+ if (context.getCarrier() != null) {
+ HttpMethod httpMethod = context.getCarrier().getMethod();
+ if (httpMethod != null && HTTP_METHODS.contains(httpMethod.name())) {
+ return httpMethod.name();
+ }
+ }
+ return null;
+ }
+
+ protected KeyValue scheme(ServerRequestObservationContext context) {
+ if (context.getCarrier() != null) {
+ String scheme = context.getCarrier().getURI().getScheme();
+ if (scheme != null) {
+ return KeyValue.of(LowCardinalityKeyNames.SCHEME, scheme);
+ }
+ }
+ return SCHEME_UNKNOWN;
+ }
+
+ protected KeyValue status(ServerRequestObservationContext context) {
+ if (context.isConnectionAborted() && (context.getResponse() == null || !context.getResponse().isCommitted())) {
+ return STATUS_UNKNOWN;
+ }
+ ServerHttpResponse response = context.getResponse();
+ if (response != null && response.getStatusCode() != null) {
+ return KeyValue.of(LowCardinalityKeyNames.STATUS, Integer.toString(response.getStatusCode().value()));
+ }
+ return STATUS_UNKNOWN;
+ }
+
+ protected KeyValue pathTemplate(ServerRequestObservationContext context) {
+ if (context.getCarrier() != null) {
+ String pattern = context.getPathPattern();
+ if (pattern != null) {
+ if (pattern.isEmpty()) {
+ return ROUTE_ROOT;
+ }
+ return KeyValue.of(LowCardinalityKeyNames.ROUTE, pattern);
+ }
+ ServerHttpResponse response = context.getResponse();
+ if (response != null && response.getStatusCode() != null) {
+ HttpStatus status = HttpStatus.resolve(response.getStatusCode().value());
+ if (status != null) {
+ if (status.is3xxRedirection()) {
+ return ROUTE_REDIRECTION;
+ }
+ if (status == HttpStatus.NOT_FOUND) {
+ return ROUTE_NOT_FOUND;
+ }
+ }
+ }
+ }
+ return ROUTE_UNKNOWN;
+ }
+
+ protected KeyValue exception(ServerRequestObservationContext context) {
+ Throwable error = context.getError();
+ if (error != null) {
+ return KeyValue.of(LowCardinalityKeyNames.EXCEPTION, error.getClass().getName());
+ }
+ return EXCEPTION_NONE;
+ }
+
+ protected KeyValue outcome(ServerRequestObservationContext context) {
+ if (context.isConnectionAborted()) {
+ return HTTP_OUTCOME_UNKNOWN;
+ }
+ ServerHttpResponse response = context.getResponse();
+ if (response != null && response.getStatusCode() != null) {
+ return HttpOutcome.forStatus(response.getStatusCode());
+ }
+ return HTTP_OUTCOME_UNKNOWN;
+ }
+
+ protected KeyValue httpUrl(ServerRequestObservationContext context) {
+ if (context.getCarrier() != null) {
+ return KeyValue.of(HighCardinalityKeyNames.URL_PATH, context.getCarrier().getPath().toString());
+ }
+ return HTTP_URL_UNKNOWN;
+ }
+
+ protected KeyValue methodOriginal(ServerRequestObservationContext context) {
+ if (context.getCarrier() != null) {
+ HttpMethod method = context.getCarrier().getMethod();
+ if (method != null) {
+ return KeyValue.of(HighCardinalityKeyNames.METHOD_ORIGINAL, method.name());
+ }
+ }
+ return ORIGINAL_METHOD_UNKNOWN;
+ }
+
+ static class HttpOutcome {
+
+ static KeyValue forStatus(HttpStatusCode statusCode) {
+ if (statusCode.is2xxSuccessful()) {
+ return HTTP_OUTCOME_SUCCESS;
+ }
+ else if (statusCode.is3xxRedirection()) {
+ return KeyValue.of(LowCardinalityKeyNames.OUTCOME, HttpStatus.Series.REDIRECTION.name());
+ }
+ else if (statusCode.is4xxClientError()) {
+ return KeyValue.of(LowCardinalityKeyNames.OUTCOME, HttpStatus.Series.CLIENT_ERROR.name());
+ }
+ else if (statusCode.is5xxServerError()) {
+ return KeyValue.of(LowCardinalityKeyNames.OUTCOME, HttpStatus.Series.SERVER_ERROR.name());
+ }
+ else {
+ return HTTP_OUTCOME_UNKNOWN;
+ }
+ }
+ }
+
+}
diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConventionTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConventionTests.java
new file mode 100644
index 000000000000..4c14d9e97a95
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConventionTests.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright 2002-present 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.http.server.reactive.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.observation.Observation;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.web.server.ServerWebExchange;
+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 OpenTelemetryServerRequestObservationConvention}.
+ *
+ * @author Brian Clozel
+ * @author Tommy Ludwig
+ */
+class OpenTelemetryServerRequestObservationConventionTests {
+
+ private final OpenTelemetryServerRequestObservationConvention convention = new OpenTelemetryServerRequestObservationConvention();
+
+ private final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+
+ private final ServerRequestObservationContext context = new ServerRequestObservationContext(
+ this.exchange.getRequest(), this.exchange.getResponse(), this.exchange.getAttributes());
+
+
+ @Test
+ void shouldHaveName() {
+ assertThat(convention.getName()).isEqualTo("http.server.request.duration");
+ }
+
+ @Test
+ void shouldHaveContextualName() {
+ assertThat(convention.getContextualName(this.context)).isEqualTo("GET");
+ }
+
+ @Test
+ void contextualNameShouldUsePathPatternWhenAvailable() {
+ this.context.setPathPattern("/test/{name}");
+ assertThat(convention.getContextualName(this.context)).isEqualTo("GET /test/{name}");
+ }
+
+ @Test
+ void setsContextualNameWithPathPatternButInvalidMethod() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.method(HttpMethod.valueOf("SPRING"), "http://localhost/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(
+ exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ context.setPathPattern("/test/{name}");
+ assertThat(convention.getContextualName(context)).isEqualTo("HTTP /test/{name}");
+ }
+
+ @Test
+ void supportsOnlyHttpRequestsObservationContext() {
+ assertThat(this.convention.supportsContext(this.context)).isTrue();
+ assertThat(this.convention.supportsContext(new Observation.Context())).isFalse();
+ }
+
+ @Test
+ void addsKeyValuesForExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("http://localhost/test/resource"));
+ exchange.getResponse().setRawStatusCode(201);
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "POST"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "201"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "POST"));
+ }
+
+ @Test
+ void addsKeyValuesForExchangeWithPathPattern() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+ exchange.getResponse().setRawStatusCode(200);
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ context.setPathPattern("/test/{name}");
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "/test/{name}"), KeyValue.of("http.response.status_code", "200"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForErrorExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ context.setError(new IllegalArgumentException("custom error"));
+ exchange.getResponse().setRawStatusCode(500);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "500"),
+ KeyValue.of("error.type", "java.lang.IllegalArgumentException"), KeyValue.of("outcome", "SERVER_ERROR"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForRedirectExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/redirect"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(302);
+ exchange.getResponse().getHeaders().add("Location", "https://example.org/other");
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "REDIRECTION"), KeyValue.of("http.response.status_code", "302"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "REDIRECTION"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/redirect"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForNotFoundExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/notFound"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(404);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "NOT_FOUND"), KeyValue.of("http.response.status_code", "404"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "CLIENT_ERROR"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/notFound"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForUnknownHttpMethodExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.method(HttpMethod.valueOf("SPRING"), "http://localhost/test"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(404);
+
+ assertThat(this.convention.getContextualName(context)).isEqualTo("HTTP");
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "_OTHER"), KeyValue.of("http.route", "NOT_FOUND"), KeyValue.of("http.response.status_code", "404"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "CLIENT_ERROR"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test"), KeyValue.of("http.request.method_original", "SPRING"));
+ }
+
+ @Test
+ void addsKeyValuesForInvalidStatusExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/invalidStatus"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(999);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "999"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "UNKNOWN"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/invalidStatus"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void supportsNullStatusCode() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "UNKNOWN"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "UNKNOWN"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForConnectionAbort() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ context.setConnectionAborted(true);
+ exchange.getResponse().setRawStatusCode(200);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "UNKNOWN"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "UNKNOWN"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForConnectionAbortWhenResponseCommitted() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ context.setConnectionAborted(true);
+ exchange.getResponse().setRawStatusCode(404);
+ exchange.getResponse().setComplete().block();
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "NOT_FOUND"), KeyValue.of("http.response.status_code", "404"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "UNKNOWN"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void urlPathExcludesQueryString() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/users/123?foo=bar"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(200);
+
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/users/123"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForCustomClientErrorStatusExchange() {
+ // Custom status code that is not an HttpStatus enum constant (HttpStatus.resolve returns null).
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/customStatus"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(499);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "499"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "CLIENT_ERROR"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/customStatus"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForCustomServerErrorStatusExchange() {
+ // Custom status code that is not an HttpStatus enum constant (HttpStatus.resolve returns null).
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test/customServerError"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(599);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "599"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SERVER_ERROR"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/customServerError"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForRootRouteExchange() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(200);
+ context.setPathPattern("");
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "root"), KeyValue.of("http.response.status_code", "200"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "http"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+ @Test
+ void addsKeyValuesForExchangeWithoutScheme() {
+ ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test/resource"));
+ ServerRequestObservationContext context = new ServerRequestObservationContext(exchange.getRequest(), exchange.getResponse(), exchange.getAttributes());
+ exchange.getResponse().setRawStatusCode(200);
+
+ assertThat(this.convention.getLowCardinalityKeyValues(context)).hasSize(6)
+ .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "200"),
+ KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "UNKNOWN"));
+ assertThat(this.convention.getHighCardinalityKeyValues(context)).hasSize(2)
+ .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET"));
+ }
+
+}
diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/OpenTelemetryServerRequestObservationConventionIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/OpenTelemetryServerRequestObservationConventionIntegrationTests.java
new file mode 100644
index 000000000000..bd1b14c73656
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/web/server/adapter/OpenTelemetryServerRequestObservationConventionIntegrationTests.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2002-present 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.server.adapter;
+
+import io.micrometer.observation.tck.TestObservationRegistry;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.HttpHandler;
+import org.springframework.http.server.reactive.observation.OpenTelemetryServerRequestObservationConvention;
+import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
+import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
+import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration test verifying that the {@link OpenTelemetryServerRequestObservationConvention}
+ * is discovered and applied through the regular {@link WebHttpHandlerBuilder} to
+ * {@link HttpWebHandlerAdapter} wiring when handling a real WebFlux request.
+ *
+ * @author Tommy Ludwig
+ * @see OpenTelemetryServerRequestObservationConvention
+ * @see HttpWebHandlerAdapter
+ */
+class OpenTelemetryServerRequestObservationConventionIntegrationTests {
+
+ private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
+
+
+ @Test
+ void shouldUseOpenTelemetryConventionForServerRequestObservations() {
+ HttpHandler httpHandler = WebHttpHandlerBuilder.webHandler(exchange -> {
+ // A HandlerMapping would have matched /users/{id} and recorded the route
+ // pattern on the current observation context; the matched handler then runs.
+ ServerRequestObservationContext.findCurrent(exchange.getAttributes())
+ .ifPresent(context -> context.setPathPattern("/users/{id}"));
+ exchange.getResponse().setStatusCode(HttpStatus.OK);
+ return Mono.empty();
+ })
+ .observationRegistry(this.observationRegistry)
+ .observationConvention(new OpenTelemetryServerRequestObservationConvention())
+ .build();
+
+ MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/users/123?foo=bar").build();
+ MockServerHttpResponse response = new MockServerHttpResponse();
+
+ httpHandler.handle(request, response).block();
+
+ assertThat(this.observationRegistry)
+ .hasObservationWithNameEqualTo("http.server.request.duration")
+ .that()
+ .hasBeenStopped()
+ .hasLowCardinalityKeyValue("http.request.method", "GET")
+ .hasLowCardinalityKeyValue("http.route", "/users/{id}")
+ .hasLowCardinalityKeyValue("url.scheme", "http")
+ .hasLowCardinalityKeyValue("http.response.status_code", "200")
+ .hasLowCardinalityKeyValue("outcome", "SUCCESS")
+ .hasLowCardinalityKeyValue("error.type", "none")
+ .hasHighCardinalityKeyValue("url.path", "/users/123")
+ .hasHighCardinalityKeyValue("http.request.method_original", "GET");
+ }
+
+}