Skip to content

Commit 702ae30

Browse files
authored
feat: get route info in spring-cloud-gateway (#9597)
1 parent 11cac29 commit 702ae30

File tree

17 files changed

+608
-0
lines changed

17 files changed

+608
-0
lines changed

docs/supported-libraries.md

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ These are the supported libraries and frameworks:
114114
| [Spark Web Framework](https://github.com/perwendel/spark) | 2.3+ | N/A | Provides `http.route` [2] |
115115
| [Spring Boot](https://spring.io/projects/spring-boot) | | [opentelemetry-spring-boot-resources](../instrumentation/spring/spring-boot-resources/library) | none |
116116
| [Spring Batch](https://spring.io/projects/spring-batch) | 3.0+ (not including 5.0+ yet) | N/A | none |
117+
| [Spring Cloud Gateway](https://github.com/spring-cloud/spring-cloud-gateway) | 2.0+ | N/A | Provides `http.route` [2] |
117118
| [Spring Data](https://spring.io/projects/spring-data) | 1.8+ | N/A | none |
118119
| [Spring Integration](https://spring.io/projects/spring-integration) | 4.1+ (not including 6.0+ yet) | [opentelemetry-spring-integration-4.1](../instrumentation/spring/spring-integration-4.1/library) | [Messaging Spans] |
119120
| [Spring JMS](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#jms) | 2.0+ | N/A | [Messaging Spans] |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Settings for the Spring Cloud Gateway instrumentation
2+
3+
| System property | Type | Default | Description |
4+
|--------------------------------------------------------------------------| ------- | ------- |---------------------------------------------------------------------------------------------|
5+
| `otel.instrumentation.spring-cloud-gateway.experimental-span-attributes` | Boolean | `false` | Enable the capture of experimental span attributes. |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
plugins {
2+
id("otel.javaagent-instrumentation")
3+
}
4+
5+
muzzle {
6+
pass {
7+
group.set("org.springframework.cloud")
8+
module.set("spring-cloud-starter-gateway")
9+
versions.set("[2.0.0.RELEASE,]")
10+
}
11+
}
12+
13+
dependencies {
14+
library("org.springframework.cloud:spring-cloud-starter-gateway:2.0.0.RELEASE")
15+
16+
testInstrumentation(project(":instrumentation:netty:netty-4.1:javaagent"))
17+
testInstrumentation(project(":instrumentation:reactor:reactor-3.1:javaagent"))
18+
testInstrumentation(project(":instrumentation:reactor:reactor-netty:reactor-netty-1.0:javaagent"))
19+
testInstrumentation(project(":instrumentation:spring:spring-webflux:spring-webflux-5.0:javaagent"))
20+
21+
testImplementation(project(":instrumentation:spring:spring-cloud-gateway:spring-cloud-gateway-common:testing"))
22+
23+
testLibrary("org.springframework.boot:spring-boot-starter-test:2.0.0.RELEASE")
24+
}
25+
26+
tasks.withType<Test>().configureEach {
27+
jvmArgs("-Dotel.instrumentation.spring-cloud-gateway.experimental-span-attributes=true")
28+
29+
// required on jdk17
30+
jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED")
31+
jvmArgs("-XX:+IgnoreUnrecognizedVMOptions")
32+
33+
systemProperty("testLatestDeps", findProperty("testLatestDeps") as Boolean)
34+
}
35+
36+
val latestDepTest = findProperty("testLatestDeps") as Boolean
37+
38+
if (latestDepTest) {
39+
// spring 6 requires java 17
40+
otelJava {
41+
minJavaVersionSupported.set(JavaVersion.VERSION_17)
42+
}
43+
} else {
44+
// spring 5 requires old logback (and therefore also old slf4j)
45+
configurations.testRuntimeClasspath {
46+
resolutionStrategy {
47+
force("ch.qos.logback:logback-classic:1.2.11")
48+
force("org.slf4j:slf4j-api:1.7.36")
49+
}
50+
}
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;
7+
8+
import static java.util.Arrays.asList;
9+
10+
import com.google.auto.service.AutoService;
11+
import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule;
12+
import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
13+
import java.util.List;
14+
15+
@AutoService(InstrumentationModule.class)
16+
public class GatewayInstrumentationModule extends InstrumentationModule {
17+
18+
public GatewayInstrumentationModule() {
19+
super("spring-cloud-gateway");
20+
}
21+
22+
@Override
23+
public List<TypeInstrumentation> typeInstrumentations() {
24+
return asList(new HandlerAdapterInstrumentation());
25+
}
26+
27+
@Override
28+
public int order() {
29+
// Later than Spring Webflux.
30+
return 1;
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;
7+
8+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRouteGetter;
9+
import org.springframework.web.server.ServerWebExchange;
10+
11+
public final class GatewaySingletons {
12+
13+
private GatewaySingletons() {}
14+
15+
public static HttpServerRouteGetter<ServerWebExchange> httpRouteGetter() {
16+
return (context, exchange) -> ServerWebExchangeHelper.extractServerRoute(exchange);
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;
7+
8+
import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasClassesNamed;
9+
import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.implementsInterface;
10+
import static net.bytebuddy.matcher.ElementMatchers.isAbstract;
11+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
12+
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
13+
import static net.bytebuddy.matcher.ElementMatchers.named;
14+
import static net.bytebuddy.matcher.ElementMatchers.not;
15+
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
16+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
17+
18+
import io.opentelemetry.context.Context;
19+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRoute;
20+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRouteSource;
21+
import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
22+
import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer;
23+
import net.bytebuddy.asm.Advice;
24+
import net.bytebuddy.description.type.TypeDescription;
25+
import net.bytebuddy.matcher.ElementMatcher;
26+
import org.springframework.web.server.ServerWebExchange;
27+
28+
public class HandlerAdapterInstrumentation implements TypeInstrumentation {
29+
30+
@Override
31+
public ElementMatcher<ClassLoader> classLoaderOptimization() {
32+
return hasClassesNamed("org.springframework.web.reactive.HandlerAdapter");
33+
}
34+
35+
@Override
36+
public ElementMatcher<TypeDescription> typeMatcher() {
37+
return not(isAbstract())
38+
.and(implementsInterface(named("org.springframework.web.reactive.HandlerAdapter")));
39+
}
40+
41+
@Override
42+
public void transform(TypeTransformer transformer) {
43+
transformer.applyAdviceToMethod(
44+
isMethod()
45+
.and(isPublic())
46+
.and(named("handle"))
47+
.and(takesArgument(0, named("org.springframework.web.server.ServerWebExchange")))
48+
.and(takesArgument(1, Object.class))
49+
.and(takesArguments(2)),
50+
this.getClass().getName() + "$HandleAdvice");
51+
}
52+
53+
@SuppressWarnings("unused")
54+
public static class HandleAdvice {
55+
@Advice.OnMethodEnter(suppress = Throwable.class)
56+
public static void methodEnter(@Advice.Argument(0) ServerWebExchange exchange) {
57+
Context context = Context.current();
58+
// Update route info for server span.
59+
HttpServerRoute.update(
60+
context,
61+
HttpServerRouteSource.NESTED_CONTROLLER,
62+
GatewaySingletons.httpRouteGetter(),
63+
exchange);
64+
// Record route info in server span.
65+
ServerWebExchangeHelper.extractAttributes(exchange, context);
66+
}
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;
7+
8+
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR;
9+
10+
import io.opentelemetry.api.common.AttributeKey;
11+
import io.opentelemetry.api.internal.StringUtils;
12+
import io.opentelemetry.api.trace.Span;
13+
import io.opentelemetry.context.Context;
14+
import io.opentelemetry.instrumentation.api.instrumenter.LocalRootSpan;
15+
import io.opentelemetry.javaagent.bootstrap.internal.InstrumentationConfig;
16+
import java.util.regex.Pattern;
17+
import org.springframework.cloud.gateway.route.Route;
18+
import org.springframework.web.server.ServerWebExchange;
19+
20+
public final class ServerWebExchangeHelper {
21+
22+
/** Route ID attribute key. */
23+
private static final AttributeKey<String> ROUTE_ID_ATTRIBUTE =
24+
AttributeKey.stringKey("spring-cloud-gateway.route.id");
25+
26+
/** Route URI attribute key. */
27+
private static final AttributeKey<String> ROUTE_URI_ATTRIBUTE =
28+
AttributeKey.stringKey("spring-cloud-gateway.route.uri");
29+
30+
/** Route order attribute key. */
31+
private static final AttributeKey<Long> ROUTE_ORDER_ATTRIBUTE =
32+
AttributeKey.longKey("spring-cloud-gateway.route.order");
33+
34+
/** Route filter size attribute key. */
35+
private static final AttributeKey<Long> ROUTE_FILTER_SIZE_ATTRIBUTE =
36+
AttributeKey.longKey("spring-cloud-gateway.route.filter.size");
37+
38+
private static final boolean CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES;
39+
40+
static {
41+
CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES =
42+
InstrumentationConfig.get()
43+
.getBoolean(
44+
"otel.instrumentation.spring-cloud-gateway.experimental-span-attributes", false);
45+
}
46+
47+
/* Regex for UUID */
48+
private static final Pattern UUID_REGEX =
49+
Pattern.compile(
50+
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
51+
52+
private static final String INVALID_RANDOM_ROUTE_ID =
53+
"org.springframework.util.AlternativeJdkIdGenerator@";
54+
55+
private ServerWebExchangeHelper() {}
56+
57+
public static void extractAttributes(ServerWebExchange exchange, Context context) {
58+
// Record route info
59+
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
60+
if (route != null && CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES) {
61+
Span serverSpan = LocalRootSpan.fromContextOrNull(context);
62+
if (serverSpan == null) {
63+
return;
64+
}
65+
serverSpan.setAttribute(ROUTE_ID_ATTRIBUTE, route.getId());
66+
serverSpan.setAttribute(ROUTE_URI_ATTRIBUTE, route.getUri().toASCIIString());
67+
serverSpan.setAttribute(ROUTE_ORDER_ATTRIBUTE, route.getOrder());
68+
serverSpan.setAttribute(ROUTE_FILTER_SIZE_ATTRIBUTE, route.getFilters().size());
69+
}
70+
}
71+
72+
public static String extractServerRoute(ServerWebExchange exchange) {
73+
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
74+
if (route != null) {
75+
return convergeRouteId(route);
76+
}
77+
return null;
78+
}
79+
80+
/**
81+
* To avoid high cardinality, we ignore random UUID generated by Spring Cloud Gateway. Spring
82+
* Cloud Gateway generate invalid random routeID, and it is fixed until 3.1.x
83+
*
84+
* @see <a
85+
* href="https://github.com/spring-cloud/spring-cloud-gateway/commit/5002fe2e0a2825ef47dd667cade37b844c276cf6"/>
86+
*/
87+
private static String convergeRouteId(Route route) {
88+
String routeId = route.getId();
89+
if (StringUtils.isNullOrEmpty(routeId)) {
90+
return null;
91+
}
92+
if (UUID_REGEX.matcher(routeId).matches()) {
93+
return null;
94+
}
95+
if (routeId.startsWith(INVALID_RANDOM_ROUTE_ID)) {
96+
return null;
97+
}
98+
return routeId;
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
import io.opentelemetry.api.trace.SpanKind;
11+
import io.opentelemetry.instrumentation.spring.gateway.common.AbstractRouteMappingTest;
12+
import io.opentelemetry.testing.internal.armeria.common.AggregatedHttpResponse;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.extension.ExtendWith;
15+
import org.springframework.boot.test.context.SpringBootTest;
16+
import org.springframework.test.context.junit.jupiter.SpringExtension;
17+
18+
@ExtendWith(SpringExtension.class)
19+
@SpringBootTest(
20+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
21+
classes = {
22+
GatewayTestApplication.class,
23+
GatewayRouteMappingTest.ForceNettyAutoConfiguration.class
24+
})
25+
class GatewayRouteMappingTest extends AbstractRouteMappingTest {
26+
27+
@Test
28+
void gatewayRouteMappingTest() {
29+
String requestBody = "gateway";
30+
AggregatedHttpResponse response = client.post("/gateway/echo", requestBody).aggregate().join();
31+
assertThat(response.status().code()).isEqualTo(200);
32+
assertThat(response.contentUtf8()).isEqualTo(requestBody);
33+
testing.waitAndAssertTraces(
34+
trace ->
35+
trace.hasSpansSatisfyingExactly(
36+
span ->
37+
span.hasName("POST path_route")
38+
.hasKind(SpanKind.SERVER)
39+
.hasAttributesSatisfying(
40+
buildAttributeAssertions("path_route", "h1c://mock.response", 0, 1)),
41+
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
42+
}
43+
44+
@Test
45+
void gatewayRandomUuidRouteMappingTest() {
46+
String requestBody = "gateway";
47+
AggregatedHttpResponse response = client.post("/uuid/echo", requestBody).aggregate().join();
48+
assertThat(response.status().code()).isEqualTo(200);
49+
assertThat(response.contentUtf8()).isEqualTo(requestBody);
50+
testing.waitAndAssertTraces(
51+
trace ->
52+
trace.hasSpansSatisfyingExactly(
53+
span ->
54+
span.hasName("POST")
55+
.hasKind(SpanKind.SERVER)
56+
.hasAttributesSatisfying(buildAttributeAssertions("h1c://mock.uuid", 0, 1)),
57+
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
58+
}
59+
60+
@Test
61+
void gatewayFakeUuidRouteMappingTest() {
62+
String requestBody = "gateway";
63+
String routeId = "ffffffff-ffff-ffff-ffff-ffff";
64+
AggregatedHttpResponse response = client.post("/fake/echo", requestBody).aggregate().join();
65+
assertThat(response.status().code()).isEqualTo(200);
66+
assertThat(response.contentUtf8()).isEqualTo(requestBody);
67+
testing.waitAndAssertTraces(
68+
trace ->
69+
trace.hasSpansSatisfyingExactly(
70+
span ->
71+
span.hasName("POST " + routeId)
72+
.hasKind(SpanKind.SERVER)
73+
.hasAttributesSatisfying(
74+
buildAttributeAssertions(routeId, "h1c://mock.fake", 0, 1)),
75+
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
76+
}
77+
}

0 commit comments

Comments
 (0)