diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java new file mode 100644 index 00000000000..17686a7d326 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java @@ -0,0 +1,132 @@ +package datadog.trace.agent.test; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.instrument.classinject.ClassInjector; +import datadog.trace.agent.tooling.AgentInstaller; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.agent.tooling.TracerInstaller; +import datadog.trace.agent.tooling.bytebuddy.matcher.ClassLoaderMatchers; +import datadog.trace.api.Config; +import datadog.trace.api.IdGenerationStrategy; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.common.writer.ListWriter; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.DDSpan; +import datadog.trace.core.PendingTrace; +import datadog.trace.core.TraceCollector; +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.util.ServiceLoader; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import net.bytebuddy.agent.ByteBuddyAgent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TestClassShadowingExtension.class) +public abstract class AbstractInstrumentationTest { + static final Instrumentation INSTRUMENTATION = ByteBuddyAgent.getInstrumentation(); + + static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(20); + + protected AgentTracer.TracerAPI tracer; + + protected ListWriter writer; + + protected ClassFileTransformer activeTransformer; + protected ClassFileTransformerListener transformerLister; + + @BeforeEach + public void init() { + // If this fails, it's likely the result of another test loading Config before it can be + // injected into the bootstrap classpath. + // If one test extends AgentTestRunner in a module, all tests must extend + assertNull(Config.class.getClassLoader(), "Config must load on the bootstrap classpath."); + + // Initialize test tracer + this.writer = new ListWriter(); + // Initialize test tracer + CoreTracer tracer = + CoreTracer.builder() + .writer(this.writer) + .idGenerationStrategy(IdGenerationStrategy.fromName(idGenerationStrategyName())) + .strictTraceWrites(useStrictTraceWrites()) + .build(); + TracerInstaller.forceInstallGlobalTracer(tracer); + this.tracer = tracer; + + ClassInjector.enableClassInjection(INSTRUMENTATION); + + // if a test enables the instrumentation it verifies, + // the cache needs to be recomputed taking into account that instrumentation's matchers + ClassLoaderMatchers.resetState(); + + assertTrue( + ServiceLoader.load( + InstrumenterModule.class, AbstractInstrumentationTest.class.getClassLoader()) + .iterator() + .hasNext(), + "No instrumentation found"); + this.transformerLister = new ClassFileTransformerListener(); + this.activeTransformer = + AgentInstaller.installBytebuddyAgent( + INSTRUMENTATION, true, AgentInstaller.getEnabledSystems(), this.transformerLister); + } + + protected String idGenerationStrategyName() { + return "SEQUENTIAL"; + } + + private boolean useStrictTraceWrites() { + return true; + } + + @AfterEach + public void tearDown() { + this.tracer.close(); + this.writer.close(); + if (this.activeTransformer != null) { + INSTRUMENTATION.removeTransformer(this.activeTransformer); + this.activeTransformer = null; + } + + // All cleanups should happen before these assertions. + // If not, a failing assertion may prevent cleanup + this.transformerLister.verify(); + this.transformerLister = null; + } + + protected void blockUntilChildSpansFinished(final int numberOfSpans) { + blockUntilChildSpansFinished(this.tracer.activeSpan(), numberOfSpans); + } + + static void blockUntilChildSpansFinished(AgentSpan span, int numberOfSpans) { + if (span instanceof DDSpan) { + TraceCollector traceCollector = ((DDSpan) span).context().getTraceCollector(); + if (!(traceCollector instanceof PendingTrace)) { + throw new IllegalStateException( + "Expected $PendingTrace.name trace collector, got $traceCollector.class.name"); + } + + PendingTrace pendingTrace = (PendingTrace) traceCollector; + long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS; + + while (pendingTrace.size() < numberOfSpans) { + if (System.currentTimeMillis() > deadline) { + throw new RuntimeException( + new TimeoutException( + "Timed out waiting for child spans. Received: " + pendingTrace.size())); + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/ClassFileTransformerListener.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/ClassFileTransformerListener.java new file mode 100644 index 00000000000..16ba4954efc --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/ClassFileTransformerListener.java @@ -0,0 +1,83 @@ +package datadog.trace.agent.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.collect.Sets; +import datadog.trace.agent.tooling.bytebuddy.matcher.GlobalIgnores; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.DynamicType; +import net.bytebuddy.utility.JavaModule; +import net.bytebuddy.utility.nullability.MaybeNull; + +public class ClassFileTransformerListener implements AgentBuilder.Listener { + + final Set transformedClassesNames = Sets.newConcurrentHashSet(); + final Set transformedClassesTypes = Sets.newConcurrentHashSet(); + final AtomicInteger instrumentationErrorCount = new AtomicInteger(0); + + @Override + public void onTransformation( + TypeDescription typeDescription, + @MaybeNull ClassLoader classLoader, + @MaybeNull JavaModule module, + boolean loaded, + DynamicType dynamicType) { + this.transformedClassesNames.add(typeDescription.getActualName()); + this.transformedClassesTypes.add(typeDescription); + } + + @SuppressForbidden // Allows System.out.println + @Override + public void onError( + String typeName, + ClassLoader classLoader, + JavaModule module, + boolean loaded, + Throwable throwable) { + // Incorrect* classes assert on incorrect api usage. Error expected. + if (typeName.startsWith("context.FieldInjectionTestInstrumentation$Incorrect") + && throwable.getMessage().startsWith("Incorrect Context Api Usage detected.")) { + return; + } + + System.out.println( + "Unexpected instrumentation error when instrumenting " + typeName + " on " + classLoader); + throwable.printStackTrace(); + instrumentationErrorCount.incrementAndGet(); + } + + @Override + public void onDiscovery( + String typeName, ClassLoader classLoader, JavaModule module, boolean loaded) { + // Nothing special to do + } + + @Override + public void onIgnored( + TypeDescription typeDescription, ClassLoader classLoader, JavaModule module, boolean loaded) { + // Nothing special to do + } + + @Override + public void onComplete( + String typeName, ClassLoader classLoader, JavaModule module, boolean loaded) { + // Nothing special to do + } + + public void verify() { + // Check instrumentation errors + int errorCount = this.instrumentationErrorCount.get(); + assertEquals(0, errorCount, errorCount + " instrumentation errors during test"); + // Check effectively transformed classes that should have been ignored + assertTrue( + this.transformedClassesTypes.stream() + .map(TypeDescription::getActualName) + .noneMatch(GlobalIgnores::isAdditionallyIgnored), + "Transformed classes match global libraries ignore matcher"); + } +} diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/build.gradle b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/build.gradle index 00757ef832a..e0ec0843b03 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/build.gradle +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/build.gradle @@ -22,9 +22,9 @@ idea { } } -// Set all compile tasks to use JDK21 but let instrumentation code targets 1.8 compatibility -tasks.withType(AbstractCompile).configureEach { - configureCompiler(it, 21, JavaVersion.VERSION_1_8) +// Set test compile task to use JDK 21 to use the virtual threads API +tasks.named("compileTestJava", JavaCompile) { + configureCompiler(it, 21) } dependencies { diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/groovy/VirtualThreadApiTest.groovy b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/groovy/VirtualThreadApiTest.groovy deleted file mode 100644 index d21540928c0..00000000000 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/groovy/VirtualThreadApiTest.groovy +++ /dev/null @@ -1,168 +0,0 @@ -import datadog.trace.agent.test.InstrumentationSpecification -import datadog.trace.api.Trace -import datadog.trace.core.DDSpan -import datadog.trace.test.util.Flaky - -// Note: test builder x2 + test factory can be refactored but are kept simple to ease with debugging. -@Flaky("class loader deadlock on virtual thread clean up while Groovy do dynamic code generation - APMLP-782") -class VirtualThreadApiTest extends InstrumentationSpecification { - def "test Thread.Builder.OfVirtual - start()"() { - setup: - def threadBuilder = Thread.ofVirtual().name("builder - started") - - when: - new Runnable() { - @Override - @Trace(operationName = "parent") - void run() { - // this child will have a span - threadBuilder.start(new JavaAsyncChild()) - // this child won't - threadBuilder.start(new JavaAsyncChild(false, false)) - blockUntilChildSpansFinished(1) - } - }.run() - - then: - TEST_WRITER.waitForTraces(1) - List trace = TEST_WRITER.get(0) - - expect: - TEST_WRITER.size() == 1 - trace.size() == 2 - trace.get(0).operationName == "parent" - trace.get(1).operationName == "asyncChild" - trace.get(1).parentId == trace.get(0).spanId - } - - def "test Thread.Builder.OfVirtual - unstarted()"() { - setup: - def threadBuilder = Thread.ofVirtual().name("builder - unstarted") - - when: - new Runnable() { - @Override - @Trace(operationName = "parent") - void run() { - // this child will have a span - threadBuilder.unstarted(new JavaAsyncChild()).start() - // this child won't - threadBuilder.unstarted(new JavaAsyncChild(false, false)).start() - blockUntilChildSpansFinished(1) - } - }.run() - - - then: - TEST_WRITER.waitForTraces(1) - List trace = TEST_WRITER.get(0) - - expect: - TEST_WRITER.size() == 1 - trace.size() == 2 - trace.get(0).operationName == "parent" - trace.get(1).operationName == "asyncChild" - trace.get(1).parentId == trace.get(0).spanId - } - - def "test Thread.startVirtual()"() { - when: - new Runnable() { - @Override - @Trace(operationName = "parent") - void run() { - // this child will have a span - Thread.startVirtualThread(new JavaAsyncChild()) - // this child won't - Thread.startVirtualThread(new JavaAsyncChild(false, false)) - blockUntilChildSpansFinished(1) - } - }.run() - - then: - TEST_WRITER.waitForTraces(1) - List trace = TEST_WRITER.get(0) - - expect: - TEST_WRITER.size() == 1 - trace.size() == 2 - trace.get(0).operationName == "parent" - trace.get(1).operationName == "asyncChild" - trace.get(1).parentId == trace.get(0).spanId - } - - def "test virtual ThreadFactory"() { - setup: - def threadFactory = Thread.ofVirtual().factory() - - when: - new Runnable() { - @Override - @Trace(operationName = "parent") - void run() { - // this child will have a span - threadFactory.newThread(new JavaAsyncChild()).start() - // this child won't - threadFactory.newThread(new JavaAsyncChild(false, false)).start() - blockUntilChildSpansFinished(1) - } - }.run() - - then: - TEST_WRITER.waitForTraces(1) - List trace = TEST_WRITER.get(0) - - expect: - TEST_WRITER.size() == 1 - trace.size() == 2 - trace.get(0).operationName == "parent" - trace.get(1).operationName == "asyncChild" - trace.get(1).parentId == trace.get(0).spanId - } - - def "test nested virtual threads"() { - setup: - def threadBuilder = Thread.ofVirtual() - - when: - new Runnable() { - @Trace(operationName = "parent") - @Override - void run() { - threadBuilder.start(new Runnable() { - @Trace(operationName = "child") - @Override - void run() { - threadBuilder.start(new Runnable() { - @Trace(operationName = "great-child") - @Override - void run() { - println "complete" - } - }) - blockUntilChildSpansFinished(1) - } - }) - blockUntilChildSpansFinished(1) - } - }.run() - - then: - assertTraces(1) { - sortSpansByStart() - trace(3) { - span { - operationName "parent" - } - span { - childOfPrevious() - operationName "child" - } - span { - childOfPrevious() - operationName "great-child" - } - } - } - } -} diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/JavaAsyncChild.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/JavaAsyncChild.java similarity index 94% rename from dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/JavaAsyncChild.java rename to dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/JavaAsyncChild.java index b93e43a5fa0..8de6b89839d 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/JavaAsyncChild.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/JavaAsyncChild.java @@ -1,3 +1,5 @@ +package testdog.trace.instrumentation.java.lang.jdk21; + import datadog.trace.api.Trace; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicBoolean; diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java new file mode 100644 index 00000000000..f702400a7a7 --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java @@ -0,0 +1,170 @@ +package testdog.trace.instrumentation.java.lang.jdk21; + +import static java.util.Collections.emptyList; +import static java.util.Comparator.comparing; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.Trace; +import datadog.trace.core.DDSpan; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class VirtualThreadApiInstrumentationTest extends AbstractInstrumentationTest { + + @DisplayName("test Thread.Builder.OfVirtual.start()") + @Test + void testBuilderOfVirtualStart() throws InterruptedException, TimeoutException { + Thread.Builder.OfVirtual threadBuilder = Thread.ofVirtual().name("builder - started"); + + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + // this child will have a span + threadBuilder.start(new JavaAsyncChild()); + // this child won't + threadBuilder.start(new JavaAsyncChild(false, false)); + blockUntilChildSpansFinished(1); + } + }.run(); + + assertConnectedTrace(); + } + + @DisplayName("test Thread.Builder.OfVirtual.unstarted()") + @Test + void testBuilderOfVirtualUnstarted() throws InterruptedException, TimeoutException { + Thread.Builder.OfVirtual threadBuilder = Thread.ofVirtual().name("builder - started"); + + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + // this child will have a span + threadBuilder.unstarted(new JavaAsyncChild()).start(); + // this child won't + threadBuilder.unstarted(new JavaAsyncChild(false, false)).start(); + blockUntilChildSpansFinished(1); + } + }.run(); + + assertConnectedTrace(); + } + + @DisplayName("test Thread.startVirtual()") + @Test + void testThreadStartVirtual() throws InterruptedException, TimeoutException { + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + // this child will have a span + Thread.startVirtualThread(new JavaAsyncChild()); + // this child won't + Thread.startVirtualThread(new JavaAsyncChild(false, false)); + blockUntilChildSpansFinished(1); + } + }.run(); + + assertConnectedTrace(); + } + + @DisplayName("test Thread.Builder.OfVirtual.factory()") + @Test + void testThreadOfVirtualFactory() throws InterruptedException, TimeoutException { + ThreadFactory factory = Thread.ofVirtual().factory(); + + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + // this child will have a span + factory.newThread(new JavaAsyncChild()).start(); + // this child won't + factory.newThread(new JavaAsyncChild(false, false)).start(); + blockUntilChildSpansFinished(1); + } + }.run(); + + assertConnectedTrace(); + } + + @DisplayName("test nested virtual threads") + @Test + void testNestedVirtualThreads() throws InterruptedException, TimeoutException { + Thread.Builder.OfVirtual threadBuilder = Thread.ofVirtual(); + CountDownLatch latch = new CountDownLatch(3); + + new Runnable() { + @Trace(operationName = "parent") + @Override + public void run() { + threadBuilder.start( + new Runnable() { + @Trace(operationName = "child") + @Override + public void run() { + threadBuilder.start( + new Runnable() { + @Trace(operationName = "great-child") + @Override + public void run() { + threadBuilder.start( + new Runnable() { + @Trace(operationName = "great-great-child") + @Override + public void run() { + System.out.println("complete"); + latch.countDown(); + } + }); + latch.countDown(); + } + }); + latch.countDown(); + } + }); + } + }.run(); + + latch.await(); + + var trace = getTrace(); + trace.sort(comparing(DDSpan::getStartTimeNano)); + assertEquals(4, trace.size()); + assertEquals("parent", trace.get(0).getOperationName()); + assertEquals("child", trace.get(1).getOperationName()); + assertEquals("great-child", trace.get(2).getOperationName()); + assertEquals("great-great-child", trace.get(3).getOperationName()); + assertEquals(trace.get(0).getSpanId(), trace.get(1).getParentId()); + assertEquals(trace.get(1).getSpanId(), trace.get(2).getParentId()); + assertEquals(trace.get(2).getSpanId(), trace.get(3).getParentId()); + } + + /** Verifies the parent / child span relation. */ + void assertConnectedTrace() { + var trace = getTrace(); + trace.sort(comparing(DDSpan::getStartTimeNano)); + assertEquals(2, trace.size()); + assertEquals("parent", trace.get(0).getOperationName()); + assertEquals("asyncChild", trace.get(1).getOperationName()); + assertEquals(trace.get(0).getSpanId(), trace.get(1).getParentId()); + } + + List getTrace() { + try { + writer.waitForTraces(1); + assertEquals(1, writer.size()); + return writer.getFirst(); + } catch (InterruptedException | TimeoutException e) { + fail("Failed to wait for trace to finish.", e); + return emptyList(); + } + } +}