From 2fbd5667892650ba4ce3d48c22551d66ec3fc337 Mon Sep 17 00:00:00 2001 From: Mateo Maza Date: Wed, 12 Aug 2026 01:15:02 -0500 Subject: [PATCH] Add WebFlux OpenTelemetry observation convention Signed-off-by: Mateo Maza --- ...tryServerHttpObservationDocumentation.java | 156 ++++++++++ ...tryServerRequestObservationConvention.java | 239 +++++++++++++++ ...rverRequestObservationConventionTests.java | 278 ++++++++++++++++++ ...ObservationConventionIntegrationTests.java | 79 +++++ 4 files changed, 752 insertions(+) create mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerHttpObservationDocumentation.java create mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConvention.java create mode 100644 spring-web/src/test/java/org/springframework/http/server/reactive/observation/OpenTelemetryServerRequestObservationConventionTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/server/adapter/OpenTelemetryServerRequestObservationConventionIntegrationTests.java 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> 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 HTTP_METHODS = Stream.of(HttpMethod.values()).map(HttpMethod::name).collect(Collectors.toUnmodifiableSet()); + + + @Override + public String getName() { + return NAME; + } + + /** + * HTTP span names SHOULD be {@code {method} {target}} if there is a (low-cardinality) {@code target} + * available. If there is no (low-cardinality) {@code {target}} available, HTTP span names + * SHOULD be {@code {method}}. + *

+ * 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"); + } + +}