Skip to content

Commit

Permalink
feat: get route info in spring-cloud-gateway (#9597)
Browse files Browse the repository at this point in the history
  • Loading branch information
123liuziming committed Nov 3, 2023
1 parent 11cac29 commit 702ae30
Show file tree
Hide file tree
Showing 17 changed files with 608 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/supported-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ These are the supported libraries and frameworks:
| [Spark Web Framework](https://github.com/perwendel/spark) | 2.3+ | N/A | Provides `http.route` [2] |
| [Spring Boot](https://spring.io/projects/spring-boot) | | [opentelemetry-spring-boot-resources](../instrumentation/spring/spring-boot-resources/library) | none |
| [Spring Batch](https://spring.io/projects/spring-batch) | 3.0+ (not including 5.0+ yet) | N/A | none |
| [Spring Cloud Gateway](https://github.com/spring-cloud/spring-cloud-gateway) | 2.0+ | N/A | Provides `http.route` [2] |
| [Spring Data](https://spring.io/projects/spring-data) | 1.8+ | N/A | none |
| [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] |
| [Spring JMS](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#jms) | 2.0+ | N/A | [Messaging Spans] |
Expand Down
5 changes: 5 additions & 0 deletions instrumentation/spring/spring-cloud-gateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Settings for the Spring Cloud Gateway instrumentation

| System property | Type | Default | Description |
|--------------------------------------------------------------------------| ------- | ------- |---------------------------------------------------------------------------------------------|
| `otel.instrumentation.spring-cloud-gateway.experimental-span-attributes` | Boolean | `false` | Enable the capture of experimental span attributes. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
plugins {
id("otel.javaagent-instrumentation")
}

muzzle {
pass {
group.set("org.springframework.cloud")
module.set("spring-cloud-starter-gateway")
versions.set("[2.0.0.RELEASE,]")
}
}

dependencies {
library("org.springframework.cloud:spring-cloud-starter-gateway:2.0.0.RELEASE")

testInstrumentation(project(":instrumentation:netty:netty-4.1:javaagent"))
testInstrumentation(project(":instrumentation:reactor:reactor-3.1:javaagent"))
testInstrumentation(project(":instrumentation:reactor:reactor-netty:reactor-netty-1.0:javaagent"))
testInstrumentation(project(":instrumentation:spring:spring-webflux:spring-webflux-5.0:javaagent"))

testImplementation(project(":instrumentation:spring:spring-cloud-gateway:spring-cloud-gateway-common:testing"))

testLibrary("org.springframework.boot:spring-boot-starter-test:2.0.0.RELEASE")
}

tasks.withType<Test>().configureEach {
jvmArgs("-Dotel.instrumentation.spring-cloud-gateway.experimental-span-attributes=true")

// required on jdk17
jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED")
jvmArgs("-XX:+IgnoreUnrecognizedVMOptions")

systemProperty("testLatestDeps", findProperty("testLatestDeps") as Boolean)
}

val latestDepTest = findProperty("testLatestDeps") as Boolean

if (latestDepTest) {
// spring 6 requires java 17
otelJava {
minJavaVersionSupported.set(JavaVersion.VERSION_17)
}
} else {
// spring 5 requires old logback (and therefore also old slf4j)
configurations.testRuntimeClasspath {
resolutionStrategy {
force("ch.qos.logback:logback-classic:1.2.11")
force("org.slf4j:slf4j-api:1.7.36")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;

import static java.util.Arrays.asList;

import com.google.auto.service.AutoService;
import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule;
import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
import java.util.List;

@AutoService(InstrumentationModule.class)
public class GatewayInstrumentationModule extends InstrumentationModule {

public GatewayInstrumentationModule() {
super("spring-cloud-gateway");
}

@Override
public List<TypeInstrumentation> typeInstrumentations() {
return asList(new HandlerAdapterInstrumentation());
}

@Override
public int order() {
// Later than Spring Webflux.
return 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;

import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRouteGetter;
import org.springframework.web.server.ServerWebExchange;

public final class GatewaySingletons {

private GatewaySingletons() {}

public static HttpServerRouteGetter<ServerWebExchange> httpRouteGetter() {
return (context, exchange) -> ServerWebExchangeHelper.extractServerRoute(exchange);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;

import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasClassesNamed;
import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.implementsInterface;
import static net.bytebuddy.matcher.ElementMatchers.isAbstract;
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
import static net.bytebuddy.matcher.ElementMatchers.named;
import static net.bytebuddy.matcher.ElementMatchers.not;
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;

import io.opentelemetry.context.Context;
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRoute;
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerRouteSource;
import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;
import org.springframework.web.server.ServerWebExchange;

public class HandlerAdapterInstrumentation implements TypeInstrumentation {

@Override
public ElementMatcher<ClassLoader> classLoaderOptimization() {
return hasClassesNamed("org.springframework.web.reactive.HandlerAdapter");
}

@Override
public ElementMatcher<TypeDescription> typeMatcher() {
return not(isAbstract())
.and(implementsInterface(named("org.springframework.web.reactive.HandlerAdapter")));
}

@Override
public void transform(TypeTransformer transformer) {
transformer.applyAdviceToMethod(
isMethod()
.and(isPublic())
.and(named("handle"))
.and(takesArgument(0, named("org.springframework.web.server.ServerWebExchange")))
.and(takesArgument(1, Object.class))
.and(takesArguments(2)),
this.getClass().getName() + "$HandleAdvice");
}

@SuppressWarnings("unused")
public static class HandleAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void methodEnter(@Advice.Argument(0) ServerWebExchange exchange) {
Context context = Context.current();
// Update route info for server span.
HttpServerRoute.update(
context,
HttpServerRouteSource.NESTED_CONTROLLER,
GatewaySingletons.httpRouteGetter(),
exchange);
// Record route info in server span.
ServerWebExchangeHelper.extractAttributes(exchange, context);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR;

import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.internal.StringUtils;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Context;
import io.opentelemetry.instrumentation.api.instrumenter.LocalRootSpan;
import io.opentelemetry.javaagent.bootstrap.internal.InstrumentationConfig;
import java.util.regex.Pattern;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.web.server.ServerWebExchange;

public final class ServerWebExchangeHelper {

/** Route ID attribute key. */
private static final AttributeKey<String> ROUTE_ID_ATTRIBUTE =
AttributeKey.stringKey("spring-cloud-gateway.route.id");

/** Route URI attribute key. */
private static final AttributeKey<String> ROUTE_URI_ATTRIBUTE =
AttributeKey.stringKey("spring-cloud-gateway.route.uri");

/** Route order attribute key. */
private static final AttributeKey<Long> ROUTE_ORDER_ATTRIBUTE =
AttributeKey.longKey("spring-cloud-gateway.route.order");

/** Route filter size attribute key. */
private static final AttributeKey<Long> ROUTE_FILTER_SIZE_ATTRIBUTE =
AttributeKey.longKey("spring-cloud-gateway.route.filter.size");

private static final boolean CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES;

static {
CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES =
InstrumentationConfig.get()
.getBoolean(
"otel.instrumentation.spring-cloud-gateway.experimental-span-attributes", false);
}

/* Regex for UUID */
private static final Pattern UUID_REGEX =
Pattern.compile(
"^[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}$");

private static final String INVALID_RANDOM_ROUTE_ID =
"org.springframework.util.AlternativeJdkIdGenerator@";

private ServerWebExchangeHelper() {}

public static void extractAttributes(ServerWebExchange exchange, Context context) {
// Record route info
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
if (route != null && CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES) {
Span serverSpan = LocalRootSpan.fromContextOrNull(context);
if (serverSpan == null) {
return;
}
serverSpan.setAttribute(ROUTE_ID_ATTRIBUTE, route.getId());
serverSpan.setAttribute(ROUTE_URI_ATTRIBUTE, route.getUri().toASCIIString());
serverSpan.setAttribute(ROUTE_ORDER_ATTRIBUTE, route.getOrder());
serverSpan.setAttribute(ROUTE_FILTER_SIZE_ATTRIBUTE, route.getFilters().size());
}
}

public static String extractServerRoute(ServerWebExchange exchange) {
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
if (route != null) {
return convergeRouteId(route);
}
return null;
}

/**
* To avoid high cardinality, we ignore random UUID generated by Spring Cloud Gateway. Spring
* Cloud Gateway generate invalid random routeID, and it is fixed until 3.1.x
*
* @see <a
* href="https://github.com/spring-cloud/spring-cloud-gateway/commit/5002fe2e0a2825ef47dd667cade37b844c276cf6"/>
*/
private static String convergeRouteId(Route route) {
String routeId = route.getId();
if (StringUtils.isNullOrEmpty(routeId)) {
return null;
}
if (UUID_REGEX.matcher(routeId).matches()) {
return null;
}
if (routeId.startsWith(INVALID_RANDOM_ROUTE_ID)) {
return null;
}
return routeId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.spring.gateway.v2_0;

import static org.assertj.core.api.Assertions.assertThat;

import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.instrumentation.spring.gateway.common.AbstractRouteMappingTest;
import io.opentelemetry.testing.internal.armeria.common.AggregatedHttpResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = {
GatewayTestApplication.class,
GatewayRouteMappingTest.ForceNettyAutoConfiguration.class
})
class GatewayRouteMappingTest extends AbstractRouteMappingTest {

@Test
void gatewayRouteMappingTest() {
String requestBody = "gateway";
AggregatedHttpResponse response = client.post("/gateway/echo", requestBody).aggregate().join();
assertThat(response.status().code()).isEqualTo(200);
assertThat(response.contentUtf8()).isEqualTo(requestBody);
testing.waitAndAssertTraces(
trace ->
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("POST path_route")
.hasKind(SpanKind.SERVER)
.hasAttributesSatisfying(
buildAttributeAssertions("path_route", "h1c://mock.response", 0, 1)),
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
}

@Test
void gatewayRandomUuidRouteMappingTest() {
String requestBody = "gateway";
AggregatedHttpResponse response = client.post("/uuid/echo", requestBody).aggregate().join();
assertThat(response.status().code()).isEqualTo(200);
assertThat(response.contentUtf8()).isEqualTo(requestBody);
testing.waitAndAssertTraces(
trace ->
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("POST")
.hasKind(SpanKind.SERVER)
.hasAttributesSatisfying(buildAttributeAssertions("h1c://mock.uuid", 0, 1)),
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
}

@Test
void gatewayFakeUuidRouteMappingTest() {
String requestBody = "gateway";
String routeId = "ffffffff-ffff-ffff-ffff-ffff";
AggregatedHttpResponse response = client.post("/fake/echo", requestBody).aggregate().join();
assertThat(response.status().code()).isEqualTo(200);
assertThat(response.contentUtf8()).isEqualTo(requestBody);
testing.waitAndAssertTraces(
trace ->
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("POST " + routeId)
.hasKind(SpanKind.SERVER)
.hasAttributesSatisfying(
buildAttributeAssertions(routeId, "h1c://mock.fake", 0, 1)),
span -> span.hasName(WEBFLUX_SPAN_NAME).hasKind(SpanKind.INTERNAL)));
}
}
Loading

0 comments on commit 702ae30

Please sign in to comment.