diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/DropApplicationUrisBuildItem.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/DropApplicationUrisBuildItem.java new file mode 100644 index 00000000000000..7d06ca4f4c66b2 --- /dev/null +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/DropApplicationUrisBuildItem.java @@ -0,0 +1,19 @@ +package io.quarkus.opentelemetry.deployment.tracing; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Represents an application uri that must be ignored for tracing. + */ +public final class DropApplicationUrisBuildItem extends MultiBuildItem { + + private final String uri; + + public DropApplicationUrisBuildItem(String uri) { + this.uri = uri; + } + + public String uri() { + return uri; + } +} diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java index 557749f5c104a4..73b3cf9f00f161 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java @@ -10,6 +10,7 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.BooleanSupplier; @@ -17,8 +18,10 @@ import jakarta.enterprise.inject.spi.EventContext; import jakarta.inject.Singleton; +import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; import org.jboss.jandex.FieldInfo; import org.jboss.jandex.IndexView; @@ -53,6 +56,7 @@ import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig.SecurityEvents.SecurityEventType; import io.quarkus.opentelemetry.runtime.tracing.DelayedAttributes; +import io.quarkus.opentelemetry.runtime.tracing.Traceless; import io.quarkus.opentelemetry.runtime.tracing.TracerRecorder; import io.quarkus.opentelemetry.runtime.tracing.cdi.TracerProducer; import io.quarkus.opentelemetry.runtime.tracing.security.EndUserSpanProcessor; @@ -69,6 +73,8 @@ public class TracerProcessor { private static final DotName SPAN_EXPORTER = DotName.createSimple(SpanExporter.class.getName()); private static final DotName SPAN_PROCESSOR = DotName.createSimple(SpanProcessor.class.getName()); private static final DotName TEXT_MAP_PROPAGATOR = DotName.createSimple(TextMapPropagator.class.getName()); + private static final DotName TRACELESS = DotName.createSimple(Traceless.class.getName()); + private static final DotName PATH = DotName.createSimple("jakarta.ws.rs.Path"); @BuildStep UnremovableBeanBuildItem ensureProducersAreRetained( @@ -131,15 +137,31 @@ UnremovableBeanBuildItem ensureProducersAreRetained( return new UnremovableBeanBuildItem(new UnremovableBeanBuildItem.BeanClassNamesExclusion(retainProducers)); } + @BuildStep + void dropApplicationUris( + CombinedIndexBuildItem combinedIndexBuildItem, + BuildProducer uris) { + String rootPath = ConfigProvider.getConfig().getOptionalValue("quarkus.http.root-path", String.class).orElse("/"); + IndexView index = combinedIndexBuildItem.getIndex(); + Collection annotations = index.getAnnotations(TRACELESS); + Set tracelessUris = generateTracelessUris(annotations.stream().toList(), rootPath); + for (String uri : tracelessUris) { + uris.produce(new DropApplicationUrisBuildItem(uri)); + } + } + @BuildStep void dropNames( Optional frameworkEndpoints, Optional staticResources, BuildProducer dropNonApplicationUris, - BuildProducer dropStaticResources) { + BuildProducer dropStaticResources, + List applicationUris) { + + List nonApplicationUris = new ArrayList<>( + applicationUris.stream().map(DropApplicationUrisBuildItem::uri).toList()); // Drop framework paths - List nonApplicationUris = new ArrayList<>(); frameworkEndpoints.ifPresent( frameworkEndpointsBuildItem -> { for (String endpoint : frameworkEndpointsBuildItem.getEndpoints()) { @@ -170,6 +192,67 @@ void dropNames( dropStaticResources.produce(new DropStaticResourcesBuildItem(resources)); } + private Set generateTracelessUris(final List annotations, final String rootPath) { + final Set applicationUris = new HashSet<>(); + for (AnnotationInstance annotation : annotations) { + AnnotationTarget.Kind kind = annotation.target().kind(); + + switch (kind) { + case CLASS -> { + AnnotationInstance classAnnotated = annotation.target().asClass().annotations() + .stream().filter(TracerProcessor::isClassAnnotatedWithPath).findFirst().orElse(null); + + if (Objects.isNull(classAnnotated)) { + continue; + } + + String classPath = classAnnotated.value().asString(); + String finalPath = combinePaths(rootPath, classPath); + + if (containsPathExpression(finalPath)) { + applicationUris.add(sanitizeForTraceless(finalPath) + "*"); + continue; + } + + applicationUris.add(finalPath + "*"); + applicationUris.add(finalPath); + } + case METHOD -> { + ClassInfo classInfo = annotation.target().asMethod().declaringClass(); + + AnnotationInstance possibleClassAnnotatedWithPath = classInfo.asClass() + .annotations() + .stream() + .filter(TracerProcessor::isClassAnnotatedWithPath) + .findFirst() + .orElse(null); + + if (Objects.isNull(possibleClassAnnotatedWithPath)) { + continue; + } + + String finalPath; + String classPath = possibleClassAnnotatedWithPath.value().asString(); + AnnotationInstance possibleMethodAnnotatedWithPath = annotation.target().annotation(PATH); + if (possibleMethodAnnotatedWithPath != null) { + String methodValue = possibleMethodAnnotatedWithPath.value().asString(); + finalPath = combinePaths(rootPath, combinePaths(classPath, methodValue)); + } else { + finalPath = combinePaths(rootPath, classPath); + } + + if (containsPathExpression(finalPath)) { + applicationUris.add(sanitizeForTraceless(finalPath) + "*"); + continue; + } + + applicationUris.add(finalPath); + } + } + } + return applicationUris; + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) SyntheticBeanBuildItem setupDelayedAttribute(TracerRecorder recorder, ApplicationInfoBuildItem appInfo) { @@ -256,6 +339,37 @@ private static ObserverConfiguratorBuildItem createEventObserver( })); } + private static boolean containsPathExpression(String value) { + return value.indexOf('{') != -1; + } + + private static String sanitizeForTraceless(final String path) { + int braceIndex = path.indexOf('{'); + if (braceIndex == -1) { + return path; + } + if (braceIndex > 0 && path.charAt(braceIndex - 1) == '/') { + return path.substring(0, braceIndex - 1); + } else { + return path.substring(0, braceIndex); + } + } + + private static boolean isClassAnnotatedWithPath(AnnotationInstance annotation) { + return annotation.target().kind().equals(AnnotationTarget.Kind.CLASS) && + annotation.name().equals(PATH); + } + + private String combinePaths(String basePath, String relativePath) { + if (!basePath.endsWith("/")) { + basePath += "/"; + } + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + return basePath + relativePath; + } + static final class SecurityEventsEnabled implements BooleanSupplier { private final boolean enabled; diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessTest.java new file mode 100644 index 00000000000000..bf97c8be15507f --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessTest.java @@ -0,0 +1,128 @@ +package io.quarkus.opentelemetry.deployment; + +import static org.hamcrest.Matchers.is; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.opentelemetry.deployment.common.exporter.InMemoryExporter; +import io.quarkus.opentelemetry.deployment.common.exporter.InMemoryMetricExporterProvider; +import io.quarkus.opentelemetry.deployment.common.traces.TraceMeResource; +import io.quarkus.opentelemetry.deployment.common.traces.TracelessClassLevelResource; +import io.quarkus.opentelemetry.deployment.common.traces.TracelessHelloResource; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class OpenTelemetryTracelessTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addPackage(InMemoryExporter.class.getPackage()) + .addAsResource("resource-config/application.properties", "application.properties") + .addAsResource( + "META-INF/services-config/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider", + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .addClasses(TracelessHelloResource.class, TracelessClassLevelResource.class, TraceMeResource.class)); + + @Inject + InMemoryExporter exporter; + + @BeforeEach + void setup() { + exporter.reset(); + } + + @Test + @DisplayName("Should not trace when the method @Path uses @PathParam") + void testingWithPathParam() { + RestAssured.when() + .get("/hello/mask/1").then() + .statusCode(200) + .body(is("mask-1")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + exporter.getSpanExporter().getFinishedSpanItems(1); + + } + + @Test + @DisplayName("Should not trace when the annotation @Traceless is at method level") + void testingTracelessHelloHi() { + + RestAssured.when() + .get("/hello").then() + .statusCode(200) + .body(is("hello")); + + RestAssured.when() + .get("/hello/hi").then() + .statusCode(200) + .body(is("hi")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + // should have only one + exporter.getSpanExporter().getFinishedSpanItems(1); + } + + @Test + @DisplayName("Should not trace when the method @Path is without '/'") + void testingHelloNoSlash() { + RestAssured.when() + .get("/hello/no-slash").then() + .statusCode(200) + .body(is("no-slash")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + // should have only one + exporter.getSpanExporter().getFinishedSpanItems(1); + } + + @Test + @DisplayName("Should not trace when the annotation is at class level") + void testingTracelessAtClassLevel() { + + RestAssured.when() + .get("class-level").then() + .statusCode(200) + .body(is("class-level")); + + RestAssured.when() + .get("/class-level/first-method").then() + .statusCode(200) + .body(is("first-method")); + + RestAssured.when() + .get("/class-level/second-method").then() + .statusCode(200) + .body(is("second-method")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + // should have only one + exporter.getSpanExporter().getFinishedSpanItems(1); + } +} diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessWithRootPathTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessWithRootPathTest.java new file mode 100644 index 00000000000000..9443f65f703fed --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryTracelessWithRootPathTest.java @@ -0,0 +1,127 @@ +package io.quarkus.opentelemetry.deployment; + +import static org.hamcrest.Matchers.is; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.opentelemetry.deployment.common.exporter.InMemoryExporter; +import io.quarkus.opentelemetry.deployment.common.exporter.InMemoryMetricExporterProvider; +import io.quarkus.opentelemetry.deployment.common.traces.TraceMeResource; +import io.quarkus.opentelemetry.deployment.common.traces.TracelessClassLevelResource; +import io.quarkus.opentelemetry.deployment.common.traces.TracelessHelloResource; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class OpenTelemetryTracelessWithRootPathTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addPackage(InMemoryExporter.class.getPackage()) + .addAsResource("resource-config/application.properties", "application.properties") + .addAsResource( + "META-INF/services-config/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider", + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .addClasses(TracelessHelloResource.class, TracelessClassLevelResource.class, TraceMeResource.class)) + .overrideConfigKey("quarkus.http.root-path", "/api"); + + @Inject + InMemoryExporter exporter; + + @BeforeEach + void setup() { + exporter.reset(); + } + + @Test + @DisplayName("Should not trace when the annotation @Traceless is at method level") + void testingTracelessHelloHi() { + + RestAssured.when() + .get("/hello").then() + .statusCode(200) + .body(is("hello")); + + RestAssured.when() + .get("/hello/hi").then() + .statusCode(200) + .body(is("hi")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + exporter.getSpanExporter().getFinishedSpanItems(1); + } + + @Test + @DisplayName("Should not trace when the method @Path uses @PathParam") + void testingWithPathParam() { + RestAssured.when() + .get("/hello/mask/1").then() + .statusCode(200) + .body(is("mask-1")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + exporter.getSpanExporter().getFinishedSpanItems(1); + + } + + @Test + @DisplayName("Should not trace when the method @Path is without '/'") + void testingHelloNoSlash() { + RestAssured.when() + .get("/hello/no-slash").then() + .statusCode(200) + .body(is("no-slash")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + exporter.getSpanExporter().getFinishedSpanItems(1); + } + + @Test + @DisplayName("Should not trace when the annotation is at class level") + void testingTracelessAtClassLevel() { + + RestAssured.when() + .get("class-level").then() + .statusCode(200) + .body(is("class-level")); + + RestAssured.when() + .get("/class-level/first-method").then() + .statusCode(200) + .body(is("first-method")); + + RestAssured.when() + .get("/class-level/second-method").then() + .statusCode(200) + .body(is("second-method")); + + RestAssured.when() + .get("/trace-me").then() + .statusCode(200) + .body(is("trace-me")); + + exporter.getSpanExporter().getFinishedSpanItems(1); + } + +} diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TraceMeResource.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TraceMeResource.java new file mode 100644 index 00000000000000..856d97666550cf --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TraceMeResource.java @@ -0,0 +1,20 @@ +package io.quarkus.opentelemetry.deployment.common.traces; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.opentelemetry.api.metrics.Meter; + +@Path("/trace-me") +public class TraceMeResource { + + @Inject + Meter meter; + + @GET + public String traceMe() { + meter.counterBuilder("trace-me").build().add(1); + return "trace-me"; + } +} diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessClassLevelResource.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessClassLevelResource.java new file mode 100644 index 00000000000000..b27e2da3851c9b --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessClassLevelResource.java @@ -0,0 +1,36 @@ +package io.quarkus.opentelemetry.deployment.common.traces; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.opentelemetry.api.metrics.Meter; +import io.quarkus.opentelemetry.runtime.tracing.Traceless; + +@Path("/class-level") +@Traceless +public class TracelessClassLevelResource { + + @Inject + Meter meter; + + @GET + public String classLevel() { + meter.counterBuilder("class-level").build().add(1); + return "class-level"; + } + + @GET + @Path("/first-method") + public String firstMethod() { + meter.counterBuilder("first-method").build().add(1); + return "first-method"; + } + + @Path("/second-method") + @GET + public String secondMethod() { + meter.counterBuilder("second-method").build().add(1); + return "second-method"; + } +} diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessHelloResource.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessHelloResource.java new file mode 100644 index 00000000000000..f9824313798b43 --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/common/traces/TracelessHelloResource.java @@ -0,0 +1,47 @@ +package io.quarkus.opentelemetry.deployment.common.traces; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +import io.opentelemetry.api.metrics.Meter; +import io.quarkus.opentelemetry.runtime.tracing.Traceless; + +@Path("/hello") +public class TracelessHelloResource { + + @Inject + Meter meter; + + @GET + @Traceless + public String hello() { + meter.counterBuilder("hello").build().add(1); + return "hello"; + } + + @Path("/hi") + @GET + @Traceless + public String hi() { + meter.counterBuilder("hi").build().add(1); + return "hi"; + } + + @Path("no-slash") + @GET + @Traceless + public String noSlash() { + meter.counterBuilder("no-slash").build().add(1); + return "no-slash"; + } + + @GET + @Path("/mask/{number}") + @Traceless + public String mask(@PathParam("number") String number) { + meter.counterBuilder("mask").build().add(Integer.parseInt(number)); + return "mask-" + number; + } +} diff --git a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/Traceless.java b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/Traceless.java new file mode 100644 index 00000000000000..254022bb8ee360 --- /dev/null +++ b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/Traceless.java @@ -0,0 +1,16 @@ +package io.quarkus.opentelemetry.runtime.tracing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies that the current path should not be select for tracing. + *

+ * Used together with {@code jakarta.ws.rs.Path} annotation. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Traceless { +}