Add WebFlux OpenTelemetry observation convention

Closes gh-37131

Signed-off-by: Mateo Maza <mateomaza.github@gmail.com>
This commit is contained in:
Mateo Maza
2026-09-04 10:21:44 +02:00
committed by Brian Clozel
parent d550ab1313
commit d798317a2b
4 changed files with 752 additions and 0 deletions
@@ -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.
*
* <p>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 <a href="https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/http/http-metrics.md">OpenTelemetry Semantic Conventions for HTTP Metrics (v1.36.0)</a>
* @see <a href="https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/http/http-spans.md">OpenTelemetry Semantic Conventions for HTTP Spans (v1.36.0)</a>
*/
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";
}
}
}
}
@@ -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<String> 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}}.
* <p>
* 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.
* <p>
* The {@code target} SHOULD be the {@code {http.route}}.
* @param context context
* @return contextual name
* @see <a href="https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/http/http-spans.md#name">OpenTelemetry Semantic Convention HTTP Span Name (v1.36.0)</a>
*/
@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;
}
}
}
}
@@ -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"));
}
}
@@ -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");
}
}